Skip to content

Commit 41e8c7a

Browse files
dmornampagent
andcommitted
Add flow-partitioned stream client benchmark
Amp-Thread-ID: https://ampcode.com/threads/T-019ef98a-e6d9-77db-9357-a6f18a78a104 Co-authored-by: Amp <amp@ampcode.com>
1 parent cd8cdd5 commit 41e8c7a

10 files changed

Lines changed: 1579 additions & 21 deletions

File tree

.scratch/transport-layer-foundation/issues/46-improve-moqx-client-stream-throughput.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,3 +370,39 @@ with object publishing.
370370
Opened #53 for the next experiment, `flow_partitions`, which should test
371371
Flow/GenStage-native partition routing before replacing or promoting any
372372
current topology.
373+
- 2026-06-19: Implemented #53 as `flow_partitions`, a candidate stream client
374+
where a Flow source feeds `GenStage.PartitionDispatcher` at the final
375+
consumer boundary and one `StreamPartitionSink` owns each shard's stream
376+
sender state and completion mailbox. The key lifecycle decision is explicit
377+
per-partition source EOF control events: source exhaustion does not mean QUIC
378+
FIN, and sinks stop only after EOF, empty queues, zero in-flight sends, and
379+
expected completions. Local validation passed (`54` tests, Credo strict clean)
380+
and a tiny `8 x 20 x 256` fake comparison kept `sender_shards` ahead
381+
(`1.76 K ips`) of `flow_partitions` (`1.65 K ips`), so `sender_shards`
382+
remains `current_best` and `flow_partitions` stays a candidate trace.
383+
- 2026-06-19: Tuned #53 and reversed the initial ranking. Source Flow
384+
parallelism is not currently safe for ordered stream workloads:
385+
`--flow-stages 2` can reorder same-stream payloads and send FIN before
386+
earlier payloads, so the CLI now rejects `--flow-stages > 1`. With safe
387+
source stages (`1`) and tuned demand/backlog, `flow_partitions` became the
388+
fake/local winner. Best observed settings:
389+
`--sender-shard-count 8 --min-demand 128 --max-demand 256 --max-queue-depth 1024`.
390+
On `32 x 100 x 1180`, the four-way tuned matrix was `flow_partitions`
391+
`410.49 ips`, `sender_shards` `264.25 ips`, `stream_owner` `181.28 ips`,
392+
and `context_owner` `43.52 ips`. On `128 x 1000 x 1180`,
393+
`flow_partitions` reached `13.64 ips` (`73.34 ms`) versus `sender_shards`
394+
`8.85 ips` (`113.01 ms`) at shard/partition `8`. Promoted
395+
`flow_partitions` to `current_best` for fake/local process-model work.
396+
- 2026-06-19: Local loopback `iperf3` + `quicprobe` calibration shows the fake
397+
process-model winner does not automatically stay ahead once the real QUIC
398+
stack and receiver are in the loop. Baseline on `127.0.0.1` was about
399+
`61.64 Gbps` TCP with zero retransmits; UDP `100 Mbps` preflight had about
400+
`0.014 ms` jitter and `0.61%` loss. Against local `quicprobe`, all receiver
401+
evidence was valid. On `32 x 100 x 1180`, `sender_shards` reached
402+
`36.55 ips` (`27.36 ms`) and `flow_partitions` reached `35.33 ips`
403+
(`28.30 ms`). On `64 x 1000 x 1180`, `sender_shards` reached `2.46 ips`
404+
(`406.54 ms`, about `1.49 Gbps` workload goodput) and `flow_partitions`
405+
reached `2.31 ips` (`432.83 ms`, about `1.40 Gbps`). Treat this as local
406+
calibration only, but it means the next optimization loop must separate fake
407+
process overhead from real transport/NIF/quic-go interaction before changing
408+
the current-best label again.

.scratch/transport-layer-foundation/issues/53-add-flow-partitioned-stream-client-implementation.md

