FreeBSD / vm_map / options DIAGNOSTIC

Quadratic vm_map Check After fork()

found 2026-10-17: smbd connects timing out on case (DIAGNOSTIC + KASAN) trigger: fork() of a process with many map entries fix: a linear depth-first check; validated in a bhyve guest, 2026-10-17 O(n × depth) → O(n)

With options DIAGNOSTIC, _vm_map_assert_consistent() re-checks a process's whole map once the map has been changed more times than it has entries. The old check found every entry by searching from the root, so one check cost the sum of all entry depths.

fork() triggers this specifically, and makes that sum quadratic. The child's map is built by inserting the entries in address order, which leaves a single path, and building it uses up the change budget, so the child's first page fault runs the check on the worst tree it will ever have. Samba hard-codes a minimum of 70,566 mutex hash chains by default, one map entry each, so every forked smbd made over a billion node visits before it could serve its client.

Where smbd's 70,566 map entries come from

One map entry per hash chain tdb file hash table of chains pthread_mutex_t process-shared, one per chain libthr off-page one-page shm mapping per mutex vm_map entry in smbd and every child it forks Chains created at startup, hard-coded by default, independent of load 6 × 10,007 = 60,042 10,000 locking, brlock, leases, smbXsrv_open/session/tcon_global SMBD_VOLATILE_TDB_HASH_SIZE, source3/include/local.h gencache gencache:hash_size 4 × 131 = 524 (tdb DEFAULT_HASH_SIZE) Total 70,566 chains; with 2 more mutexes per database, 70,588 one-page mappings in the smbd parent
The count comes from Samba's defaults, not from traffic. Each database's mutex area is created and initialized in full when the file is created, so an idle server has the same 70,588 mappings as a busy one. On Linux the mutex lives inside the tdb file and costs no mapping; FreeBSD's libthr gives every process-shared mutex its own page.

fork() copies the map in address order

Parent: a splayed tree, roughly balanced a4 a2 a6 a1 a3 a5 a7 deepest entry: 3 steps vmspace_fork() vm_map_entry_link() for a1, a2, ... a7 in order Child: each insert is the new root, the old tree its left child a7 a6 a5 a4 a3 a2 a1 depth 123 456 7 Each link also counts as one map update, so the child starts with nupdates == nentries: one update short of a full check.
The copy is the worst shape a search tree can have. Linking an entry splays the tree at its address; an address above every existing entry leaves the whole tree on the new entry's left. After n links the child's tree is a path n deep, and its update counter is exactly n.

The first fault: one splay, then the whole check

After fork: a path of n a9 a8 a7 a6 a5 a4 a3 a2 a1 first page fault: a deep entry vm_map_lookup_entry() splay at a1 nupdates = n + 1 > n full check runs After the splay: a path of about n/2, one leaf off each link (schematic) a1 a8 a6 a9 a4 a7 a2 a5 a3 links: a8, a6, a4, a2 leaves: a9, a7, a5, a3 the splay roughly halves the depth, then the check runs on this tree The check runs inside the fault, before the lookup gives up its write lock, so the child cannot continue until it finishes.
The trigger is a count, not an event. The full check runs once the map has had more updates than it has entries. The fork's own links bring the count to exactly n, so the child's first lookup tips it over and the check runs on the freshly copied tree with its depth only halved.

Old check: one search from the root per entry

Visits to find each entry from the root (n = 9 example) 1a1 5a2 6a3 4a4 5a5 3a6 4a7 2a8 3a9 33 visits for 9 entries; the unsplayed path would take 45. Scaling it up Link i of the path takes i visits; its leaf takes one more. sum ≈ 2 × (n/2)² / 2 = n² / 4 smbd by default: n ≥ 70,566 70,566² = 4,979,560,356 70,566² / 4 ≈ 1,244,890,089 visits If the fault lands near the root and the path stays whole: 70,566² / 2 ≈ 2,489,780,178 visits So a forked smbd makes a minimum of ~1.2 billion node visits in its first page fault.
Cost is the sum of all depths. On a balanced tree that is O(n log n). On the post-fork tree the depths grow linearly along the path, so their sum grows with n², which is why doubling the entries made the stall about four times longer.

