How PostgreSQL Allocates Memory Fast

palloc is not malloc: PostgreSQL quantises every allocation to a power of two, keeps a freelist per size class, carves the tail of a dying block into free chunks, and recycles whole memory contexts.

Every C program that wants to be fast needs to be fast at allocating memory. PostgreSQL allocates memory in the tightest loop imaginable: a hash join probes its hash table once per outer row, an aggregate re-evaluates its transition function once per input row, and a sort copies tuples into a buffer. In a query that touches ten million rows, that is tens of millions of allocations, and every one of them has to be cheap.

PostgreSQL’s answer is palloc(), and palloc() is emphatically not malloc(). It does not call malloc() per allocation. It does not keep a general-purpose heap. It quantises every request to a power of two, keeps one free list per size class, carves the tail of an exhausted block into free chunks rather than throwing it away, and recycles whole memory context objects so that a per-tuple context never has to go back to malloc() at all.

This is a walk through how it all works, based on reading the PostgreSQL source (version 20devel, commit e75fcc9867f).

The idea: lifetimes, not ownership

The foundational idea is not an allocator technique at all. It is that memory has a lifetime, and the lifetime should decide who frees it.

PostgreSQL’s src/backend/utils/mmgr/README states it plainly:

The main advantage of memory contexts over plain use of malloc/free is that the entire contents of a memory context can be freed easily, without having to request freeing of each individual chunk within it. This is both faster and more reliable than per-chunk bookkeeping.

So instead of tracking every individual allocation, PostgreSQL builds a tree of memory contexts, each with its own lifetime, and frees a whole subtree in one operation. A per-tuple context is reset after every tuple. A per-query context is reset after every query. A per-transaction context is reset after every transaction. And the allocator inside each context is free to make whatever local trade-offs it likes, because the context boundary, not the individual free, is what actually matters for memory return.

The tree is a plain first-child/next-sibling structure in src/include/nodes/memnodes.h:

c
typedef struct MemoryContextData
{
	pg_node_attr(abstract)		/* there are no nodes of this type */

	NodeTag		type;			/* identifies exact kind of context */
	/* these two fields are placed here to minimize alignment wastage: */
	bool		isReset;		/* T = no space allocated since last reset */
	bool		allowInCritSection; /* allow palloc in critical section */
	Size		mem_allocated;	/* track memory allocated for this context */
	const MemoryContextMethods *methods;	/* virtual function table */
	MemoryContext parent;		/* NULL if no parent (toplevel context) */
	MemoryContext firstchild;	/* head of linked list of children */
	MemoryContext prevchild;	/* previous child of same parent */
	MemoryContext nextchild;	/* next child of same parent */
	const char *name;			/* context name */
	const char *ident;			/* context ID if any */
	MemoryContextCallback *reset_cbs;	/* list of reset/delete callbacks */
} MemoryContextData;

Note the methods pointer: a memory context is a virtual object. The same tree can be rooted at an AllocSetContext, a SlabContext, a BumpContext or a GenerationContext, and each has completely different allocation behaviour while presenting the same API.

One indirection, but a deliberate one

palloc() does not call malloc(). It calls through the context’s method table. From src/backend/utils/mmgr/mcxt.c:

c
/*
 * Call the given function in the MemoryContextMethods for the memory context
 * type that 'pointer' belongs to.
 */
#define MCXT_METHOD(pointer, method) \
	mcxt_methods[GetMemoryChunkMethodID(pointer)].method

And palloc() itself:

c
void *
palloc(Size size)
{
	/* duplicates MemoryContextAlloc to avoid increased overhead */
	void	   *ret;
	MemoryContext context = CurrentMemoryContext;

	Assert(MemoryContextIsValid(context));
	AssertNotInCriticalSection(context);

	context->isReset = false;

	/*
	 * For efficiency reasons, we purposefully offload the handling of
	 * allocation failures to the MemoryContextMethods implementation as this
	 * allows these checks to be performed only when an actual malloc needs to
	 * be done to request more memory from the OS.  Additionally, not having
	 * to execute any instructions after this call allows the compiler to use
	 * the sibling call optimization.  If you're considering adding code after
	 * this call, consider making it the responsibility of the 'alloc'
	 * function instead.
	 */
	ret = context->methods->alloc(context, size, 0);
	/* We expect OOM to be handled by the alloc function */
	Assert(ret != NULL);
	VALGRIND_MEMPOOL_ALLOC(context, ret, size);

	return ret;
}

Three things in that comment deserve attention.

First, the out-of-memory check lives in the allocator, not here. The generic wrapper does not test for NULL and call ereport(ERROR). It cannot: an allocation that is satisfied from an existing free list never touches the operating system, so there is no failure to report. Only when the allocator actually has to malloc() more memory from the OS can it fail — and that is where the check belongs:

c
void *
MemoryContextAllocationFailure(MemoryContext context, Size size, int flags)
{
	if ((flags & MCXT_ALLOC_NO_OOM) == 0)
	{
		if (TopMemoryContext)
			MemoryContextStats(TopMemoryContext);
		ereport(ERROR,
				(errcode(ERRCODE_OUT_OF_MEMORY),
				 errmsg("out of memory"),
				 errdetail("Failed on request of size %zu in memory context \"%s\".",
						   size, context->name)));
	}
	return NULL;
}

Second, the tail call. Because the comment says “not having to execute any instructions after this call”, palloc() compiles to something close to a jump: load context->methods->alloc, load size, call it, return the result. The compiler is free to turn the final return ret; into a tail jump into AllocSetAlloc(), avoiding a stack frame in the wrapper entirely. Add an instruction after the call and that optimisation evaporates. This is why the comment ends with a warning to future contributors: “If you’re considering adding code after this call, consider making it the responsibility of the ‘alloc’ function instead.”

Third, palloc0 does not call palloc. It is a copy of the same function with the MemSetAligned bolted on, for the same reason — /* duplicates MemoryContextAlloc to avoid increased overhead */. The extra call would cost a stack frame on one of the hottest paths in the server.

The MemoryContextAlloc() variant, which takes an explicit context rather than using CurrentMemoryContext, carries the identical comment and the identical shape:

c
void *
MemoryContextAlloc(MemoryContext context, Size size)
{
	void	   *ret;

	Assert(MemoryContextIsValid(context));
	AssertNotInCriticalSection(context);

	context->isReset = false;

	/*
	 * For efficiency reasons, we purposefully offload the handling of
	 * allocation failures to the MemoryContextMethods implementation as this
	 * allows these checks to be performed only when an actual malloc needs to
	 * be done to request more memory from the OS.  Additionally, not having
	 * to execute any instructions after this call allows the compiler to use
	 * the sibling call optimization.  If you're considering adding code after
	 * this call, consider making it the responsibility of the 'alloc'
	 * function instead.
	 */
	ret = context->methods->alloc(context, size, 0);

	VALGRIND_MEMPOOL_ALLOC(context, ret, size);

	return ret;
}

How pfree knows where to send the pointer

The method-table dispatch works in both directions. pfree() does not take a context argument — a chunk can be freed from any context — so it has to find the right methods from the pointer alone. The trick is an 8-byte header stored immediately before every chunk.

From src/include/utils/memutils_memorychunk.h:

c
typedef struct MemoryChunk
{
#ifdef MEMORY_CONTEXT_CHECKING
	Size		requested_size;
#endif

	/* bitfield for storing details about the chunk */
	uint64		hdrmask;		/* must be last */
} MemoryChunk;

In a normal build MemoryChunk is exactly 8 bytes. The file’s header comment describes how those 64 bits are carved up:

The MemoryChunk contains a uint64 field named ‘hdrmask’. This field is used to encode 4 separate pieces of information. Starting with the least significant bits of ‘hdrmask’, the bit space is reserved as follows:

  1. 4-bits to indicate the MemoryContextMethodID as defined by MEMORY_CONTEXT_METHODID_MASK
  2. 1-bit to denote an “external” chunk (see below)
  3. 30-bits reserved for the MemoryContext to use for anything it requires. Most MemoryContexts likely want to store the size of the chunk here.
  4. 30-bits for the number of bytes that must be subtracted from the chunk to obtain the address of the block that the chunk is stored on.

And the trick that makes it add up to 64 bits:

If you’re paying close attention, you’ll notice this adds up to 65 bits rather than 64 bits. This is because the highest-order bit of #3 is the same bit as the lowest-order bit of #4. We can do this as we insist that the chunk and block pointers are both MAXALIGNed, therefore the relative offset between those will always be a MAXALIGNed value which means the lowest order bit is always 0.

So one 64-bit word carries the method ID (4 bits, to dispatch pfree), an “external” flag, 30 bits of context-specific payload, and a 30-bit offset back to the owning block. Reading it back:

c
static inline void
MemoryChunkSetHdrMask(MemoryChunk *chunk, void *block,
					  Size value, MemoryContextMethodID methodid)
{
	Size		blockoffset = (char *) chunk - (char *) block;

	Assert((char *) chunk >= (char *) block);
	Assert((blockoffset & MEMORYCHUNK_BLOCKOFFSET_MASK) == blockoffset);
	Assert(value <= MEMORYCHUNK_MAX_VALUE);
	Assert((int) methodid <= MEMORY_CONTEXT_METHODID_MASK);

	chunk->hdrmask = (((uint64) blockoffset) << MEMORYCHUNK_BLOCKOFFSET_BASEBIT) |
		(((uint64) value) << MEMORYCHUNK_VALUE_BASEBIT) |
		methodid;
}

GetMemoryChunkMethodID() in mcxt.c reads the low 4 bits, and pfree() becomes a one-line indirect call:

c
void
pfree(void *pointer)
{
#ifdef USE_VALGRIND
	MemoryContext context = GetMemoryChunkContext(pointer);
#endif

	MCXT_METHOD(pointer, free_p) (pointer);

	VALGRIND_MEMPOOL_FREE(context, pointer);
}

The 8-byte header is the price of admission. Every allocation pays it. For an 8-byte allocation, that means the usable chunk is 8 bytes and the header is 8 bytes — a 100% overhead. This is precisely why the other allocator types exist.

The AllocSet: power-of-two size classes

aset.c is the default allocator, and it is where all the interesting tricks live. The file’s own header comment describes the design:

c
/*--------------------
 * Chunk freelist k holds chunks of size 1 << (k + ALLOC_MINBITS),
 * for k = 0 .. ALLOCSET_NUM_FREELISTS-1.
 *
 * Note that all chunks in the freelists have power-of-2 sizes.  This
 * improves recyclability: we may waste some space, but the wasted space
 * should stay pretty constant as requests are made and released.
 *
 * A request too large for the last freelist is handled by allocating a
 * dedicated block from malloc().  The block still has a block header and
 * chunk header, but when the chunk is freed we'll return the whole block
 * to malloc(), not put it on our freelists.
 *
 * CAUTION: ALLOC_MINBITS must be large enough so that
 * 1<<ALLOC_MINBITS is at least MAXALIGN,
 * or we may fail to align the smallest chunks adequately.
 * 8-byte alignment is enough on all currently known machines.  This 8-byte
 * minimum also allows us to store a pointer to the next freelist item within
 * the chunk of memory itself.
 *
 * With the current parameters, request sizes up to 8K are treated as chunks,
 * larger requests go into dedicated blocks.  Change ALLOCSET_NUM_FREELISTS
 * to adjust the boundary point; and adjust ALLOCSET_SEPARATE_THRESHOLD in
 * memutils.h to agree.
 *--------------------
 */

#define ALLOC_MINBITS		3	/* smallest chunk size is 8 bytes */
#define ALLOCSET_NUM_FREELISTS	11
#define ALLOC_CHUNK_LIMIT	(1 << (ALLOCSET_NUM_FREELISTS-1+ALLOC_MINBITS))
/* Size of largest chunk that we use a fixed size for */
#define ALLOC_CHUNK_FRACTION	4
/* We allow chunks to be at most 1/4 of maxBlockSize (less overhead) */

Eleven free lists. The chunk sizes are:

Free list indexChunk sizeNotes
08 bytes1 << 3; also the minimum chunk
116 bytes
232 bytes
364 bytes
4128 bytes
5256 bytes
6512 bytes
71 kB
82 kB
94 kB
108 kBALLOC_CHUNK_LIMIT
—> 8 kBdedicated malloc() block

A request of 100 bytes lands in free list 4 and gets a 128-byte chunk. A request of 129 bytes lands in free list 5 and gets 256 bytes. The waste is bounded by a factor of two and, crucially, constant — which is the whole point. A 100-byte allocation freed and re-requested always gets the same 128-byte chunk, forever, because it goes back on free list 4.

The context itself:

c
typedef struct AllocSetContext
{
	MemoryContextData header;	/* Standard memory-context fields */
	/* Info about storage allocated in this context: */
	AllocBlock	blocks;			/* head of list of blocks in this set */
	MemoryChunk *freelist[ALLOCSET_NUM_FREELISTS];	/* free chunk lists */
	/* Allocation parameters for this context: */
	uint32		initBlockSize;	/* initial block size */
	uint32		maxBlockSize;	/* maximum block size */
	uint32		nextBlockSize;	/* next block size to allocate */
	uint32		allocChunkLimit;	/* effective chunk size limit */
	/* freelist this context could be put in, or -1 if not a candidate: */
	int			freeListIndex;	/* index in context_freelists[], or -1 */
} AllocSetContext;

