PostgreSQL · virtual memory · TLB · huge pages

How much RAM PostgreSQL spends on page tables, and why

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.

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 the ceiling it stays: how long the backend has been running and how much of the pool its queries reach. Time matters more. Most workloads touch enough of 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.
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 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.

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 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 the same page as the first one.

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

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 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 →
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
600
4 KB pages — page-table ceiling
2 MB huge pages — page-table ceiling
Read this as an upper limit. The explorer assumes every backend has read the whole pool. Section 01 explains why a real one stays below that, and the VmPTE command there gives you your own number.
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. An entry still costs the same 8 bytes — it now describes 2 MB instead of 4 KB. That is the whole trick.

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
07 — 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, 600 backends give a ceiling of 37.5 GB.
Warm-up is not shared eitherBackend 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 entriesOne 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 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.