CPU, Memory, and I/O7 min read

PostgreSQL Huge Pages: TLB Pressure and Large shared_buffers

The bigger the shared_buffers and the more connections mapping them, the more 4KB pages cost you in TLB misses and page-table memory. Huge pages are cheap to set up if you size them honestly.

The server had 256GB of memory and shared_buffers set to 64GB, which is a thoroughly reasonable configuration, and yet something about its CPU profile bothered me. Under a buffer-heavy workload the kernel time was higher than I expected, and a look at /proc/meminfo showed PageTables consuming gigabytes on its own. The database was spending real resources just maintaining the address maps for its own buffer cache. Switching PostgreSQL to huge pages recovered a few percent of CPU and shrank the page tables from gigabytes to nearly nothing. Then, months later, the same setting refused to let the server boot after a maintenance window, because someone had raised shared_buffers without telling the kernel to reserve more huge pages. Both halves of that story are worth knowing before you touch this.

Huge pages are one of those optimizations that is genuinely simple, genuinely effective past a certain scale, and genuinely easy to misconfigure in a way that bites during a reboot at the worst possible moment. This is the operational version: why it works, how to size it, and how to verify it actually engaged.

Why 4KB pages get expensive at scale

Linux manages memory in pages, and the default page size on x86-64 is 4KB. Every page a process touches needs a translation from virtual to physical address, and those translations live in page tables. The CPU caches recent translations in the TLB, the translation lookaside buffer, because walking page tables in memory is slow. A modern core has on the order of a thousand or a few thousand TLB entries for data. With 4KB pages, that cache covers a few megabytes of address space. Every access outside that window triggers a page walk.

Now consider what a large shared_buffers means in those terms. Sixty-four gigabytes of buffer cache is roughly sixteen million 4KB pages. Every backend process maps that entire shared segment into its own address space and maintains its own page tables for it, which is where those gigabytes of PageTables in meminfo come from: multiply the mapping by a few hundred connections. Worse, a workload with good cache hit ratios is by definition touching random buffers constantly, and random access across sixteen million pages is the exact access pattern that thrashes a TLB sized for megabytes. The data is all in memory, there is no I/O anywhere, and the CPU still burns cycles on address translation.

Huge pages change the unit. A 2MB huge page covers 512 times the address space of a 4KB page, so the same TLB now reaches gigabytes instead of megabytes, the page tables shrink by the same factor of 512, and page walks get rare. That is the entire mechanism. There is no magic, just much less bookkeeping per byte of buffer cache.

The PostgreSQL knobs: huge_pages and huge_page_size

PostgreSQL's side of the configuration is one setting with three values. huge_pages = try is the default: the server asks the kernel for huge pages for its shared memory, and if the kernel cannot provide them, it falls back to normal pages and starts anyway. huge_pages = on makes huge pages mandatory: if the kernel has not reserved enough, the server refuses to start. huge_pages = off disables the request entirely. You can inspect the current state, plus the page size the server will request, with:

SHOW shared_buffers;
SHOW huge_pages;
SHOW huge_page_size;

huge_page_size defaults to 0, which means the operating system's default huge page size, 2MB on typical x86-64 Linux, and that is what you want almost everywhere.

My strong opinion from the reboot incident: use on, not try, in production. The try behavior sounds friendly and is actually a trap, because a server that silently falls back to 4KB pages looks perfectly healthy until you notice months later that the sysctl you set never took effect and you have been running without huge pages the whole time. The on behavior fails loudly at startup, during a controlled restart, with an error message that tells you exactly how much shared memory it tried to allocate. Loud at boot beats silent for a year.

Sizing vm.nr_hugepages honestly

The kernel side is one sysctl: vm.nr_hugepages, the number of 2MB pages the kernel reserves for explicit huge page allocations. Reserved huge pages are carved out of RAM, are not swappable, and cannot be used for anything else, so this is a real commitment of memory and it should be sized rather than guessed. PostgreSQL 15 made this trivial: the postgres binary can compute its own shared memory requirement in huge pages directly from the configuration, without starting the server:

