Skip to content

perf(py): decode subscriber samples without copying the payload - #272

Closed
YuanYuYuan wants to merge 3 commits into
mainfrom
perf/py-zero-copy-callback
Closed

perf(py): decode subscriber samples without copying the payload#272
YuanYuYuan wants to merge 3 commits into
mainfrom
perf/py-zero-copy-callback

Conversation

@YuanYuYuan

@YuanYuYuan YuanYuYuan commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Summary

The Python subscriber callback path copies every message before the callback can see it.

build_with_callback routes through RawBytesCdrSerdes::deserialize, whose Output is an owned RawBytesMessage carrying no lifetime. A serdes that only forwards bytes therefore has no way to express "borrow the payload" and must to_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 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, same liveliness and graph registration.

This also brings the callback path in line with the polling recv() path in hiroz-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::deserialize returns 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 strings on the compiled .so: build_with_sample_callback appears 0 times in the copy arm, 7 in the borrow arm).

Run 1 (5+6 reps, 16 000 round trips each), half-trip p50:

payload copy borrow delta
64 B 114.5 µs 114.1 µs within noise
4 KB 120.9 µs 121.0 µs within noise
64 KB 224.4 µs 221.1 µs −3.4 µs

Run 2, larger payloads and 12 paired reps, added specifically to test whether the saving scales with payload:

payload n baseline median delta sem significance
64 KB 12 252.4 µs −0.6 µs 3.1 t = −0.54; sign test p = 1.00
4 MB 12 6310.3 µs −326.2 µs 146.4 t = −1.82, p ≈ 0.10; sign 8/12, p = 0.39

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_callback is additive; build_with_callback is unchanged.

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.
The doc block moved here when this was split out of #250 referenced
`CallbackDispatcher`, which #250 introduces and main does not have, so
rustdoc could not resolve it and check-rustdoc-links failed. The sentence
was also meaningless here for the same reason.
Half this PR was comment. The mechanism needs stating once -- owned
Output, no lifetime, forced copy -- not restating three ways.
@YuanYuYuan

Copy link
Copy Markdown
Collaborator Author

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:

  • 64 KB: run 1 gave −3.4 µs; run 2 at n=12 gives −0.6 µs ± 3.1 (sign test p = 1.00). The original result does not reproduce — its tight interval was a small sample that happened to cluster.
  • 4 MB: −326 µs ± 146, t = −1.82, p ≈ 0.10, sign 8/12. Right direction and a plausible magnitude, but not significant, and the between-rep spread (±507 µs) dominates.

What remains is a structural argument — RawBytesCdrSerdes::deserialize returns an owned RawBytesMessage with no lifetime, so the copy is forced by the type signature — plus consistency with the polling recv() path, which already borrowed. That is a real but small coherence gain, and not enough on its own to justify adding a public builder method to the core crate.

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.

@YuanYuYuan YuanYuYuan closed this Jul 29, 2026
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.

1 participant