Skip to content

Scan for MIME boundaries and base64 whitespace a word at a time (no new dependencies) - #142

Closed
kurok wants to merge 2 commits into
staktrace:masterfrom
kurok:memmem-boundary-search
Closed

Scan for MIME boundaries and base64 whitespace a word at a time (no new dependencies)#142
kurok wants to merge 2 commits into
staktrace:masterfrom
kurok:memmem-boundary-search

Conversation

@kurok

@kurok kurok commented Aug 27, 2026

Copy link
Copy Markdown

Revised after the review: no new dependency, no unsafe. The two byte-at-a-time loops in the parse path are replaced by word-at-a-time scans in plain std, in one new module, src/bytescan.rs.

The two loops. On a message with large attachments they are most of parse_mail: sampling a structure-only parse of a 767 KiB message (four base64 attachments) put 96% of the cycles in find_from_u8's scan for the boundary, and once that was fixed, 78% of a full parse was in decode_base64's iter().filter(!is_ascii_whitespace).collect(). Their speed also depended on where the linker happened to place them — the same instructions ran at half speed on x86-64 when a loop straddled a 64-byte boundary — so unrelated changes in a dependent crate moved parse time 2x.

The scans. usize chunks read with chunks_exact + from_le_bytes (plain loads), tested with the classic exact word tricks (haszero, hasless; Anderson's Bit Twiddling Hacks), and trailing_zeros to jump to a hit rather than rescan the word:

  • find_byte: four words per branch in the no-hit case; find scans for key[0] and compares the rest only at candidates. find_from_u8 keeps its asserts and semantics.
  • strip_ascii_whitespace: a word with no byte below 0x21 cannot contain whitespace and is skipped whole; each candidate bit is re-checked with is_ascii_whitespace, so 0x0B and other control bytes are kept exactly as before; runs are copied in one piece.

Semantics are unchanged; bytescan::tests checks both against the loops they replace over a generated corpus at every alignment and over every byte value. cargo test and cargo fmt --check pass; the module is clippy-clean.

Measured from a dependent crate (fast_mail_parser) on the message above, interleaved A/B, Apple M4:

before after
full parse (all bodies decoded) 1.09 ms 0.24 ms
structure-only parse (no bodies decoded) 0.37 ms 0.03 ms

Within measurement noise of the memchr version this replaces, and a few percent faster on the decoding path.

find_from_u8 scanned byte by byte, and on a multipart message it is where
parse_mail spends its time: sampling a parse of a 767 KiB message with four
base64 attachments put 96% of the cycles in find_from_u8_line_prefix.

The loop's speed also depended on where the linker happened to place it. The
same instructions ran at half speed on x86-64 when the loop straddled a 64-byte
boundary, so unrelated changes in a dependent crate -- a version bump, a
different rustc -- moved parse time by 2x with no change to this code.

memmem is a vectorised search with runtime CPU dispatch: faster at every input
size, and laid out independently of whoever calls it. Measured from a dependent
crate on the message above: full parse 1.10 ms -> 0.76 ms; a structure-only
parse that decodes no bodies 0.37 ms -> 0.03 ms.

Semantics are unchanged: the first occurrence of `key` at or after `ix_start`,
None when there is none. The two asserts stay. memchr's MSRV is 1.61.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
kurok added a commit to namecheap/fast_mail_parser that referenced this pull request Aug 27, 2026
* Search for MIME boundaries with memchr::memmem via a patched mailparse

The codegen cliff #120 and #204 were circling has one cause, and it is a loop
in a dependency. Sampling a metadata-mode parse of the 767 KiB fixture put
96.5% of the time in mailparse's find_from_u8, the byte-by-byte scan
parse_mail runs for every MIME boundary.

Its x86-64 instruction stream is byte-identical under rustc 1.97.1 and 1.98.0:
88 instructions, only label hashes differ. Yet two runners measured the
metadata path at +96% under 1.98.0. The compiler had not made the loop slower;
it had moved it. The crate hash changes with the rustc version -- and with the
package version, which is #204 -- which changes symbol names, link order, and
the loop's address. On the runners' Zen CPUs a scalar loop straddling the
wrong 64-byte boundary falls out of the micro-op cache and runs at half speed.
An Apple M4 measured the same two builds at +/-0.2%, which is why the effect
never showed locally.

The fix is a memchr::memmem search: vectorised, and laid out independently of
this crate. It lives in vendor/mailparse -- upstream 0.16.1 with that one
function changed and memchr added -- applied through [patch.crates-io] so the
crate name and version stay what every other dependency expects. The same
change is upstream as staktrace/mailparse#142; PATCH.md records the removal
steps for when a release carries it.

Interleaved A/B against master on the M4, controls within 1.2%:

  parse_email(mode="metadata")   0.365 ms -> 0.030 ms
  parse_email (full)             1.094 ms -> 0.743 ms
  parse_email_tree               1.124 ms -> 0.736 ms
  parse_many, 8 x 767 KiB        9.082 ms -> 6.189 ms

Every mode faster, no output change: 803 tests pass and the vendored crate's
own suite passes. memchr 2.8.3 is the only new crate in the lockfile.

Two things the vendoring needed that a [patch] alone does not give. maturin
follows [dependencies] path entries into the sdist but not [patch] ones, so
without an explicit include the sdist would have built from the unpatched
crates.io copy -- silently, and passing every test. The include is now
explicit and the sdist job's install-from-source is what checks it. And the
fuzz crate resolves mailparse on its own, so it carries the same patch, or it
would be fuzzing a different parser than the one shipped.

The lint job runs the vendored crate's tests, with a separate target dir: a
target/ inside vendor/mailparse would be swept into the sdist by the include
glob, which is also why .gitignore now names it.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>

* Quote the gate's numbers in the README and changelog

Every figure in these passages says "on the CI runner", so they change with the
gate that measured this branch: an AMD EPYC 9V45, median of three interleaved
rounds. The M4 numbers stay in the changelog as the local confirmation, with the
reason they are smaller -- that machine never had the slow layout to lose.

The hardware paragraph under the headline table used to cite the old ratios as
its example of CPU dependence. It now cites them as the before, since they are
no longer what any runner will measure.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>

---------

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
decode_base64 removed whitespace with iter().filter().cloned().collect(): a
test and a bounds-checked push for every byte of the body. On a message with
large base64 attachments that filter is where the parse spends its time --
sampling a full parse of a 767 KiB message put 78% of the cycles in it, more
than in the base64 decode itself.

Whitespace is now located with memchr and the runs between are copied whole.
A base64 body is almost entirely 76-byte lines ending in CRLF, so the common
case is one search and one memcpy per line. Tabs and form feeds are rare
enough that they get a second search over each run rather than a place in
the first.

The set of bytes removed is unchanged -- exactly what u8::is_ascii_whitespace
names -- and a test checks the new function against the filter it replaces
over every byte value, alone and next to whitespace, including the vertical
tab that is_ascii_whitespace does not include.

Signed-off-by: kurok <22548029+kurok@users.noreply.github.com>
@staktrace

Copy link
Copy Markdown
Owner

Sorry, I'm not accepting PRs which add external dependencies, particularly ones that rely heavily on unsafe code.

@staktrace staktrace closed this Aug 28, 2026
@kurok kurok changed the title Use memchr for the two byte-at-a-time loops in the parse path Scan for MIME boundaries and base64 whitespace a word at a time (no new dependencies) Aug 28, 2026
@kurok

kurok commented Aug 28, 2026

Copy link
Copy Markdown
Author

Understood, and thanks for the quick look. Revised without the dependency: the two loops now use a word-at-a-time scan in plain std — one new module, no unsafe, no new crates — with the same measured effect (full parse of a 767 KiB message 1.09 → 0.24 ms, structure-only 0.37 → 0.03 ms). The PR description has the details; the tests compare both functions against the loops they replace over every alignment and byte value. If this is still not something you want to carry, no problem at all.

@kurok

kurok commented Aug 28, 2026

Copy link
Copy Markdown
Author

GitHub won't reopen a PR whose branch was force-pushed after closing, so the dependency-free revision is in #143.

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.

2 participants