Skip to content

Split the binding into modules, and share its duplicated getters and batch loops (#233) - #262

Open
kurok wants to merge 4 commits into
feat/234-step2-flat-loopfrom
feat/233-binding-modules
Open

kurok wants to merge 4 commits into
feat/234-step2-flat-loopfrom
feat/233-binding-modules

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

Closes #233 (phases 1 and 2; phase 3 is out of scope in the issue itself).

Stacked on #261#260#259#258. Each retargets itself as the one below merges. Two commits: the dedupe, then the move.

Phase 1 — the dedupe

date_parsed had three byte-identical copies, children three, the decode-and-cache body two, the strict gate four, and the parse_many result loop three. They are now src/convert.rs.

The file already stated the rule, on resolve_workers: "Out of line and shared by all three modes rather than written three times: the message is part of the API, and three copies of it are three chances for them to stop agreeing." The strict rejection's text and what raise_on_error=False puts in a failed slot are part of the API in exactly that sense.

One item on the issue's list is not here: headers_dict. The issue cites six byte-identical five-line headers getters — but that evidence predates #231, which replaced them with a cached Headers::to_dict. All six are already one-line delegations, and the dict is memoised as well as shared, which is strictly better than what the issue asked for. Nothing to do.

decode_into keeps the #[cold] #[inline(never)] both copies carried, so the cached fast path in the getters above it is undisturbed, and children_list is a tiny non-recursive generic — the #99 incident was a generic wrapping the whole parse body, which is not this shape. Behaviour is unchanged including which errors propagate out of a batch rather than landing in a slot: Py::new's fallibility stays outside the outcome, exactly where it was.

Phase 2 — the split

module what is in it
errors.rs the four exception types, to_py_err, strict_rejection, the panic backstop
payload.rs Payload, payload_to_bytes, Pinned, RetainedBytes
convert.rs Headers, addresses, phase 1's helpers
metadata.rs PyAttachmentMetadata, PyMailMetadata
flat.rs ParseWarning, PyAddress, PyAttachment, PyMail
lazy.rs PyLazyAttachment, PyLazyMail
tree.rs the three node types, metadata_node, lazy_node
api.rs the three #[pyfunction]s and their mode dispatch

2000 lines → an 85-line root that is the module list and the #[pymodule]. All eight files sit directly under src/, because the sdist include glob is src/* and is one level deep.

Code was moved, not edited. Visibility widened from private to pub(crate) only where a moved item is read across files, never with #[allow(dead_code)].

The checks that matter for a pure move

Attributes survived — after a correction. Final counts are #[inline(never)] 23, #[cold] 7, #[inline(always)] 1, against a pre-#233 baseline of 22, 6 and 1. The +1 on each is decode_into itself, which is what the issue asked for: the attributes move onto the shared helper and the two thin wrappers keep theirs.

I got that wrong first time round. Phase 1 dropped #[cold] #[inline(never)] from both decode wrappers, and my original check compared the commit before the split against the commit after — which verified the move changed nothing but silently accepted phase 1's loss. The third commit restores them. See the comment below for how the gate caught it; the lesson is that the baseline for an attribute count is the commit before the whole PR, not before its last step.

The sdist really carries them. Built locally with python -m build --sdist and listed: all eight modules present. CI's sdist manifest check now names them rather than trusting the glob, so a glob that stopped matching fails by naming the missing file instead of surfacing as a confusing compile error later in that job.

Nothing public moved. __init__.pyi, tests/test_contract.py's frozen sets, docs/ and vendor/ are untouched; 867 Python tests pass, including test_contract.py, test_stub_matches_runtime.py, the #157 header-order tests and test_stdlib_parity.py with no DIVERGENCES edit.

Measurements

Local interleaved A/B on an M4, 3 rounds, against this PR's base:

Benchmark base (#261) this PR
parse_message 0.158 ms 0.156 ms −1.0%
parse_metadata 0.029 ms 0.029 ms +0.1%
parse_tree 0.155 ms 0.155 ms −0.1%
parse_lazy_untouched 0.031 ms 0.030 ms −0.4%
parse_many 1.310 ms 1.290 ms −1.5%
parse_qp_message 0.130 ms 0.131 ms +1.2% (worst)

Noise floor 2.2%; worst real movement +1.2%. ab_median.py reports attachment_reread at "+97.6%" in this PR's favour — ignore it, as on #259: that benchmark is 42–83 ns and both sides sample the same two timer ticks, so the median flips between them. It is quantisation, not a measurement.

Verification: 867 Python tests; cargo clippy --workspace --all-targets -D warnings -W clippy::cast_possible_truncation; cargo fmt; mypy --strict; ruff; sdist built and inspected.

…phase 1)

date_parsed had three byte-identical copies, children three, the
decode-and-cache body two, the strict gate four, and the parse_many
result loop three. They are now src/convert.rs.

The file already stated the rule, on resolve_workers: "Out of line and
shared by all three modes rather than written three times: the message is
part of the API, and three copies of it are three chances for them to stop
agreeing." The strict rejection's text and what raise_on_error=False puts
in a failed slot are part of the API in exactly that sense.

Not included: headers_dict. The issue lists six byte-identical headers
getters, but that evidence predates #231, which replaced them with a
cached Headers::to_dict -- so all six are already one-line delegations and
the dict is memoised as well as shared.

decode_into keeps the #[cold] #[inline(never)] both copies carried, so the
cached fast path in the getters above it is undisturbed, and children_list
is a tiny non-recursive generic: the #99 incident was a generic wrapping
the whole parse body, which is not this shape.

Behaviour is unchanged, including which errors propagate out of a batch
rather than landing in a slot: Py::new's fallibility stays outside the
outcome, exactly where it was.
The crate root was the 2000-line binding file, and `mod convert;` from
phase 1 was its only module boundary. A change to one mode could not be
reviewed without loading the other seventeen hundred lines -- which is
backwards for a library whose reason to exist is a small fast core with a
thin wrapper around it.

Eight files, all directly under src/ because the sdist include glob is
`src/*` and is one level deep:

  errors.rs    the four exception types, to_py_err, the panic backstop
  payload.rs   Payload, payload_to_bytes, Pinned, RetainedBytes
  convert.rs   Headers, addresses, and phase 1's shared helpers
  metadata.rs  PyAttachmentMetadata, PyMailMetadata
  flat.rs      ParseWarning, PyAddress, PyAttachment, PyMail
  lazy.rs      PyLazyAttachment, PyLazyMail
  tree.rs      the three node types, metadata_node, lazy_node
  api.rs       the three pyfunctions and their mode dispatch

Code was moved, not edited. Visibility widened from private to pub(crate)
only where a moved item is read across files, never by #[allow(dead_code)].
Every #[inline(never)], #[cold] and #[inline(always)] stayed on the item it
was on -- 21, 5 and 1, the same counts as the commit before this one, which
is the check that matters most here: the #99 incident was one of those
being lost.

The sdist was built locally and confirmed to carry all eight modules, and
CI's sdist manifest check now names them rather than trusting the glob, so
a glob that stopped matching fails by naming the missing file instead of
surfacing as a compile error later in the job.

Phase 3 -- splitting the core crate the same way -- is explicitly out of
scope in the issue and is not here.
The issue said the attributes move onto decode_into AND the two thin
wrappers keep theirs, so the cached fast path in the getters above them is
undisturbed. I dropped them from the wrappers in phase 1. Their own doc
comments still said "#[cold] and out of line", which is how it was caught.

Losing an #[inline(never)] is the documented 24% failure mode in this
crate (#99), so this is not a cosmetic restoration. Attribute counts are
now 23 inline(never) and 7 cold against the pre-#233 baseline's 22 and 6:
+1 each, which is decode_into itself.
@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

The gate caught a real regression, and it was mine

The benchmark gate failed twice on parse_qp_message, +8.2% then +8.3%, on AMD EPYC 7763 both times — base 0.247/0.248 ms against 0.267/0.268 ms. Reproducible, not a noisy draw.

My first instinct was wrong and I want to record it, because it is the instinct this repo's history encourages. This PR is binding-only: git diff base..HEAD -- crates/ vendor/ is 0 bytes, so the quoted-printable decoder is byte-identical. The obvious reading was #204/#240 code-layout scatter, and parse_qp_message is the benchmark with exactly that history — three earlier PRs failed on it at +9.2%, +7.3% and +19.0% for changes that could not reach QP decoding.

So I measured it instead of asserting it, with the instrument #240 built for this. layout-ab.yml, 4 salted builds of this branch, 3 interleaved rounds:

spread across salts
parse_qp_message +1.1%
parse_message +1.6%
parse_qp_dense_escapes +0.4%
control floor (mailparser_lib___full_read) +6.6%

parse_qp_message is not link-order sensitive on this codebase — 1.1% across four differently-salted builds. An 8.3% delta could not be explained away as layout, and the hypothesis was dead.

The actual cause. The issue specifies that the #[cold] #[inline(never)] moves onto decode_into and "the two thin wrappers keep their attributes so the cached fast path in the getter above them is unchanged". Phase 1 dropped them from both wrappers. Their own doc comments still read "#[cold] and out of line", which is what gave it away.

My attribute check had missed it because I compared the commit before the split against the commit after — which correctly proved the move changed nothing, and silently inherited phase 1's loss. Against the real baseline the counts were 21/5 where they should have been 23/7.

After restoring them, on the same CPU class as both failures:

parse_qp_message base this PR
EPYC 7763, before the fix (×2) 0.247 / 0.248 ms 0.267 / 0.268 ms +8.2%, +8.3%
EPYC 7763, after the fix 0.246 ms 0.252 ms +2.3%
Xeon 8573C, after the fix 0.259 ms 0.264 ms +1.8%

Same runner class, same base value, treatment down from 0.267 to 0.252. That is causal, not a luckier draw — the first passing run was on a different CPU and I did not want to rest on it, so I re-ran until one landed on 7763.

Worst treatment delta is now +3.5% (parse_qp_dense_escapes) against a 1.0% noise floor, verdict no significant difference, 11/11 green.

Two things worth keeping from this: the gate earned its keep here — an organisational refactor was quietly costing 8% on a decode path — and "it's layout" is a hypothesis that this repo now has a tool to test rather than a conclusion to reach for.

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.

1 participant