Lines changed: 162 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
33
# Add Flow-partitioned stream client implementation
44

5-
Status: ready-for-agent
5+
Status: done
66
Type: AFK
77
Category: performance
88

@@ -35,8 +35,8 @@ final shard boundary.
3535

3636
Flow and GenStage already provide the lower-level shape we want:
3737

38-
- `Flow.partition/2` materializes partitioned producers with
39-
`GenStage.PartitionDispatcher`;
38+
- `Flow.into_stages/3` can use `GenStage.PartitionDispatcher` as the
39+
consumer-facing dispatcher;
4040
- `GenStage.PartitionDispatcher` routes events to named partitions;
4141
- each consumer subscribes with `partition: shard_index`;
4242
- each partition consumer can own shard-local stream sender state and receive
@@ -65,7 +65,7 @@ The implementation should keep the benchmark model unchanged:
6565

6666
The expected data path is:
6767

68-
`Flow source -> Flow.partition/2 -> one GenStage shard sink per partition`
68+
`Flow source -> GenStage.PartitionDispatcher -> one GenStage shard sink per partition`
6969

7070
Partitioning should be deterministic and diagnosable. Prefer explicit stream
7171
index modulo shard count over opaque stream-handle hashing:
@@ -94,24 +94,24 @@ timed function returns.
9494

9595
## Acceptance criteria
9696

97-
- [ ] `bench/moqxprobe` contains a new named stream implementation,
97+
- [x] `bench/moqxprobe` contains a new named stream implementation,
9898
`flow_partitions`, registered alongside the existing stream
9999
implementations.
100-
- [ ] The implementation uses `Flow.partition/2` or an equivalent
100+
- [x] The implementation uses `Flow.partition/2` or an equivalent
101101
GenStage-native partitioned producer path instead of manually routing
102102
payloads through `send(worker, event)`.
103-
- [ ] The implementation remains Flow-fed and uses the same payload event shape
103+
- [x] The implementation remains Flow-fed and uses the same payload event shape
104104
as the other stream implementations.
105-
- [ ] Each shard owns its stream sender state and handles send completions in
105+
- [x] Each shard owns its stream sender state and handles send completions in
106106
its own OTP process.
107-
- [ ] The implementation reports configured shard count, active shard count,
107+
- [x] The implementation reports configured shard count, active shard count,
108108
routed/received payload count, completion count, queue/window pressure,
109109
and timing diagnostics comparable to `sender_shards`.
110-
- [ ] Fake-target tests prove the implementation completes the same workload
110+
- [x] Fake-target tests prove the implementation completes the same workload
111111
and receiver evidence as the existing implementations.
112-
- [ ] A fake/local Benchee comparison records `context_owner`, `stream_owner`,
112+
- [x] A fake/local Benchee comparison records `context_owner`, `stream_owner`,
113113
`sender_shards`, and `flow_partitions` with the same input shape.
114-
- [ ] Promote `flow_partitions` to current best only if it is cleaner and at
114+
- [x] Promote `flow_partitions` to current best only if it is cleaner and at
115115
least performance-neutral against `sender_shards`; otherwise keep it as a
116116
historical/rejected architecture with the evidence recorded.
117117

@@ -138,3 +138,153 @@ cleaner OTP/process ownership model:
138138

