Skip to content

Decode base64 bodies with SIMD, keeping data-encoding as the arbiter (#228) - #244

Merged
kurok merged 2 commits into
masterfrom
feat/228-base64-simd
Sep 17, 2026
Merged

kurok merged 2 commits into
masterfrom
feat/228-base64-simd

Conversation

@kurok

@kurok kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

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_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 and not 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, which are exactly the two lenient cases this library has always accepted. So "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 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:

  1. 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 equal DecodeError on reject.
  2. 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.
  3. Three Python tests pin the lenient cases that now reach the fallback (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.

form what it does full parse
decode_inplace over the stripped buffer, then truncate reuses the strip's allocation, no second one 9% slower
decode_to_vec into an exactly-sized buffer (shipped) one extra allocation, no zero-fill 51% faster

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.md both 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_fit wasn't the cost (identical 0.274 ms with and without); and linking base64-simd without 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%.

Benchmark master this branch
parse_message 0.254 ms 0.168 ms −34%
parse_tree 0.246 ms 0.159 ms −35%
full_read 0.261 ms 0.172 ms −34%
parse_many (8 × 767 KiB) 1.989 ms 1.318 ms −34%

mode="metadata" (+0.2%) and parse_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 = master is slower.

Benchmark simd-to-vec master Delta
test__fast_mail_parser___full_read 0.172 ms 0.261 ms +51.9%
test__fast_mail_parser___parse_lazy_all_attachments 0.176 ms 0.265 ms +50.8%
test__fast_mail_parser___parse_lazy_untouched 0.043 ms 0.043 ms +1.5%
test__fast_mail_parser___parse_many 1.318 ms 1.989 ms +50.9%
test__fast_mail_parser___parse_many_metadata 0.240 ms 0.238 ms -0.7%
test__fast_mail_parser___parse_message 0.168 ms 0.254 ms +50.7%
test__fast_mail_parser___parse_message_strict 0.166 ms 0.253 ms +52.0%
test__fast_mail_parser___parse_metadata 0.030 ms 0.030 ms +0.2%
test__fast_mail_parser___parse_tree 0.159 ms 0.246 ms +55.0%
test__fast_mail_parser___parse_tree_lazy_untouched 0.042 ms 0.042 ms -0.3%
test__fast_mail_parser___parse_tree_metadata 0.031 ms 0.031 ms +0.4%
test__mail_parser___parse_message 5.490 ms 5.548 ms +1.1% control
test__mailparser_lib___full_read 5.874 ms 5.873 ms -0.0% control
test__stdlib_email___full_read 7.471 ms 7.483 ms +0.2% control
test__threaded___parse_many 0.734 ms 1.015 ms +38.2% informational
test__threaded___parse_many_metadata_small 2.538 ms 2.550 ms +0.5% informational
test__threaded___parse_many_small 2.762 ms 2.746 ms -0.5% informational
test__threaded___threadpool_parse_email 0.630 ms 0.892 ms +41.6% informational
test__threaded___threadpool_parse_email_small 14.079 ms 14.182 ms +0.7% informational

Noise 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-simd 0.8.0 is MIT and pulls vsimd 0.8.0 and outref 0.5.2, both MIT, all from crates.io — covered by deny.toml's allowlist and source policy. I verified the licenses by hand locally; cargo deny and cargo audit are 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.md says 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-dir elsewhere — 53 + 25 passed, including test_base64_content_encoding_multiple_strings unchanged), cargo fmt --all --check and cargo 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. No target/ under vendor/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.yml gains the target in its matrix. .gitignore gains what cargo fuzz leaves behind, since running the new target locally is now something a contributor will do.

@kurok
kurok force-pushed the feat/228-base64-simd branch from 719b2e7 to 1a24df4 Compare September 17, 2026 08:12
@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto c1da4f8 now that #242 has landed. CHANGELOG.md was the only conflict; both entries kept.

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 pull_request-triggered workflows against the PR's merge commit — and a conflicting PR has none, so test.yml never ran at all. The four CodeQL checks passed and nothing else existed, which is a state that reads as "green" at a glance while Lint, the test matrix, the benchmark gate, the fuzz pass and cargo deny/cargo audit had not run. The rebase makes the PR mergeable and the full suite is running now.

Re-measured against the new base:

Benchmark master c1da4f8 this branch
parse_message 0.239 ms 0.157 ms −52%
parse_tree 0.248 ms 0.157 ms −57%
full_read 0.257 ms 0.172 ms −50%
parse_many (8 × 767 KiB) 1.989 ms 1.326 ms −50%

Controls within 0.3%. One honest note: parse_many_metadata read +2.8% slower in this run, above that run's noise floor. Metadata mode never calls decode_base64, so it is either layout or the measurement; it is well inside the 7% gate, and I will re-check it against the gate's own numbers rather than assert it away.

kurok added a commit that referenced this pull request Sep 17, 2026
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>
@kurok
kurok force-pushed the feat/228-base64-simd branch from 1a80ddc to 28f5c58 Compare September 17, 2026 08:41
@kurok
kurok merged commit e7bbb05 into master Sep 17, 2026
15 checks passed
@kurok
kurok deleted the feat/228-base64-simd branch September 17, 2026 08:45
@kurok

kurok commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto abb31b8 (post-#243). Green: 15/15, CLEAN.

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 decode_base64 came back 13–19% slower:

Benchmark M4 vs abb31b8 x86 gate (this run)
parse_metadata −19.1% (slower) −1.5% (faster)
parse_metadata_str −18.8% (slower) +2.1%
parse_many_metadata −18.7% (slower) −0.8%
parse_lazy_untouched −12.6% (slower) −4.2% (faster)
parse_tree_metadata −18.3% (slower) −0.1%

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 PATCH.md warns about, and because the next person to rebase this branch onto a different master may well see it again and should not spend the afternoon I did rediscovering that it is placement.

kurok added a commit that referenced this pull request Sep 17, 2026
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>
kurok added a commit that referenced this pull request Sep 17, 2026
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>
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.

Decode base64 with base64-simd in the vendored mailparse, keeping data-encoding as the rejection oracle

1 participant