Skip to content

feat(memory): 📈 name the resident bytes the operator ledger cannot see - #293

Open
diagonal-hamiltonian wants to merge 9 commits into
mainfrom
perf/memacct
Open

feat(memory): 📈 name the resident bytes the operator ledger cannot see#293
diagonal-hamiltonian wants to merge 9 commits into
mainfrom
perf/memacct

Conversation

@diagonal-hamiltonian

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

Copy link
Copy Markdown
Collaborator

🤖 AI text below 🤖

Summary

At a fixed 1,569,152,761 terms the operator ledger's total is flat from 1 to 32 nodes while the kernel's peak RSS grows 2.15×. Coverage falls 91% → 40%, and no field names the difference.

It is three things, not one:

at N=32 what it is
79.5 GiB glibc holding freed chunks it never returned — faulted while live, resident forever, belonging to no structure a ledger can name
9.9 GiB reserved capacity nothing ever wrote, which the kernel therefore never charges (at N=1 the ledger over-claims: 94.2 vs 89.8 GiB resident)
2.2 GiB the in-process transport, which no field covered at all

With the fields below, the five identity terms sum to the measured peak exactly at both rungs. The remaining 45 GiB is 256 Python interpreters, mpi4py and MPI's own buffers — beyond the engine's reach, but now the named residue.

Reserved bytes are not free: peak RSS follows capacity, because each growth holds the old fully-written buffer and the new one at once. So shrink_to_fit cannot recover them and must cost another copy — only fewer or earlier growths help.

Changes

  • transport_bytes — inside total_bytes(), filled once by the partitioned facade. A partition reports 0, so the sum over S cannot multiply it.
  • reserved_bytes() — derived, not stored: the row-store, inverted-index and coefficient slacks.
  • d_terms_peak_bytes / d_indexing_peak_bytes — the growth duplicate at the two reallocation sites, since capacity-at-end cannot represent a peak over time. Each carries its resting field's other terms, so a peak can never read below the bytes it is a peak of. Both sites are cold; no hot loop changes.
  • detail::process_memory()VmRSS/VmHWM and malloc_info(3), bound as d_proc_*, so coverage needs no external harness. Agrees with /usr/bin/time -v to 100.0% at 8 ranks and 99.9% at 256. Zero-filled off Linux/glibc, and cannot throw.
  • detail/MemoryBytes.hcapacity_bytes / capacity_slack_bytes, shared by the operator store, the inverted index and both transports.

Verification

Deucalion dev-x86, commit c828609f, _core.so md5 ab0fc4055a03… — jobs 1853830 (1 node) and 1853831 (2 nodes):

suite result
ctest -L unit 247/247
ctest -L serial 246/246
ctest -L mpi 1/1
Python MPI suite 593 passed × 4 layouts (world 2 → 256)

Main's baseline is 241 unit / 240 serial; this adds six cases, all confirmed to actually run rather than be silently absent. The case covering the peak-over-time counters was mutation-tested: reverting just the overflow term makes it fail 4176 <= 10508, the negative growth duplicate it exists to catch.

Caveats

  • The table's figures were measured on Deucalion x86 (Hubbard c10 / atol 2.6e-06, layout B, 8×16 per node), jobs 1851543/1851544, against _core.so 3b2113539aee…. Later revisions changed the accounting code, not the quantities; they have not been re-measured since.
  • -L unit / -L serial do not reach PartitionGroup::transport_memory_bytes() through a real multi-partition group; the C++ cases test the breakdown arithmetic and process_memory() directly.

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

@github-actions

Copy link
Copy Markdown

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

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

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

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #293   +/-   ##
=======================================
  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.

@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as draft August 26, 2026 17:01
At a fixed 1,569,152,761 terms the ledger's own total is flat from 1 to 32 nodes while
the kernel's peak RSS grows 2.15x, so coverage falls 91% -> 40% and the difference is
silent. Measured on Deucalion x86, hubbard c10 / atol 2.6e-06, layout B (8 ranks x 16
partitions per node), jobs 1851543 (N=1) and 1851544 (N=32), _core.so md5
3b2113539aee3ea59a0f0488f990a304.

The gap is three things, not one:

  * 79.5 GiB of a 215 GiB job at N=32 is glibc holding freed chunks it never returned.
    Those pages were faulted while the chunk was live and stay resident, so every large
    transient the engine allocates and frees is charged to the process forever while
    belonging to no structure a ledger can name. It is ~0.3 GiB per rank at every width,
    flat across a 32x range in terms per rank, so at 256 ranks it reaches 54.4 B/term
    against a ledger of 62.0.
  * 9.9 GiB of total_bytes() at N=32 is reserved capacity nothing ever wrote, which the
    kernel therefore never charges. At N=1 the ledger reads 94.2 GiB against 89.8 GiB
    resident: it over-claims, and the old coverage figure hid that by comparing an
    end-of-run capacity against a peak-over-time.
  * 2.2 GiB is the in-process transport, which no field covered at all.

So the engine now reports:

  * transport_bytes, a real field inside total_bytes(), filled once by the partitioned
    facade from PartitionGroup::transport_memory_bytes(). A partition reports 0, so the
    sum over S cannot multiply it. d_transport_staging_bytes breaks out the payload
    funnel and stays outside total_bytes().
  * reserved_bytes, rolling up the row-store, inverted-index and coefficient slacks.
    Reserved and never written -- but NOT free, and the comment says so: job 1851566
    measured peak RSS against that slack directly and found it tracks capacity, not used
    bytes (flat to +/-0.073 GiB within a x1.5 branch, stepping -0.650 GiB at the capacity
    boundary), because each growth holds the old fully-written buffer and the new one at
    once. The corollary is that shrink_to_fit cannot recover it and must cost another
    copy; only fewer or earlier growths help.
  * d_terms_peak_bytes / d_indexing_peak_bytes, the growth duplicate at the two
    monolithic reallocation sites. Capacity at end cannot represent a peak over time.
    Both sites are cold paths, so no hot loop changes.
  * detail::process_memory(), reading VmRSS/VmHWM and malloc_info(3), bound as d_proc_*.
    Coverage is now derivable from one call instead of an external harness, and
    d_proc_peak_rss_bytes agrees with `/usr/bin/time -v` to 100.0% at 8 ranks and 99.9%
    at 256. It is zero-filled off Linux/glibc and cannot throw, and equally when something
    replaces malloc: under ASan or TSan glibc's arenas stay empty, so the byte fields read
    zero and the test asserts that absence is coherent rather than skipping the case.

The five identity terms sum to the measured peak exactly at both rungs. What remains is
45 GiB at N=32 that is 256 Python interpreters, mpi4py and MPI's own buffers, which the
engine cannot reach; it is now the named residue rather than an unexplained one.

Assisted-by: claude-code:claude-opus-5
Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>

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 extends monoprop’s memory reporting so the operator “byte ledger” can be reconciled with observed peak RSS, by adding explicit fields for transport, reserved-but-unwritten capacity, peak-over-time growth duplication, and per-process kernel/allocator diagnostics.

Changes:

  • Adds per-process diagnostics via detail::process_memory() (VmRSS/VmHWM + glibc malloc_info(3)), surfaced to Python as d_proc_*.
  • Expands MPOperatorMemoryBreakdown with transport_bytes, reserved/slack metrics, and peak-over-time counters from the two main growth sites.
  • Wires group-owned transport memory into the partitioned facade and adds C++ tests validating the new accounting behavior.

Reviewed changes

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

