Speed up MultipartParser callback dispatch

JO
7d ago
+151-22

Hoist the nine multipart callback lookups out of the per-byte loop in MultipartParser._internal_write: each is now resolved once per write() with a _noop fallback, and the data_callback helper takes the resolved function directly. This removes the per-event "on_" + name concatenation, dict lookup, and cast — the same shape as the QuerystringParser optimization in #316, extended to the multipart parser.

Behavior is unchanged: the full suite passes, and a differential oracle comparing the complete callback event stream (every event name plus every data payload) between base and patched trees reports zero divergences across whole, multi-chunk, and 1-byte-chunked parses of all benchmark forms.

Proof

Wall time, interleaved best-of-9, fresh parser per iteration, CPython 3.14.2, base = main @ 51ff1a4. Workloads use the builders in tests/test_benchmarks.py (plus an 8 MiB upload written in 64 KiB chunks):

WorkloadBasePatchedSpeedup (2 runs)
100-part form (LARGE_FORM)432.2 / 426.0 µs334.4 / 336.9 µs1.26–1.29x
2-part form (SIMPLE_FORM)13.9 / 13.5 µs11.4 / 11.4 µs1.18–1.22x
8 MiB upload, 64 KiB chunks779.8 / 722.9 µs675.0 / 680.0 µs1.06–1.16x (noisy at this scale)
1 MiB CRLF boundary-char worst case108.3 / 101.2 µs105.3 / 99.1 µs~1.02x (data-copy bound, unchanged)
8 MiB upload, single write()——~1.00x (data-copy bound, unchanged)

Peak memory (tracemalloc, same workloads): 3.5 KiB before and after on both the 100-part form and the 8 MiB upload — no change.

Reproduction: check out this branch and main side by side, then time MultipartParser(BOUNDARY, callbacks).write(LARGE_FORM) from tests/test_benchmarks.py in alternating best-of-9 loops (each run needs uv sync --frozen for the dev venv; python --version must print 3.14.2 to match these figures).

Notes

  • Event-heavy parses (many parts/headers per byte) gain the most; bulk data movement is untouched, so single-write() throughput is flat — reported above rather than omitted.
  • Like #316, callbacks are resolved once per write(), so a set_callback issued from inside a callback takes effect on the next write() rather than mid-chunk. No test in the suite depends on mid-chunk re-registration.
  • Tests: new TestMultipartParser with test_no_callbacks / test_none_callbacks (mirroring the querystring ones from #316) plus test_events_match_across_chunkings, which pins the joined payload per data channel and the notification sequence across 1/3/7/64-byte chunkings. Gates green: 161 passed, ruff format --check, ruff check, mypy clean.