Eleven pointers, plus four sizing parameters, plus one integer for the context recycling pool. That is the entire state of an AllocSet.

And the block:

c
typedef struct AllocBlockData
{
	AllocSet	aset;			/* aset that owns this block */
	AllocBlock	prev;			/* prev block in aset's blocks list, if any */
	AllocBlock	next;			/* next block in aset's blocks list, if any */
	char	   *freeptr;		/* start of free space in this block */
	char	   *endptr;			/* end of space in this block */
}			AllocBlockData;

Blocks are obtained from malloc() and carved into chunks. freeptr walks forward as the active block fills up.

Computing the free list index

Given a request size, the allocator has to decide which of the eleven free lists to use. That is AllocSetFreeIndex():

c
static inline int
AllocSetFreeIndex(Size size)
{
	int			idx;

	if (size > (1 << ALLOC_MINBITS))
	{
		/*----------
		 * At this point we must compute ceil(log2(size >> ALLOC_MINBITS)).
		 * This is the same as
		 *		pg_leftmost_one_pos32((size - 1) >> ALLOC_MINBITS) + 1
		 * or equivalently
		 *		pg_leftmost_one_pos32(size - 1) - ALLOC_MINBITS + 1
		 *
		 * However, for platforms without intrinsic support, we duplicate the
		 * logic here, allowing an additional optimization.  It's reasonable
		 * to assume that ALLOC_CHUNK_LIMIT fits in 16 bits, so we can unroll
		 * the byte-at-a-time loop in pg_leftmost_one_pos32 and just handle
		 * the last two bytes.
		 *
		 * Yes, this function is enough of a hot-spot to make it worth this
		 * much trouble.
		 *----------
		 */
#ifdef HAVE_BITSCAN_REVERSE
		idx = pg_leftmost_one_pos32((uint32) size - 1) - ALLOC_MINBITS + 1;
#else
		uint32		t,
					tsize;

		/* Statically assert that we only have a 16-bit input value. */
		StaticAssertDecl(ALLOC_CHUNK_LIMIT < (1 << 16),
						 "ALLOC_CHUNK_LIMIT must be less than 64kB");

		tsize = size - 1;
		t = tsize >> 8;
		idx = t ? pg_leftmost_one_pos[t] + 8 : pg_leftmost_one_pos[tsize];
		idx -= ALLOC_MINBITS - 1;
#endif

		Assert(idx < ALLOCSET_NUM_FREELISTS);
	}
	else
		idx = 0;

	return idx;
}

“Yes, this function is enough of a hot-spot to make it worth this much trouble.” That is a real PostgreSQL comment, and it explains the shape of the code. pg_leftmost_one_pos32() is the count-leading-zeros primitive:

