Skip to content

gc: negative-cache the unregistered 1 MiB classes — they are 90.7% of every classification miss left, and only 0.14% are rejected by the span check #9852

Description

@proggeramlug

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:

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions