perf(py): decode subscriber samples without copying the payload - #272
perf(py): decode subscriber samples without copying the payload#272YuanYuYuan wants to merge 3 commits into
Conversation
The Python callback path went through `RawBytesCdrSerdes::deserialize`, whose `Output` is an owned `RawBytesMessage` carrying no lifetime, so the whole payload was `to_vec()`d before the closure ran -- one full copy per message, scaling with payload size, discarded as soon as msgspec had decoded out of it. Adds `ZSubBuilder::build_with_sample_callback`, which hands the callback the `Sample` so it can borrow the payload and decode straight out of the network buffer, and switches `hiroz-py` to it. Everything else about the subscriber is unchanged: same encoding validation, same dispatch rules, same liveliness and graph registration. Split out of #250. It shared a call site with that PR's re-entrancy fix, which is proximity, not a reason to review them together.
Half this PR was comment. The mechanism needs stating once -- owned Output, no lifetime, forced copy -- not restating three ways.
|
Closing: the justification did not survive measurement. This was split out of #250 as an optimisation. Two independent A/B runs now show no demonstrated benefit at any payload size:
What remains is a structural argument — If the payload copy is worth removing later, the way to establish it is a targeted microbenchmark of the receive path, not an end-to-end ping-pong: at multi-MB sizes the transport variance grows faster than the effect, so the harness used here cannot resolve it at any practical rep count. |
Summary
The Python subscriber callback path copies every message before the callback can see it.
build_with_callbackroutes throughRawBytesCdrSerdes::deserialize, whoseOutputis an ownedRawBytesMessagecarrying no lifetime. A serdes that only forwards bytes therefore has no way to express "borrow the payload" and mustto_vec()the whole message — a full copy per received message, discarded as soon as msgspec has decoded out of it.Key Changes
Adds
ZSubBuilder::build_with_sample_callback, which hands the callback theSampleso it can borrow the payload and decode straight out of the network buffer, and switcheshiroz-pyto it.Everything else about the subscriber is unchanged: same encoding validation, same dispatch, same liveliness and graph registration.
This also brings the callback path in line with the polling
recv()path inhiroz-py/src/pubsub.rs, which already borrowed. The two receive paths disagreeing was the odd part.What fails without this
Nothing. This is an optimisation with no failing baseline, and the justification is below.
The structural claim, which is certain from the source:
RawBytesCdrSerdes::deserializereturns an owned value with no lifetime parameter, so the copy is not incidental — the type signature makes it unavoidable on that path. That is visible in the code, not inferred from timing.The measurement does not support a perf claim. Two independent runs, interleaved paired A/B of release wheels differing only at this call site (verified by
stringson the compiled.so:build_with_sample_callbackappears 0 times in the copy arm, 7 in the borrow arm).Run 1 (5+6 reps, 16 000 round trips each), half-trip p50:
Run 2, larger payloads and 12 paired reps, added specifically to test whether the saving scales with payload:
Run 2 contradicts run 1 at 64 KB. The −3.4 µs from run 1 does not reproduce; at n=12 the 64 KB delta is indistinguishable from zero. Run 1's tight interval was a small sample that happened to cluster.
At 4 MB the point estimate is the right sign and a plausible magnitude (a 4 MB memcpy at ~10 GB/s is ~400 µs), but it does not reach significance, and the between-rep spread (±507 µs, baseline wandering 5.6–7.0 ms) dominates. Resolving it would need n ≈ 40–50 or a harness that suppresses that drift.
Conclusion: no measured performance benefit is demonstrated at any payload size.
So the case for merging rests entirely on the structural argument and the consistency with
recv(). The benchmark has now been run twice and supports nothing; treat this PR as a correctness/consistency change, not an optimisation. If that is not reason enough to carry a new public builder method, the right call is to close it.Breaking Changes
None.
build_with_sample_callbackis additive;build_with_callbackis unchanged.