Skip to content

Corgi hash key lane - #832

Merged
frankmcsherry merged 16 commits into
master-nextfrom
corgi-hash-key-lane
Aug 16, 2026
Merged

Corgi hash key lane#832
frankmcsherry merged 16 commits into
master-nextfrom
corgi-hash-key-lane

Conversation

@frankmcsherry

Copy link
Copy Markdown
Member

Encourages DDIR's Corgi backend to use hashes in place of non-primitive keys (anything other than integers). Generally good news, but some slowdown reported with narrow compound keys, where adding the hash increases the sort times because of the radix passes required.

frankmcsherry and others added 16 commits August 14, 2026 20:46
Corgi must hand the proxy seam an integer key, and the seam works properly only
when the arrangement is already sorted by that integer. Until now corgi supplied
the integer but not the order: `ids()` derived it per retire and threw it away,
so for anything but a primitive-integer key the arrangement was in structural key
order and the identifier was in some other order. Everything downstream paid for
that mismatch — the identifier was re-hashed every retire, and `changed` could
not be turned into needles, so the accumulated history was scanned in full.

The rule is now applied once, at ingest (`CorgiChunker::flush`): a key that is
already a primitive integer is used as it stands; any other key shape is hashed
and the hash PREPENDED, making the key `Prod([hash, key])`. `from_columns` sorts
lexicographically over lanes, so that is hash order with the real key as
tie-break, and the identifier is lane 0 either way. The real key stays in the
column, so colliding keys land adjacent and sub-sorted rather than
indistinguishable, and reads recover it by dropping a lane (an `Arc` bump).

The hash is computed once and thereafter moves as data: merge, advance and settle
permute key columns with `gather_lanes`, so no transducer recomputes it. This is
the distinction the earlier hash-ordering attempt missed — a coordinate is
carried, a sidecar is recomputed.

What this deletes:

* `collect_present`'s scan branch, and with it `seek_needles`' `Option`, the
  seek-vs-scan heuristic and `SEEK_ADVANTAGE`. Every key shape now seeks, and
  seeks by `find_ranges` over a `u64` leaf — corgi's fast path.
* `ids()` on keys. It reads lane 0.

Egress strips the lane in the two places a key leaves an arrangement: the
container built by `as_collection`, and the key handed to the join's projection,
which is written against the key the program declared. Values are untouched —
value order is load-bearing for Min/Collect, and value identifiers are a separate
question.

53 tests, plus the 4 heavy release ones. `pair_keys` caught the join egress.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YH7iq9JoXmf7ATpq1gZaQS
… cheap

Deleting the scan branch cost the primitive-integer programs: scc +1.2%, ident
+3.4%, both of which this change should not have touched at all. The heuristic
was earning its keep — broad-churn retires (loads, label cascades) present most
of the key space, and a gallop per changed key loses to a flat membership test
there.

Restored, with both branches reading the identifier lane. That is strictly better
than the version this branch started from: the seek is `find_ranges` over a `u64`
leaf for EVERY key shape, and the scan BORROWS the lane rather than materializing
it — a structural key used to be forced onto the scan (its on-the-fly hash could
not be inverted into a needle) and forced to allocate a hash vector per chunk per
retire to take it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YH7iq9JoXmf7ATpq1gZaQS
…umes

`advance_leaf` was the only regime that blocks: it seeks `from`, ends at a key
boundary, and writes back where to resume. It was reachable only by keys that
were a single u64 lane. `advance_lanes` and `advance_structured` opened with
`*from = None` and staged the whole intersection in one call, which is the join
half of the windowing problem.

Now that every arrangement leads with its identifier and is sorted by it, the
leaf walk applies to every key shape — it seeks and resumes on that lane. The
dispatch tries it first and the two whole-key walks become the fallback. Three
sites moved off whole-key-shaped needles onto the lane: the block seek, the
batched probe, and `LeafView`'s pull. `needle_like` goes with them — building a
needle in the key's own shape was only ever a way to search a column that had no
integer to search by.

The one exposure is a hash collision, which would cross-product two distinct keys
sharing an identifier. `one_key` checks per matched key, not per row: runs are
contiguous and sub-sorted by the real key, so a run holds one key exactly when
its first and last rows agree, and the same pass over both sides' runs confirms
they matched on the key rather than the hash. On failure the call is redone by
the whole-key walks, which compare the real key and cannot be fooled.

That path cannot be provoked with a real hash, so it is mutation-tested both
ways: forcing the fallback for every hashed key leaves all 53 tests passing (it
computes the same answer), and panicking in the fallback also leaves them passing
(it is never taken, so the leaf walk really is handling these keys).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YH7iq9JoXmf7ATpq1gZaQS
The block bound used to be discovered from what had already been read: pull a
fixed number of rows per chunk, then take the least last-pulled identifier, since
anything at or beyond it might have more rows outside somebody's buffer. Whatever
sat past that bound was discarded and read again by the next block. The waste is
structural rather than incidental — a chunk whose keys are sparse spans a wide
identifier range with its rows and a dense one spans a narrow range, and the
bound is the minimum, so the sparse chunks re-read most of what they touched,
every block.

A totally ordered identifier makes the other order possible. `block_horizon`
reads ONE value per chunk — the identifier a budget's worth of rows in, bumped
past its own run so a key is never split — and takes the least. `block_ends` then
binary-searches that bound in each chunk. Each chunk is read once, over exactly
the rows the block needs, and nothing is read twice.

This is why it could not have been written this way before: choosing a bound
before reading requires a single ordered value you can both index by position and
seek by value, and until the arrangement led with its identifier, a non-scalar
key had neither.

Falling out of it:

* `pull_horizon` and its extend loop are gone. The degenerate case they existed
  for — a run longer than the budget denying all progress — cannot arise, because
  the bound is taken past the run at the budget, so the chunk that set it always
  contributes at least a budget of rows. Progress is by construction, not by
  retry.
* `LeafView` borrows its identifiers straight from the chunk's lane instead of
  gathering them into a `Vec`. The block reads keys; it no longer copies them.
* Seeking the resume point is a `partition_point` on that slice rather than a
  column probe.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YH7iq9JoXmf7ATpq1gZaQS
…-lane

# Conflicts:
#	differential-dataflow/src/operators/int_proxy/reduce.rs
#	interactive/src/corgi/reduce.rs
@frankmcsherry
frankmcsherry marked this pull request as ready for review August 16, 2026 16:37
@frankmcsherry
frankmcsherry merged commit 78e6f1e into master-next Aug 16, 2026
6 checks passed
@frankmcsherry
frankmcsherry deleted the corgi-hash-key-lane branch August 16, 2026 18:17
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