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).

  • ringSharedMemoryRingBuffer; the reader copies each payload out of shared memory (so it cannot hand out live views).

  • poolSharedMemorySegmentPool; 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:

../_static/data/example_benchmark_arena_transport.png

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 VideoPackets payload scales with the requested size rather than collapsing to the content’s natural compressed size. Uses spdl.io’s in-process encoder rather than the ffmpeg CLI, 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.

main() None[source]

Sweep payload sizes x types x transports; print a table and optionally a CSV.

read_csv(path: str) list[Row][source]

Read benchmark rows written by write_csv().

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 over transport, 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_sec is not positive).

  • duration_sec – If positive, keep running timed passes until this many seconds have elapsed instead of stopping after runs passes — 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 path as CSV (one column per Row field).

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; 0 otherwise. Lower is better — moving a payload via the arena should cost less CPU than the pickle + copy of plain IPC.

items_per_s: float

Mean items received per second over the timed passes.

kind: str

"bytes", "numpy", "torch", or "packets".

Type:

Payload kind

mb_per_s: float

Mean payload throughput in MB/s (items_per_s x payload bytes).

mb_per_s_hi: float

Upper bound of the ~95% confidence interval of mb_per_s.

mb_per_s_lo: float

Lower bound of the ~95% confidence interval of mb_per_s (normal approximation over the timed passes).

peak_rss_mb: float = 0.0

Peak resident memory of the config’s process tree (consumer + producer), in MB. Only populated by --isolate; 0 otherwise. Shared arena pages may be counted in both processes, so treat it as an upper-bound proxy and compare within a (size, kind) row across transports.

size_mb: int

Requested payload size in MiB (the sweep knob).

speedup: float

items_per_s relative to the no-arena baseline for this (size, kind).

transport: str

"no-arena", "ring", or "pool".

Type:

Transport used