Skip to content

Lazy modes borrow the pinned payload instead of copying every deferred leaf (#239) - #258

Open
kurok wants to merge 1 commit into
masterfrom
feat/239-lazy-borrow
Open

kurok wants to merge 1 commit into
masterfrom
feat/239-lazy-borrow

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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_maxrss of a subprocess parsing a 96 MiB single-attachment message in mode="lazy" and reading nothing:

peak growth
master 192.3 MiB — the payload, plus a copy of the attachment
this PR 96.2 MiB — the payload

Counted in the core (bench/tests/allocs.rs, peak live bytes), before → after:

fixture / mode master this PR
large_message.eml lazy 790,993 23,398
large_message.eml tree-lazy 798,111 14,332
valid_message.eml tree-lazy 113,597 15,356
attachment_message.eml tree-lazy 6,696 6,295

A 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 PyLazyAttachment or a PyLazyMimePart leaf 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 in parse_many — 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. Readme.md, the three __init__.pyi docstrings and the CHANGELOG all say so.

Three things the ranges have to get right

Each has its own guard, because each fails silently.

  • A repaired message is parsed from a rebuilt copy. The Malformed message: body lost and a MIME boundary reported as a header key #150 separator repair means the offsets index the rebuild, not the caller's payload — so LazyMail and the new DeferredTree carry 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.
  • A leaf below a message/rfc822 node 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. Retain has 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.
  • 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 — 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-fuzz stays 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:

Benchmark master this PR
parse_lazy_untouched 0.041 ms 0.030 ms −26%
parse_tree_lazy_untouched 0.040 ms 0.030 ms −25%
parse_lazy_all_attachments 0.173 ms 0.164 ms −5%

Nothing 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 warnings on all three crates, cargo fmt, mypy --strict, ruff, and the vendored-mailparse and bench-lockfile checks. 527K fuzz executions of parse_agreement with 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 .so CPython refuses to load (mis-aligned LINKEDIT string pool). It reproduces on master, so it is not from this change; CARGO_PROFILE_RELEASE_STRIP=none works around it, and both sides of the A/B above were built that way.

@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

The benchmark gate failed once and passed on re-run — both numbers, for the record

Run 1 (failed, worst delta +16.3%): the regressions were parse_tree_metadata +16.3%, parse_many_metadata +12.7%, parse_metadata and parse_metadata_str +11.2% each, headers_first_read +7.1%. Noise floor from the controls: 3.9%.

Four of those five run code this PR cannot reach. parse_metadata and parse_metadata_str go through parse_email_metadatametadata_from_payload, which this diff does not touch and which never calls build_node; headers_first_read is a full parse; parse_many_metadata is parse_many_as(..., parse_email_metadata). The only changed thing on any metadata path is build_node's third parameter, which the tree walk takes and the flat metadata path does not have.

Run 2 (passed, worst delta +1.8%, noise floor 0.8%), per-round minima:

Benchmark base this PR
parse_lazy_untouched 0.094 / 0.095 / 0.093 0.055 / 0.055 / 0.054 −42%
parse_tree_lazy_untouched 0.091 / 0.092 / 0.091 0.053 / 0.054 / 0.053 −42%
parse_metadata 0.050 / 0.051 / 0.050 0.050 / 0.051 / 0.049 flat
parse_tree_metadata 0.053 / 0.054 / 0.053 0.053 / 0.053 / 0.052 flat

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 (parse_lazy_untouched 0.041 → 0.030 ms, parse_tree_lazy_untouched 0.040 → 0.030 ms, nothing else past the noise floor), and the allocation counts in bench/tests/allocs.rs, which are deterministic and need no runner at all.

…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Lazy modes: borrow the pinned payload through byte ranges instead of copying every deferred leaf

1 participant