The measurement
The direct-indexed page-class table (PR to follow, branch perf/page-class-table)
takes classification misses from 18.48 % to 5.97 % on a 3300-char
claude-code turn. The counter it added says exactly where the remaining 5.97 %
goes, and it is one population:
[gc-page-class], cumulative over one turn, both arms of the same binary
(PERRY_GC_PAGE_CLASS_TABLE=0 is the 4-way control):
| 3300-char |
4-way |
table |
| lookups |
382,589,551 |
422,291,253 |
| misses |
70,706,843 (18.48 %) |
25,207,589 (5.97 %) |
| — on registered classes |
50,297,750 |
2,336,644 |
| — on unregistered addresses |
20,409,093 |
22,870,945 |
| — of those, out of span |
1 |
32,144 |
The unregistered population is 90.7 % of every miss that is left (85.9 % at
400 chars), and it is untouched by the table: 5.33 % of lookups before, 5.42 %
after. These are candidate addresses in no registered block at all — the
authoritative PageGenerationMap has no answer for them either, so nothing is
cached for them in either arm and each one pays a full hash lookup that always
fails.
The fact that makes this actionable — and it refutes the design spec
The spec for the table asserted that these addresses would be "rejected by the
same bounds check that indexes the table — no separate filter needed", on the
reasoning that an address in no registered block is outside the arena's span.
That is false, and the counter measures by how much: of the 22,870,945
unregistered-address misses, 32,144 are out of span. 0.14 %. Over 99.8 %
of them fall inside [base, base + len), land on a dead table entry, and go
on to the map exactly as before. Out-of-span is also not sound as a proof of
unregistered — a registration past PAGE_CLASS_TABLE_MAX_SPAN is refused and
deliberately left outside the span — so the bounds check could not have been
used this way even if the distribution had been favourable.
The fix, and why it is cheap
Cache the negative. A table entry that means "no registered range covers this
1 MiB class" answers these lookups without touching the map.
The correctness argument is already paid for by the existing design:
- Invalidation is free. Every negative entry becomes wrong exactly when a
block is registered into that class — and all three PageGenerationMap
mutation sites already end with an unconditional
invalidate_generation_cache(), which bumps the epoch and makes every entry
(positive and negative alike) stale at once. No new invalidation obligation.
- The precondition is already computed, just discarded. The negative is
only sound for a class with no registered range — a class can be partially
registered, and caching "absent" for the whole class would then be wrong for
other addresses in it. classify_*_uncached currently writes
pages.get(&key).and_then(|slot| slot.find(addr)), which collapses "no such
class" and "class exists, address not in any of its ranges" into one None.
Splitting those two is the whole of the change: cache the negative only
when pages.get(&key) is itself None.
- The lookup returns a tri-state (
Hit(range) / KnownAbsent / Unknown);
KnownAbsent maps to HeapGeneration::Unknown and to None, which is what
the uncached arm returns for these addresses today.
Why it is worth doing
Classification is the per-execution cost under three callers with no cheaper
predicate of their own. The write barrier's remembered_child_needs_tracking
runs 35,871,391 times per turn with 95.23 % on its cheapest arm, so
there is no predicate left to fix there; mark_addr and the side-table prunes
pay the same cost. Measured on the same turn, the two pure-miss leaf symbols
(classify_heap_space_in_range_uncached, classify_heap_generation_uncached)
are 0.59 % of active main-thread samples after the table, down from 3.37 %.
Removing ~91 % of what is left is the remaining headroom on this line, and
there is no other lever on it.
Deliberately not in the table PR, which is scoped to replacing the cache
structure and is measured on that basis. Filed so nobody has to re-derive the
0.14 %.
Provenance: secret-tests/cc-perf-campaign/RESULT_page_class_table.md §4a,
counter runs /tmp/ov_pc_{table,4way}_{400,3300}.diag.
The measurement
The direct-indexed page-class table (PR to follow, branch
perf/page-class-table)takes classification misses from 18.48 % to 5.97 % on a 3300-char
claude-code turn. The counter it added says exactly where the remaining 5.97 %
goes, and it is one population:
[gc-page-class], cumulative over one turn, both arms of the same binary(
PERRY_GC_PAGE_CLASS_TABLE=0is the 4-way control):The unregistered population is 90.7 % of every miss that is left (85.9 % at
400 chars), and it is untouched by the table: 5.33 % of lookups before, 5.42 %
after. These are candidate addresses in no registered block at all — the
authoritative
PageGenerationMaphas no answer for them either, so nothing iscached for them in either arm and each one pays a full hash lookup that always
fails.
The fact that makes this actionable — and it refutes the design spec
The spec for the table asserted that these addresses would be "rejected by the
same bounds check that indexes the table — no separate filter needed", on the
reasoning that an address in no registered block is outside the arena's span.
That is false, and the counter measures by how much: of the 22,870,945
unregistered-address misses, 32,144 are out of span. 0.14 %. Over 99.8 %
of them fall inside
[base, base + len), land on a dead table entry, and goon to the map exactly as before. Out-of-span is also not sound as a proof of
unregistered — a registration past
PAGE_CLASS_TABLE_MAX_SPANis refused anddeliberately left outside the span — so the bounds check could not have been
used this way even if the distribution had been favourable.
The fix, and why it is cheap
Cache the negative. A table entry that means "no registered range covers this
1 MiB class" answers these lookups without touching the map.
The correctness argument is already paid for by the existing design:
block is registered into that class — and all three
PageGenerationMapmutation sites already end with an unconditional
invalidate_generation_cache(), which bumps the epoch and makes every entry(positive and negative alike) stale at once. No new invalidation obligation.
only sound for a class with no registered range — a class can be partially
registered, and caching "absent" for the whole class would then be wrong for
other addresses in it.
classify_*_uncachedcurrently writespages.get(&key).and_then(|slot| slot.find(addr)), which collapses "no suchclass" and "class exists, address not in any of its ranges" into one
None.Splitting those two is the whole of the change: cache the negative only
when
pages.get(&key)is itselfNone.Hit(range)/KnownAbsent/Unknown);KnownAbsentmaps toHeapGeneration::Unknownand toNone, which is whatthe uncached arm returns for these addresses today.
Why it is worth doing
Classification is the per-execution cost under three callers with no cheaper
predicate of their own. The write barrier's
remembered_child_needs_trackingruns 35,871,391 times per turn with 95.23 % on its cheapest arm, so
there is no predicate left to fix there;
mark_addrand the side-table prunespay the same cost. Measured on the same turn, the two pure-miss leaf symbols
(
classify_heap_space_in_range_uncached,classify_heap_generation_uncached)are 0.59 % of active main-thread samples after the table, down from 3.37 %.
Removing ~91 % of what is left is the remaining headroom on this line, and
there is no other lever on it.
Deliberately not in the table PR, which is scoped to replacing the cache
structure and is measured on that basis. Filed so nobody has to re-derive the
0.14 %.
Provenance:
secret-tests/cc-perf-campaign/RESULT_page_class_table.md§4a,counter runs
/tmp/ov_pc_{table,4way}_{400,3300}.diag.