139139
If the experiment fails, record why and keep it in the registry as historical
140140
or rejected evidence rather than deleting the trace.
141+
142+
## Comments
143+
144+
### 2026-06-19 implementation
145+
146+
Implemented the first `flow_partitions` candidate.
147+
148+
Important design correction: `Flow.partition/2` partitions internal Flow
149+
stages, but `Flow.into_stages/3` still defaults to a demand dispatcher for the
150+
external consumers. The final consumer boundary must use
151+
`GenStage.PartitionDispatcher` directly, so `MOQXProbe.Traffic` now provides
152+
`start_partitioned_payloads/3` that starts a Flow source and attaches sinks
153+
with:
154+
155+
```elixir
156+
dispatcher: {GenStage.PartitionDispatcher, partitions: 0..(n - 1), hash: hash}
157+
```
158+
159+
Added `MOQXProbe.Traffic.StreamPartitionSink`:
160+
161+
- one GenStage consumer per partition;
162+
- explicit partition subscription;
163+
- shard-local stream sender state;
164+
- per-stream queue and send window ownership;
165+
- backend send-completion handling in `handle_info/2`;
166+
- final snapshot emission before normal stop.
167+
168+
Termination is explicit. The benchmark driver appends one
169+
`%{control: :source_eof, partition: partition}` event per partition after the
170+
payload workload. A partition sink treats source EOF as "no more payload events
171+
for this partition", not as QUIC FIN. The sink stops only when source EOF has
172+
arrived, local queues are empty, in-flight sends are zero, and expected send
173+
completions have been observed.
174+
175+
Initial local tests cover:
176+
177+
- direct partition sink lifecycle through Flow source EOF and send-completion
178+
drain;
179+
- fake-target `flow_partitions` benchmark completion through
180+
`bench/stream_clients.exs`;
181+
- CLI/registry inclusion as a `candidate` implementation.
182+
183+
Validation:
184+
185+
- `cd bench/moqxprobe && mix format --check-formatted && mix test && mix credo --strict`:
186+
54 tests passed, Credo strict found no issues.
187+
- Single-candidate smoke:
188+
`mix run bench/stream_clients.exs -- --target fake --input flow-generated --implementation flow_partitions --stream-count 8 --payload-count 20 --payload-size 256 --stream-send-window 4 --sender-shard-count 2 --benchee-warmup 0 --benchee-time 1 --benchee-memory-time 0 --benchee-reduction-time 0 --git-sha test-smoke`
189+
reached about `1.55 K ips` (`646.35 us` average).
190+
- Four-way fake comparison on the same tiny shape:
191+
`stream_owner` about `1.89 K ips`, `sender_shards` about `1.76 K ips`,
192+
`flow_partitions` about `1.65 K ips`, and `context_owner` about `0.81 K ips`.
193+
194+
Initial decision: keep `sender_shards` as `current_best`. `flow_partitions`
195+
was cleaner in ownership and termination semantics, but the first small local
196+
smoke was not performance-neutral against `sender_shards`, so it remained a
197+
`candidate` architecture trace.
198+
199+
### 2026-06-19 tuning pass
200+
201+
Tuned the fake-target comparison across shard/partition count and GenStage
202+
demand/backlog.
203+
204+
Important correctness boundary:
205+
206+
- `--flow-stages 2` is unsafe for the current ordered stream workload. It can
207+
reorder payload events for the same stream and deliver a non-final payload
208+
after the `finish?: true` payload, causing `:send_side_finished`. The CLI now
209+
rejects `--flow-stages > 1` for this stream script until a source architecture
210+
can preserve per-stream order with parallel source stages.
211+
212+
Small shape, `32 x 100 x 1180`, window `16`, fake target:
213+
214+
- shard/partition `2`, demand `64`, queue `256`: `sender_shards` `134.42 ips`,
215+
`flow_partitions` `125.75 ips`;
216+
- shard/partition `4`, demand `64`, queue `256`: `flow_partitions`
217+
`145.25 ips`, `sender_shards` `137.64 ips`;
218+
- shard/partition `8`, demand `64`, queue `256`: `flow_partitions`
219+
`134.34 ips`, `sender_shards` `129.71 ips`;
220+
- shard/partition `4`, demand `128`, queue `512`: `flow_partitions`
221+
`322.71 ips`, `sender_shards` `267.83 ips`;
222+
- shard/partition `4`, demand `256`, queue `1024`: `flow_partitions`
223+
`337.18 ips`, `sender_shards` `279.93 ips`;
224+
- shard/partition `8`, demand `256`, queue `1024`: `flow_partitions`
225+
`422.29 ips`, `sender_shards` `262.85 ips`;
226+
- shard/partition `16`, demand `256`, queue `1024`: `flow_partitions`
227+
`409.07 ips`, `sender_shards` `210.56 ips`.
228+
229+
Four-way tuned small-shape matrix with shard/partition `8`, demand `256`,
230+
queue `1024`:
231+
232+
- `flow_partitions`: `410.49 ips`;
233+
- `sender_shards`: `264.25 ips`;
234+
- `stream_owner`: `181.28 ips`;
235+
- `context_owner`: `43.52 ips`.
236+
237+
Large fake shape, `128 x 1000 x 1180`, window `16`, demand `256`,
238+
queue `1024`:
239+
240+
- shard/partition `8`: `flow_partitions` `13.64 ips` (`73.34 ms` average),
241+
`sender_shards` `8.85 ips` (`113.01 ms` average);
242+
- shard/partition `16`: `flow_partitions` `13.50 ips` (`74.10 ms` average),
243+
`sender_shards` `6.68 ips` (`149.65 ms` average).
244+
245+
Decision: promote `flow_partitions` to `current_best` for fake/local
246+
process-model work. Keep `sender_shards` as a historical comparison trace.
247+
Best observed local fake settings for now:
248+
249+
`--sender-shard-count 8 --flow-stages 1 --min-demand 128 --max-demand 256 --max-queue-depth 1024`
250+
251+
### 2026-06-19 local quicprobe calibration
252+
253+
Ran the tuned `flow_partitions` setup against a same-host `quicprobe` target
254+
with local `iperf3` preflights and receiver evidence enabled.
255+
256+
Target setup:
257+
258+
- `iperf3 --server --bind 127.0.0.1 --port 55201`
259+
- `quicprobe server --addr 127.0.0.1:55433 --alpn moqx-test --datagram-semantics drain --evidence-http-addr 127.0.0.1:55434`
260+
- TLS used the repo integration CA/cert under `.tmp/integration-certs`.
261+
262+
Preflight:
263+
264+
- TCP loopback: about `61.64 Gbps`, zero retransmits.
265+
- UDP loopback at `100 Mbps`: about `0.014 ms` jitter, `0.61%` loss.
266+
267+
Small real-QUIC shape, `32 x 100 x 1180`, window `16`, shard/partition `8`,
268+
demand `128..256`, queue `1024`:
269+
270+
- `sender_shards`: `36.55 ips`, `27.36 ms` average;
271+
- `flow_partitions`: `35.33 ips`, `28.30 ms` average;
272+
- receiver evidence: `38/38` valid, each sample delivered `32` completed
273+
streams and `3,776,000` bytes.
274+
275+
Heavier real-QUIC shape, `64 x 1000 x 1180`, window `16`, shard/partition `8`,
276+
demand `128..256`, queue `1024`:
277+
278+
- `sender_shards`: `2.46 ips`, `406.54 ms` average, about `1.49 Gbps`
279+
workload goodput;
280+
- `flow_partitions`: `2.31 ips`, `432.83 ms` average, about `1.40 Gbps`
281+
workload goodput;
282+
- receiver evidence: `6/6` valid, each sample delivered `64` completed
283+
streams and `75,520,000` bytes.
284+
285+
Conclusion: the fake-target process-model win is real, but local quicprobe
286+
does not yet confirm `flow_partitions` as the better end-to-end QUIC sender.
287+
Keep this result as calibration only. The next performance loop should compare
288+
fake, same-host quicprobe, and remote quicprobe explicitly so we can see where
289+
the bottleneck moves from BEAM process ownership into transport/NIF/QUIC-stack
290+
interaction.

0 commit comments

Comments
 (0)