Skip to content

perf(operator): ⚡ index the term store with a persistent fingerprint table - #353

Draft
diagonal-hamiltonian wants to merge 3 commits into
perf/stack-3-sparse-exchangefrom
perf/stack-4-term-table
Draft

diagonal-hamiltonian wants to merge 3 commits into
perf/stack-3-sparse-exchangefrom
perf/stack-4-term-table

Conversation

@diagonal-hamiltonian

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

Main indexes the term store with an open-addressing hash table of 8-byte slots keyed on the row's
folded value. This PR replaces it with TermTable (new, 4-byte fingerprint-keyed slots, batched
probe), sitting behind main's own accessor protocol: find_batch, find_batch_positions, find,
and emplace/bulk_insert/bulk_insert_hashed are replaced at their call sites by
TermTable::find_batch/find and MPOperator::index_appended_rows (main's existing
reindex_after_growth door), without changing what the protocol does. The join key itself is
folded off the row by free functions in a new RowKey.h (join_tag, key_of_positions, key_of),
so OperatorIndex depends on routing through exactly one header.

Two measured optimizations land alongside the table: an empty-table early return in
find_batch (two branches per query removed), and the two by-value operator lookups going through
TermTable::find directly rather than building a store-wide TermLookup map — dropping that
transient structure entirely, with no rebuild forced and no timing cost (see Measurements).

This is PR 4 of 7, based on perf/stack-3-sparse-exchange. The next PR
(perf/stack-5-one-round-join) is the protocol rewrite this table exists to serve. This level's time budget was 1.05× main; its worst rung is within noise.

Changes

Engine

  • cpp/monoprop/detail/operator/TermTable.h (new, 274 lines): 4-byte slots, fingerprint key,
    batched probe; find_batch early-returns on an empty table.
  • cpp/monoprop/detail/operator/RowKey.h (new, 58 lines): join_tag, labels<NumBits>(),
    key_of_positions<NumBits>(pos, k), key_of(bitset) as free functions.
  • cpp/monoprop/detail/operator/OperatorIndex.h (860 → 619 lines): Slot/Table/find*/
    bulk_insert*/emplace/fold_hash* deleted; key_of_row(i), RowBlock/row_block/
    positions_at, public row_eq_positions added. for_each now walks rows in index order (the
    table it used to walk is gone) rather than main's table-slot order.
  • cpp/monoprop/detail/operator/MPOperator.h: lazily rebuilt term_table_;
    insert_incoming_misses/insert_deferred_self_misses drop the bulk_insert_hashed call and
    keep the existing reindex_after_growth door; the two by-value lookups go through
    TermTable::find(store, mono).
  • cpp/monoprop/detail/evolution/layer_build/Engine.h, Resolve.h: minimal adapter — fold the key
    per query, call find_batch; the protocol itself is untouched.
  • .../monomial_propagator/MonomialPropagator.inl: drops the emplace call from construction.

Tests

  • cpp/tests/term_table_tests.cpp (new, 424 lines): the batched probe pinned directly (5 cases plus
    a collision and a staleness-guard case) against a dense-map oracle over the same rows.
  • cpp/tests/operator_index_tests.cpp: index cases replaced with key/for_each/
    row_eq_positions/row_block cases (row_block_agrees_with_the_per_row_accessors).
  • cpp/tests/bulk_insert_tests.cpp deleted (188 lines) — main's bulk_insert/
    bulk_insert_hashed are removed from the store's public surface by this PR.
  • cpp/tests/mp_operator_tests.cpp, evolution_detail_tests.cpp, mpi_utils_tests.cpp,
    sparse_resolve_tests.cpp, simulator_copy_tests.cpp: adapted to the new store signatures.
  • cpp/tests/README.md: "Operator store" section rewritten fresh.

Docs

  • docs/content/docs/features/parallelism.mdx: the join key and persistent term table paragraphs.

Measurements

Gated multiset vs origin/main c5e88c8's raw bits: for_each's new index-ordered walk moves the
golden monomial WALK ORDER (monoseq) at every P, though it moves no value and no term set —
routing already required multiset from PR 2 onward, but this PR would need it independently even at
P=1. gates.sh record md5 a7a612809122770771d1d9f8604a14ff (rebased onto PR 3, tip 69a137d).

3 interleaved reps, ratios only:

rung time st4/mainB time st3/mainB (predecessor) peak st4/mainB peak st3/mainB (predecessor)
L1-hubbard 1.006 1.008 0.797 0.878
L1-pauli 1.009 0.981 0.778 0.903
M2a-hubbard 0.990 0.993 0.804 0.951

Time is within the noise floor on every rung, comfortably inside this level's 1.05× time budget —
the worst intermediate time this level was flagged to risk did not materialize;
TermTable::find_batch behind main's protocol costs nothing measurable. indexing_bytes (the
field this PR redefines) is exactly 0.500× main on every rung (8-byte slots → 4-byte), which is
also most of this PR's peak-RSS win on top of PR 1's store gain.

O6 (dropping TermLookup.h for by-value lookups through TermTable::find) was measured
separately, not inferred: update_initial_operator came out at 1.0005× and 0.991× the predecessor
(PR 3) across two interleaved rounds at 4.32 M terms — no measurable difference; get_operator
forces no rebuild because it only reaches the table while init_op_map is non-empty, drained after
the first call.

Notes for reviewers

  • RowBlock/row_block/positions_at land here but have no engine consumer until PR 5
    (perf/stack-5-one-round-join), where Scan.h uses them — this is the one piece of this PR that
    nothing in this PR calls;
    coverage is row_block_agrees_with_the_per_row_accessors against the per-row accessors directly.
  • Ledger: indexing_bytes keeps its name and now reports the term table (it is the index now) —
    no term_table_bytes key was added, and no vestige field was kept. Main also folded in
    sizeof(OperatorIndex) (~200 B); this PR drops that rather than keeping it as an unused vestige.
  • bulk_insert_tests.cpp is deleted outright, not adapted, because the calls it tested no longer
    exist on the store's public surface.
  • A test name matching Boost's _<digits> exclusion pattern (cpp/tests/boostAddTests.cmake:209
    drops any case name matching it) would have been silently never registered with ctest; named to
    avoid it from the start (term_table_settles_a_key_collision_by_the_row_confirm).
  • Exactness argument: row layout and row indices are untouched — rows still enter through
    grow_rows_geometric + set_positions, and misses still take base + j in query-stream order
    (pinned against an unordered_map oracle in term_table_tests.cpp and against the dense insert
    path in sparse_resolve_tests.cpp). The table is a prefilter plus a positions confirm, so it
    answers exactly what main's value-keyed index answered for any query; a 32-bit key collision costs
    one extra compare (constructed and pinned in tests).

Checklist

  • Tests added or updated to cover the changes
  • Documentation updated (docstrings, docs/, CONTRIBUTING.md) if needed
  • CHANGELOG / release notes updated if applicable (n/a — the repository has no CHANGELOG)

AI/LLM disclosure

  • I did not use LLM tooling, or used it only privately for ideation
  • I used the following tool to help write this PR description: Claude Code (claude-sonnet-5)
  • I used the following tool to generate or modify code: Claude Code (claude-opus-5)

Important

By opening this PR I confirm that I have read CONTRIBUTING.md and I agree to the terms of the Contributor License Agreement.

Warning

If you're contributing on behalf of your employer, contact cla@algorithmiq.fi to arrange a Corporate CLA.

The store's own open-addressing index over row values is replaced by TermTable, a
persistent table keyed on the top 32 bits of a term's routing fingerprint, behind
the call sites main already had: the gate probe, the miss insert and the two
by-value lookups.

Slots are 4 bytes rather than the 8 of an (index, hash) pair -- a row index in the
low log2(slots) bits, the next hash bits above it as a compare prefilter -- and no
key is resident: the table folds a row's key off the row when it indexes it
(OperatorIndex::key_of_row), streaming the store on a rebuild and reading the rows
a gate has just written on an append. Every key match is confirmed against the
query's positions, so a collision costs a compare and can never produce a wrong
partner. The table is lazy and owned by MPOperator exactly as the inverted index
is: materialised on first use, appended by reindex_after_growth, rebuilt by its
staleness guard after a growth that bypassed that door.

Two things do not move. Miss indices are still assigned base+j in query-stream
order, so a gate's row layout is unchanged; and the by-value lookups
(get_operator's init-map drain, update_initial_operator) go through the same table
instead of a transient monomial map, measured neutral at 4.3 M terms.

The key fold lives in the new operator/RowKey.h, so the store does not include the
MPI routing header. indexing_bytes keeps its name and now reports the table: it is
the index. At the L1 Hubbard cell (9.95 M terms) it is 64 MiB against 128 MiB, the
ledger total 0.848x and VmHWM 0.899x, at 1.011x the propagate wall.

Assisted-by: ClaudeCode:claude-opus-5
…e rows

Seven cases over the persistent table: every stored row found under its own key
whichever tier holds it, absent keys missing, growth holding the load bound while
keeping every earlier row, and the staleness guard catching a growth that bypassed
reindex_after_growth.

Two of them are the properties the gate resolve rests on. The batched probe is
compared to an unordered_map over the same rows, answer for answer and in the miss
order the resolve assigns row indices in -- a miss takes base+j in query-stream
order -- so a pipeline that reordered or lost a query would show up as a different
row layout. And a key collision is constructed rather than hoped for: Gaussian
elimination over the labels' top halves gives a term whose key is 0, so s and s ^ d
share both a home slot and a prefilter, and only the row confirm can separate them.

Assisted-by: ClaudeCode:claude-opus-5
The rank-routing section already derives the fingerprint; the join key is its top
half, and this says what the table hashed by it costs, why the key is a plain
projection rather than a mixed one (a receiver folds the same number off positions
it decoded from the wire), why it is the high half, and that a structured collision
is still only a prefilter. The ledger section names indexing_bytes, which reports
that table.

cpp/tests/README.md's operator-store bullet is rewritten for the cases that exist:
term_table_tests.cpp, the row-key and block accessors in operator_index_tests.cpp,
and bulk_insert_tests.cpp gone with the insert it tested.

Assisted-by: ClaudeCode:claude-opus-5
@coderabbitai

coderabbitai Bot commented Sep 10, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added documentation Improvements or additions to documentation cpp labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown

Docs preview: https://pr-353.monoprop-docs.pages.dev

@codecov

codecov Bot commented Sep 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (perf/stack-3-sparse-exchange@812b1bc). Learn more about missing BASE report.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@                       Coverage Diff                       @@
##             perf/stack-3-sparse-exchange     #353   +/-   ##
===============================================================
  Coverage                                ?   97.70%           
===============================================================
  Files                                   ?       14           
  Lines                                   ?      742           
  Branches                                ?       98           
===============================================================
  Hits                                    ?      725           
  Misses                                  ?       12           
  Partials                                ?        5           
Flag Coverage Δ
cpp 97.70% <ø> (?)

Flags with carried forward coverage won't be shown. Click here to find out more.

@sonarqubecloud

Copy link
Copy Markdown

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

Labels

cpp documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant