Decode base64 bodies with SIMD, keeping data-encoding as the arbiter (#228) - #244
Conversation
719b2e7 to
1a24df4
Compare
|
Rebased onto Worth recording why this PR showed only CodeQL results before the rebase. It was opened after #242 merged, so it conflicted with master from the moment it was created. GitHub evaluates Re-measured against the new base:
Controls within 0.3%. One honest note: |
The changelog entry carried only the Apple M4 A/B. The gate's EPYC run on PR #244 measured the same change interleaved against the merge base -- parse_email 0.536 -> 0.316 ms (-41%), parse_many 4.382 -> 2.583 ms (-41%), controls within 1.1% -- which is the number a reader on x86 wants and the one the issue asks to be recorded here. It also settles the one thing the local run left open: parse_many_metadata read +2.8% on the M4, above that run's noise floor, and reads +0.9% on x86. Metadata mode never calls decode_base64, so that was the measurement. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
…228) After #213/#214/#218 fixed the two byte-at-a-time loops, what was left of a full parse was the base64 decode proper: 53% of it in data_encoding's scalar loop, four table lookups per three bytes, at scalar peak on both CPUs tried. The 0.9.0 changelog said as much in one line. The only lever left on it is wider lanes. decode_base64 now decodes the whitespace-free buffer with base64_simd::STANDARD -- AVX2/SSE4.1 with runtime detection on x86-64, NEON on aarch64 -- and falls back to data_encoding::BASE64_MIME_PERMISSIVE on any rejection. The fallback is what makes this a speed change rather than a change to what this library accepts. STANDARD accepts a strict subset on a whitespace-free buffer: same alphabet, padding required by both, and STANDARD is additionally strict about '=' appearing mid-stream and about non-zero trailing bits -- the two lenient cases this library has always accepted. So the set "SIMD accepts, data-encoding rejects" is empty, anything both accept decodes to the same bytes, and every rejection still carries data_encoding's own DecodeError { position, kind } into MailParseError::Base64DecodeError and out to Python with identical text. Three things hold that up rather than one. simd_and_data_encoding_agree in body.rs sweeps a built corpus: every length to 200 padded and unpadded, every byte at every position of a one- and two-block body, mid-stream padding on and off boundaries, both trailing-bit shapes, every whitespace byte interleaved, and 0x0b/0x00 which are not whitespace and must survive the strip to be rejected. The new base64_agreement fuzz target asserts the same three-way agreement -- acceptance, bytes, and error text -- on arbitrary input through the public entry point; it ran 3,578,785 executions clean in 60 s, and its canary was checked to still fire. Three Python tests pin the lenient cases that now reach the fallback, so a fast path that swallowed them would not look green. On the shape of the call, which is not cosmetic. The out-of-place decode is what ships. The in-place form -- decode_inplace over the stripped buffer, then truncate -- reuses the strip's allocation instead of making a second one and reads strictly better. Built as the extension is built (lto = true, codegen-units = 1) it made the whole parse 9% SLOWER, twice, where this form makes it 51% faster; standalone the two decoders are within 10% of each other on the same payloads. That gap is the code-layout sensitivity of #204, and the function and PATCH.md both say so, because the tidier version is the one a future reader will reach for. Measured on an Apple M4 (10 vCPU), two independent interleaved A/Bs pooled to 8 rounds per side, pure-Python controls within 1.1%: parse_email 0.254 -> 0.168 ms -34% parse_email_tree 0.246 -> 0.159 ms -35% full_read 0.261 -> 0.172 ms -34% parse_many (8x) 1.989 -> 1.318 ms -34% mode="metadata" and untouched lazy parses never call this function and are flat, as they must be. base64-simd is MIT and pulls vsimd and outref, both MIT, all from crates.io; cargo deny's allowlist covers them. Its last release is 2022-12 and it is unsafe-heavy SIMD -- PATCH.md says so plainly rather than burying it, and notes that the fallback bounds a rejection bug to a slow path but bounds nothing about a wrong-bytes bug, which is what the differential test and the fuzz target are for. The PR fuzz budget stays at a minute: three targets at 20 s rather than two at 30 s. deep-fuzz.yml gains the target in its matrix. .gitignore gains the artifacts cargo fuzz leaves behind, since running the new target locally is now something a contributor will do. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
The changelog entry carried only the Apple M4 A/B. The gate's EPYC run on PR #244 measured the same change interleaved against the merge base -- parse_email 0.536 -> 0.316 ms (-41%), parse_many 4.382 -> 2.583 ms (-41%), controls within 1.1% -- which is the number a reader on x86 wants and the one the issue asks to be recorded here. It also settles the one thing the local run left open: parse_many_metadata read +2.8% on the M4, above that run's noise floor, and reads +0.9% on x86. Metadata mode never calls decode_base64, so that was the measurement. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
1a80ddc to
28f5c58
Compare
|
Rebased onto One thing the rebase turned up that is worth recording, because it is the #204 failure mode again and it nearly read as a blocker. Re-measuring on the M4 against the new base — i.e. with #227 in master — the decode paths were still ~−40%, but the paths that never call
That is above the 7% threshold, so I did not assume it away. What I established before pushing:
The gate above is the verdict, and it is flat: worst delta +2.1%, inside the threshold, controls at 0.5%. So this is an M4-only artifact — the mirror image of #204, where Zen showed swings the M4 never did. Recording it because it is a live example of the thing |
vendor/mailparse is upstream 0.16.1 with three functions changed, and every speed number this library advertises comes from it. Upstream declined the change (#217), so the copy is a permanent carry and each mailparse release is a hand-merge -- the moment either half of the delta is most likely to be lost or half-applied. Ownership of that copy rested entirely on prose, and the prose had already drifted: the lint-job comment said only the boundary search changed, and the README said both loops moved to memchr when one of them is bytescan.rs. PATCH.md asserted exactly which files differ from the registry crate, but nothing re-checked it and there was no machine-readable patch to re-apply. I hit this class of mistake myself while rebasing #238: #244 had merged with its PATCH.md entry describing only half of what it changed, and I noticed only because a rebase conflict made me read the file. Nothing in CI did. upstream.patch is the delta in machine-readable form. check_vendored_mailparse.sh re-applies it on every run: download the published crate, verify its sha256, apply the patch, diff -r against this directory, fail on any output. Nothing else can see this. The vendored test suite passes on *unpatched* upstream -- it tests behaviour, and the patch does not change behaviour -- so a half-applied hand-merge would surface only as an unexplained 4-10x regression in the benchmark gate, on whichever unrelated PR ran next. Given what the gate has been doing this week, it might well have been read as layout noise and waved through. The script also asserts the two things PATCH.md's sync recipe is easiest to get wrong: that both root manifests require exactly the vendored version -- a Dependabot bump of one alone would silently switch the build back to the registry crate -- and that Cargo.lock still records mailparse with no `source =` line, which is the signature of [patch.crates-io] being in effect. Verified the guard actually fails, rather than assuming it: an edit to a vendored source without regenerating the patch, a drifted root requirement, and a corrupted patch each exit 1, and the clean tree passes. It runs locally too (MAILPARSE_CRATE_FILE to skip the download, and it reads the version with awk and falls back to shasum, so it does not need python 3.11 or GNU coreutils). The sdist check gains the same treatment: bytescan.rs, qp.rs and the patch must be present, and a stray target/ or Cargo.lock from running cargo in the vendored crate must not be -- both hazards .gitignore documents and nothing enforced. No Rust source change, so the wheel is byte-identical -- which also means the benchmark gate compares identical binaries here and cannot flip on placement. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
vendor/mailparse is upstream 0.16.1 with three functions changed, and every speed number this library advertises comes from it. Upstream declined the change (#217), so the copy is a permanent carry and each mailparse release is a hand-merge -- the moment either half of the delta is most likely to be lost or half-applied. Ownership of that copy rested entirely on prose, and the prose had already drifted: the lint-job comment said only the boundary search changed, and the README said both loops moved to memchr when one of them is bytescan.rs. PATCH.md asserted exactly which files differ from the registry crate, but nothing re-checked it and there was no machine-readable patch to re-apply. I hit this class of mistake myself while rebasing #238: #244 had merged with its PATCH.md entry describing only half of what it changed, and I noticed only because a rebase conflict made me read the file. Nothing in CI did. upstream.patch is the delta in machine-readable form. check_vendored_mailparse.sh re-applies it on every run: download the published crate, verify its sha256, apply the patch, diff -r against this directory, fail on any output. Nothing else can see this. The vendored test suite passes on *unpatched* upstream -- it tests behaviour, and the patch does not change behaviour -- so a half-applied hand-merge would surface only as an unexplained 4-10x regression in the benchmark gate, on whichever unrelated PR ran next. Given what the gate has been doing this week, it might well have been read as layout noise and waved through. The script also asserts the two things PATCH.md's sync recipe is easiest to get wrong: that both root manifests require exactly the vendored version -- a Dependabot bump of one alone would silently switch the build back to the registry crate -- and that Cargo.lock still records mailparse with no `source =` line, which is the signature of [patch.crates-io] being in effect. Verified the guard actually fails, rather than assuming it: an edit to a vendored source without regenerating the patch, a drifted root requirement, and a corrupted patch each exit 1, and the clean tree passes. It runs locally too (MAILPARSE_CRATE_FILE to skip the download, and it reads the version with awk and falls back to shasum, so it does not need python 3.11 or GNU coreutils). The sdist check gains the same treatment: bytescan.rs, qp.rs and the patch must be present, and a stray target/ or Cargo.lock from running cargo in the vendored crate must not be -- both hazards .gitignore documents and nothing enforced. No Rust source change, so the wheel is byte-identical -- which also means the benchmark gate compares identical binaries here and cannot flip on placement. Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
Closes #228.
Branched from
master(73f56da), independent of #242 and #243.What changed
After #213/#214/#218 fixed the two byte-at-a-time loops, what remained of a full parse was the base64 decode proper: 53% of it in
data_encoding's scalar loop — four table lookups per three bytes, at scalar peak on both CPUs tried. The 0.9.0 changelog said it in one line: "what remains is the base64 decode proper."The only lever left on that is wider lanes.
decode_base64now decodes the whitespace-free buffer withbase64_simd::STANDARD(AVX2/SSE4.1 with runtime detection on x86-64, NEON on aarch64), and falls back todata_encoding::BASE64_MIME_PERMISSIVEon any rejection.The fallback is what makes this a speed change and not a change to what this library accepts.
STANDARDaccepts a strict subset on a whitespace-free buffer — same alphabet, padding required by both, andSTANDARDis additionally strict about=appearing mid-stream and about non-zero trailing bits, which are exactly the two lenient cases this library has always accepted. So "SIMD accepts,data_encodingrejects" is empty, anything both accept decodes to the same bytes, and every rejection still carriesdata_encoding's ownDecodeError { position, kind }intoMailParseError::Base64DecodeErrorand reaches Python with identical text.How that claim is held up
Three independent things, because "the fast path is a subset" is an argument, not evidence:
simd_and_data_encoding_agree(vendored,src/body.rs) sweeps a built corpus against the old decoder as oracle: every length to 200 padded and unpadded, every byte at every position of a one- and two-block body, mid-stream=on and off block boundaries, both trailing-bit shapes, every ASCII whitespace byte interleaved, and\x0b/\x00— which are not whitespace, so they survive the strip and must be rejected. Asserts equal bytes on accept and an equalDecodeErroron reject.base64_agreement, a new fuzz target, asserts the same three-way agreement — acceptance, bytes, and error text — on arbitrary input through the public entry point. 3,578,785 executions clean in 60 s locally. I also confirmed its canary still fires (FMP_FUZZ_CANARY=1→ deliberate panic), since an unarmed canary is a silent hole.test_midstream_padding_still_decodes,test_nonzero_trailing_bits_still_decode,test_missing_padding_still_raises). Without them a fast path that quietly swallowed those would still look green.The shape of the call is not cosmetic
The issue asks for both capacity strategies to be measured. They were, and the result is the most interesting thing in this PR.
decode_inplaceover the stripped buffer, thentruncatedecode_to_vecinto an exactly-sized buffer (shipped)Same machine, same corpus, same decoder — and standalone the two are within 10% of each other (77 µs vs 70 µs on a 781 KB payload). The in-place form is the one that reads better, which is precisely why the function and
PATCH.mdboth carry a note saying not to "tidy" it back.Getting there ruled things out in order rather than guessing: the fixture takes the fast path (all three parts are strict-clean — length % 4 = 0, no mid-stream
=, zero trailing bits), so it wasn't silently falling back;shrink_to_fitwasn't the cost (identical 0.274 ms with and without); and linkingbase64-simdwithout calling it costs 1.5–3.4%, at or below that run's noise floor, so it isn't mere linkage either. What's left is the code-layout sensitivity of #204, which this crate builds squarely into:lto = true,codegen-units = 1.Numbers
Apple M4 (10 vCPU), rustc 1.98.0, CPython 3.12. Two independent interleaved A/Bs pooled to 8 rounds per side (
ab_median.py). Pure-Python controls within 1.1%.parse_messageparse_treefull_readparse_many(8 × 767 KiB)mode="metadata"(+0.2%) andparse_lazy_untouched(+1.5%) never call this function and are flat, which is the control that says the gain is the decode and not the weather.Full pooled A/B report (8 rounds/side)
Measured on
Apple M4, 10 vCPU.Median of 8 interleaved rounds per side; each value is a benchmark's minimum. Positive delta =
masteris slower.test__fast_mail_parser___full_readtest__fast_mail_parser___parse_lazy_all_attachmentstest__fast_mail_parser___parse_lazy_untouchedtest__fast_mail_parser___parse_manytest__fast_mail_parser___parse_many_metadatatest__fast_mail_parser___parse_messagetest__fast_mail_parser___parse_message_stricttest__fast_mail_parser___parse_metadatatest__fast_mail_parser___parse_treetest__fast_mail_parser___parse_tree_lazy_untouchedtest__fast_mail_parser___parse_tree_metadatatest__mail_parser___parse_messagetest__mailparser_lib___full_readtest__stdlib_email___full_readtest__threaded___parse_manytest__threaded___parse_many_metadata_smalltest__threaded___parse_many_smalltest__threaded___threadpool_parse_emailtest__threaded___threadpool_parse_email_smallNoise floor from the pure-Python controls: 1.1% (they cannot be affected by the build, so this is measurement error).
Worst treatment delta: +55.0% (
test__fast_mail_parser___parse_tree).Supply chain
base64-simd0.8.0 is MIT and pullsvsimd0.8.0 andoutref0.5.2, both MIT, all from crates.io — covered bydeny.toml's allowlist and source policy. I verified the licenses by hand locally;cargo denyandcargo auditare not installed on this machine, so the CI jobs are the actual gate for them.Stated plainly rather than buried: base64-simd's last release is 2022-12 and it is
unsafe-heavy SIMD.PATCH.mdsays so, and notes that the fallback bounds the blast radius of a rejection bug to a slow path but bounds nothing about a wrong-bytes bug — which is what the differential test and the fuzz target exist for.Checks run locally
pytest tests --ignore=tests/benchmark(806 passed, 2 skipped), the vendored suite (cargo test --manifest-path vendor/mailparse/Cargo.toml --target-direlsewhere — 53 + 25 passed, includingtest_base64_content_encoding_multiple_stringsunchanged),cargo fmt --all --checkandcargo fmt --manifest-path vendor/mailparse/Cargo.toml --check(the root one does not format the vendored crate),cargo clippy --all-targets -- -D warnings -W clippy::cast_possible_truncation,mypy --strict,ruff check .— all green. Notarget/undervendor/mailparse/.The M4 is not a proxy for x86, and the issue expects a larger win on the gate's EPYC (AVX2 from a lower scalar base). The Benchmark quality gate job is the authoritative number; I'll add its result to the CHANGELOG entry once it reports.
Housekeeping
The PR fuzz budget stays at a minute — three targets at 20 s rather than two at 30 s — and
deep-fuzz.ymlgains the target in its matrix..gitignoregains whatcargo fuzzleaves behind, since running the new target locally is now something a contributor will do.