PostgreSQL · virtual memory · TLB · huge pages

Your database owns 32 GB.
It is describing it 600 times over.

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 page changes.

01 — THE COST

A page table is memory spent describing memory

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.

4 KB at a time

The default page size on x86-64. Linux maps and faults memory in units of 4 KB. Nothing smaller, and by default nothing larger.

8 bytes per entry

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.

Once per process

This is the part that hurts. The database does not pay 64 MB once. Every backend pays it separately.

You can check this on your own server

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.

every postgres process on one production box
$ 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
Ceiling and reality are not the same thing. 64 MB per backend is the ceiling. That is what a backend costs after it has read all 32 GB. A page table only creates entries for pages the process has really touched, so a real backend stays below that number. Two things decide how far below: how long the backend has been running and how much of the pool its queries reach. Time matters more. A backend that only reads a few small tables does stay cheap, but that is rare. Most workloads touch enough of shared_buffers that after a few days 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. Every number on this page is a ceiling. Your own number will be lower, and that is the number that decides if this work is worth it.
02 — THE LOOKUP

Every memory access starts with a translation

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 a tree in memory. Each read depends on the one before it, and only then can your real memory access start. Step through both cases.

Step 1 of 5
TLB — hardware cache of translations PAGE WALK — one read after another PHYSICAL ADDRESS
TLB reach is the number that matters. A core holds about 1500–2000 second-level TLB entries. With 4 KB pages they cover 8 MB of memory at one time. With 2 MB pages the same entries cover 4 GB. A backend that scans a 32 GB shared_buffers goes past 8 MB all the time. It goes past 4 GB much less often.
03 — THE ROOT CAUSE

PostgreSQL forks. Page tables do not merge.

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 a page that the first one has already faulted in.

Step 1 of 5
BACKEND — one OS process PAGE TABLE — private, up to 64 MB SHARED_BUFFERS — one physical copy MINOR FAULT
A thread-based engine does not have this problem. One address space means one page table for all workers. The PostgreSQL process model turns a fixed cost into a cost that you multiply by your connection count. That is also why a connection pooler helps, even if you never turn on huge pages.
04 — FIRST TOUCH

The fault that has nothing to do with disk

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 table and lets the instruction run again. One fault is cheap. The total is not: about 84 million faults to fully warm ten backends against a 32 GB shared_buffers.

Step 1 of 5
Minor, not major. A major fault reads from disk. A minor fault only fixes the page table, because the physical page is already there. That is why this cost is so easy to miss. There is no I/O wait to look at and no slow query to blame. There is only CPU time that goes into the kernel, a few microseconds at a time.

When first touch became a real problem

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.

01

Empty pool

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.

02

Fault under a spinlock

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.

03

Preempted mid-fault

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.

04

Everyone spins

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 removed the fuel, and the problem went away. huge_pages = on did not fix the lock or the scheduler. There were simply 512× fewer first-touch faults, so there were 512× 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 →
05 — THE EXPLORER

Put in your own numbers

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.

32 GB
300
4 KB pages — page-table ceiling
2 MB huge pages — page-table ceiling
Real systems stay below this ceiling. The explorer assumes that every backend has read the whole pool. In practice a backend only creates entries for the pages it has really touched, so its page table grows with everything it has read since it started. The main factor is time. A backend that does not live long stays small. One that has been open for days gets close to the ceiling. The only real exception is a backend that always queries the same few small tables — that one stays cheap however long it runs. So read this chart as an upper limit, and take your real numbers from the VmPTE command in section 01.
06 — THE FIX

One entry for 2 MB instead of 512 entries

A huge page is not a different kind of memory. It is the same RAM, described in bigger units. The page table now stops one level earlier, because one entry covers 2 MB directly. The last level disappears completely, and with it 511 of every 512 entries and 511 of every 512 faults.

Page size
PAGE-TABLE LEVEL 2 MB HUGE PAGE — last level removed SAME PHYSICAL MEMORY
For 32 GB of shared_buffers4 KB pages2 MB huge pages
Entries needed8,388,60816,384
Page table, per backend64 MB128 KB
Faults to fully warm one backend8,388,60816,384
TLB reach (2048 entries)8 MB4 GB
Ceiling at 300 backends18.75 GB37.5 MB
Ceiling at 600 backends37.5 GB75 MB
What you get, in order of importance. First, several gigabytes of RAM come back. The page cache can use them, or you can give them to a larger shared_buffers. Second, CPU usage usually drops by 5–20%, because the TLB hits more often and the faults stop happening. The RAM is the safe win. The CPU is a bonus, and it depends a lot on how random your access pattern is.
Explicit, not transparent. Transparent Huge Pages are a different mechanism. The kernel only tries: it gives you huge pages when it can, and it may take them back later. With some settings, the work of building them can also cause latency spikes. An explicit hugetlb reservation is a guarantee. THP is only a chance, so one cannot replace the other. The post covers the settings, the sizing, and the ways a reservation can fail without telling you: PostgreSQL configuration →
07 — THE OTHER LEVER

A pooler works on the other half of the formula

The cost is backends × shared_buffers ÷ page_size. Huge pages make the third part smaller. A connection pooler puts a limit on the first part, and it needs no reboot, no kernel settings and no reserved memory. If you do not use one yet, start there.

the same shared_buffers underneath both500 clients through a pool of 30 give you 30 page tables, not 500. The cost now depends on your pool size, not on how many connections your application opens.

pool_size sets the limit

500 clients, pool_size = 30 → 30 backends. About 1.9 GB of page tables instead of 31 GB.

Transaction mode

Server connections are reused much more often. Each backend keeps a hot working set, and its translations stay in memory.

Recycling

server_lifetime and server_idle_timeout close big backends and drop their page tables. But they also drop warm ones. This is a trade, not a free win.

One detail decides how bad this gets: how the pool chooses the next connection. If it spreads queries evenly — round-robin, or always the connection that was idle longest — then every backend slowly reads most of shared_buffers. They all become hot, and each one carries a full-size table. If it prefers the connection it used last, the traffic stays on a few connections. Only those become hot, and the rest stay cold with small tables until an idle timeout closes them. Drivers and poolers use different names for this, so do not trust the name. Compare VmPTE across your backends instead. Similar numbers mean all of them are hot. Very different numbers mean only a few are.
They work on different things. A pooler makes the problem smaller. Huge pages make each unit cheaper. Together they give you 30 backends at 128 KB each, and that is a number nobody needs to think about.
08 — RECAP

What to take away

The claimWhy it is true
Page tables cost real RAM8 bytes per 4 KB page. A fully warmed 32 GB shared_buffers needs 64 MB of entries to describe it.
The cost multiplies by backendsEach process keeps its own table, so the total grows with your connection count. For one 32 GB pool, 300 backends give a ceiling of 18.75 GB.
Warm-up is not shared eitherBackend B faults on a page that backend A has already faulted in, 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 entriesOne entry covers 2 MB instead of 4 KB. The same memory, 512× fewer descriptions of it, and 512× fewer first-touch faults to build them.
Shared memory onlywork_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 scaleWith a small shared_buffers and few connections, none of this is your problem. The measurement tells you which case you are in.
Measure before and after. 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.