Benchmark arena transport¶
Targeted benchmark: arena transport throughput (regular IPC vs ring vs pool).
Measures how the shared-memory arena affects the cost of shipping large payloads
from a backend Pipeline running in a subprocess to the
main process, via spdl.pipeline.run_pipeline_in_subprocess().
A backend pipeline produces a fixed dataset of {"data": payload} items; the
main process receives them through one of three transports and does nothing with
them (a no-op stand-in for a training loop), so the measured throughput isolates
transfer + restore with no decode or other per-item work in the way:
no-arena — payloads are pickled over the multiprocessing queue (the default).
ring —
SharedMemoryRingBuffer; the reader copies each payload out of shared memory (so it cannot hand out live views).pool —
SharedMemorySegmentPool; the reader restores each payload as a zero-copy view directly over shared memory.
It sweeps payload sizes x four payload types — bytes, NumPy arrays, Torch
tensors, and spdl.io VideoPackets — each of which the arena offloads as a
large binary.
Results
From --isolate at 32 MiB on a CPU-only host (--num-items 16 --runs 10):
each (kind, transport) runs in its own freshly-spawned process, one at a time,
so the CPU s and peak RSS columns are attributable to that one config
(getrusage over the timed passes / from a pre-arena baseline; a single shared
process would accumulate both across configs and could not separate them). The
arena raises throughput and drops the CPU of moving a payload to near zero —
the pool restores a zero-copy view, so it does essentially no per-item work, while
plain IPC spends several CPU-seconds pickling and copying. bytes / NumPy gain
the most. Torch transfers tensors through shared memory itself (its
multiprocessing reducer), so plain IPC already avoids a bulk copy — its throughput
barely moves and plain IPC is already its leanest memory; the ring even adds CPU
(it copies out), yet the pool still drops its CPU to ~0. Packets gain less
throughput (restoring rebuilds the AVPacket structures) but the pool slashes
their CPU and memory too. peak RSS includes a fixed per-process baseline, so
compare it within a row; for bytes / NumPy / packets the no-arena pickle
buffers are the heaviest, and the pool the lightest.
kind transport recv MB/s speedup CPU s peak RSS MB
------------------------------------------------------------
bytes no-arena 719 1.00x 7.1 1872
bytes ring 2684 3.73x 1.8 1015
bytes pool 3760 5.23x 0.0 673
numpy no-arena 647 1.00x 7.7 1865
numpy ring 3202 4.95x 1.1 1009
numpy pool 3767 5.82x 0.0 669
torch no-arena 2899 1.00x 0.6 603
torch ring 3106 1.07x 1.2 1262
torch pool 3116 1.08x 0.0 834
packets no-arena 555 1.00x 8.6 1885
packets ring 1359 2.45x 3.3 1236
packets pool 1622 2.92x 0.1 825
(recv MB/s with speedup vs no-arena; CPU s and peak RSS are per-config,
lower is better. Throughput speedup grows with payload size — at 2 / 8 MiB
it is smaller; see the size sweep below.)
In the plot below, each line is one (kind, transport) across payload sizes (a separate throughput sweep): colour and marker encode the payload type and line style the transport (dotted = no-arena, dashed = ring, solid = pool); the shaded band is the ~95% confidence interval of the mean:
Example
$ python benchmark_arena_transport.py --sizes 2 8 32 --output results.csv
$ python benchmark_arena_transport_plot.py --input results.csv --output plot.png
Source¶
Source
Click here to see the source.
1#!/usr/bin/env python3
2# Copyright (c) Meta Platforms, Inc. and affiliates.
3# All rights reserved.
4#
5# This source code is licensed under the BSD-style license found in the
6# LICENSE file in the root directory of this source tree.
7
8
9"""Targeted benchmark: arena transport throughput (regular IPC vs ring vs pool).
10
11Measures how the shared-memory arena affects the cost of shipping large payloads
12from a backend :py:class:`~spdl.pipeline.Pipeline` running in a subprocess to the
13main process, via :py:func:`spdl.pipeline.run_pipeline_in_subprocess`.
14
15A backend pipeline produces a fixed dataset of ``{"data": payload}`` items; the
16main process receives them through one of three transports and does nothing with
17them (a no-op stand-in for a training loop), so the measured throughput isolates
18**transfer + restore** with no decode or other per-item work in the way:
19
20- **no-arena** — payloads are pickled over the multiprocessing queue (the default).
21- **ring** — :py:class:`~spdl.pipeline.SharedMemoryRingBuffer`; the reader copies
22 each payload out of shared memory (so it cannot hand out live views).
23- **pool** — :py:class:`~spdl.pipeline.SharedMemorySegmentPool`; the reader
24 restores each payload as a zero-copy view directly over shared memory.
25
26It sweeps payload sizes x four payload types — ``bytes``, NumPy arrays, Torch
27tensors, and ``spdl.io`` ``VideoPackets`` — each of which the arena offloads as a
28large binary.
29
30**Results**
31
32From ``--isolate`` at 32 MiB on a CPU-only host (``--num-items 16 --runs 10``):
33each ``(kind, transport)`` runs in its own freshly-spawned process, one at a time,
34so the ``CPU s`` and ``peak RSS`` columns are attributable to that one config
35(``getrusage`` over the timed passes / from a pre-arena baseline; a single shared
36process would accumulate both across configs and could not separate them). The
37arena raises throughput *and* drops the CPU of moving a payload to near zero —
38the pool restores a zero-copy view, so it does essentially no per-item work, while
39plain IPC spends several CPU-seconds pickling and copying. ``bytes`` / NumPy gain
40the most. Torch transfers tensors through shared memory itself (its
41multiprocessing reducer), so plain IPC already avoids a bulk copy — its throughput
42barely moves and plain IPC is already its leanest memory; the ring even adds CPU
43(it copies out), yet the pool still drops its CPU to ~0. Packets gain less
44throughput (restoring rebuilds the ``AVPacket`` structures) but the pool slashes
45their CPU and memory too. ``peak RSS`` includes a fixed per-process baseline, so
46compare it within a row; for ``bytes`` / NumPy / packets the no-arena pickle
47buffers are the heaviest, and the pool the lightest.
48
49.. code-block:: text
50
51 kind transport recv MB/s speedup CPU s peak RSS MB
52 ------------------------------------------------------------
53 bytes no-arena 719 1.00x 7.1 1872
54 bytes ring 2684 3.73x 1.8 1015
55 bytes pool 3760 5.23x 0.0 673
56 numpy no-arena 647 1.00x 7.7 1865
57 numpy ring 3202 4.95x 1.1 1009
58 numpy pool 3767 5.82x 0.0 669
59 torch no-arena 2899 1.00x 0.6 603
60 torch ring 3106 1.07x 1.2 1262
61 torch pool 3116 1.08x 0.0 834
62 packets no-arena 555 1.00x 8.6 1885
63 packets ring 1359 2.45x 3.3 1236
64 packets pool 1622 2.92x 0.1 825
65 (recv MB/s with speedup vs no-arena; CPU s and peak RSS are per-config,
66 lower is better. Throughput speedup grows with payload size — at 2 / 8 MiB
67 it is smaller; see the size sweep below.)
68
69In the plot below, each line is one (kind, transport) across payload sizes (a
70separate throughput sweep): colour and marker encode the payload type and line
71style the transport (dotted = no-arena, dashed = ring, solid = pool); the shaded
72band is the ~95% confidence interval of the mean:
73
74.. image:: ../../_static/data/example_benchmark_arena_transport.png
75
76**Example**
77
78.. code-block:: shell
79
80 $ python benchmark_arena_transport.py --sizes 2 8 32 --output results.csv
81 $ python benchmark_arena_transport_plot.py --input results.csv --output plot.png
82"""
83
84from __future__ import annotations
85
86__all__ = [
87 "Row",
88 "create_video_data",
89 "main",
90 "read_csv",
91 "run_transport",
92 "write_csv",
93]
94
95import argparse
96import csv
97import gc
98import multiprocessing as mp
99import resource
100import statistics
101import tempfile
102import time
103from collections.abc import Iterator
104from dataclasses import asdict, dataclass, fields
105from itertools import product
106from typing import Any
107
108import numpy as np
109import spdl.io
110import torch
111from spdl.pipeline import (
112 PipelineBuilder,
113 run_pipeline_in_subprocess,
114 SharedMemoryRingBuffer,
115 SharedMemorySegmentPool,
116)
117
118_KINDS = ("bytes", "numpy", "torch", "packets")
119_TRANSPORTS = ("no-arena", "ring", "pool")
120# 4K frames: at the benchmark's payload sizes the encoder fills the bitrate with
121# real frame data instead of mostly CBR filler, so the packets resemble a real
122# high-resolution decode workload.
123_VIDEO_SIZE = "3840x2160"
124
125
126@dataclass(frozen=True)
127class Row:
128 """Row()
129
130 One benchmark measurement: throughput for a (size, kind, transport)."""
131
132 size_mb: int
133 """Requested payload size in MiB (the sweep knob)."""
134
135 kind: str
136 """Payload kind: ``"bytes"``, ``"numpy"``, ``"torch"``, or ``"packets"``."""
137
138 transport: str
139 """Transport used: ``"no-arena"``, ``"ring"``, or ``"pool"``."""
140
141 items_per_s: float
142 """Mean items received per second over the timed passes."""
143
144 mb_per_s: float
145 """Mean payload throughput in MB/s (``items_per_s`` x payload bytes)."""
146
147 mb_per_s_lo: float
148 """Lower bound of the ~95% confidence interval of ``mb_per_s`` (normal
149 approximation over the timed passes)."""
150
151 mb_per_s_hi: float
152 """Upper bound of the ~95% confidence interval of ``mb_per_s``."""
153
154 speedup: float
155 """``items_per_s`` relative to the no-arena baseline for this (size, kind)."""
156
157 peak_rss_mb: float = 0.0
158 """Peak resident memory of the config's process tree (consumer + producer), in
159 MB. Only populated by ``--isolate``; ``0`` otherwise. Shared arena pages may be
160 counted in both processes, so treat it as an upper-bound proxy and compare
161 within a (size, kind) row across transports."""
162
163 cpu_sec: float = 0.0
164 """Total CPU seconds (user + system, consumer + producer) the config consumed.
165 Only populated by ``--isolate``; ``0`` otherwise. Lower is better — moving a
166 payload via the arena should cost less CPU than the pickle + copy of plain IPC."""
167
168
169def create_video_data(target_bytes: int, duration_seconds: float = 2.0) -> bytes:
170 """Generate an H.264 MP4 whose stream is approximately ``target_bytes``.
171
172 Encodes low-entropy frames at a constant target bitrate (x264 CBR, which pads
173 with filler to hold the rate), so the serialized ``VideoPackets`` payload
174 scales with the requested size rather than collapsing to the content's natural
175 compressed size. Uses ``spdl.io``'s in-process encoder rather than the
176 ``ffmpeg`` CLI, so it also works on minimal container images that do not ship
177 the CLI.
178
179 Args:
180 target_bytes: Desired size of the encoded video stream, in bytes.
181 duration_seconds: Clip duration; the bitrate is derived from it.
182
183 Returns:
184 The encoded MP4 file contents as raw bytes.
185 """
186 width, height = (int(x) for x in _VIDEO_SIZE.split("x"))
187 frame_rate = (30, 1)
188 num_frames = max(1, round(frame_rate[0] / frame_rate[1] * duration_seconds))
189 bit_rate = max(64_000, int(target_bytes * 8 / duration_seconds))
190 kbps = bit_rate // 1000
191 batch_size = 4 # bound peak memory: 4K yuv444p frames are ~25 MiB each
192 with tempfile.NamedTemporaryFile(suffix=".mp4") as tmp_file:
193 muxer = spdl.io.Muxer(tmp_file.name)
194 encoder = muxer.add_encode_stream(
195 config=spdl.io.video_encode_config(
196 height=height,
197 width=width,
198 pix_fmt="yuv444p",
199 frame_rate=frame_rate,
200 bit_rate=bit_rate,
201 ),
202 encoder="libx264",
203 # CBR with a tight VBV so the encoder pads low-entropy frames with
204 # filler up to the target rate (mirrors the ffmpeg nal-hrd=cbr path).
205 encoder_config={
206 "preset": "ultrafast",
207 "x264-params": f"nal-hrd=cbr:vbv-maxrate={kbps}:vbv-bufsize={kbps}",
208 },
209 )
210 with muxer.open():
211 for start in range(0, num_frames, batch_size):
212 n = min(batch_size, num_frames - start)
213 array = np.zeros((n, 3, height, width), dtype=np.uint8)
214 frames = spdl.io.create_reference_video_frame(
215 array=array,
216 pix_fmt="yuv444p",
217 frame_rate=frame_rate,
218 pts=start,
219 )
220 if (packets := encoder.encode(frames)) is not None:
221 muxer.write(0, packets)
222 if (packets := encoder.flush()) is not None:
223 muxer.write(0, packets)
224 with open(tmp_file.name, "rb") as f:
225 return f.read()
226
227
228def _make_payload(kind: str, size: int, video_bytes: bytes | None) -> object:
229 """Build one payload of ``kind`` whose serialized size is roughly ``size``."""
230 if kind == "bytes":
231 return bytes(size)
232 if kind == "numpy":
233 return np.zeros(max(1, size // 4), dtype=np.float32)
234 if kind == "torch":
235 return torch.zeros(max(1, size // 4), dtype=torch.float32)
236 if kind == "packets":
237 assert video_bytes is not None
238 return spdl.io.demux_video(video_bytes)
239 raise ValueError(f"unknown kind: {kind}")
240
241
242def _payload_nbytes(kind: str, size: int, video_bytes: bytes | None) -> int:
243 """The on-the-wire size of one payload, used to size the arena."""
244 payload: Any = _make_payload(kind, size, video_bytes)
245 if kind == "packets":
246 return len(payload.__getstate__())
247 if kind == "numpy":
248 return int(payload.nbytes)
249 if kind == "torch":
250 return int(payload.element_size() * payload.nelement())
251 return len(payload)
252
253
254@dataclass(frozen=True)
255class _Dataset:
256 """A picklable finite dataset: yields ``num_items`` payload items.
257
258 Only the small spec is pickled into the subprocess; the payload itself is
259 built lazily in ``__iter__`` (i.e. in the worker), so shipping the dataset
260 across the process boundary stays cheap regardless of payload size.
261
262 Most kinds reuse one payload object so the measured per-item cost is transfer
263 + restore rather than construction. Torch is the exception: it moves a CPU
264 tensor's storage into shared memory in place on the first IPC send and reuses
265 it afterwards, so reusing one tensor would hide the real cost (a fresh
266 shared-memory segment per distinct tensor). It therefore yields a distinct
267 tensor per item, matching how a real pipeline produces tensors.
268 """
269
270 kind: str
271 size: int
272 num_items: int
273 video_bytes: bytes | None = None
274
275 def __iter__(self) -> Iterator[dict[str, object]]:
276 # Torch moves a CPU tensor's storage into shared memory in place on the
277 # first cross-process send and reuses that same segment for later sends of
278 # the *same* tensor (torch.multiprocessing's reducer + its storage cache).
279 # So if the benchmark reused one tensor, every send after the first would
280 # be nearly free and hide the real per-item cost (a fresh shm segment per
281 # distinct tensor) — build a distinct tensor per item for torch instead.
282 reused = (
283 None
284 if self.kind == "torch"
285 else _make_payload(self.kind, self.size, self.video_bytes)
286 )
287 for _ in range(self.num_items):
288 payload = (
289 reused
290 if reused is not None
291 else _make_payload(self.kind, self.size, self.video_bytes)
292 )
293 yield {"data": payload}
294
295
296def _make_arena(
297 transport: str, payload_nbytes: int, buffer_size: int, num_items: int
298) -> SharedMemoryRingBuffer | SharedMemorySegmentPool | None:
299 """Construct the arena for a transport mode, sized to hold a whole iteration.
300
301 The arena does not block the writer when full — it raises "shared-memory
302 arena full" and relies on the pipeline's queue backpressure to bound the
303 in-flight units. That bound only holds when the reader keeps up; when restore
304 is much slower than offload (e.g. rebuilding ``VideoPackets`` on a busy host),
305 the writer can get a whole iteration ahead. So size the arena for the
306 worst case — every item of one iteration in flight at once — which never
307 overruns regardless of the producer/consumer speed gap.
308 """
309 if transport == "no-arena":
310 return None
311 slots = max(2 * buffer_size + 6, num_items + 2)
312 unit = max(1 << 20, payload_nbytes * 2) # headroom for envelope + alignment
313 if transport == "ring":
314 return SharedMemoryRingBuffer(capacity=unit * slots)
315 if transport == "pool":
316 return SharedMemorySegmentPool(segment_size=unit, count=slots)
317 raise ValueError(f"unknown transport: {transport}")
318
319
320def run_transport(
321 dataset: _Dataset,
322 transport: str,
323 *,
324 payload_nbytes: int,
325 num_items: int,
326 buffer_size: int,
327 runs: int,
328 duration_sec: float = 0.0,
329 usage: list[float] | None = None,
330) -> list[float]:
331 """Run the backend pipeline once per timed pass; return per-pass items/second.
332
333 Builds a one-stage backend pipeline (source -> sink) that runs in a
334 subprocess via :py:func:`run_pipeline_in_subprocess`, ships its items to the
335 main process over ``transport``, and consumes them in a no-op loop. One warmup
336 pass is discarded; the per-pass throughput of each timed pass is returned so
337 the caller can summarize it (mean + confidence interval). The subprocess and
338 arena are reused across passes and torn down at the end.
339
340 Args:
341 dataset: The (picklable) backend data source.
342 transport: One of ``"no-arena"``, ``"ring"``, ``"pool"``.
343 payload_nbytes: On-the-wire size of one payload, used to size the arena.
344 num_items: Number of items per pass (must match ``dataset.num_items``).
345 buffer_size: Pipeline sink buffer size / arena in-flight unit count.
346 runs: Number of timed passes (used when ``duration_sec`` is not positive).
347 duration_sec: If positive, keep running timed passes until this many
348 seconds have elapsed instead of stopping after ``runs`` passes — used
349 for sustained, fixed-duration host-stat sampling by an external sampler.
350 usage: If given, one element is appended: the CPU seconds (user + system,
351 this process + the producer subprocess) spent across the timed passes.
352 The snapshot is taken *after* the warmup pass, so process-startup and
353 import costs are excluded and only the transport's per-item work counts.
354
355 Returns:
356 One throughput sample (items per second) per timed pass.
357 """
358 arena = _make_arena(transport, payload_nbytes, buffer_size, num_items)
359 config = (
360 PipelineBuilder()
361 .add_source(dataset)
362 .add_sink(buffer_size=buffer_size)
363 .get_config()
364 )
365 src = run_pipeline_in_subprocess(
366 config, num_threads=1, arena=arena, buffer_size=buffer_size
367 )
368 try:
369 for item in src: # warmup pass (subprocess spawn + first-touch costs)
370 del item
371 cpu_base = _cpu_now() # after warmup: imports + arena warm-up excluded
372 samples: list[float] = []
373 deadline = time.perf_counter() + duration_sec
374 while True:
375 n = 0
376 t0 = time.perf_counter()
377 for item in src:
378 n += 1
379 del item # release the view so the pool can recycle the segment
380 samples.append(num_items / (time.perf_counter() - t0))
381 assert n == num_items, f"{transport}: expected {num_items}, got {n}"
382 done_by_count = duration_sec <= 0 and len(samples) >= runs
383 done_by_time = duration_sec > 0 and time.perf_counter() >= deadline
384 if done_by_count or done_by_time:
385 break
386 if usage is not None:
387 usage.append(_cpu_now() - cpu_base)
388 return samples
389 finally:
390 # Drop the iterable so its finalizer closes + unlinks the arena before the
391 # next config reuses the shared-memory namespace.
392 del src
393 gc.collect()
394
395
396def _confidence_interval(samples: list[float]) -> tuple[float, float]:
397 """~95% confidence interval of the mean (normal approximation).
398
399 Degenerate cases (a single pass) return ``(mean, mean)``.
400 """
401 mean = statistics.mean(samples)
402 if len(samples) < 2:
403 return mean, mean
404 half = 1.96 * statistics.stdev(samples) / (len(samples) ** 0.5)
405 return mean - half, mean + half
406
407
408def _run_config(size_mb: int, kind: str, args: argparse.Namespace) -> list[Row]:
409 """Benchmark one (size, kind) across the selected transports; one Row each."""
410 size = size_mb << 20
411 # For packets, synthesize a clip whose serialized payload tracks ``size`` (the
412 # other kinds build a payload of exactly ``size`` bytes directly).
413 vb = create_video_data(size) if kind == "packets" else None
414 payload_nbytes = _payload_nbytes(kind, size, vb)
415 dataset = _Dataset(kind, size, args.num_items, vb)
416 factor = payload_nbytes / 1e6 # items/s -> MB/s
417 # First pass: run each transport in the requested order (order is preserved so
418 # the --duration-sec window markers stay aligned with an external sampler) and
419 # collect its mean + CI.
420 measured: list[tuple[str, float, float, float]] = []
421 for i, transport in enumerate(args.transports):
422 if i and args.gap_sec:
423 time.sleep(args.gap_sec) # idle valley between per-transport windows
424 if args.duration_sec:
425 # Timestamped window markers so externally-sampled host stats (e.g. an
426 # external once-per-minute CPU/memory sampler) can be attributed to each config.
427 print(
428 f"### begin {kind} {size_mb}M {transport} ts={time.time():.0f}",
429 flush=True,
430 )
431 samples = run_transport(
432 dataset,
433 transport,
434 payload_nbytes=payload_nbytes,
435 num_items=args.num_items,
436 buffer_size=args.buffer_size,
437 runs=args.runs,
438 duration_sec=args.duration_sec,
439 )
440 if args.duration_sec:
441 print(
442 f"### end {kind} {size_mb}M {transport} ts={time.time():.0f}",
443 flush=True,
444 )
445 mean = statistics.mean(samples)
446 lo, hi = _confidence_interval(samples)
447 measured.append((transport, mean, lo, hi))
448 # Second pass: resolve the no-arena baseline after every transport is measured,
449 # so speedup does not depend on the order transports were run (and defaults to
450 # 1.0 when no-arena was not among --transports).
451 baseline = next((m for (t, m, _lo, _hi) in measured if t == "no-arena"), 0.0)
452 return [
453 Row(
454 size_mb=size_mb,
455 kind=kind,
456 transport=transport,
457 items_per_s=mean,
458 mb_per_s=mean * factor,
459 mb_per_s_lo=lo * factor,
460 mb_per_s_hi=hi * factor,
461 speedup=mean / baseline if baseline else 1.0,
462 )
463 for transport, mean, lo, hi in measured
464 ]
465
466
467def _cpu_now() -> float:
468 """CPU seconds (user + system) for this process plus its reaped children."""
469 s = resource.getrusage(resource.RUSAGE_SELF)
470 c = resource.getrusage(resource.RUSAGE_CHILDREN)
471 return s.ru_utime + s.ru_stime + c.ru_utime + c.ru_stime
472
473
474def _peak_rss_mb() -> float:
475 """Peak RSS (self + reaped children) in MB (``ru_maxrss`` is KiB on Linux)."""
476 s = resource.getrusage(resource.RUSAGE_SELF)
477 c = resource.getrusage(resource.RUSAGE_CHILDREN)
478 return (s.ru_maxrss + c.ru_maxrss) / 1024
479
480
481def _isolated_worker(
482 kind: str,
483 size: int,
484 transport: str,
485 payload_nbytes: int,
486 num_items: int,
487 buffer_size: int,
488 runs: int,
489 vb: bytes | None,
490 q: "mp.Queue[tuple[str, object, float, float]]",
491) -> None:
492 """Run one config in this fresh process; report samples + CPU + peak RSS.
493
494 Run as the target of a freshly-spawned process. Because the process starts
495 clean and exits after one config, the measurements attribute resources to
496 *this* config alone — a single long-lived process accumulates them across
497 configs and cannot separate them, which is the whole point of ``--isolate``.
498 CPU is measured over the timed passes only (excludes this worker's and the
499 producer's import/startup cost); peak RSS is the growth from a baseline taken
500 before the arena and payloads are built. The video bytes are encoded in the
501 parent and passed in, so the encoder's footprint does not land in this peak.
502 """
503 try:
504 rss_base = _peak_rss_mb()
505 usage: list[float] = []
506 dataset = _Dataset(kind, size, num_items, vb)
507 samples = run_transport(
508 dataset,
509 transport,
510 payload_nbytes=payload_nbytes,
511 num_items=num_items,
512 buffer_size=buffer_size,
513 runs=runs,
514 usage=usage,
515 )
516 cpu = usage[0] if usage else 0.0
517 q.put(("ok", samples, cpu, _peak_rss_mb() - rss_base))
518 except Exception as e: # surface failure instead of hanging the parent
519 q.put(("err", f"{type(e).__name__}: {e}", 0.0, 0.0))
520
521
522def _run_isolated(
523 mp_ctx: "mp.context.BaseContext",
524 kind: str,
525 size: int,
526 transport: str,
527 payload_nbytes: int,
528 num_items: int,
529 buffer_size: int,
530 runs: int,
531 vb: bytes | None,
532) -> tuple[list[float], float, float]:
533 """Run ``_isolated_worker`` in a fresh process; return ``(samples, cpu, rss)``."""
534 q: "mp.Queue[tuple[str, object, float, float]]" = mp_ctx.Queue()
535 p = mp_ctx.Process( # pyre-ignore[16]
536 target=_isolated_worker,
537 args=(
538 kind,
539 size,
540 transport,
541 payload_nbytes,
542 num_items,
543 buffer_size,
544 runs,
545 vb,
546 q,
547 ),
548 )
549 p.start()
550 status, payload, cpu, rss = q.get()
551 p.join()
552 if status != "ok":
553 raise RuntimeError(str(payload))
554 return payload, cpu, rss # pyre-ignore[7]
555
556
557def _run_config_isolated(
558 size_mb: int, kind: str, args: argparse.Namespace, mp_ctx: "mp.context.BaseContext"
559) -> list[Row]:
560 """Benchmark one (size, kind), each transport in its own fresh process.
561
562 Per-process isolation is what makes the CPU-time and peak-RSS columns
563 meaningful: each transport's footprint is measured from a clean slate, so the
564 arena's shared-memory cost and the no-arena pickle/copy cost are attributable
565 rather than piled into one accumulating process.
566 """
567 size = size_mb << 20
568 # Encode in the parent so the (large) encoder footprint stays out of the
569 # per-config worker's peak RSS.
570 vb = create_video_data(size) if kind == "packets" else None
571 payload_nbytes = _payload_nbytes(kind, size, vb)
572 factor = payload_nbytes / 1e6 # items/s -> MB/s
573 # First pass: measure each transport in its own process.
574 measured: list[tuple[str, float, float, float, float, float]] = []
575 for transport in args.transports:
576 samples, cpu, rss = _run_isolated(
577 mp_ctx,
578 kind,
579 size,
580 transport,
581 payload_nbytes,
582 args.num_items,
583 args.buffer_size,
584 args.runs,
585 vb,
586 )
587 mean = statistics.mean(samples)
588 lo, hi = _confidence_interval(samples)
589 measured.append((transport, mean, lo, hi, cpu, rss))
590 # Second pass: resolve the no-arena baseline after every transport is measured,
591 # so speedup is order-independent (and defaults to 1.0 when no-arena was not
592 # among --transports).
593 baseline = next((m for (t, m, *_rest) in measured if t == "no-arena"), 0.0)
594 return [
595 Row(
596 size_mb=size_mb,
597 kind=kind,
598 transport=transport,
599 items_per_s=mean,
600 mb_per_s=mean * factor,
601 mb_per_s_lo=lo * factor,
602 mb_per_s_hi=hi * factor,
603 speedup=mean / baseline if baseline else 1.0,
604 peak_rss_mb=rss,
605 cpu_sec=cpu,
606 )
607 for transport, mean, lo, hi, cpu, rss in measured
608 ]
609
610
611def _table_header() -> str:
612 """Header for the pivoted table (one transport column group per row)."""
613 return f"{'kind':<8} {'size':>5} {'no-arena':>9} {'ring':>15} {'pool':>15}"
614
615
616def _cell(
617 idx: dict[tuple[int, str, str], Row], size_mb: int, kind: str, transport: str
618) -> str:
619 """MB/s for one (size, kind, transport), with speedup in (x); ``-`` if not run."""
620 r = idx.get((size_mb, kind, transport))
621 if r is None:
622 return "-"
623 if transport == "no-arena":
624 return f"{r.mb_per_s:.0f}"
625 return f"{r.mb_per_s:.0f} ({r.speedup:.2f}x)"
626
627
628def _table_row(idx: dict[tuple[int, str, str], Row], size_mb: int, kind: str) -> str:
629 """One pivoted row: no-arena/ring/pool MB/s, with ring/pool speedup in (x)."""
630 na = _cell(idx, size_mb, kind, "no-arena")
631 ring = _cell(idx, size_mb, kind, "ring")
632 pool = _cell(idx, size_mb, kind, "pool")
633 return f"{kind:<8} {size_mb:>4}M {na:>9} {ring:>15} {pool:>15}"
634
635
636def _print_table(rows: list[Row]) -> None:
637 """Print one row per (size, kind); transports are columns (MB/s, speedup)."""
638 idx = {(r.size_mb, r.kind, r.transport): r for r in rows}
639 keys = sorted(
640 {(r.size_mb, r.kind) for r in rows},
641 key=lambda k: (_KINDS.index(k[1]), k[0]),
642 )
643 header = _table_header()
644 print(header)
645 print("-" * len(header))
646 for size_mb, kind in keys:
647 print(_table_row(idx, size_mb, kind))
648 print("(throughput in MB/s; (Nx) = speedup over the no-arena baseline)")
649
650
651def _print_isolate_table(rows: list[Row]) -> None:
652 """One row per (size, kind, transport): throughput, CPU time, peak RSS.
653
654 Used for ``--isolate`` runs, where CPU and RSS are per-config (each transport
655 ran in its own process).
656 """
657 rows = sorted(
658 rows,
659 key=lambda r: (_KINDS.index(r.kind), r.size_mb, _TRANSPORTS.index(r.transport)),
660 )
661 header = (
662 f"{'kind':<8} {'size':>5} {'transport':<9} {'recv MB/s':>10} "
663 f"{'speedup':>8} {'CPU s':>7} {'peak RSS MB':>12}"
664 )
665 print(header)
666 print("-" * len(header))
667 for r in rows:
668 print(
669 f"{r.kind:<8} {r.size_mb:>4}M {r.transport:<9} {r.mb_per_s:>10.0f} "
670 f"{r.speedup:>7.2f}x {r.cpu_sec:>7.1f} {r.peak_rss_mb:>12.0f}"
671 )
672 print(
673 "(recv MB/s with speedup vs no-arena; CPU s and peak RSS are per-config, "
674 "lower is better)"
675 )
676
677
678def write_csv(rows: list[Row], path: str) -> None:
679 """Write benchmark rows to ``path`` as CSV (one column per :class:`Row` field)."""
680 names = [f.name for f in fields(Row)]
681 with open(path, "w", newline="") as f:
682 writer = csv.DictWriter(f, fieldnames=names)
683 writer.writeheader()
684 writer.writerows(asdict(r) for r in rows)
685 print(f"wrote {len(rows)} rows to {path}")
686
687
688def read_csv(path: str) -> list[Row]:
689 """Read benchmark rows written by :py:func:`write_csv`."""
690 with open(path, newline="") as f:
691 return [
692 Row(
693 size_mb=int(d["size_mb"]),
694 kind=d["kind"],
695 transport=d["transport"],
696 items_per_s=float(d["items_per_s"]),
697 mb_per_s=float(d["mb_per_s"]),
698 mb_per_s_lo=float(d["mb_per_s_lo"]),
699 mb_per_s_hi=float(d["mb_per_s_hi"]),
700 speedup=float(d["speedup"]),
701 peak_rss_mb=float(d.get("peak_rss_mb") or 0.0),
702 cpu_sec=float(d.get("cpu_sec") or 0.0),
703 )
704 for d in csv.DictReader(f)
705 ]
706
707
708def _parse_args() -> argparse.Namespace:
709 parser = argparse.ArgumentParser(
710 description="Arena transport throughput: regular IPC vs ring vs pool"
711 )
712 parser.add_argument("--num-items", type=int, default=300)
713 parser.add_argument("--buffer-size", type=int, default=3)
714 parser.add_argument(
715 "--sizes", type=int, nargs="+", default=[2, 8, 32], help="payload sizes (MiB)"
716 )
717 parser.add_argument("--runs", type=int, default=3)
718 parser.add_argument(
719 "--kinds",
720 nargs="+",
721 choices=_KINDS,
722 default=list(_KINDS),
723 help="payload types to benchmark",
724 )
725 parser.add_argument(
726 "--transports",
727 nargs="+",
728 choices=_TRANSPORTS,
729 default=list(_TRANSPORTS),
730 help="transports to benchmark (use one at a time to isolate host stats)",
731 )
732 parser.add_argument(
733 "--duration-sec",
734 type=float,
735 default=0.0,
736 help="if >0, run each (transport, kind, size) for this long instead of "
737 "--runs passes — for sustained host-stat sampling by an external sampler",
738 )
739 parser.add_argument(
740 "--gap-sec",
741 type=float,
742 default=0.0,
743 help="idle seconds inserted between transports, to separate per-transport "
744 "windows for once-per-minute host-stat sampling",
745 )
746 parser.add_argument(
747 "--isolate",
748 action="store_true",
749 help="run each (size, kind, transport) in its own fresh process and report "
750 "per-config CPU time and peak RSS (clean memory/CPU attribution, no "
751 "carryover between configs)",
752 )
753 parser.add_argument("--output", help="optional path to write results as CSV")
754 return parser.parse_args()
755
756
757def main() -> None:
758 """Sweep payload sizes x types x transports; print a table and optionally a CSV."""
759 args = _parse_args()
760 # spawn (not fork) so each isolated worker starts from a clean interpreter,
761 # giving comparable per-config peak RSS rather than inheriting the parent's.
762 mp_ctx = mp.get_context("spawn") if args.isolate else None
763 rows: list[Row] = []
764 for size_mb, kind in product(args.sizes, args.kinds):
765 try:
766 if mp_ctx is not None:
767 rows.extend(_run_config_isolated(size_mb, kind, args, mp_ctx))
768 else:
769 rows.extend(_run_config(size_mb, kind, args))
770 except (OSError, RuntimeError) as e:
771 # Building a kind's payload (e.g. encoding the packets clip) can fail
772 # on some hosts; skip that kind rather than abort the whole sweep.
773 print(f"### skip {kind} {size_mb}M: {type(e).__name__}: {e}", flush=True)
774 if args.isolate:
775 _print_isolate_table(rows)
776 else:
777 _print_table(rows)
778 if args.output:
779 write_csv(rows, args.output)
780
781
782if __name__ == "__main__":
783 main()
API Reference¶
Functions
- create_video_data(target_bytes: int, duration_seconds: float = 2.0) bytes[source]¶
Generate an H.264 MP4 whose stream is approximately
target_bytes.Encodes low-entropy frames at a constant target bitrate (x264 CBR, which pads with filler to hold the rate), so the serialized
VideoPacketspayload scales with the requested size rather than collapsing to the content’s natural compressed size. Usesspdl.io’s in-process encoder rather than theffmpegCLI, so it also works on minimal container images that do not ship the CLI.- Parameters:
target_bytes – Desired size of the encoded video stream, in bytes.
duration_seconds – Clip duration; the bitrate is derived from it.
- Returns:
The encoded MP4 file contents as raw bytes.
- run_transport(dataset: _Dataset, transport: str, *, payload_nbytes: int, num_items: int, buffer_size: int, runs: int, duration_sec: float = 0.0, usage: list[float] | None = None) list[float][source]¶
Run the backend pipeline once per timed pass; return per-pass items/second.
Builds a one-stage backend pipeline (source -> sink) that runs in a subprocess via
run_pipeline_in_subprocess(), ships its items to the main process overtransport, and consumes them in a no-op loop. One warmup pass is discarded; the per-pass throughput of each timed pass is returned so the caller can summarize it (mean + confidence interval). The subprocess and arena are reused across passes and torn down at the end.- Parameters:
dataset – The (picklable) backend data source.
transport – One of
"no-arena","ring","pool".payload_nbytes – On-the-wire size of one payload, used to size the arena.
num_items – Number of items per pass (must match
dataset.num_items).buffer_size – Pipeline sink buffer size / arena in-flight unit count.
runs – Number of timed passes (used when
duration_secis not positive).duration_sec – If positive, keep running timed passes until this many seconds have elapsed instead of stopping after
runspasses — used for sustained, fixed-duration host-stat sampling by an external sampler.usage – If given, one element is appended: the CPU seconds (user + system, this process + the producer subprocess) spent across the timed passes. The snapshot is taken after the warmup pass, so process-startup and import costs are excluded and only the transport’s per-item work counts.
- Returns:
One throughput sample (items per second) per timed pass.
- write_csv(rows: list[Row], path: str) None[source]¶
Write benchmark rows to
pathas CSV (one column perRowfield).
Classes
- class Row[source]¶
One benchmark measurement: throughput for a (size, kind, transport).
- cpu_sec: float = 0.0¶
Total CPU seconds (user + system, consumer + producer) the config consumed. Only populated by
--isolate;0otherwise. Lower is better — moving a payload via the arena should cost less CPU than the pickle + copy of plain IPC.
- mb_per_s_lo: float¶
Lower bound of the ~95% confidence interval of
mb_per_s(normal approximation over the timed passes).