Show a summary per file
File Description
src/monoprop/bindings/binder.h Exposes new operator memory breakdown fields (including per-process diagnostics) to Python.
cpp/tests/mp_operator_tests.cpp Adds unit tests covering new breakdown fields and process_memory() behavior.
cpp/monoprop/detail/ProcessMemory.h Introduces ProcessMemory struct and the process_memory() API contract.
cpp/monoprop/detail/ProcessMemory.cpp Implements /proc + glibc allocator parsing to populate per-process diagnostics.
cpp/monoprop/detail/partition/PartitionGroup.h Adds group-owned transport memory reporting as {total, staging}.
cpp/monoprop/detail/operator/OperatorIndex.h Adds peak-over-time tracking for row storage and index growth.
cpp/monoprop/detail/operator/MPOperator.h Adds new breakdown fields and computes slack/reserved/peak values in estimate_memory_usage().
cpp/monoprop/detail/operator/InvertedIndex.h Adds inverted-index slack computation (reserved-but-unwritten capacity).
cpp/monoprop/detail/mpi/ShmComm.h Adds transport memory reporting for the shared-memory comm path.
cpp/monoprop/detail/mpi/HybridComm.h Adds transport memory/staging reporting for the hybrid MPI comm path.
cpp/monoprop/detail/monomial_propagator/MonomialPropagator.inl Ensures group transport bytes are added once (not multiplied across partitions).
cpp/monoprop/detail/CMakeLists.txt Adds the new ProcessMemory sources to the build.

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

Comment thread cpp/monoprop/detail/ProcessMemory.cpp Outdated
Comment thread cpp/monoprop/detail/operator/OperatorIndex.h
Comment thread cpp/monoprop/detail/operator/MPOperator.h Outdated
diagonal-hamiltonian and others added 6 commits August 27, 2026 09:47
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ng field

Copilot review:
- ProcessMemory reads /proc via ifstream, so fopen's "e" mode flag is no longer a
  portability question that could silently zero VmRSS/VmHWM.
- reserve_rows reads the growth duplicate off the POST-reserve capacity; reserve()
  may hand back more than asked.
- Restores the insert_absent_terms contract 20bd33b truncated mid-sentence:
  currently-absent keys and per_slot-writes-before-bulk_insert are preconditions the
  code does not enforce. Restores the state_scored_rows_ comment, which the same
  commit replaced with a wrong one (it is a scored-row watermark, not a cache).

Correctness of the peaks. rows_peak_bytes() omitted the overflow map while
operator_terms_bytes counts it, so an operator with enough spilled rows reported a
peak BELOW its resting bytes and a negative growth duplicate; index_peak_bytes()
compared a table-only peak against a figure including sizeof(OperatorIndex). Both
now carry their resting field's other terms, and both counters are stored in
elements so the two accessors read the same way. Covered by a new test whose keys
have popcount > the inline width -- grow_in_batches cannot reach the overflow path,
its keys top out at popcount 9.

/simplify:
- capacity_bytes / capacity_slack_bytes move to detail/MemoryBytes.h, deducing the
  element size. Replaces HybridComm's private cap_bytes_, three hand-rolled copies
  in InvertedIndex::slack_bytes(), and the explicit `elem` argument at four sites.
- reserved_bytes is derived from the three slacks it rolls up rather than stored,
  removing a fourth state to keep in sync and an operator+= line.
- process_memory() holds its documented "never throws" in code: a noexcept wrapper
  round an inner reader, so a throw yields a zero-filled result rather than a
  partially-filled one. malloc_info's open_memstream buffer is now owned, so the
  string copy throwing cannot leak it.
- Named constants for the malloc_info tags and raw string literals for the XML.

Gated on Deucalion dev-x86 (job 1853765, _core.so ab0fc4055a03): ctest -L unit
247/247, -L serial 246/246, both including the new case. The prior revision passed
the full 2-node sweep (job 1853743): 245 serial, 1/1 mpi, 593 Python x 10 layouts.

Assisted-by: ClaudeCode:claude-opus-5
Removed comments explaining the transport_bytes variable.

Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>
Corrected grammatical error in comment regarding memory management.

Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>
Signed-off-by: Aaron Miller <61472721+diagonal-hamiltonian@users.noreply.github.com>
@diagonal-hamiltonian
diagonal-hamiltonian marked this pull request as ready for review August 28, 2026 10:02
@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 python test-in-draft Run CI even in Draft mode

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants