Skip to content

WIP: Unit value optimizations - #67

Open
imlvts wants to merge 17 commits into
Adam-Vandervorst:masterfrom
imlvts:unit-value-optimizations
Open

WIP: Unit value optimizations#67
imlvts wants to merge 17 commits into
Adam-Vandervorst:masterfrom
imlvts:unit-value-optimizations

Conversation

@imlvts

@imlvts imlvts commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

imlvts and others added 17 commits August 19, 2026 21:22
A LineListNode payload slot holds either a child pointer or a value, and the
drop paths tested which one it was and then called ManuallyDrop::drop on the
value. For a value type with no drop glue that call does nothing, but the
branches around it still run, and the elision was left entirely to the
optimizer.

The obvious guard, needs_drop::<V>(), would be wrong here. The slot is a
LocalOrHeap<V, _>, which carries an unconditional Drop impl and boxes any V
too large to store inline, so a [u8; 64] has no drop glue of its own but does
own a heap allocation. val_slot_needs_drop::<V>() covers both halves, and
ValSlotStorage is now the type alias the union itself uses so the size
threshold cannot drift away from the layout.

This is a code-size and clarity change, not a throughput one: build-and-drop
of a 1M-path PathMap<()> measures ~189ms either way in release, because the
cost there is allocator traffic and cache misses, not the folded branches.
The value is that the elision is explicit and holds in debug too.

Tests cover both halves of the predicate: a drop-counting value asserting no
leaks after removes, prunes and a shared-clone drop, and a 64-byte value with
no drop glue whose heap allocations only miri can check. Unit tests pin the
predicate's value for (), u32, Vec<u8> and [u8; 64] so the elision cannot stop
happening silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…_into

The trie shares subtries by pointer, so an algebraic operation frequently ends
up with the same node on both sides. Join and meet answered that case as
Identity and subtract answered it as None, without descending -- which is only
sound when the value operation is idempotent. Both IDEMPOTENT constants
documented themselves as informational only, so a value type that actually
combines its operands, such as a multiset adding multiplicities, would have
had its structurally shared branches silently skipped instead of combined.

Six sites take the shortcut, not the three that grep for ptr_eq finds: the
outer TrieNodeODRc methods, and the TaggedNodeRef dispatchers underneath them,
which is where it fires at every level of the descent. There are two copies of
the dispatchers, one per slim_dispatch setting. All six now consult the
constant.

Also adds the shortcut that was missing. TrieNodeODRc::join_into had no
pointer check at all and went straight to make_mut(), which deep-copies the
node exactly when it is shared -- precisely the case where both sides are
likely to be the same pointer. Three call sites that hand-rolled
make_mut().join_into_dyn() now route through it and inherit the check.
Joining a 1M-path PathMap<()> into a clone of itself goes from ~110ms to
effectively free.

No in-tree value type declares IDEMPOTENT = false, so no existing behavior
changes. The new tests use a multiset Count whose join adds occurrences and
whose subtract removes one; all four of them failed before this change. A
helper asserts the two maps genuinely share a root node, so the tests cannot
quietly stop exercising the shortcut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layout work needs to know where the bytes actually are, and node counts alone
do not answer that. `memory_profile` walks physical (deduplicated) nodes and
tallies bytes for list nodes, dense nodes and cell nodes separately, along with
the list-node key-length distribution and the dense-node slot capacity.

Two things it immediately showed, both of which contradict estimates made from
reading the code:

  - The split between list-node and dense-node bytes swings from 80/20 to
    53/47 depending on key shape, so a per-node saving in either one means
    very different things for different workloads.
  - `ByteNode::item_count` counts a slot's child link and value as two separate
    items, so it is not the slot count. Sizing the slot array from it is wrong
    in both directions; `slot_count` and `slot_capacity` are what that needs.

`LINE_LIST_NODE_SIZE` now derives the list node's size from KEY_BYTES_CNT
rather than asserting a hardcoded 64, so the constant can be swept without
hand-editing the assertion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds to the perf notes: where the bytes actually are by node type, the dense
slot-array over-allocation, and three things that were measured and rejected --
the S3 key-byte reclaim (not implementable, and worthless anyway), the
KEY_BYTES_CNT sweep (no single constant serves both long-path and short-key
workloads), and bounded growth for dense slot arrays (pays for its memory in
build time).

Also sizes S1 properly against allocated rather than counted slots, and records
why its implementation is harder than the struct definition suggests: CoFree
values are cloned and dropped outside the node that owns them, so moving the
presence bit out costs a manual Drop/Clone and a parallel mask through all four
algebraic operations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The packing works and is miri-clean, and the memory it saves is real: -5.1% on
MORK-shaped data, -13.0% on random keys, -14.1% on shakespeare, landing within a
tenth of a percent of what the profiler predicted. But it measures ~9% slower on
iteration over the MORK dataset, and 5% memory is not worth risking 9% iteration
on the workload that matters. The likely cause is `has_rec()` going from an
`Option` null check to a pointer-tag comparison, on a hot path, in tries that are
list-node-heavy and so collect little of the dense-node win.

The code moves to `experiment/cofree-pointer-packing`, kept to show the approach
is viable and to record what it costs. The notes keep the full result, the
invariant it introduces, and the two places it can be silently defeated.

The regression tests come to the main line as
`tests/prefix_value_preservation.rs`. They cover values on paths that are proper
prefixes of other paths -- the slots that hold both a value and a child, which is
where value loss hides -- across copy-on-write, node upgrades, grafting, algebra
and removal. That property is worth pinning down whatever the representation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The survey claimed a path kept alive by `remove_val(prune = false)` costs a
pointer and a node, because a `LineListNode` records it by pointing the slot's
child link at the empty-node sentinel. Measured, it costs nothing:

  - `new_empty()` is a bogus address carrying EMPTY_NODE_TAG. No allocation, no
    node.
  - The payload word it occupies is part of a fixed-size struct, so marking the
    slot "dangling" in the header instead would free zero bytes.
  - A `DenseByteNode` never used the sentinel at all -- it already represents a
    dangling path as a CoFree holding neither child nor value, which is exactly
    what S4 proposed building.

Removing half of shakespeare's values without pruning produces 14,212 dangling
slots and leaves the trie byte for byte identical, 4,095,744 both ways.
`dangling_path_survey` asserts that, so the claim does not have to be re-derived
from the struct definitions that got it wrong the first time.

Also checked the one caller that really does allocate: a WriteZipper opened on
an absent path allocates an empty LineListNode for its root but does not leave
it behind. 2000 such zippers, created and dropped without writing, moved the
byte count by exactly zero, and no allocated empty node appears in any trie
profiled here.

The profiler gains `empty_nodes` and `dangling_slots` counters, which is what
made this decidable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The survey said merkleize "requires V: Hash to hash nothing", implying a const
branch around the value hash. Both hash sites take `Option<&V>`, not `V`, and
that distinction is the whole story:

  - `().hash()` writes zero bytes, so the payload already costs nothing and the
    call folds away. There is no branch to add.
  - `Option<&()>` writes 8 bytes, and None and Some(&()) hash differently. That
    is the discriminant, and for a set trie it is the only information present --
    whether the path is a member. Eliding it would make a path with a value hash
    identically to one without, and merkleize would merge structurally distinct
    tries.

The V: Hash bound cannot come off either: it is needed for a general V, and ()
satisfies it for free.

The one adjacent remnant -- the "value, no child" branch building a fresh hasher
per leaf to compute what is a constant for any zero-sized V -- was priced at
3.03ns x 91,692 leaves = 0.28ms against a 21.03ms merkleize, or 1.3%. That is
below the noise floor, in the routine where a subtle change silently corrupts
structural sharing.

No code change; recording the measurements so this is not re-attempted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The survey said `LocalOrHeap<V, [u8; 8]>` is why a payload slot is 8 bytes wide.
It is not. `LocalOrHeap` is a fixed-size cell that boxes anything too large to
fit, so the value arm is the same width for every V, and the union is 8 bytes
whether V is `()` or `[u8; 1024]` -- because the child pointer arm needs 8 bytes
regardless. Deleting the value arm would save nothing, and for `()` it already
costs nothing at runtime. `val_slot_layout_tests` asserts both.

What the investigation did turn up is real, though it is not a unit-value
concern. Values live overwhelmingly in list-node slots -- 99.93% on MORK-shaped
data, 100% on fixed-width random keys -- and those are exactly the slots that
box. So for a value over the threshold, one heap allocation per value. Measured
on 91,692 paths, going from an 8-byte value to a 9-byte one costs +19% on
insertion and +36% on drop, entirely allocator traffic.