New check: one depth-first pass with bounds

Each entry visited once, carrying (lbound, ubound) a1 a8 a6 a9 a4 a7 a2 a5 a3 123 954 768 Visit order in teal: 9 entries, 9 visits. Pending (one slot per address bit) a8: a9 side waits (to the top) a6: a7 first, a4 side waits a4: a5 first, a2 side waits never more than 2 in use here A leaf has two threads, so the walk pops: a7 then a4, a5 then a2, and a3 then a9 last. At n = 70,566 The path has ~35,000 links, each with a leaf on its right. The leaf is the smaller span, so the walk takes it, pops, and continues down the path with one slot in use. Total: every entry once O(n) instead of O(n²) No overflow case: each pending entry at least halves the address span, so 64 slots (1 KB) cover any tree on a 64-bit address space. A node fits only one place in the bounds, so a corrupt map asserts; it cannot make the walk loop.
Bounds replace the root searches. A depth-first walk knows each subtree's lower and upper bound from its ancestors, which is what the old per-entry search recomputed for every entry. Each link is checked as a thread (equal to its bound) or a child (inside the bounds), and the header's first and last links against the leftmost and rightmost entries, so no separate walk over the list is needed.

Measured: fork() with N map entries

0 s 4 s 8 s 12 s 16 s 0 20k 40k 60k 80k 100k 120k 140k map entries in the parent fork() to wait4(), wall clock 5,000 entries, stock: 0.021 s 10,000 entries, stock: 0.070 s 20,000 entries, stock: 0.286 s 30,000 entries, stock: 0.677 s 40,000 entries, stock: 1.137 s 50,000 entries, stock: 1.720 s 60,000 entries, stock: 2.789 s 70,000 entries, stock: 3.806 s 80,000 entries, stock: 4.682 s 90,000 entries, stock: 6.464 s 100,000 entries, stock: 7.830 s 110,000 entries, stock: 9.310 s 120,000 entries, stock: 11.274 s 130,000 entries, stock: 13.416 s 140,000 entries, stock: 15.401 s 5,000 entries, fixed: 0.008 s 10,000 entries, fixed: 0.013 s 20,000 entries, fixed: 0.032 s 30,000 entries, fixed: 0.038 s 40,000 entries, fixed: 0.044 s 50,000 entries, fixed: 0.053 s 60,000 entries, fixed: 0.058 s 70,000 entries, fixed: 0.070 s 80,000 entries, fixed: 0.081 s 90,000 entries, fixed: 0.089 s 100,000 entries, fixed: 0.101 s 110,000 entries, fixed: 0.109 s 120,000 entries, fixed: 0.123 s 130,000 entries, fixed: 0.125 s 140,000 entries, fixed: 0.132 s stock: 15.4 s fixed: 0.13 s doubling the entries: ~4× the time stock check fixed check
Quadratic against nearly flat. The stock check curves upward with the square of the entries: 3.8 s at 70k, 15.4 s at 140k. The fixed check stays near the axis, 0.13 s at 140k, and part of that is the fork and exit themselves. Example from our test system: an 8-vCPU bhyve guest running GENERIC + options DIAGNOSTIC, both kernels built from the same tree except for the fix; the parent holds one mapping with alternating page protections, and the child writes one page and exits. Both kernels were measured at the same 15 sizes.
EntriesStockFixed
5,0000.021 s0.008 s
10,0000.070 s0.013 s
20,0000.286 s0.032 s
30,0000.677 s0.038 s
40,0001.137 s0.044 s
50,0001.720 s0.053 s
60,0002.789 s0.058 s
70,0003.806 s0.070 s
80,0004.682 s0.081 s
90,0006.464 s0.089 s
100,0007.830 s0.101 s
110,0009.310 s0.109 s
120,00011.274 s0.123 s
130,00013.416 s0.125 s
140,00015.401 s0.132 s