Repository navigation
Speed up MultipartParser callback dispatch - #337
John-Cusack wants to merge 2 commits into
Conversation
Hoist the nine multipart callback lookups out of the per-byte loop in _internal_write, resolving each once per write() with a _noop fallback, the same shape as the QuerystringParser optimization in Kludex#316. data_callback takes the resolved function directly, removing the per-event 'on_' + name concatenation, dict lookup and cast.
Merging this PR will improve performance by 30.55%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | test_parse_multipart_large_form |
7.2 ms | 5.5 ms | +30.55% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing John-Cusack:rs-upstream-perf (e7af72a) with main (51ff1a4)
Footnotes
-
5 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
There was a problem hiding this comment.
2 issues found and verified against the latest diff
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="tests/test_multipart.py">
<violation number="1" location="tests/test_multipart.py:725">
P2: This comparison drops callback ordering: moving `part_data` after `part_end` still produces the same summary. Normalize adjacent data fragments while preserving every event’s position, then compare the normalized sequences.</violation>
</file>
<file name="python_multipart/multipart.py">
<violation number="1" location="python_multipart/multipart.py:1144">
P3: This comment says there is “one dict lookup per write”, but the block performs nine `.get()` calls per write. Say “one lookup per callback per write” to keep the performance description accurate.</violation>
</file>
Reply to a comment to ask cubic a question or push back. It learns from your replies.
View guided diff | Re-trigger cubic
| joined: dict[str, list[bytes]] = {} | ||
| notifications: list[str] = [] | ||
| for event in events: | ||
| if len(event) == 2: | ||
| joined.setdefault(event[0], []).append(event[1]) | ||
| else: | ||
| notifications.append(event[0]) | ||
| return ({name: b"".join(payloads) for name, payloads in joined.items()}, notifications) |
There was a problem hiding this comment.
P2: This comparison drops callback ordering: moving part_data after part_end still produces the same summary. Normalize adjacent data fragments while preserving every event’s position, then compare the normalized sequences.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_multipart.py, line 725:
<comment>This comparison drops callback ordering: moving `part_data` after `part_end` still produces the same summary. Normalize adjacent data fragments while preserving every event’s position, then compare the normalized sequences.</comment>
<file context>
@@ -644,6 +644,101 @@ def test_invalid_max_size(self) -> None:
+ # Chunk boundaries legitimately split data callbacks, so compare the
+ # joined payload per channel plus the notification-event sequence.
+ def summarize(events: list[tuple[Any, ...]]) -> tuple[Any, ...]:
+ joined: dict[str, list[bytes]] = {}
+ notifications: list[str] = []
+ for event in events:
</file context>
| joined: dict[str, list[bytes]] = {} | |
| notifications: list[str] = [] | |
| for event in events: | |
| if len(event) == 2: | |
| joined.setdefault(event[0], []).append(event[1]) | |
| else: | |
| notifications.append(event[0]) | |
| return ({name: b"".join(payloads) for name, payloads in joined.items()}, notifications) | |
| normalized: list[tuple[Any, ...]] = [] | |
| for event in events: | |
| if ( | |
| len(event) == 2 | |
| and normalized | |
| and len(normalized[-1]) == 2 | |
| and normalized[-1][0] == event[0] | |
| ): | |
| previous = normalized[-1] | |
| normalized[-1] = (event[0], previous[1] + event[1]) | |
| else: | |
| normalized.append(event) | |
| return tuple(normalized) |
| current_header_size = self._current_header_size | ||
|
|
||
| # Hoist callback dispatch out of the per-byte loop (same shape as the | ||
| # QuerystringParser optimization in #316): one dict lookup per write |
There was a problem hiding this comment.
P3: This comment says there is “one dict lookup per write”, but the block performs nine .get() calls per write. Say “one lookup per callback per write” to keep the performance description accurate.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python_multipart/multipart.py, line 1144:
<comment>This comment says there is “one dict lookup per write”, but the block performs nine `.get()` calls per write. Say “one lookup per callback per write” to keep the performance description accurate.</comment>
<file context>
@@ -1140,6 +1140,39 @@ def _internal_write(self, data: bytes, length: int) -> int:
current_header_size = self._current_header_size
+ # Hoist callback dispatch out of the per-byte loop (same shape as the
+ # QuerystringParser optimization in #316): one dict lookup per write
+ # instead of a `"on_" + name` concatenation, dict lookup and `cast`
+ # per emitted event.
</file context>
| # QuerystringParser optimization in #316): one dict lookup per write | |
| # QuerystringParser optimization in #316): one lookup per callback per write |
There was a problem hiding this comment.
2 issues found across 3 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="python_multipart/multipart.py">
<violation number="1" location="python_multipart/multipart.py:1147">
P3: This snapshot changes `BaseParser.set_callback()` semantics without documenting the operation boundary: a callback registered during `on_part_begin` no longer affects later events in the same `write()`. Document that callback updates made during a write take effect from the next `write()` (while updates between writes apply normally).\n\n(Based on your team's feedback about callback update timing.) [d68ae4b8-e1a0-4dee-a221-824cce4f1a50]</violation>
</file>
<file name="tests/test_multipart.py">
<violation number="1" location="tests/test_multipart.py:732">
P2: This comparison can miss a broken callback stream: it derives its expected result from the optimized parser and discards ordering between notifications and data callbacks. Compare against an explicit expected sequence, merging only adjacent chunks from the same data callback.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Re-trigger cubic
| joined.setdefault(event[0], []).append(event[1]) | ||
| else: | ||
| notifications.append(event[0]) | ||
| return ({name: b"".join(payloads) for name, payloads in joined.items()}, notifications) |
There was a problem hiding this comment.
P2: This comparison can miss a broken callback stream: it derives its expected result from the optimized parser and discards ordering between notifications and data callbacks. Compare against an explicit expected sequence, merging only adjacent chunks from the same data callback.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At tests/test_multipart.py, line 732:
<comment>This comparison can miss a broken callback stream: it derives its expected result from the optimized parser and discards ordering between notifications and data callbacks. Compare against an explicit expected sequence, merging only adjacent chunks from the same data callback.</comment>
<file context>
@@ -644,6 +644,101 @@ def test_invalid_max_size(self) -> None:
+ joined.setdefault(event[0], []).append(event[1])
+ else:
+ notifications.append(event[0])
+ return ({name: b"".join(payloads) for name, payloads in joined.items()}, notifications)
+
+ whole = self.parse()
</file context>
| # QuerystringParser optimization in #316): one dict lookup per write | ||
| # instead of a `"on_" + name` concatenation, dict lookup and `cast` | ||
| # per emitted event. | ||
| callbacks = cast("MultipartCallbacks", self.callbacks) |
There was a problem hiding this comment.
P3: This snapshot changes BaseParser.set_callback() semantics without documenting the operation boundary: a callback registered during on_part_begin no longer affects later events in the same write(). Document that callback updates made during a write take effect from the next write() (while updates between writes apply normally).\n\n(Based on your team's feedback about callback update timing.) [d68ae4b8-e1a0-4dee-a221-824cce4f1a50]
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At python_multipart/multipart.py, line 1147:
<comment>This snapshot changes `BaseParser.set_callback()` semantics without documenting the operation boundary: a callback registered during `on_part_begin` no longer affects later events in the same `write()`. Document that callback updates made during a write take effect from the next `write()` (while updates between writes apply normally).\n\n(Based on your team's feedback about callback update timing.) [d68ae4b8-e1a0-4dee-a221-824cce4f1a50]</comment>
<file context>
@@ -1140,6 +1140,39 @@ def _internal_write(self, data: bytes, length: int) -> int:
+ # QuerystringParser optimization in #316): one dict lookup per write
+ # instead of a `"on_" + name` concatenation, dict lookup and `cast`
+ # per emitted event.
+ callbacks = cast("MultipartCallbacks", self.callbacks)
+ on_part_begin = callbacks.get("on_part_begin")
+ on_part_data = callbacks.get("on_part_data")
</file context>
…am PR (#80) (#80) Target Kludex/python-multipart (313M dl/mo, highest-download prod-live original not yet reviewed; PyPI 0.0.32 re-verified). Pure-Python callback hoist in _internal_write, same shape as upstream #316: 1.26-1.29x on 100-part form, memory-neutral, event oracle 0 divergences. Upstream PR Kludex/python-multipart#337 (open, not a draft). Co-authored-by: John Cusack <john@cusack.example>
Hoist the nine multipart callback lookups out of the per-byte loop in
MultipartParser._internal_write: each is now resolved once perwrite()with a_noopfallback, and thedata_callbackhelper takes the resolved function directly. This removes the per-event"on_" + nameconcatenation, dict lookup, andcast— the same shape as theQuerystringParseroptimization 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 intests/test_benchmarks.py(plus an 8 MiB upload written in 64 KiB chunks):LARGE_FORM)SIMPLE_FORM)write()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
mainside by side, then timeMultipartParser(BOUNDARY, callbacks).write(LARGE_FORM)fromtests/test_benchmarks.pyin alternating best-of-9 loops (each run needsuv sync --frozenfor the dev venv;python --versionmust print 3.14.2 to match these figures).Notes
write()throughput is flat — reported above rather than omitted.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.TestMultipartParserwithtest_no_callbacks/test_none_callbacks(mirroring the querystring ones from Speed up querystring callback dispatch #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.