c
static inline int
pg_leftmost_one_pos32(uint32 word)
{
#ifdef HAVE__BUILTIN_CLZ
	Assert(word != 0);

	return 31 - __builtin_clz(word);

On x86 this compiles to a single bsr instruction (or lzcnt). One instruction to convert “how big is this request?” into “which free list do I use?”. The portable fallback uses a 256-entry byte table and only looks at the top two bytes of the value, which is why the code carries the StaticAssertDecl about ALLOC_CHUNK_LIMIT fitting in 16 bits.

The index is stored in the chunk header

Here is the part that makes pfree() cheap. When a chunk is freed, the allocator has to know which free list to put it back on. It could recompute it from the chunk’s size — but it doesn’t need to, because the chunk header has 30 spare bits.

Recall the MemoryChunk layout: bits 5–34 are a 30-bit “value” field that the context may use for whatever it likes. aset.c uses it to store the free list index:

c
	/* store the free list index in the value field */
	MemoryChunkSetHdrMask(chunk, block, fidx, MCTX_ASET_ID);

and AllocSetFree() reads it straight back out:

c
		fidx = MemoryChunkGetValue(chunk);
		Assert(FreeListIdxIsValid(fidx));
		link = GetFreeListLink(chunk);

No clz, no lookup table, no search. One shift and one mask. The comment in aset.c makes the rationale explicit:

Note that all chunks in the freelists have power-of-2 sizes. This improves recyclability: we may waste some space, but the wasted space should stay pretty constant as requests are made and released.

The free list index is the size class, so storing the index is exactly equivalent to storing the size — but it is 4 bits instead of 32, and it needs no arithmetic to recover.

A free list needs a next pointer per chunk. Where does that go? PostgreSQL puts it in the chunk’s own user data:

c
/*
 * Obtain a AllocFreeListLink for the given chunk.  Allocation sizes are
 * always at least sizeof(AllocFreeListLink), so we reuse the pointer's memory
 * itself to store the freelist link.
 */
#define GetFreeListLink(chkptr) \
	(AllocFreeListLink *) ((char *) (chkptr) + ALLOC_CHUNKHDRSZ)

This is exactly why ALLOC_MINBITS is 3 — the smallest chunk is 8 bytes, which is exactly enough to hold a pointer. When the chunk is live, that space is the caller’s data. When the chunk is free, it is the list link. No separate array of freelist nodes, no extra allocation.

The allocation fast path

AllocSetAlloc() is the whole story for a typical small allocation:

c
void *
AllocSetAlloc(MemoryContext context, Size size, int flags)
{
	AllocSet	set = (AllocSet) context;
	AllocBlock	block;
	MemoryChunk *chunk;
	int			fidx;
	Size		chunk_size;
	Size		availspace;

	Assert(AllocSetIsValid(set));

	/* due to the keeper block set->blocks should never be NULL */
	Assert(set->blocks != NULL);

	/*
	 * If requested size exceeds maximum for chunks we hand the request off to
	 * AllocSetAllocLarge().
	 */
	if (size > set->allocChunkLimit)
		return AllocSetAllocLarge(context, size, flags);

	/*
	 * Request is small enough to be treated as a chunk.  Look in the
	 * corresponding free list to see if there is a free chunk we could reuse.
	 * If one is found, remove it from the free list, make it again a member
	 * of the alloc set and return its data address.
	 *
	 * Note that we don't attempt to ensure there's space for the sentinel
	 * byte here.  We expect a large proportion of allocations to be for sizes
	 * which are already a power of 2.  If we were to always make space for a
	 * sentinel byte in MEMORY_CONTEXT_CHECKING builds, then we'd end up
	 * doubling the memory requirements for such allocations.
	 */
	fidx = AllocSetFreeIndex(size);
	chunk = set->freelist[fidx];
	if (chunk != NULL)
	{
		AllocFreeListLink *link = GetFreeListLink(chunk);

		/* Allow access to the chunk header. */
		VALGRIND_MAKE_MEM_DEFINED(chunk, ALLOC_CHUNKHDRSZ);

		Assert(fidx == MemoryChunkGetValue(chunk));

		/* pop this chunk off the freelist */
		VALGRIND_MAKE_MEM_DEFINED(link, sizeof(AllocFreeListLink));
		set->freelist[fidx] = link->next;
		VALGRIND_MAKE_MEM_NOACCESS(link, sizeof(AllocFreeListLink));
		...
		return MemoryChunkGetPointer(chunk);
	}

	/*
	 * Choose the actual chunk size to allocate.
	 */
	chunk_size = GetChunkSizeFromFreeListIdx(fidx);
	Assert(chunk_size >= size);

	block = set->blocks;
	availspace = block->endptr - block->freeptr;

	/*
	 * If there is enough room in the active allocation block, we will put the
	 * chunk into that block.  Else must start a new one.
	 */
	if (unlikely(availspace < (chunk_size + ALLOC_CHUNKHDRSZ)))
		return AllocSetAllocFromNewBlock(context, size, flags, fidx);

	/* There's enough space on the current block, so allocate from that */
	return AllocSetAllocChunkFromBlock(context, block, size, chunk_size, fidx);
}

The order of operations is the whole design:

  1. Too big? Hand off to AllocSetAllocLarge(), which does a dedicated malloc().
  2. Free list non-empty? Pop it. This is the common case in a long-running loop, and it costs: one clz, one array load, one pointer swap. No malloc, no free, no copying.
  3. Room in the active block? Bump freeptr and write the header.
  4. Otherwise carve the tail (below) and start a new block.

Notice the Assert(fidx == MemoryChunkGetValue(chunk)); — the assertion that the stored index really does match the list the chunk came off. That is the invariant that makes pfree O(1) with respect to size classes.

Carving the tail of a dying block

Step 4 is where the interesting work happens. When the active block cannot satisfy the request, PostgreSQL does not throw away the remaining space in it. It carves the remainder into free chunks, one per size class, and puts them on the free lists.

From AllocSetAllocFromNewBlock():

c
	/*
	 * The existing active (top) block does not have enough room for the
	 * requested allocation, but it might still have a useful amount of space
	 * in it.  Once we push it down in the block list, we'll never try to
	 * allocate more space from it. So, before we do that, carve up its free
	 * space into chunks that we can put on the set's freelists.
	 *
	 * Because we can only get here when there's less than ALLOC_CHUNK_LIMIT
	 * left in the block, this loop cannot iterate more than
	 * ALLOCSET_NUM_FREELISTS-1 times.
	 */
	while (availspace >= ((1 << ALLOC_MINBITS) + ALLOC_CHUNKHDRSZ))
	{
		AllocFreeListLink *link;
		MemoryChunk *chunk;
		Size		availchunk = availspace - ALLOC_CHUNKHDRSZ;
		int			a_fidx = AllocSetFreeIndex(availchunk);

		/*
		 * In most cases, we'll get back the index of the next larger freelist
		 * than the one we need to put this chunk on.  The exception is when
		 * availchunk is exactly a power of 2.
		 */
		if (availchunk != GetChunkSizeFromFreeListIdx(a_fidx))
		{
			a_fidx--;
			Assert(a_fidx >= 0);
			availchunk = GetChunkSizeFromFreeListIdx(a_fidx);
		}

		chunk = (MemoryChunk *) (block->freeptr);

		/* Prepare to initialize the chunk header. */
		VALGRIND_MAKE_MEM_UNDEFINED(chunk, ALLOC_CHUNKHDRSZ);
		block->freeptr += (availchunk + ALLOC_CHUNKHDRSZ);
		availspace -= (availchunk + ALLOC_CHUNKHDRSZ);

		/* store the freelist index in the value field */
		MemoryChunkSetHdrMask(chunk, block, a_fidx, MCTX_ASET_ID);
#ifdef MEMORY_CONTEXT_CHECKING
		chunk->requested_size = InvalidAllocSize;	/* mark it free */
#endif
		/* push this chunk onto the free list */
		link = GetFreeListLink(chunk);

		VALGRIND_MAKE_MEM_DEFINED(link, sizeof(AllocFreeListLink));
		link->next = set->freelist[a_fidx];
		VALGRIND_MAKE_MEM_NOACCESS(link, sizeof(AllocFreeListLink));

		set->freelist[a_fidx] = chunk;
	}

Read that loop carefully. Suppose the block has 5000 bytes left. It takes the largest chunk that fits into 5000 bytes: 4096 bytes, on free list 9, leaving 904. Then 512 bytes (free list 6), leaving 392. Then 256 (free list 5), leaving 136. Then 64 (free list 3), leaving 72. Then 32 (free list 2), leaving 0. Loop ends.

So one dying block yields five reusable chunks in five different size classes, and nothing at all is wasted — not even the 8-byte chunk headers, which are counted against the available space as the loop runs. Compare that to the naive behaviour of throwing the tail away: those 5000 bytes would have been returned to malloc() (or worse, kept and never reused), and the next 4096-byte request would have had to malloc() again.

Then the block size doubles:

c
	/*
	 * The first such block has size initBlockSize, and we double the space in
	 * each succeeding block, but not more than maxBlockSize.
	 */
	blksize = set->nextBlockSize;
	set->nextBlockSize <<= 1;
	if (set->nextBlockSize > set->maxBlockSize)
		set->nextBlockSize = set->maxBlockSize;

So a context starts with an 8 kB block (ALLOCSET_DEFAULT_INITSIZE), then 16 kB, then 32 kB, up to maxBlockSize (8 MB by default). The rationale is in the header comment:

The first block allocated for an allocset has size initBlockSize. Each time we have to allocate another block, we double the block size (if possible, and without exceeding maxBlockSize), so as to reduce the bookkeeping load on malloc().

Note also the retry loop for huge blocks:

c
	/*
	 * We could be asking for pretty big blocks here, so cope if malloc fails.
	 * But give up if there's less than 1 MB or so available...
	 */
	while (block == NULL && blksize > 1024 * 1024)
	{
		blksize >>= 1;
		if (blksize < required_size)
			break;
		block = (AllocBlock) malloc(blksize);
	}

Asking for 64 MB and getting a failure is survivable; asking for 64 MB when the machine has 40 MB free is not, so PostgreSQL halves the request and tries again down to 1 MB.

Chunk limits are per-context, not global

The 8 kB ALLOC_CHUNK_LIMIT is the global maximum, but each context computes its own effective limit:

c
static inline uint32
AllocSetComputeChunkLimit(Size maxBlockSize)
{
	uint32		allocChunkLimit;

	/*
	 * Compute the allocation chunk size limit for this context.  It can't be
	 * more than ALLOC_CHUNK_LIMIT because of the fixed number of freelists.
	 * If maxBlockSize is small then requests exceeding the maxBlockSize, or
	 * even a significant fraction of it, should be treated as large chunks
	 * too.  For the typical case of maxBlockSize a power of 2, the chunk size
	 * limit will be at most 1/8th maxBlockSize, so that given a stream of
	 * requests that are all the maximum chunk size we will waste at most
	 * 1/8th of the allocated space.
	 ...
	 */
	allocChunkLimit = ALLOC_CHUNK_LIMIT;
	while ((Size) (allocChunkLimit + ALLOC_CHUNKHDRSZ) >
		   (Size) ((maxBlockSize - ALLOC_BLOCKHDRSZ) / ALLOC_CHUNK_FRACTION))
		allocChunkLimit >>= 1;

	return allocChunkLimit;
}

A context with maxBlockSize of 8 kB (the ALLOCSET_SMALL_SIZES preset) gets a chunk limit of 1 kB or so, so that a stream of maximum-size requests cannot waste more than a quarter of the block. Small contexts therefore do not get 8 kB chunks they can never fill.

repalloc: sometimes it is free

Growing a chunk usually means copying. But not always. AllocSetRealloc() first checks whether the existing chunk is already big enough:

c
	/*
	 * Chunk sizes are aligned to power of 2 in AllocSetAlloc().  Maybe the
	 * allocated area already is >= the new size.  (In particular, we will
	 * fall out here if the requested size is a decrease.)
	 */
	if (oldchksize >= size)
	{
		...
		/* Disallow access to the chunk header. */
		VALGRIND_MAKE_MEM_NOACCESS(chunk, ALLOC_CHUNKHDRSZ);

		return pointer;
	}

oldchksize is GetChunkSizeFromFreeListIdx(fidx) — the chunk size, not the requested size. So a 40-byte allocation lives in a 64-byte chunk; repallocing it to 60 bytes finds 64 >= 60 and returns the same pointer, with no copy at all. The extra 24 bytes were already there, reserved by the power-of-two quantisation.

This is a free side effect of the quantisation scheme. The waste is already paid; handing it back to the caller costs nothing.

Growing past the chunk size does require a copy, and the comment is candid about why the obvious optimisation was rejected:

c
		/*
		 * Enlarge-a-small-chunk case.  We just do this by brute force, ie,
		 * allocate a new chunk and copy the data.  Since we know the existing
		 * data isn't huge, this won't involve any great memcpy expense, so
		 * it's not worth being smarter.  (At one time we tried to avoid
		 * memcpy when it was possible to enlarge the chunk in-place, but that
		 * turns out to misbehave unpleasantly for repeated cycles of
		 * palloc/repalloc/pfree: the eventually freed chunks go into the
		 * wrong freelist for the next initial palloc request, and so we leak
		 * memory indefinitely.  See pgsql-hackers archives for 2007-08-11.)
		 */

“the eventually freed chunks go into the wrong freelist” — enlarging in place would change the chunk’s size class, and when the chunk is later freed it would go onto a different free list than the one it came off, which permanently removes a chunk from the list it was supposed to return to. A slow leak, per cycle, forever. Good lesson.

Internal fragmentation: the trade-off

Quantising to powers of two is not free. A 100-byte request occupies 128 bytes. Here is the full picture, ignoring the 8-byte chunk header:

Request sizeChunk sizeWasted bytesOverhead
187700%
916778%
17321588%
33643194%
651286397%
12925612798%
25751225599%
5131 kB511100%
10252 kB1023100%
20494 kB2047100%
40978 kB4095100%
8193dedicated block0header only

The “overhead” column is worst-case (a request one byte above a power of two). Averaged over a uniformly distributed range of request sizes, a request wastes roughly a third of its chunk — and, crucially, the waste is predictable, which matters more than the average, because predictable overhead can be budgeted for.

The counter-argument is in the header comment:

Note that all chunks in the freelists have power-of-2 sizes. This improves recyclability: we may waste some space, but the wasted space should stay pretty constant as requests are made and released.

A general-purpose allocator that returns exactly the requested size wastes nothing on paper, but then a 100-byte free chunk can only satisfy a 100-byte request, and the space in a 700-byte gap can satisfy nothing. Quantisation guarantees that free space is always usable, at the cost of always being a bit larger than needed.

The other side of the ledger: requests above 8 kB bypass the whole scheme and get their own malloc() block, so large allocations (index tuples, TOAST chunks, hash table buckets) pay no quantisation waste at all.

Recycling whole contexts

Now the second half of the story. Contexts themselves are allocated with malloc() and freed with free(). If a query creates a per-tuple context and destroys it per tuple, that is a malloc/free pair per tuple — exactly the thrashing the context system was supposed to avoid.

PostgreSQL’s answer is a pool of dead contexts. The design note is in aset.c:

c
/*
 * Rather than repeatedly creating and deleting memory contexts, we keep some
 * freed contexts in freelists so that we can hand them out again with little
 * work.  Before putting a context in a freelist, we reset it so that it has
 * only its initial malloc chunk and no others.  To be a candidate for a
 * freelist, a context must have the same minContextSize/initBlockSize as
 * other contexts in the list; but its maxBlockSize is irrelevant since that
 * doesn't affect the size of the initial chunk.
 *
 * We currently provide one freelist for ALLOCSET_DEFAULT_SIZES contexts
 * and one for ALLOCSET_SMALL_SIZES contexts; the latter works for
 * ALLOCSET_START_SMALL_SIZES too, since only the maxBlockSize differs.
 *
 * Ordinarily, we re-use freelist contexts in last-in-first-out order, in
 * hopes of improving locality of reference.  But if there get to be too
 * many contexts in the list, we'd prefer to drop the most-recently-created
 * contexts in hopes of keeping the process memory map compact.
 * We approximate that by simply deleting all existing entries when the list
 * overflows, on the assumption that queries that allocate a lot of contexts
 * will probably free them in more or less reverse order of allocation.
 *
 * Contexts in a freelist are chained via their nextchild pointers.
 */
#define MAX_FREE_CONTEXTS 100	/* arbitrary limit on freelist length */

“Contexts in a freelist are chained via their nextchild pointers.” That is a wonderful piece of field-hacking: a deleted context is no longer in the context tree, so its nextchild field (which links it to its siblings) is dead weight. It is reused as the free-list link.

The pool itself:

c
typedef struct AllocSetFreeList
{
	int			num_free;		/* current list length */
	AllocSetContext *first_free;	/* list header */
} AllocSetFreeList;

/* context_freelists[0] is for default params, [1] for small params */
static AllocSetFreeList context_freelists[2] =
{
	{
		0, NULL
	},
	{
		0, NULL
	}
};

Two pools: one for “ordinary” contexts and one for “small” contexts, matching the two size presets in memutils.h:

c
#define ALLOCSET_DEFAULT_MINSIZE   0
#define ALLOCSET_DEFAULT_INITSIZE  (8 * 1024)
#define ALLOCSET_DEFAULT_MAXSIZE   (8 * 1024 * 1024)
#define ALLOCSET_DEFAULT_SIZES \
	ALLOCSET_DEFAULT_MINSIZE, ALLOCSET_DEFAULT_INITSIZE, ALLOCSET_DEFAULT_MAXSIZE

#define ALLOCSET_SMALL_MINSIZE	  0
#define ALLOCSET_SMALL_INITSIZE  (1 * 1024)
#define ALLOCSET_SMALL_MAXSIZE  (8 * 1024)
#define ALLOCSET_SMALL_SIZES \
	ALLOCSET_SMALL_MINSIZE, ALLOCSET_SMALL_INITSIZE, ALLOCSET_SMALL_MAXSIZE

Deleting a context puts it on the pool

AllocSetDelete() is where the recycling happens:

c
	/*
	 * If the context is a candidate for a freelist, put it into that freelist
	 * instead of destroying it.
	 */
	if (set->freeListIndex >= 0)
	{
		AllocSetFreeList *freelist = &context_freelists[set->freeListIndex];

		/*
		 * Reset the context, if it needs it, so that we aren't hanging on to
		 * more than the initial malloc chunk.
		 */
		if (!context->isReset)
			MemoryContextResetOnly(context);

		/*
		 * If the freelist is full, just discard what's already in it.  See
		 * comments with context_freelists[].
		 */
		if (freelist->num_free >= MAX_FREE_CONTEXTS)
		{
			while (freelist->first_free != NULL)
			{
				AllocSetContext *oldset = freelist->first_free;

				freelist->first_free = (AllocSetContext *) oldset->header.nextchild;
				freelist->num_free--;

				/* Destroy the context's vpool --- see notes below */
				VALGRIND_DESTROY_MEMPOOL(oldset);

				/* All that remains is to free the header/initial block */
				free(oldset);
			}
			Assert(freelist->num_free == 0);
		}

		/* Now add the just-deleted context to the freelist. */
		set->header.nextchild = (MemoryContext) freelist->first_free;
		freelist->first_free = set;
		freelist->num_free++;

		return;
	}

Push at the head → LIFO. The comment explains why: “in hopes of improving locality of reference”. The most recently freed context was also the most recently allocated, so it is the one whose memory is hottest in the cache.

The pool is capped at 100 contexts. When it overflows, the entire pool is thrown away rather than just the oldest entry — because, as the comment says, “queries that allocate a lot of contexts will probably free them in more or less reverse order of allocation”, so dropping everything keeps the process memory map compact.

Creating a context takes one from the pool

The other half is AllocSetContextCreateInternal():

c
	/*
	 * Check whether the parameters match either available freelist.  We do
	 * not need to demand a match of maxBlockSize.
	 */
	if (minContextSize == ALLOCSET_DEFAULT_MINSIZE &&
		initBlockSize == ALLOCSET_DEFAULT_INITSIZE)
		freeListIndex = 0;
	else if (minContextSize == ALLOCSET_SMALL_MINSIZE &&
			 initBlockSize == ALLOCSET_SMALL_INITSIZE)
		freeListIndex = 1;
	else
		freeListIndex = -1;

	/*
	 * If a suitable freelist entry exists, just recycle that context.
	 */
	if (freeListIndex >= 0)
	{
		AllocSetFreeList *freelist = &context_freelists[freeListIndex];

		if (freelist->first_free != NULL)
		{
			/* Remove entry from freelist */
			set = freelist->first_free;
			freelist->first_free = (AllocSet) set->header.nextchild;
			freelist->num_free--;

			/* nextBlockSize should have been reset in AllocSetReset() */
			Assert(set->nextBlockSize == set->initBlockSize);

			if (set->maxBlockSize != maxBlockSize)
			{
				/*
				 * Update its maxBlockSize and allocChunkLimit; everything
				 * else should be OK.
				 */
				set->maxBlockSize = maxBlockSize;
				set->allocChunkLimit = AllocSetComputeChunkLimit(maxBlockSize);
			}

			/* Reinitialize its header, installing correct name and parent */
			MemoryContextCreate((MemoryContext) set,
								T_AllocSetContext,
								MCTX_ASET_ID,
								parent,
								name);

			((MemoryContext) set)->mem_allocated =
				KeeperBlock(set)->endptr - ((char *) set);

			return (MemoryContext) set;
		}
	}

No malloc. The context object is reused verbatim, only its header is re-initialised and its maxBlockSize adjusted (the block size limit is the only parameter that does not affect the initial chunk’s size, so a context can be recycled across different maxBlockSize values).

The result: a per-tuple context costs zero system allocations once the pool is warm. Create, use, destroy, repeat, and the whole cycle is pointer arithmetic.

Reset versus delete

Two operations, and the difference matters:

  • MemoryContextReset() — free everything allocated in the context (and its children), but keep the context object itself. The context remains usable.
  • MemoryContextDelete() — free the context object too. The name is gone.

For AllocSet, the two differ in one crucial detail: the keeper block.

c
/*
 * AllocSetReset
 *		Frees all memory which is allocated in the given set.
 *
 * Actually, this routine has some discretion about what to do.
 * It should mark all allocated chunks freed, but it need not necessarily
 * give back all the resources the set owns.  Our actual implementation is
 * that we give back all but the "keeper" block (which we must keep, since
 * it shares a malloc chunk with the context header).  In this way, we don't
 * thrash malloc() when a context is repeatedly reset after small allocations,
 * which is typical behavior for per-tuple contexts.
 */

The “keeper block” is the region of memory immediately following the AllocSetContext struct, in the same malloc() chunk:

c
/* Obtain the keeper block for an allocation set */
#define KeeperBlock(set) \
	((AllocBlock) (((char *) set) + MAXALIGN(sizeof(AllocSetContext))))

/* Check if the block is the keeper block of the given allocation set */
#define IsKeeperBlock(set, block) ((block) == (KeeperBlock(set)))

So the very first malloc() a context makes contains both the context header and its first block. AllocSetReset() keeps that block, wipes its contents, and free()s everything else:

c
	block = set->blocks;

	/* New blocks list will be just the keeper block */
	set->blocks = KeeperBlock(set);

	while (block != NULL)
	{
		AllocBlock	next = block->next;

		if (IsKeeperBlock(set, block))
		{
			/* Reset the block, but don't return it to malloc */
			char	   *datastart = ((char *) block) + ALLOC_BLOCKHDRSZ;
			...
			block->freeptr = datastart;
			block->prev = NULL;
			block->next = NULL;
		}
		else
		{
			/* Normal case, release the block */
			context->mem_allocated -= block->endptr - ((char *) block);
			...
			free(block);
		}
		block = next;
	}

So a per-tuple context that allocates 200 bytes and is reset per tuple does exactly one malloc() (at creation) and zero free()s (until it is deleted). Compare with malloc/free per tuple, which would be two system calls per tuple.

AllocSetDelete() is the version that gives everything back, and it is also the version that puts the context on the recycling pool.

The other allocators

aset.c is the general-purpose one, but PostgreSQL ships four context types, because different allocation patterns want different trade-offs. The method table in mcxt.c lists them all:

c
static const MemoryContextMethods mcxt_methods[] = {
	/* aset.c */
	[MCTX_ASET_ID].alloc = AllocSetAlloc,
	...
	/* generation.c */
	[MCTX_GENERATION_ID].alloc = GenerationAlloc,
	...
	/* slab.c */
	[MCTX_SLAB_ID].alloc = SlabAlloc,
	...
	/* alignedalloc.c */
	[MCTX_ALIGNED_REDIRECT_ID].alloc = NULL,	/* not required */
	...
	/* bump.c */
	[MCTX_BUMP_ID].alloc = BumpAlloc,
	...

Bump: no chunk header at all

The bump.c header comment states the design goal:

Bump is a MemoryContext implementation designed for memory usages which require allocating a large number of chunks, none of which ever need to be pfree’d or realloc’d. Chunks allocated by this context have no chunk header and operations which ordinarily require looking at the chunk header cannot be performed. For example, pfree, realloc, GetMemoryChunkSpace and GetMemoryChunkContext are all not possible with bump allocated chunks. The only way to release memory allocated by this context type is to reset or delete the context.

And the size of the header is literally zero:

c
/* No chunk header unless built with MEMORY_CONTEXT_CHECKING */
#ifdef MEMORY_CONTEXT_CHECKING
#define Bump_CHUNKHDRSZ	sizeof(MemoryChunk)
#else
#define Bump_CHUNKHDRSZ	0
#endif

No 8-byte header per allocation. The allocation is just a bump of a pointer:

c
	/* point the freeptr beyond this chunk */
	block->freeptr += (Bump_CHUNKHDRSZ + chunk_size);
	Assert(block->freeptr <= block->endptr);

The cost is that the operations that need the header are refused outright:

c
/*
 * BumpFree
 *		Unsupported.
 */
void
BumpFree(void *pointer)
{
	elog(ERROR, "%s is not supported by the bump memory allocator", "pfree");
}

Same for BumpRealloc, BumpGetChunkContext and BumpGetChunkSpace. The MEMORY_CONTEXT_CHECKING build adds the header back precisely so that these mistakes are caught rather than silently corrupting memory.

Where is Bump used? Wherever there are “a large number of short-lived chunks where performance matters” — hash aggregation, tuplesort, set operations, nodeSubplan, the TID store. The header comment explains the shape of the win:

Because bump allocated chunks don’t have a chunk header, it can fit more chunks on each block. This means we can do more with less memory and fewer cache lines.

Slab: one size, and the free list lives in the chunk

slab.c is for the opposite extreme: many objects of exactly the same size. The chunk size is fixed at context creation time:

c
MemoryContext
SlabContextCreate(MemoryContext parent,
				  const char *name,
				  Size blockSize,
				  Size chunkSize)

and enforced at allocation time:

c
	/*
	 * Make sure we only allow correct request size.  This doubles as the
	 * MemoryContextCheckSize check.
	 */
	if (unlikely(size != slab->chunkSize))
		SlabAllocInvalidSize(context, size);

The free list is stored inside the free chunk, exactly as in aset.c — but with an important difference. In aset.c the chunk’s user data holds the link; in slab.c the link is written over the start of the chunk’s data area, and the chunk size is forced to be at least a pointer:

c
	/*
	 * Ensure there's space to store the pointer to the next free chunk
	 * in the memory of the (otherwise) unused allocation.
	 */
	if (chunkSize < sizeof(MemoryChunk *))
		chunkSize = sizeof(MemoryChunk *);
c
	/* push this chunk onto the head of the block's free list */
	*(MemoryChunk **) pointer = block->freehead;
	block->freehead = chunk;

The interesting policy in slab.c is “fullest block first”. The header comment explains the motivation:

Slab can also help reduce memory fragmentation in cases where longer-lived chunks remain stored on blocks while most of the other chunks have already been pfree’d. We give priority to putting new allocations into the “fullest” block. This help avoid having too many sparsely used blocks around and allows blocks to more easily become completely unused which allows them to be eventually free’d.

The implementation is a small array of lists, partitioned by how many free chunks a block has:

c
/*
 * The number of partitions to divide the blocklist into based their number
 * of free chunks.  There must be at least 2.
 */
#define SLAB_BLOCKLIST_COUNT 3

with a deliberately clever index computation:

c
static inline int32
SlabBlocklistIndex(SlabContext *slab, int nfree)
{
	int32		index;
	int32		blocklist_shift = slab->blocklist_shift;

	Assert(nfree >= 0 && nfree <= slab->chunksPerBlock);

	/*
	 * Determine the blocklist index based on the number of free chunks.  We
	 * must ensure that 0 free chunks is dedicated to index 0.  Everything
	 * else must be >= 1 and < SLAB_BLOCKLIST_COUNT.
	 *
	 * To make this as efficient as possible, we exploit some two's complement
	 * arithmetic where we reverse the sign before bit shifting.  This results
	 * in an nfree of 0 using index 0 and anything non-zero staying non-zero.
	 * This is exploiting 0 and -0 being the same in two's complement.  When
	 * we're done, we just need to flip the sign back over again for a
	 * positive index.
	 */
	index = -((-nfree) >> blocklist_shift);

	if (nfree == 0)
		Assert(index == 0);
	else
		Assert(index >= 1 && index < SLAB_BLOCKLIST_COUNT);

	return index;
}

-((-nfree) >> shift) — for nfree == 0 this is -0 == 0, and for anything else it is a positive number. Two’s complement does the work.

The high-water-mark trick

slab.c has one more elegant trick. When a fresh block is allocated, all of its chunks are free — but writing out a free list for every chunk would mean touching the entire block at creation time. Instead, slab.c keeps a pointer to the next never-used chunk and a count of how many there are:

c
typedef struct SlabBlock
{
	SlabContext *slab;			/* owning context */
	int32		nfree;			/* number of chunks on free + unused chunks */
	int32		nunused;		/* number of unused chunks */
	MemoryChunk *freehead;		/* pointer to the first free chunk */
	MemoryChunk *unused;		/* pointer to the next unused chunk */
	dlist_node	node;			/* doubly-linked list for blocklist[] */
} SlabBlock;

The header comment explains it:

When we allocate a new block, technically all chunks are free, however, to avoid having to write out the entire block to set the linked list for the free chunks for every chunk in the block, we instead store a pointer to the next “unused” chunk on the block and keep track of how many of these unused chunks there are. When a new block is malloc’d, all chunks are unused. The unused pointer starts with the first chunk on the block and as chunks are allocated, the unused pointer is incremented. As chunks are pfree’d, the unused pointer never goes backwards. The unused pointer can be thought of as a high watermark for the maximum number of chunks in the block which have been in use concurrently. When a chunk is pfree’d the chunk is put onto the head of the free list and the unused pointer is not changed. We only consume more unused chunks if we run out of free chunks on the free list. This method effectively gives priority to using previously used chunks over previously unused chunks, which should perform better due to CPU caching effects.

A high-water mark for concurrency. Freed chunks are reused before virgin space is touched, so the hot parts of the block stay hot in cache.

Generation: for FIFO lifetimes

The fourth allocator, generation.c, is for the case where chunks are freed roughly in the order they were allocated:

This memory context is based on the assumption that the chunks are freed roughly in the same order as they were allocated (FIFO), or in groups with similar lifespan (generations - hence the name of the context).

Instead of a global free list, each block tracks allocated and freed chunks, and an empty block is recycled for future allocations before it is handed back to malloc().

The temporary context

There is no src/backend/utils/mmgr/temp.c in this tree — the four allocator types are aset.c, slab.c, generation.c and bump.c, and the file listing in src/backend/utils/mmgr/ confirms it. The “temporary context” of PostgreSQL’s design is instead a policy, described in the README:

When creating a prepared statement, the parse and plan trees will be built in a temporary context that’s a child of MessageContext (so that it will go away automatically upon error). On success, the finished plan is copied to the prepared statement’s private context, and the temp context is released; this allows planner temporary space to be recovered before execution begins.

The word “temporary” here describes the lifetime, not the allocator type. It is an ordinary AllocSetContext created with AllocSetContextCreate() and destroyed with MemoryContextDelete() once the plan has been copied out. What makes it “temporary” is that it is a child of MessageContext and is destroyed — not reset — once the planner is finished with it.

The executor’s per-tuple contexts work the same way, and they are where the context recycling machinery pays for itself most visibly.

Caveats

Memory is not returned to the OS promptly. pfree() on a chunk smaller than 8 kB does not call free(); it pushes the chunk onto a free list and moves on. The memory stays in the process. Only a context reset or delete, or the release of a dedicated large block, actually hands memory back — and even then, whether the OS reclaims it is malloc’s business, not PostgreSQL’s. A backend that allocated 1 GB of short-lived chunks will keep that 1 GB resident until the contexts holding it are reset.

Fragmentation is real, but bounded. Within a context, the free lists keep chunks segregated by size class, so a freed 100-byte chunk cannot satisfy a 1000-byte request. That is deliberate (recyclability over density), but it means a context whose allocation size distribution changes over time will accumulate idle chunks in the wrong classes.

Reset is not delete. MemoryContextReset() keeps the context object and its keeper block; MemoryContextDelete() frees everything and (for an AllocSet with poolable parameters) puts the context object itself on the recycling pool. Choosing reset where you meant delete leaves the context alive; choosing delete where you meant reset churns the pool.

pfree on a bump chunk is an error, not a no-op. The bump allocator has no chunk header, so it cannot find the freelist to return the chunk to. BumpFree() raises elog(ERROR, "pfree is not supported by the bump memory allocator"). If you are using a bump context, you must let the whole context go.

repalloc on a slab chunk is only allowed at the same size. A slab context allocates one fixed size, and SlabRealloc() errors out for any other size:

c
	/* can't do actual realloc with slab, but let's try to be gentle */
	if (size == slab->chunkSize)
		return pointer;

	elog(ERROR, "slab allocator does not support realloc()");

The 8-byte header is real overhead. For an AllocSet, every allocation pays 8 bytes of header plus up to 2× quantisation waste. That is why Slab, Bump and Generation exist, and why MemoryContextAllocAligned() has to allocate extra bytes to build a “redirection” chunk for the alignment.

What it all means in practice

palloc is a size-class allocator, not a heap. If you are porting C code that assumes malloc semantics — variable sizes, exact accounting, memory returned promptly — some of that will not hold.

The 8 kB threshold is a cliff, not a slope. Requests of 8192 bytes or less are quantised chunks in a shared block; requests of 8193 bytes or more get their own malloc(). That is why ALLOCSET_SEPARATE_THRESHOLD exists in memutils.h — “tuplesort/tuplestore need to know it” — so they can account for the difference.

repalloc is often free, and that is not a coincidence. The power-of-two quantisation already reserved the space. If you are writing code that grows a buffer, repalloc within the same size class costs nothing.

Free-list churn is the price of the free list. The pfree path reads the index out of the chunk header and pushes the chunk onto a list; it does not coalesce with neighbours and does not return memory. The payoff is that the next palloc of the same size class is a single array load and a pointer swap.

Contexts are the unit of memory lifetime, and recycling them is what makes per-tuple contexts viable. The difference between “allocate a per-tuple context with malloc/free” and “reuse one from the pool” is two system calls per tuple versus none.

Do not allocate in the wrong context. The CurrentMemoryContext pointer is the single biggest source of memory leaks in PostgreSQL extensions. The README is blunt about it: “Only in very circumscribed code should it ever point at a context having greater than transaction lifespan, since doing so risks permanent memory leaks.”

Further reading

  • src/backend/utils/mmgr/README — the design overview: contexts, lifetimes, the tree, and why they exist.
  • src/backend/utils/mmgr/mcxt.c — the context-type-independent layer: MemoryContextAlloc(), palloc(), the method table, and the OOM handling.
  • src/backend/utils/mmgr/aset.c — the default allocator: size classes, AllocSetFreeIndex(), tail carving, AllocSetRealloc(), and context recycling.
  • src/backend/utils/mmgr/bump.c — the header-less bump allocator.
  • src/backend/utils/mmgr/slab.c — the fixed-size allocator: fullest block first, and the high-water mark.
  • src/backend/utils/mmgr/generation.c — the FIFO/generation allocator.
  • src/backend/utils/mmgr/alignedalloc.c — aligned allocation via a redirection chunk.
  • src/include/utils/memutils.h — the public API and the ALLOCSET_* size presets.
  • src/include/utils/memutils_memorychunk.h — the 8-byte chunk header and its bit layout.
  • src/include/nodes/memnodes.h — MemoryContextData and the MemoryContextMethods vtable.
  • src/include/port/pg_bitutils.h — pg_leftmost_one_pos32(), the count-leading-zeros primitive behind the size-class lookup.
  • src/include/utils/palloc.h — the palloc/pfree/repalloc API and its documented differences from the C library.
  • The pg_backend_memory_contexts system view for runtime inspection of every live context in the backend.

PostgresPerformanceMemoryInternals