Speed up MultipartParser callback dispatch
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):
| Workload | Base | Patched | Speedup (2 runs) |
|---|---|---|---|
100-part form (LARGE_FORM) | 432.2 / 426.0 µs | 334.4 / 336.9 µs | 1.26–1.29x |
2-part form (SIMPLE_FORM) | 13.9 / 13.5 µs | 11.4 / 11.4 µs | 1.18–1.22x |
| 8 MiB upload, 64 KiB chunks | 779.8 / 722.9 µs | 675.0 / 680.0 µs | 1.06–1.16x (noisy at this scale) |
| 1 MiB CRLF boundary-char worst case | 108.3 / 101.2 µs | 105.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 aset_callbackissued from inside a callback takes effect on the nextwrite()rather than mid-chunk. No test in the suite depends on mid-chunk re-registration. - Tests: new
TestMultipartParserwithtest_no_callbacks/test_none_callbacks(mirroring the querystring ones from #316) plustest_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,mypyclean.