Widening the cell would fix that only by growing every node for every V,
including the ones that need none of it, and sizing it per-V needs
generic_const_exprs. So the fix here is documentation: TrieValue now describes
the cliff, since it is invisible in the type system and lands on whoever picks
a value type. `val_is_boxed` gives the threshold one home, which
`val_slot_needs_drop` now shares.

The profiler gains a `dense_val_slots` counter, which is what made the
list-vs-dense split measurable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The survey said the generic result plumbing -- AlgebraicResult, merge,
combine_algebraic_results, the Hetero* traits -- is where a PathMap<()> spends
its time. Profiled with perf on 400k-path joins, meets and subtracts, with
cycles attributed by source line so inlined code lands on its own line, ring.rs
accounts for 1.06% and combine_algebraic_results for 2.86%, against 29.72% in
malloc. The proposed rewrite would have chased a twenty-fifth of the runtime,
and depended on the shelved S1 besides.

What the profile did find is worth more than F4 was. ByteNode::pjoin allocates
the result slot vector and clones every slot into it before knowing whether the
result is a new node or simply one of its operands. When the answer is Identity
that work is discarded: on 400k-path joins, 99.7% of calls discard it when one
operand is a subset of the other, and 100% at 95% overlap, wasting 94.8% and
99.6% of all slot clones respectively -- each an atomic refcount increment and a
matching decrement, plus a Vec allocation and free per call.

End to end this inverts the expected ordering: joining two disjoint 400k tries
into a new 800k one takes 48.75ms, while joining a trie with a 95% overlapping
one -- whose answer is essentially the first operand -- takes 74.96ms. The join
with nothing to do is 1.5x slower than the one that doubles the trie.

Recorded with the fix sketched (defer materialization until the first slot
result that is neither SELF_IDENT nor COUNTER_IDENT, back-filling the prefix
from whichever side was still identity). Not attempted; it is general rather
than unit-value work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ime goes

The survey wanted both payload enums collapsed to a nullable pointer plus a bit.

ValOrChild<()> is already one word: `Val(())` carries nothing, so rustc encodes
it in the child pointer's null niche. Nothing to do, and a third variant would
silently undo it -- hence a test.

PayloadRef cannot use that niche, having two pointer-carrying variants plus
None, so one word goes to the discriminant. But it is 16 bytes for every V, not
just for (), and it is a transient built on the stack during meets and never
stored in a trie, so its width costs memory nowhere.

The path is hot, which is the part the survey got right: on list-node-heavy set
algebra, node_get_payloads plus pmeet_generic and its inner loop are 17.7% of
cycles. The cycles are not in the enum, though -- 4.38% is `is_used` header bit
tests, 3.03% key-length extraction, 3.29% key-slice construction, and 1.97% the
inner function's prologue spilling its arrays. Only that last part scales with
payload width, and shrinking a two-element array from 48 to 32 bytes reclaims a
fraction of it.

