2

I read

For CPython, id(x) is the memory address where x is stored.

And it's a given the id of an object never changes, which means an object always stored at a given memory address in its lifetime. This leads to the question: what about fragmentation of (virtual) memory?

Say an object A is at address 1 (has id 1), takes up 10 bytes, so it takes up addresses 1-10. Object B has id 11 and takes up bytes 11-12, and object C takes up addresses 13-22. Once B goes out of scope and gets GC'd, we'll have fragmentation.

How is this conundrum resolved?

flow2k
  • 3,999
  • 40
  • 55
  • 1
    CPython will likely reuse B's memory for another object of the same size later, see e.g. https://stackoverflow.com/q/3877230/3001761. It's during the *object's* lifetime, once it's "dead" that ID is available again. – jonrsharpe Mar 05 '19 at 08:22
  • 1
    @jonrsharpe If B's memory can only be used later for the an object of the same size, then this is exactly what internal fragmentation is. If there are two small blocks, then we can't use both for something bigger...is can we? Surely CPython has a way to get around this problem? – flow2k Mar 05 '19 at 20:24

1 Answers1

3

CPython uses its own memory allocator for small objects, the pymalloc-allocator. A quite good description can be found in the code itself.

This allocator is pretty good at avoiding memory fragmentation, because it reuses the freed memory efficiently. However, it is only a heuristic and one could come up with scenarios which lead to memory fragmentation.

Let's play through what happens when we allocate an object with size of 1byte.

CPython has its own so called arena for objects smaller than 512 bytes. Obviously, 1 byte request will be managed by its allocator.

Requested sizes are divided in 64 different classes: 0th-class is for sizes of 1..8 bytes, 1th-class is for sizes or 9..16 bytes and so on - this is due to required alignment of 8 bytes. Every of the above classes has its own more or less independent/dedicated memory. Our request is for the 0th-class.

Let's assume this is the first request for this size-class. A new "pool" will be created or an empty pool reused. A pool is 4KB big, and thus has space for 512 8-byte "blocks". Despite requestion only 1 byte we will be blocking another 7 bytes of the occupied block, so they cannot be used for another objects. All free blocks are kept in a list - in the beginning all 512 blocks are in this list. The allocator deletes the first block from this free-block-list and returns its address as pointer.

The pool itself is marked as "used" and added to a list of used pools for 0th-class.

Now, allocation another object with size <=8 bytes happens as follows. At first we look at the list of used pools for 0th-class and will find a pool which already in use, i.e. has some used and some free blocks. Allocator uses the first free block, deletes it from the list of free blocks and returns its address as pointer.

Deleting first object is easy - we add the occupied block as head of the list of free blocks in the (so far single) used pool.

When a new object of 8 byte is created, so the first block in the free-block-list is used, and this is the block which was used by the first, now deleted, object.

As you can see the memory gets reused, and thus memory fragmentation is vastly reduced. This doesn't mean there cannot be memory fragmentation:

After allocating 512 1-byte-objects, the first pool becomes "full" and a new pool for 0th-class-sizes will be created/used. Once also we add another 512 objects also the second pool becomes "full". And so on.

Now, if the first 511 elements are deleted - there will be still one byte which blocks whole 4KB, which cannot be used for other classes.

Only when the last block is freed, the pool becomes "empty" and thus can be reused for other size-classes.


The empty pools are not returned to the OS, but stay in the arena and are reused. However, the pymalloc manages multiple arenas, and if an arena becomes "unused" it might be freed and occupied memory (i.e. pools) is returned to the OS.

ead
  • 32,758
  • 6
  • 90
  • 153
  • Wow, thanks for reading through the code and giving this detailed breakdown. Yes, if we group similar-sized objects into pools/regions of memory, then the fragmentation is less. Hmm, I guess if `id` didn't have to be kept constant, avoiding fragmentation would be easier because we can simply relocate/compact the small objects. I am wondering why Python has this constant `id` requirement...but I guess that would be a whole new other topic/question. – flow2k Mar 06 '19 at 08:44
  • @flow2k I'm not sure there is any memory allocator, which would use the proposed strategy of relocating of the pointers - in this case it would have to update all places where the old address is saved - that doesn't look like a cheap operation (one also has to track all these places). – ead Mar 06 '19 at 08:52
  • There definitely needs to be more work done, but perhaps one efficient way is to simply have one extra layer of redirection: maintain a map/table that maps the "virtual" address of the object to the virtual memory address - yes, this is similar to the paging mechanism in OS, which was introduced with the express desire of alleviating fragmentation of physical memory. I wonder why this is not done in the allocators - perhaps just for efficiency? – flow2k Mar 09 '19 at 20:32