postgres -C shared_memory_size_in_huge_pages -D /var/lib/postgresql/16/main
24676

grep -i huge /proc/meminfo
HugePages_Total:       0
HugePages_Free:        0
HugePages_Rsvd:        0
Hugepagesize:       2048 kB

sysctl -w vm.nr_hugepages=25000

Run the -C command as the postgres OS user so it reads the real configuration. The number it prints is the minimum; add a small margin, one or two percent, because other shared consumers occasionally want a few pages, and set the result. On versions older than 15 there is no shared_memory_size_in_huge_pages, so the practical alternative is to start the server once with huge_pages = on and no pages reserved: the startup error reports the requested allocation size, and you divide by 2MB and round up.

Two operational notes that save pain later. First, persist the setting in /etc/sysctl.d/, not just with sysctl -w, or the next reboot silently drops the reservation and your huge_pages = on server does not come back. That is exactly how my maintenance-window incident happened. Second, remember the reservation is static. If you later grow shared_buffers, you must grow vm.nr_hugepages first, and if you shrink shared_buffers you can hand pages back. Treat the two values as one coupled configuration and change them together.

Verifying with /proc, not with hope

Do not assume huge pages engaged because the server started. Check the counters before and after startup:

grep -i huge /proc/meminfo
HugePages_Total:   25000
HugePages_Free:      324
HugePages_Rsvd:        0
HugePages_Surp:        0
Hugepagesize:       2048 kB

After PostgreSQL starts, HugePages_Free should drop by roughly the number shared_memory_size_in_huge_pages reported. If Free still equals Total, the server is not using them regardless of what the configuration says, and with huge_pages = try that is exactly the silent-fallback case to catch. While you are in meminfo, look at PageTables again after a few days of uptime: on a large-buffer, high-connection server the reduction there is often the most visible win of all. For per-process confirmation, the smaps file of any backend shows the shared segment mapped with a 2048kB kernel page size, but the meminfo counters are the quick check I run every time.

Transparent huge pages are a different animal

A necessary warning, because the naming collides. Linux also has transparent huge pages, THP, where the kernel tries to automatically promote ordinary 4KB allocations into 2MB pages in the background. THP is a frequent source of database latency spikes: the kernel's compaction and defragmentation work to assemble contiguous 2MB regions stalls processes at unpredictable moments, and PostgreSQL's access patterns make it worse. The PostgreSQL huge_pages setting discussed here uses explicit huge pages reserved via vm.nr_hugepages and does not need THP at all. The standard operational practice on database servers is to disable THP or set it to madvise so only applications that explicitly request it are affected:

cat /sys/kernel/mm/transparent_hugepage/enabled
always madvise [never]

echo never > /sys/kernel/mm/transparent_hugepage/enabled

The bracketed value is the active one. Make the change persistent through your init system or tuned profile, because the sysfs file resets on reboot.

Honest expectations, and where MonPG fits

Where is the cutoff? There is no honest universal one, because the costs scale with the mapping and with how many processes carry it, not with shared_buffers alone. A 2GB buffer cache is half a million pages and a few megabytes of page tables per backend; with a modest connection count the TLB pressure is small and you may never measure it. Grow the buffer cache into the tens of gigabytes, or multiply a mid-sized one by several hundred connections, and the overhead shows up in CPU profiles while the page-table memory becomes worth reclaiming. The trustworthy check is the one from my story rather than anyone's gigabyte rule of thumb: look at PageTables in /proc/meminfo under your real connection count and decide from that number. As for the payoff, expect a few percent of CPU on a buffer-cache-bound workload, not a transformation. It is hygiene, like alignment or sensible swappiness: cheap, one-time, and quietly compounding.

The change itself lives at the OS layer, so the way you confirm it mattered is from the database side: query latency distributions and buffer-cache behavior before and after. I run that comparison in MonPG, which records query latency history and table- and index-level statistics for production PostgreSQL, and that record is what turns a change like this into something verifiable instead of anecdotal. Our PostgreSQL sizing guide covers how to think about shared_buffers itself, and the post on PostgreSQL memory tuning is the natural next read for the settings that sit alongside this one.