Header decoding and key slicing is where that path actually spends its time, and
is the thing to look at if it ever needs to be faster.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ByteNode::psubtract` opened with `let mut btn = self.clone()` -- the whole node
including its slot array, with a refcount bump per slot -- before it knew whether
the subtraction would remove anything. When the answer came back `Identity`, all
of that was discarded.

That is the common case, not the corner case. Instrumented on 400k-path
subtractions:

    disjoint operands   100.0% of calls return Identity;  431,714 of 431,714
                        slot clones wasted (100%)
    5% overlap           71.2% return Identity;           251,481 of 432,028
                        wasted (58.2%)
    a 25% subset          0.0% return Identity;           nothing wasted

`btn` is now an `Option`, left `None` while the result is still identical to
`self` and cloned at the first actual change. Indexing stays correct because the
clone is taken before any modification, so `btn` still matches `self` everywhere
the loop has already been.

Min of 3 runs, 400k paths:

    subtract, disjoint     30.04ms -> 17.18ms   -42.8%
    subtract, 5% overlap   34.87ms -> 25.18ms   -27.8%
    subtract, 25% subset   40.43ms -> 39.20ms    -3.0%
    subtract, 95% subset   69.96ms -> 69.44ms    -0.7%

The last two are cases with nothing to reclaim, and land inside the noise floor;
`meet`, whose code is untouched, moved -8.9% to +0.1% across the same operands,
which is that floor.

This is the pattern A1 complained about, minus the mask arithmetic it proposed:
the descent is already narrowed to the intersection of the two masks in all four
operations, and the value dimension cannot become mask ops without the shelved
slot split. `pjoin` and `prestrict` waste work the same way but build a fresh
slot vector rather than cloning, so deferring them needs a back-fill; not
attempted here.

Verified with the differential algebra test and miri: 4 + 5 + 5 integration
tests and 76 filtered lib tests across subtract, restrict, meet and join, all
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two thirds of A1 was already done or blocked: the descent is already narrowed to
the mask intersection in all four operations, and the value dimension needs the
shelved slot split. Its aside about psubtract's speculative clone was the whole
prize -- 42.8% off a disjoint subtract, 27.8% at 5% overlap, neutral where there
is nothing to reclaim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…restrict bug

Both were implemented the same way psubtract was -- hold off building the slot
vector until the first result that is neither SELF_IDENT nor COUNTER_IDENT, and
back-fill the prefix from whichever operand was still identity, which the flags
make recoverable since a live flag implies that operand's mask equals the result
mask. Correct, and reverted anyway.

Interleaved, min of 4 rounds per side, with meet and subtract as untouched
controls, join went +10.6% on disjoint operands and -13.4% on a 25% subset.
Swapping Vec::push for an unchecked write into the reserved slot cut the disjoint
regression to +3.7% but did not grow the wins. prestrict landed inside the noise
in both directions across two runs.

psubtract pays because it cloned the whole node up front and barely recurses on
disjoint operands, so the clone was nearly the whole call. These two only skip
the slot vector, and the recursive descent -- which still visits every
overlapping byte -- dominates. F4 measured 99.6% of pjoin's slot clones wasted at
95% overlap, and removing all of them bought about 6%.

Separately: writing a differential oracle for restrict turned up a correctness
bug. restrict(a, a) should be a, and is not when a path both carries a value and
branches -- {ab, abc, abd} loses `ab`, {a, ab, abc} loses `a`. One child is fine,
two or more and the value goes. It reproduces on master, so it predates this
work; the existing differential tests cover join, meet and subtract but not
restrict. The oracle and the minimal repro are committed, #[ignore]d so the suite
stays green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cause, cost, and why the bug survived: restrict was the only one of the four
algebraic operations without a differential test. A standalone repro lives on
branch restrict-branching-value-repro, off master.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ByteNode::join_into` allocated a slot array sized to the union of the two masks
and moved every slot into it, even when the union equals `self.mask` -- when
`other` contributes no byte `self` does not already have. In that case nothing
needs inserting and the overlapping slots can be joined where they sit.

That case is the norm for an incremental ingest. Instrumented on a 400k-path
trie joined with batches of varying overlap:

    disjoint                0.3% of calls,   5.2% of slot moves avoidable
    batch, 1% new keys     95.3% of calls,  95.3% of slot moves avoidable
    already contained (5%)  100% of calls,   100% avoidable
    already contained (95%) 100% of calls,   451,600 slot moves avoidable

The time it buys is modest, because these are slot *moves* -- a memcpy of the
CoFrees -- rather than the clones that made the same fix worth 42.8% in
`psubtract`, where each clone was an atomic refcount bump. What is avoided here
is one allocation and one memcpy per node. Interleaved, min of 8 rounds, with
`join` as an untouched control:

    join_into, disjoint            61.55ms -> 61.81ms   +0.4%
    join_into, 1% new keys          2.37ms ->  2.21ms   -6.8%
    join_into, contained (5%)      12.81ms -> 12.51ms   -2.3%
    join_into, contained (95%)     80.42ms -> 76.54ms   -4.8%
    join (control), all four                            -0.8% to +0.5%

The direction is consistent across all four shapes with the control flat, and
there is no regression on the disjoint case, which is the one the new branch
cannot help. The 1% figure has a 22-43% run-to-run spread on a 2ms measurement,
so read it as "no worse"; the 95% case, at 8% spread, is the reliable one.

Verified with the differential algebra tests and miri.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
In-place join_into when the other operand brings no new bytes: 95-100% of calls
on incremental-ingest shapes, -4.8% on the most reliable case, no regression on
disjoint operands. Modest because these are slot moves rather than the clones
that made the same idea worth 42.8% in psubtract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The branch was rebased onto master now that the restrict fix has landed there
(b2a0c09), so every commit hash in the notes had moved. The restrict fix drops
out of the branch's own table -- it is upstream, and git dropped the duplicate
during the rebase as already applied.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant