Multi-library compare memory: --bundle-facts-out still pins OLD snapshots per member¶
Index: Known gaps — closed; see the archive.
Status (2026-11-01): CLOSED. --bundle-facts-out keeps each OLD member on disk, not resident. The release owns a temp-dir, content-addressed blob store (storage/bundle_facts_spool.BlobSpool); release_snapshot_retention.stash_member_evidence encodes a member's OLD snapshot into it as that member's comparison finishes, and the entry keeps a SpooledBlob handle beside the compact bundle evidence. release_bundle.write_bundle_facts_out spools each stranded library as it resolves it, then writes the document from the store one blob at a time: the archive form copies the blobs (they are the G40 blobs, storage/bundle_facts_archive.assemble_bundle_facts_archive, which the API's write_bundle_facts_archive now also goes through), and the JSON form splices each blob re-padded to its depth (storage/json_stream.PreEncoded), never decoding it. Member order is fixed before the write, so completion order never reaches the bytes, which equal the eager writer's. A .zip PATH now selects the archive form, as its published schema already said. Measured on a six-member release (docs/contribute/memory.md, round 3): parent peak RSS 913.7 → 369.7 MiB for the JSON form (the no-baseline control run is 370.1 MiB) and 2305.0 → 516.8 MiB for the archive, with the written bytes unchanged. Tests: tests/test_bundle_facts_out_spool.py. The surface_graph ordering question below is settled: the base revision's own captures differ run to run in dict key order, outside surface_graph too, recorded as its own open gap. The text below is the history.
Earlier status (re-verified 2026-10-09 at 456989f): PARTIAL. Closed: JUnit uses SymbolInventory, compressed baseline streaming, and the attrs aliasing. Still open: OLD-side retention for --bundle-facts-out (no spooling handle). Graph sharing and Fact pooling were rejected as negative results, and the surface_graph ordering question is unverified.
Three retention defects were measured and closed — per-side/per-consumer
member retention, the unbounded content-keyed half of the AST acquisition
table, and the baseline write's three simultaneous full-size copies (see
docs/contribute/memory.md for the numbers and the harness).
This entry records, deliberately, what is not closed, so the next
attempt starts from the measurement rather than from the same three places.
The OLD side is still retained in full, per member — but only for
--bundle-facts-out now. Half of this is closed: JUnit was the second
consumer and turned out to read four attributes off the snapshot, so it now
takes the compact model.symbol_inventory.SymbolInventory
and the snapshot is released when the member's comparison finishes.
--bundle-facts-out genuinely needs the whole document and still pins it.
Bounding that means spooling completed members — writing each member's
OLD snapshot to the storage codec as its comparison finishes and reading it
back when the folds run — rather than deciding retention. What makes it a
real change and not a tweak: --bundle-facts-out additionally resolves
stranded libraries that never produced a pair. The archive writer
(storage/bundle_facts_archive.py) already spools encoded blobs and is the
mechanism to reuse; what is missing is a bounded, lazy per-member handle
that consumer can take instead of a live object.
A compressed baseline write no longer joins. Closed.
storage/incremental_encode.py compresses the same fragment stream chunk
by chunk, and the determinism story the previous note asked for was
supplied rather than assumed: gzip's frame is assembled here with mtime=0
and OS=0xFF pinned explicitly and a raw-deflate payload from one
zlib.compressobj, which is byte-identical to the previous
gzip.compress output for every chunking down to one byte; zstd is
byte-identical whenever the decoded size is known up front. One residual:
the bundle-facts producer cannot state that size (it is streaming), so its
zstd frames omit the declared content size. The frame is legal, round-trips,
and validate_zstd_frame_completeness already handles CONTENTSIZE_UNKNOWN
— but it loses the declared-size cross-check that catches a frame truncated
mid-header, and its bytes differ from the one-shot encoder's for the same
content. A caller that can cheaply state the total should pass
decoded_size=.
Three attrs dictionaries per graph entity: closed, and worth reading for
the shape of the next one. Every GraphNode/GraphEdge held
facts[0].attrs, resolved and attrs with equal contents in the
single-producer shape a real graph is almost entirely made of (a measured
six-library release: 10,277 producer facts across 2,771 nodes and 7,506
edges, exactly one fact each). model.graph_facts.resolve_entity_attrs
returns the fact's own dict for that case and ensure_facts_and_resolve
aliases attrs to it — measured at −2.43 MiB and −13,851 objects on a
graph of that shape, and multiplied by the member count while
--bundle-facts-out retention holds. The aliasing is sound only because
attrs/resolved were already derived views that
ensure_facts_and_resolve overwrites on every call, which is why a direct
entity.attrs[k] = v was already documented as silently dropped and no
production call site performs one. Do not extend the aliasing to the
multi-fact case: that dict is a genuine merge result and sharing it would
make one producer's backfill rewrite another's evidence.
Whole-graph sharing across release members was investigated and not
shipped. The audit's observation — that all 10,277 serialized node/edge
payloads matched by content hash across six OLD-side member graphs — is a
statement about that fixture's shared headers, not a licence to share the
objects. build_header_only_graph(snap, ast_root, ...) takes the member's
own snapshot, so a correct sharing key would have to cover every
declaration that snapshot contributes, not just the header/compiler/macro/
target context the brief lists; and the graph is mutated after
construction (augment_graph_with_includes, degraded_passes,
extractor_passes, finalize()), so a shared instance would need a
copy-on-write or freeze boundary that does not exist today. The raw AST
is already shared (dumper_cache's in-process memo plus the disk cache),
so what would be saved is construction time and per-member graph storage,
not re-parsing. Reducing the per-entity storage was the sound half and is
what shipped; sharing the graph itself needs the same
inventory-of-every-mutation work the shared-header-declaration item below
already calls for, and must not be justified by one fixture's content-hash
coincidence.
Scalar Fact pooling was measured and deliberately not shipped.
Pooling immutable scalar Fact values on Function/Param after
construction reduced a whole six-member scan's parent peak by 4.4%
(127.1 MiB against 132.9 MiB on a small fixture) — a poor return for a
change that must preserve the UNKNOWN/ABSENT/PARTIAL/FAILED/
PRESENT distinctions exactly and must never share a mutable list/dict
payload. In a narrower controlled experiment (twelve 5,000-function
models, 116.37 MiB of Python allocations) the same pooling reached 57.77
MiB, which is the reason not to drop the idea: the gap between the two says
the win is real in the model objects and is being swamped by everything
else resident during a real scan. The right next step is therefore not
to pool after construction but to avoid the duplicate construction — and
per AGENTS.md's own guidance, the larger follow-on (shared immutable
header declarations plus per-member bindings; two shared surfaces plus
member overlays measured 22.89 MiB in the same experiment) requires an
inventory of every mutation of a Function after construction and an
explicit ownership boundary first. Sharing today's mutable Function
objects by reference is not that work and must not be presented as it.
Concurrency was not re-tuned. The per-worker memory clamp
(workflows/release_jobs.py) sizes off a budget described as "each holding
up to two full snapshots resident". That description is still accurate:
retention now keeps at most one full snapshot per member after a
comparison, but both sides are live during one, which is what the clamp
budgets. Changing the constant without measuring a real fan-out's
co-resident set would repeat the gap that constant already carries (see
perf.release_admission_ignores_co_resident_set's own known gaps). A
process pool is separately ruled out on evidence: four processes on a fixed
Python-heavy workload took process-tree PSS from ~57 MiB to ~226 MiB.
The matcher work is a CPU change, not a memory one, and is out of scope
here. Token-indexed and trie prototypes preserved nesting, ordering and
range semantics and cut synthetic scanning time substantially, but a Python
trie retained 6.91 MiB against a regex's 1.39 MiB. If it is taken up, it
needs adversarial common-prefix and randomized differential tests against
the current matcher as oracle, and it must not be justified as a memory
fix. id()-keyed match caching was measured and rejected: warm production
lookups are ~0.60 µs, stable string keys already benefit from cached
hashes, and interning inside each lookup was worse.
No oneDAL operand was available. /mnt/cached_oses/napetrov/tmp-abi/l2b6/
does not exist in this workspace, so the 35:47.83 / 17.82 GiB six-library
figures could not be reproduced, the exact command/frontend/cache state
behind them could not be established, and none of the reductions recorded
here is a measured oneDAL reduction. The fixture used is a real compiled
six-member C++ release, which is the right shape and a far smaller
scale. Whether the whole job fits a nominal-16-GB runner is therefore
untested; the next experiment is to run
scripts/bench_release_memory.py --keep against the real oneDAL tree with
--trace, and read release.ast_scope and release.member.retained
against the sampled cgroup peak to see which of the two remaining owners
(OLD-side member retention, or one member's own working set) dominates
there.
Unverified: surface_graph member ordering across runs. The
before/after baseline documents in the measurement above were equal on
every field except the ordering within surface_graph.nodes/edges (equal
multisets, identical graph_id). Nothing on that branch touches
surface-graph construction and the streaming encoder is byte-identical to
the eager one over a fixed document, so pre-existing run-to-run
nondeterminism is the likely cause — but the check that settles it (run the
unchanged base revision twice, diff the two baselines) was started and
lost when the workspace's /tmp was cleared, and is therefore not
claimed. Anyone touching baseline determinism should run it first:
python scripts/bench_release_memory.py --members 3 --apis 120 --records 10
--variants bundle-facts --repeat 2 and cmp the two baseline.json
files. If they differ, the ordering is genuinely unstable and that is its
own (pre-existing) gap, not a property of the streaming write.