Conversation
The benchmark gate failed once and passed on re-run — both numbers, for the recordRun 1 (failed, worst delta +16.3%): the regressions were Four of those five run code this PR cannot reach. Run 2 (passed, worst delta +1.8%, noise floor 0.8%), per-round minima:
The two metadata benchmarks that read +11% and +16% in run 1 are flat to the microsecond in run 2, on a run whose control noise floor is a fifth of run 1's. That is the code-layout scatter #204 documented and #240 measured — the gate's 7% threshold sits inside it — and it is why the gate's own output says to re-run before acting on a verdict. Verdict stands on the agreement of three instruments that do not share the runner: the x86 gate twice, a local interleaved A/B on an M4 twice ( |
…d leaf (#239) Deferring a decode used to copy the part's encoded bytes out of the message, which is the opposite of what deferring is for. Parsing a 96 MiB single- attachment message in mode="lazy" cost 96 MiB on top of the payload the caller still held -- and base64 is 1.33x what it encodes, so a retained part could cost more than the decoded bytes it avoided producing. A deferred part now keeps its offsets in the buffer it was parsed from, and the result keeps that buffer alive. Measured on a 96 MiB message with one unread attachment: peak RSS 192.3 MiB before, 96.2 MiB after. Counted in the core on the attachment-heavy fixture, a lazy tree peaked at 798,111 bytes and now peaks at 14,332 -- the same as a metadata tree, because a range is not a copy -- and a flat lazy parse dropped from 790,993 to 23,398. This changes the memory contract. A PyLazyMail attachment or a PyLazyMimePart leaf pins the payload it was parsed from for as long as it is reachable, so keeping one attachment out of a mailbox keeps that whole message rather than just the part. The pin is per message even in parse_many, so one slot never holds another's payload; and a caller who wants the bytes without the message reads content, which is a decoded copy, and drops the attachment. Three things the ranges have to get right, each with its own guard: - A repaired message (#150) is parsed from a rebuilt copy, so the offsets index the rebuild and not the caller's payload. LazyMail and the new DeferredTree carry that buffer instead of dropping it, and a core test asserts the rebuild is one byte longer and that the right buffer gives the right bytes. - A leaf below a message/rfc822 node was parsed out of a decode of that body, which no caller holds, so it keeps a copy. Retain has three states rather than two for exactly this: "keep nothing" and "keep a copy because there is nothing to point into" are different answers, and conflating them dropped those leaves' bytes entirely -- caught by a core test, invisible to the Python suite, which now has one too. - An offset that is off by one still decodes, to something else. Retained::of is bounds-checked rather than asserted so it can never index out of range, and the PR-time fuzz job now runs with debug assertions (-a) so the silent fallback to copying fails the build instead of becoming a quiet regression. Local interleaved A/B on an M4, 3 rounds, twice: parse_lazy_untouched 0.041 -> 0.030 ms and parse_tree_lazy_untouched 0.040 -> 0.030 ms, with nothing else moving past a 1.2% control noise floor. 864 Python tests, 10 core tests, clippy, fmt, mypy --strict and ruff clean; 527K fuzz executions of parse_agreement with debug assertions live, plus the other three targets, no findings.
98feb40 to
ec898a7
Compare
Closes #239.
Deferring a decode used to copy the part's encoded bytes out of the message, which is the opposite of what deferring is for. A deferred part now keeps its offsets in the buffer it was parsed from, and the result keeps that buffer alive.
What it costs and saves
ru_maxrssof a subprocess parsing a 96 MiB single-attachment message inmode="lazy"and reading nothing:Counted in the core (
bench/tests/allocs.rs, peak live bytes), before → after:large_message.emllazylarge_message.emltree-lazyvalid_message.emltree-lazyattachment_message.emltree-lazyA lazy tree now allocates exactly what a metadata tree allocates, because a range is not a copy. The old table's note that a lazy tree could hold more than a fully decoded one — 6,696 against 6,323 bytes, base64 being 4/3 of what it decodes to — no longer describes anything, and the assertion that replaces it is the one that was false before: a lazy tree's peak is under a quarter of the input.
The contract this changes
A deferred part pins the payload. A
PyLazyAttachmentor aPyLazyMimePartleaf keeps the message it was parsed from alive for as long as it is reachable, so keeping one attachment out of a mailbox keeps that message rather than just the part. Values are unchanged in every mode. The pin is per message even inparse_many— one slot never holds another's payload — and a caller who wants the bytes without the message readscontent, which is a decoded copy, and drops the attachment.Readme.md, the three__init__.pyidocstrings and theCHANGELOGall say so.Three things the ranges have to get right
Each has its own guard, because each fails silently.
LazyMailand the newDeferredTreecarry that buffer instead of dropping it. A core test asserts the rebuild is one byte longer and that resolving against it gives the right bytes, since resolving against the payload would also "work" and return something one byte off.message/rfc822node has nothing to point into. Its bytes came from decoding that body, which is dropped when the walk leaves the subtree, so it keeps a copy.Retainhas three states rather than two for exactly this reason: "keep nothing" and "keep a copy because there is no buffer" are different answers, and my first version conflated them — which dropped those leaves' bytes entirely and turned lazy mode into metadata mode for that subtree. A core test caught it; the Python suite did not, so it has one now too.Retained::ofis bounds-checked rather than asserted, so it can never index out of range — but its fallback is a silent copy, which would degrade to exactly the behaviour this issue removes. The PR-time fuzz job therefore runs with-a(debug assertions), where that fallback is a panic.deep-fuzzstays on the release build, where executions per second matter more.Measurements
Local interleaved A/B, Apple M4, 3 rounds per side,
ab_median.py, run twice:parse_lazy_untouchedparse_tree_lazy_untouchedparse_lazy_all_attachmentsNothing else moved past the control noise floor (1.2% and 2.7% in the two runs); the worst non-target value was −1.9%. Both runs reproduced the two headline numbers within 0.7 points of each other.
Verification: 864 Python tests, 10 core Rust tests (which run in debug, where
Retained::of's assertion is live),bench/tests/allocs.rs,cargo clippy --all-targets -D warningson all three crates,cargo fmt,mypy --strict,ruff, and the vendored-mailparse and bench-lockfile checks. 527K fuzz executions ofparse_agreementwith debug assertions on, plus the other three targets, no findings.Note for anyone building locally on macOS 26/27:
strip = "debuginfo"in[profile.release]currently produces a.soCPython refuses to load (mis-aligned LINKEDIT string pool). It reproduces on master, so it is not from this change;CARGO_PROFILE_RELEASE_STRIP=noneworks around it, and both sides of the A/B above were built that way.