Skip to content

perf(operator): ⚡ release the init_op_map buckets once its terms bind - #268

Open
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
perf/init-op-map-release
Open

perf(operator): ⚡ release the init_op_map buckets once its terms bind#268
diagonal-hamiltonian wants to merge 2 commits into
mainfrom
perf/init-op-map-release

Conversation

@diagonal-hamiltonian

@diagonal-hamiltonian diagonal-hamiltonian commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

get_operator() now erases bound init_op_map entries in place during its single store->find()
pass via erase_if, then releases the drained buckets with rehash(0)1,148,190,448 B → 896 B
of retained buckets at the one point where the map has ever been measured large.
perf/init-op-map-release 5113bec, one commit plus a merge of main (which pulls #272,
pyproject.toml only) on e0c528e, 3 files +92/−14 against the 668c90b merge base.

  1. The release is the point. erase/clear keep bucket_count() on unordered_flat_map, and
    init_operator_bytes reports exactly that — so a fully drained map keeps holding, and reporting,
    its whole bucket array. rehash(0) on an emptied map releases to bucket_count() == 0. It is
    called only when the pass actually erased something.
  2. Erasing in place drops a lookup and a temporary. main collects bound keys into a
    std::vector<Monomial> and then re-hashes each one through init_op_map.erase(mono); erase_if
    removes both the vector and that second hash per bound entry.
  3. init_operator_entries / d_init_operator_entries exposes the live entries behind
    init_operator_bytes, outside total_bytes() like every other d_ diagnostic. Without it,
    bytes and entries are indistinguishable and dead buckets read as live storage.
  4. The map is per-rank, and only for rank-owned terms
    (cpp/monoprop/detail/monomial_propagator/MonomialPropagator.inl:155-157), so its size is
    obs_terms / ranks. A 1-rank layout concentrates the whole map in one rank; wide layouts divide
    it.

What this does not claim. Not "one find pass instead of two" — main already does one find pass
plus one erase(key) per bound entry. That claim was true only of an earlier branch whose partial arm
re-ran find over every entry.

Adversarial review. An independent review traced the form into pinned Boost 1.88 — erase_if is
an ADL-only hidden friend; foa snapshots each group's occupied mask before walking it and erase
never reallocates; rehash(0) reaches 0 buckets because the if (n) guard skips capacity_for's
~29-slot minimum — and did not break it.

Changes

  • get_operator(): erase_if binds and erases in one pass, then rehash(0) on a map whose size
    changed. The deferred-erase key vector is gone.
  • MPOperatorMemoryBreakdown::init_operator_entries, summed in operator+=, excluded from
    total_bytes(), filled from init_op_map.size() in estimate_memory_usage.
  • d_init_operator_entries added to the Python memory-breakdown dict in binder.h.
  • Four mp_operator_tests.cpp cases: fully bound, partially bound, nothing bound, and one pinning
    init_operator_entries out of total_bytes().

Measurement

The mechanism is measured, but not by an A/B campaign, and the campaign grid provably cannot see
it.
Both statements matter, so both are given.

The measurement is a single standalone operator-memory ledger reading on the random-Heisenberg
workload at a 7M-term observable, recorded in the round-3 memory results. init_op_map held
1,148,190,448 bytes with d_init_operator_entries = 0 — a fully drained map still holding its
entire bucket array, because erase never shrinks bucket_count() — and 896 bytes after the
release. The same drain also removed a transient std::vector<Monomial> of keys worth 448 MB of
peak across 16 partitions
.

This is one reading from a standalone ledger script, not an interleaved A/B campaign: no reps, no
paired per-rep ratios, and therefore no agree column to report.
The form it measured was
swap-with-empty; this PR uses erase_if + rehash(0). So it characterises the mechanism and its
order of magnitude, not this exact diff.

main this branch
init_operator_bytes, held for the propagator's lifetime 1,148,190,448 896

d_init_operator_entries cannot prove the map grew, and should not be read that way.
initialize_operator_caches_() runs at construction
(MonomialPropagator.inl:187, calling get_operator() at :500), when every entry binds — so the
counter reads 0 on both arms at every recording point. It proves the drain, not the growth. What
differs between arms is init_operator_bytes: ~1.15 GB held for the propagator's whole lifetime on
main, ~896 B here.

The benchmark grid cannot see this, and there is now evidence rather than an assertion. Across
the 320 artifacts of the most recent benchmark campaign, all 560 init_operator_bytes readings take
exactly two values: 8357 (280 readings) and 15525 (280) — the empty-map floor, with no third
value anywhere. The reason is that the 39.58 B/term figure tracks the observable, not the
operator, and both grid models carry a single-term observable: hubbard's is one number operator
n_{site,spin}
(packages/monoprop-bench-tools/src/monoprop_bench_tools/models.py:334-343), pauli's is a single Z
on one qubit (models.py:567-572). A single-term observable puts one entry in init_op_map, so
there are no buckets to release.

A campaign for this is possible and is deliberately not being run. --obs-terms already exists
(benches/conftest.py:119-127, default 10000), the private harness already drives the random
workload with an observable-term knob, and random is already an accepted grid workload. The only
thing missing is the grid row: the benchmark grid has no many-term-observable row. The
justification for merging without one is the code argument plus the ledger figure above — a drained
map holding its buckets misreports init_operator_bytes by its whole allocation on any workload that
grows it — not an inability to measure.

Gates: ctest -L unit 220/220, -L serial 219/219, -L mpi 1/1, and 592 passed at each of four
MPI layouts. The fully-bound test pins bucket_count() == 0 and is mutation-verified — removing
rehash(0) fails it.

Relationship to the open PRs

Independent of #259 and #263: no stacking is needed and this sits on main directly.

It touches all three of MPOperator.h, cpp/tests/mp_operator_tests.cpp and
src/monoprop/bindings/binder.h in common with those PRs, but MPOperator.h and binder.h merge
clean against both — the edits land in different regions of each file. The only conflict is one
adjacent-insertion hunk in cpp/tests/mp_operator_tests.cpp against each.

No breakdown-key collision either: #259 adds matched_scratch_bytes inside total_bytes(),
this PR adds d_init_operator_entries outside it.

Checklist

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

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
  • I used the following tool to generate or modify code: Claude Code

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.

get_operator() bound pending init_op_map terms, collected the bound monomials into a temporary
vector, then erased them in a second loop -- one extra hash lookup per bound entry. erase_if erases
in place during the single store->find() pass, dropping both.

The release is the point. erase/clear keep bucket_count() on unordered_flat_map, and
init_operator_bytes reports exactly that, so a fully drained map keeps holding and reporting its whole
allocation. rehash(0) on an emptied map releases to bucket_count() == 0.

Note this does NOT claim "one find pass instead of two" against main: main already does one find pass
plus one erase(key) per bound entry. That claim was true only of an earlier branch whose partial arm
re-ran find over every entry.

init_operator_entries (Python d_init_operator_entries) exposes the live entries behind
init_operator_bytes so a reader can tell held bytes from dead buckets. Outside total_bytes(), like
every other d_ diagnostic.

The fully-bound test pins bucket_count() == 0; mutation-verified -- removing rehash(0) fails it.
@github-actions

Copy link
Copy Markdown

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

@diagonal-hamiltonian
diagonal-hamiltonian requested a lite review from Copilot August 21, 2026 11:41
@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as ready for review August 21, 2026 11:42

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes detail::MPOperator::get_operator() by erasing bound init_op_map entries in-place via erase_if and then explicitly releasing the underlying bucket allocation with rehash(0) when the map was drained/changed, ensuring init_operator_bytes reflects freed capacity rather than lingering buckets.

Changes:

  • Replace the prior “collect keys then erase” approach in get_operator() with a single-pass erase_if and conditional rehash(0) to release drained buckets.
  • Add new memory diagnostic init_operator_entries (exposed to Python as d_init_operator_entries) to report live entries separately from bucket-backed byte estimates.
  • Add targeted C++ unit tests covering fully-bound, partially-bound, and nothing-bound cases, plus aggregation semantics for the new diagnostic.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
cpp/monoprop/detail/operator/MPOperator.h Implements in-place erasure + conditional rehash(0) in get_operator(), and adds/records init_operator_entries in the memory breakdown.
cpp/tests/mp_operator_tests.cpp Adds white-box tests validating bucket release/shrink behavior and that init_operator_entries stays out of total_bytes().
src/monoprop/bindings/binder.h Exposes init_operator_entries as a diagnostic entry in operator_memory_breakdown for Python consumers.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@codecov

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.70%. Comparing base (e0c528e) to head (5113bec).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #268   +/-   ##
=======================================
  Coverage   97.70%   97.70%           
=======================================
  Files          14       14           
  Lines         742      742           
  Branches       98       98           
=======================================
  Hits          725      725           
  Misses         12       12           
  Partials        5        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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants