Every PostgreSQL backend is a separate process. Every process keeps its own private page table to describe the same shared_buffers. Six hundred backends means six hundred copies of that description. Nothing is shared, each copy is built from zero, and all of them use real RAM. This page shows where that memory goes, and what a 2 MB huge page changes.
Your program never uses a physical memory address. It uses virtual addresses, and something has to translate them. That something is the page table. It lives in RAM and holds one entry for every 4 KB page the process has touched. Each entry is small — only 8 bytes. But a 32 GB shared_buffers has 8,388,608 pages, so the numbers grow fast.
The default page size on x86-64. Linux maps and faults memory in units of 4 KB. Nothing smaller, and by default nothing larger.
Each page needs one entry. It says where the page really is and what you may do with it. 8.4M entries * 8 B = 64 MB to describe 32 GB.
This is the part that hurts. The database does not pay 64 MB once. Every backend pays it separately.
Two numbers show all of this on any running server, and you do not need root to read them. PageTables in /proc/meminfo is the total for the whole system. VmPTE in /proc/<pid>/status is the part that belongs to one process.
$ awk '/VmPTE/ {t += $2; n++} END {if (n) printf "processes: %d\ntotal: %.1f MB\nper proc: %.1f MB\n", n, t/1024, t/n/1024}' \ $(pgrep -x postgres | sed 's|.*|/proc/&/status|') processes: 602 total: 18662.0 MB per proc: 31.0 MB
shared_buffers that after a few days, sometimes hours, the backend gets close to the ceiling anyway. The server above is real: 602 processes at 31 MB each, about half the ceiling, and still 18.2 GB of RAM used to describe memory the server already owns.The CPU keeps recent translations in the TLB, a small and very fast hardware table. A hit costs almost nothing. On a miss the CPU has to walk the page table — a tree in memory. That means four trips to RAM, strictly one after another, before your actual memory access can even start. Step through both cases.
shared_buffers goes past 8 MB all the time. It goes past 4 GB much less often.A backend is a process, not a thread. They all map the same physical shared_buffers, but each one keeps its own private page table to describe it. Watch what happens when a second backend touches the same page as the first one.
When a backend touches a buffer for the first time, it takes a page fault, even though the data is already in RAM. Nothing is read from disk. The kernel just fills in the missing entry in that process's page table and lets the instruction run again. One fault is cheap. The total is not: about 5 billion faults to fully warm 600 backends against a 32 GB shared_buffers.
In normal work this is not painful: once a backend is warm, it stops faulting. The dangerous moment is when shared_buffers is empty and still filling up, because then every access is a first touch. In 2026 a scheduler change in Linux 7.0 turned that moment into a real outage on servers with a large pool and huge pages off. PostgreSQL became about two times slower. Read the chain below slowly, because no single step in it looks like a problem.
A large shared_buffers with nothing in it yet. Every buffer the backend asks for is a first touch, so the fault rate is as high as it will ever be.
The backend holds a buffer-allocation spinlock when the minor fault happens. Normally this does not matter. The fault takes microseconds, then the lock is free again.
After the scheduler change, the kernel can stop the backend inside the fault, while it still holds the spinlock. Now a process that is not running holds the lock.
A spinlock does not sleep. Every other backend that needs a buffer burns CPU while it waits for a process that is not running. Throughput drops by half.
huge_pages = on did not fix the lock or the scheduler. There were simply 512x fewer first-touch faults, so there were 512x fewer chances to be unlucky inside one. This is the shape of the whole optimisation. It does not make one operation faster. It removes a very large number of small events, and it takes away the risk that lives inside them. The full write-up of the incident →This is simple maths: shared_buffers / page_size * 8 bytes * backends. Move the sliders to your production settings to see the ceiling for both page sizes. Then run the VmPTE command from section 01 and compare the left number with your real server.
VmPTE command there gives you your own number.A huge page is not a different kind of memory. It is the same RAM, described in bigger units. An entry still costs the same 8 bytes — it now describes 2 MB instead of 4 KB. That is the whole trick.
| For 32 GB of shared_buffers | 4 KB pages | 2 MB huge pages |
|---|---|---|
| Entries needed | 8,388,608 | 16,384 |
| Page table, per backend | 64 MB | 128 KB |
| Faults to fully warm one backend | 8,388,608 | 16,384 |
| TLB reach (2048 entries) | 8 MB | 4 GB |
| Ceiling at 300 backends | 18.75 GB | 37.5 MB |
| Ceiling at 600 backends | 37.5 GB | 75 MB |
| The claim | Why it is true |
|---|---|
| Page tables cost real RAM | 8 bytes per 4 KB page. A fully warmed 32 GB shared_buffers needs 64 MB of entries to describe it. |
| The cost multiplies by backends | Each process keeps its own table, so the total grows with your connection count. For one 32 GB pool, 600 backends give a ceiling of 37.5 GB. |
| Warm-up is not shared either | Backend B faults on a page that backend A has already touched, because B's own table is empty there. These faults are minor. There is no I/O, so nothing in your metrics points at them. |
| Huge pages remove 511 of every 512 entries | One entry covers 2 MB instead of 4 KB. The same memory, 512x fewer descriptions of it, and 512x fewer first-touch faults to build them. |
| Shared memory only | work_mem, maintenance_work_mem, catalog and plan caches all stay on 4 KB pages. Huge pages work on the shared memory segment and nothing else. |
| It only matters at scale | With a small shared_buffers and few connections, none of this is your problem. The measurement tells you which case you are in. |
grep '^PageTables' /proc/meminfo takes one second and answers the only question that matters. Is this costing you a few hundred megabytes, or tens of gigabytes? If it is a few hundred MB, spend your time on something else. If it is several GB, the migration is worth the work. Everything above only explains that one number. The steps to roll it out are in the post.