perf(operator): ⚡ release the init_op_map buckets once its terms bind - #268
perf(operator): ⚡ release the init_op_map buckets once its terms bind#268diagonal-hamiltonian wants to merge 2 commits into
Conversation
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.
|
Docs preview: https://pr-268.monoprop-docs.pages.dev |
There was a problem hiding this comment.
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-passerase_ifand conditionalrehash(0)to release drained buckets. - Add new memory diagnostic
init_operator_entries(exposed to Python asd_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 Report✅ All modified and coverable lines are covered by tests. 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
Flags with carried forward coverage won't be shown. Click here to find out more. |
|



🤖 AI text below 🤖
Summary
get_operator()now erases boundinit_op_mapentries in place during its singlestore->find()pass via
erase_if, then releases the drained buckets withrehash(0)— 1,148,190,448 B → 896 Bof retained buckets at the one point where the map has ever been measured large.
perf/init-op-map-release5113bec, one commit plus a merge ofmain(which pulls #272,pyproject.tomlonly) one0c528e, 3 files +92/−14 against the668c90bmerge base.erase/clearkeepbucket_count()onunordered_flat_map, andinit_operator_bytesreports exactly that — so a fully drained map keeps holding, and reporting,its whole bucket array.
rehash(0)on an emptied map releases tobucket_count() == 0. It iscalled only when the pass actually erased something.
maincollects bound keys into astd::vector<Monomial>and then re-hashes each one throughinit_op_map.erase(mono);erase_ifremoves both the vector and that second hash per bound entry.
init_operator_entries/d_init_operator_entriesexposes the live entries behindinit_operator_bytes, outsidetotal_bytes()like every otherd_diagnostic. Without it,bytes and entries are indistinguishable and dead buckets read as live storage.
(
cpp/monoprop/detail/monomial_propagator/MonomialPropagator.inl:155-157), so its size isobs_terms / ranks. A 1-rank layout concentrates the whole map in one rank; wide layouts divideit.
What this does not claim. Not "one find pass instead of two" —
mainalready does one find passplus one
erase(key)per bound entry. That claim was true only of an earlier branch whose partial armre-ran
findover every entry.Adversarial review. An independent review traced the form into pinned Boost 1.88 —
erase_ifisan ADL-only hidden friend; foa snapshots each group's occupied mask before walking it and
erasenever reallocates;
rehash(0)reaches 0 buckets because theif (n)guard skipscapacity_for's~29-slot minimum — and did not break it.
Changes
get_operator():erase_ifbinds and erases in one pass, thenrehash(0)on a map whose sizechanged. The deferred-erase key vector is gone.
MPOperatorMemoryBreakdown::init_operator_entries, summed inoperator+=, excluded fromtotal_bytes(), filled frominit_op_map.size()inestimate_memory_usage.d_init_operator_entriesadded to the Python memory-breakdown dict inbinder.h.mp_operator_tests.cppcases: fully bound, partially bound, nothing bound, and one pinninginit_operator_entriesout oftotal_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_mapheld1,148,190,448 bytes with
d_init_operator_entries= 0 — a fully drained map still holding itsentire bucket array, because
erasenever shrinksbucket_count()— and 896 bytes after therelease. The same drain also removed a transient
std::vector<Monomial>of keys worth 448 MB ofpeak 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
agreecolumn to report. The form it measured wasswap-with-empty; this PR uses
erase_if+rehash(0). So it characterises the mechanism and itsorder of magnitude, not this exact diff.
init_operator_bytes, held for the propagator's lifetimed_init_operator_entriescannot prove the map grew, and should not be read that way.initialize_operator_caches_()runs at construction(
MonomialPropagator.inl:187, callingget_operator()at:500), when every entry binds — so thecounter 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 onmain, ~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_bytesreadings takeexactly 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 singleZon one qubit (
models.py:567-572). A single-term observable puts one entry ininit_op_map, sothere are no buckets to release.
A campaign for this is possible and is deliberately not being run.
--obs-termsalready exists(
benches/conftest.py:119-127, default 10000), the private harness already drives therandomworkload with an observable-term knob, and
randomis already an accepted grid workload. The onlything 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_bytesby its whole allocation on any workload thatgrows it — not an inability to measure.
Gates:
ctest -L unit220/220,-L serial219/219,-L mpi1/1, and 592 passed at each of fourMPI layouts. The fully-bound test pins
bucket_count() == 0and is mutation-verified — removingrehash(0)fails it.Relationship to the open PRs
Independent of #259 and #263: no stacking is needed and this sits on
maindirectly.It touches all three of
MPOperator.h,cpp/tests/mp_operator_tests.cppandsrc/monoprop/bindings/binder.hin common with those PRs, butMPOperator.handbinder.hmergeclean 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.cppagainst each.No breakdown-key collision either: #259 adds
matched_scratch_bytesinsidetotal_bytes(),this PR adds
d_init_operator_entriesoutside it.Checklist
docs/,CONTRIBUTING.md) if neededCHANGELOG/ release notes updated if applicableAI/LLM disclosure
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.