Benchmark pinned arena

Benchmarking shared-arena memory pinning

Compare a reusable pool and FIFO ring with pin_memory=False and True. Pinned receiving memory can avoid an extra CPU staging copy before H2D. See Shared-memory arenas for arena usage.

Reference results

Historical 8-MiB FP32 Torch batches, with three fresh-process repetitions per arm in one resource-matched GPU allocation and 256 fully checked batches per trial, gave the following results.

Checked 8 MiB throughput, H2D helper time and CPU cost
Historical pageable versus pinned throughput

Backend

Pageable items/s

Pinned items/s

Pinned / pageable

Pool

137.09 ± 0.39

184.14 ± 5.73

1.343

Ring

104.06 ± 5.96

149.42 ± 5.83

1.436

Mean ± sample SD, not confidence intervals. The checked end-to-end workflow includes generation, transport, synchronized H2D and full GPU verification; H2D helper wall time is not pure DMA.

These numbers and the figure describe an earlier workflow, not a fresh run of the current public four-arm benchmark. The figure includes additional IPC and copy-out controls. Pinned ring output uses a different owning copy-out allocation, so its gain cannot be attributed to page locking alone.

Pinning helped this workload but consumes page-locked host memory. Compare the end-to-end trade-off in your application; this is not a training-speed promise.

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
  8r"""Benchmarking shared-arena memory pinning
  9========================================
 10
 11Compare a reusable pool and FIFO ring with ``pin_memory=False`` and ``True``.
 12Pinned receiving memory can avoid an extra CPU staging copy before H2D.
 13See :doc:`../optimization_guide/shared_memory_arenas` for arena usage.
 14
 15Reference results
 16-----------------
 17
 18Historical 8-MiB FP32 Torch batches, with three fresh-process repetitions per
 19arm in one resource-matched GPU allocation and 256 fully checked batches per
 20trial, gave the following results.
 21
 22.. image:: /_static/data/pinned_arena_8mib.png
 23   :alt: Checked 8 MiB throughput, H2D helper time and CPU cost
 24
 25.. list-table:: Historical pageable versus pinned throughput
 26   :header-rows: 1
 27
 28   * - Backend
 29     - Pageable items/s
 30     - Pinned items/s
 31     - Pinned / pageable
 32   * - Pool
 33     - 137.09 ± 0.39
 34     - 184.14 ± 5.73
 35     - 1.343
 36   * - Ring
 37     - 104.06 ± 5.96
 38     - 149.42 ± 5.83
 39     - 1.436
 40
 41Mean ± sample SD, not confidence intervals. The checked end-to-end workflow
 42includes generation, transport, synchronized H2D and full GPU verification;
 43H2D helper wall time is not pure DMA.
 44
 45These numbers and the figure describe an earlier workflow, not a fresh run of
 46the current public four-arm benchmark. The figure includes additional IPC and
 47copy-out controls. Pinned ring output uses a different owning copy-out allocation,
 48so its gain cannot be attributed to page locking alone.
 49
 50Pinning helped this workload but consumes page-locked host memory. Compare the
 51end-to-end trade-off in your application; this is not a training-speed promise.
 52"""
 53
 54from __future__ import annotations
 55
 56import argparse
 57import gc
 58import hashlib
 59import inspect
 60import json
 61import math
 62import multiprocessing as mp
 63import os
 64import platform
 65import statistics
 66import sys
 67import time
 68from collections.abc import Callable, Iterable, Iterator, Mapping
 69from functools import partial
 70from itertools import product
 71from pathlib import Path
 72from tempfile import TemporaryDirectory
 73from typing import Any
 74
 75import numpy as np
 76import torch
 77from spdl.io import transfer_tensor_h2d
 78from spdl.pipeline import (
 79    iterate_in_subprocess,
 80    SharedMemoryRingBuffer,
 81    SharedMemorySegmentPool,
 82)
 83
 84__all__ = [
 85    "configure_process",
 86    "make_payload",
 87    "produce_payloads",
 88    "verify_payload",
 89    "restore_payload",
 90    "transfer_payload",
 91    "process_item",
 92    "run_pass",
 93    "run_benchmark",
 94    "run_isolated",
 95    "main",
 96]
 97
 98_ARMS = (
 99    "pool-pageable",
100    "pool-pinned",
101    "ring-pageable",
102    "ring-pinned",
103)
104_CPU_ARMS = ("pool-pageable", "ring-pageable")
105_DTYPES = {"fp32": ("float32", 4), "fp16": ("float16", 2), "uint8": ("uint8", 1)}
106_SCHEMA = "public-iterate-v1"
107_RETIRED_FLAGS = (
108    "--runtime-checks",
109    "--trace-dir",
110    "--threshold",
111    "--seam",
112    "--retain-items",
113    "--consumer-delay",
114    "--sample-interval",
115)
116
117
118def configure_process() -> None:
119    """Select one Torch CPU thread in each process before benchmark setup/timing.
120
121    Use this as the public subprocess initializer, not at module import time.
122    It does not initialize CUDA or change another process's thread count.
123
124    Example:
125        >>> configure_process()
126        >>> torch.get_num_threads()
127        1
128    """
129    torch.set_num_threads(1)
130
131
132def _pattern(count: int, sequence: int, leaf: int) -> bytes:
133    shift = (sequence * 17 + leaf * 29) % 251
134    cycle = bytes((i + shift) % 251 for i in range(251))
135    return (cycle * ((count + 250) // 251))[:count]
136
137
138def _leaf_sizes(size: int, kind: str, dtype: str) -> list[int]:
139    elements = max(1, size // _DTYPES[dtype][1])
140    if kind != "nested":
141        return [elements]
142    first = max(1, elements // 2)
143    return [first, max(1, elements - first), 3]
144
145
146def make_payload(size: int, kind: str, dtype: str, sequence: int) -> Any:
147    """Create fresh deterministic CPU data, including for independent verification.
148
149    Args:
150        size: Positive requested byte budget. Leaf lengths are dtype-rounded;
151            a nested payload also includes a three-element leaf.
152        kind: ``torch``, ``numpy`` or a ``nested`` dictionary/list/tuple tensor tree.
153        dtype: ``fp32``, ``fp16`` or ``uint8``.
154        sequence: Nonnegative item identity incorporated into every leaf.
155
156    Returns:
157        A CPU tensor, NumPy array or nested CPU tensor tree; no CUDA storage.
158
159    Example:
160        >>> make_payload(8192, 'torch', 'fp32', sequence=1).numel()
161        2048
162    """
163    if size <= 0 or sequence < 0 or kind not in ("torch", "numpy", "nested"):
164        raise ValueError("invalid payload size, sequence or kind")
165    if dtype not in _DTYPES:
166        raise ValueError("unsupported dtype")
167    arrays = [
168        np.frombuffer(_pattern(n, sequence, i), dtype=np.uint8)
169        .astype(_DTYPES[dtype][0])
170        .reshape(-1)
171        for i, n in enumerate(_leaf_sizes(size, kind, dtype))
172    ]
173    leaves = arrays if kind == "numpy" else [torch.from_numpy(a) for a in arrays]
174    if kind == "nested":
175        return {"main": leaves[0], "nested": [leaves[1], (leaves[2],)]}
176    return leaves[0]
177
178
179def _map_leaves(batch: Any, fn: Callable[[Any], Any]) -> Any:
180    if isinstance(batch, dict):
181        return {key: _map_leaves(value, fn) for key, value in batch.items()}
182    if isinstance(batch, list):
183        return [_map_leaves(value, fn) for value in batch]
184    if isinstance(batch, tuple):
185        return tuple(_map_leaves(value, fn) for value in batch)
186    return fn(batch)
187
188
189def _leaves(batch: Any) -> Iterator[Any]:
190    if isinstance(batch, Mapping):
191        for value in batch.values():
192            yield from _leaves(value)
193    elif isinstance(batch, (list, tuple)):
194        for value in batch:
195            yield from _leaves(value)
196    else:
197        yield batch
198
199
200class _Payloads:
201    def __init__(self, config: dict[str, Any], worker: int) -> None:
202        self.config = config
203        self.worker = worker
204        self.epoch = 0
205
206    def __iter__(self) -> Iterator[dict[str, Any]]:
207        config, epoch = self.config, self.epoch
208        self.epoch += 1
209        for index in range(config["items"]):
210            if torch.cuda.is_initialized():
211                raise AssertionError("producer initialized CUDA")
212            sequence = (epoch * config["workers"] + self.worker) * config[
213                "items"
214            ] + index
215            start, cpu_start = time.perf_counter(), time.process_time()
216            payload = make_payload(
217                config["size"], config["kind"], config["dtype"], sequence
218            )
219            yield {
220                "sequence": sequence,
221                "worker": self.worker,
222                "epoch": epoch,
223                "payload": payload,
224                "producer": {
225                    "pid": os.getpid(),
226                    "generation_s": time.perf_counter() - start,
227                    "generation_cpu_s": time.process_time() - cpu_start,
228                    "cuda_initialized": torch.cuda.is_initialized(),
229                    "cpu_threads": torch.get_num_threads(),
230                },
231            }
232            del payload
233        if torch.cuda.is_initialized():
234            raise AssertionError("producer initialized CUDA during production")
235
236
237def produce_payloads(
238    config: dict[str, Any], worker: int = 0
239) -> Iterable[dict[str, Any]]:
240    """Return a pickle-safe, reiterable CPU source for a public subprocess boundary.
241
242    Args:
243        config: ``size``, ``kind``, ``dtype``, ``items`` per epoch and ``workers``.
244        worker: Producer index in ``[0, workers)``.
245
246    Returns:
247        A reiterable source, not a single-use generator. Each epoch produces fresh
248        values and globally distinct sequence identifiers. Rows include generation
249        wall/CPU time and the producer's CUDA-uninitialized/thread-count receipt.
250        Pass ``configure_process`` as the boundary's initializer.
251
252    Example:
253        >>> cfg = dict(size=8192, kind='torch', dtype='fp32', items=2, workers=1)
254        >>> source = produce_payloads(cfg)
255        >>> [row['sequence'] for row in source]
256        [0, 1]
257        >>> [row['sequence'] for row in source]
258        [2, 3]
259    """
260    if config["items"] <= 0 or not 0 <= worker < config["workers"]:
261        raise ValueError("invalid producer item count or index")
262    return _Payloads(config, worker)
263
264
265def restore_payload(batch: Any) -> tuple[Any, dict[str, float]]:
266    """Prepare already-restored public boundary outputs as CPU tensor leaves.
267
268    No serialized blobs, registry or reader endpoints are accepted here. Arena
269    restoration belongs entirely to ``iterate_in_subprocess``.
270
271    Args:
272        batch: Tensor/array/tree already yielded by the public iterable.
273
274    Returns:
275        ``(tensor_batch, phases)``; phases measure only NumPy-to-Torch/tree
276        preparation, not the preceding transport or arena restoration.
277
278    Example:
279        >>> batch, phases = restore_payload(make_payload(16, 'numpy', 'fp32', 0))
280        >>> batch.numel()
281        4
282    """
283    start, cpu_start = time.perf_counter(), time.process_time()
284    result = _map_leaves(
285        batch,
286        lambda leaf: torch.from_numpy(leaf) if isinstance(leaf, np.ndarray) else leaf,
287    )
288    return result, {
289        "preparation_s": time.perf_counter() - start,
290        "preparation_cpu_s": time.process_time() - cpu_start,
291    }
292
293
294def _verify_tree(actual: Any, expected: Any, device: str, sequence: int) -> None:
295    if isinstance(expected, dict):
296        if not isinstance(actual, dict) or actual.keys() != expected.keys():
297            raise AssertionError("payload structure mismatch")
298        for key in expected:
299            _verify_tree(actual[key], expected[key], device, sequence)
300    elif isinstance(expected, (list, tuple)):
301        if type(actual) is not type(expected) or len(actual) != len(expected):
302            raise AssertionError("payload structure mismatch")
303        for value, target in zip(actual, expected, strict=True):
304            _verify_tree(value, target, device, sequence)
305    else:
306        if not isinstance(actual, torch.Tensor) or actual.device.type != device:
307            raise AssertionError("payload device/type mismatch")
308        if actual.dtype != expected.dtype or actual.shape != expected.shape:
309            raise AssertionError("payload shape/dtype mismatch")
310        if not torch.equal(actual.cpu(), expected):
311            raise AssertionError(
312                f"entire payload value mismatch for sequence {sequence}"
313            )
314
315
316def verify_payload(
317    batch: Any, size: int, kind: str, dtype: str, sequence: int, *, device: str = "cpu"
318) -> None:
319    """Check structure, dtype, shape, device and every value against fresh CPU data.
320
321    Args:
322        batch: Prepared tensor/tree to check, on CPU or CUDA.
323        size: Requested payload byte budget.
324        kind: Original ``torch``, ``numpy`` or ``nested`` payload kind.
325        dtype: Original ``fp32``, ``fp16`` or ``uint8`` dtype.
326        sequence: Expected producer item identity.
327        device: Required leaf device type, ``cpu`` or ``cuda``.
328
329    Raises:
330        AssertionError: Structure, layout, device or any value differs.
331
332    Example:
333        >>> batch = make_payload(8192, 'torch', 'fp32', 1)
334        >>> verify_payload(batch, 8192, 'torch', 'fp32', 1)
335    """
336    expected, _ = restore_payload(make_payload(size, kind, dtype, sequence))
337    _verify_tree(batch, expected, device, sequence)
338
339
340def transfer_payload(batch: Any) -> tuple[Any, dict[str, Any]]:
341    """Use only the public synchronized H2D helper, with its cache disabled.
342
343    Args:
344        batch: CPU tensor or supported tensor tree.
345
346    Returns:
347        ``(gpu_batch, phases)`` with measured helper completion wall/process-CPU
348        time only. Unmeasured internal pin/copy/stream phases are omitted.
349
350    Example:
351        On a CUDA consumer, call ``gpu, phases = transfer_payload(cpu_batch)``.
352    """
353    start, cpu_start = time.perf_counter(), time.process_time()
354    result = transfer_tensor_h2d(batch, num_caches=0)
355    return result, {
356        "host_h2d_completion_s": time.perf_counter() - start,
357        "helper_cpu_s": time.process_time() - cpu_start,
358    }
359
360
361def process_item(item: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
362    """Prepare, optionally transfer, and fully verify one public output.
363
364    Args:
365        item: Producer row already restored by the public subprocess iterable.
366        config: Payload settings and ``device`` (``cpu`` or ``cuda``).
367
368    Returns:
369        Scalar/list receipts only, with no retained tensors. Every arm uses this
370        identical consumer workflow. Source pinning is reported per leaf,
371        including small inline/pageable leaves.
372
373    Raises:
374        AssertionError: Producer CUDA/thread receipt or full value check fails.
375
376    Example:
377        Use this with rows from ``iterate_in_subprocess(produce_factory)``.
378    """
379    producer = item["producer"]
380    if producer["cuda_initialized"] or producer["cpu_threads"] != 1:
381        raise AssertionError("producer must use one CPU thread without CUDA")
382    batch, phases = restore_payload(item["payload"])
383    nbytes = sum(leaf.numel() * leaf.element_size() for leaf in _leaves(batch))
384    pinned = [leaf.is_pinned() for leaf in _leaves(batch)]
385    h2d: dict[str, Any] = {
386        "host_h2d_completion_s": None,
387        "helper_cpu_s": None,
388    }
389    checked = batch
390    if config["device"] == "cuda":
391        checked, h2d = transfer_payload(batch)
392    start = time.perf_counter()
393    verify_payload(
394        checked,
395        config["size"],
396        config["kind"],
397        config["dtype"],
398        item["sequence"],
399        device=config["device"],
400    )
401    return {
402        "sequence": item["sequence"],
403        "worker": item["worker"],
404        "epoch": item["epoch"],
405        "producer": producer,
406        "payload_bytes": nbytes,
407        **phases,
408        **h2d,
409        "verify_s": time.perf_counter() - start,
410        "source_is_pinned": pinned,
411    }
412
413
414def run_pass(
415    sources: Iterable[Iterable[dict[str, Any]]], config: dict[str, Any]
416) -> dict[str, Any]:
417    """Run one whole checked epoch, consuming producers in round-robin order.
418
419    Args:
420        sources: One public subprocess iterable per producer, already initialized.
421        config: Payload, per-producer ``items``, worker count and device settings.
422
423    Returns:
424        Epoch wall/consumer CPU time and per-item receipts. Wall includes epoch
425        command dispatch, generation, public transport/restore, preparation,
426        synchronized H2D if selected, full verification and final epoch completion.
427        ``next_output_s`` combines waiting, transport and restoration; it is not
428        an internal read/copy/registration timer. No payloads are retained.
429
430    Example:
431        Call once for warmup and again on the same sources for measured work.
432    """
433    iterators = [iter(source) for source in sources]
434    if len(iterators) != config["workers"]:
435        raise ValueError("one public boundary is required per producer")
436    rows = []
437    start, cpu_start = time.perf_counter(), time.process_time()
438    for _ in range(config["items"]):
439        for worker, iterator in enumerate(iterators):
440            fetch_start = time.perf_counter()
441            item = next(iterator)
442            fetch_s = time.perf_counter() - fetch_start
443            if item["worker"] != worker:
444                raise AssertionError("producer identity mismatch")
445            row = process_item(item, config)
446            row["next_output_s"] = fetch_s
447            rows.append(row)
448            del item
449    sentinel = object()
450    for iterator in iterators:
451        if next(iterator, sentinel) is not sentinel:
452            raise AssertionError("producer yielded too many items")
453    return {
454        "wall_s": time.perf_counter() - start,
455        "consumer_cpu_s": time.process_time() - cpu_start,
456        "items": rows,
457    }
458
459
460def _memory_sample() -> dict[str, Any]:
461    result: dict[str, Any] = {"consumer_pss_bytes": None, "tmpfs_available_bytes": None}
462    try:
463        for line in Path("/proc/self/smaps_rollup").read_text().splitlines():
464            if line.startswith("Pss:"):
465                result["consumer_pss_bytes"] = int(line.split()[1]) * 1024
466    except (FileNotFoundError, PermissionError, ProcessLookupError):
467        pass
468    try:
469        fs = os.statvfs("/dev/shm")
470        result["tmpfs_available_bytes"] = fs.f_bavail * fs.f_frsize
471    except (FileNotFoundError, PermissionError):
472        pass
473    return result
474
475
476def _source_receipt() -> dict[str, Any]:
477    module = sys.modules[__name__]
478    try:
479        source = inspect.getsource(module).encode("utf-8")
480    except (OSError, TypeError):
481        source = None
482    return {
483        "benchmark_sha256": hashlib.sha256(source).hexdigest()
484        if source is not None
485        else None,
486        "hash_scope": "benchmark_source_utf8",
487        "cuda_initialized": torch.cuda.is_initialized(),
488        "python": platform.python_version(),
489        "torch": str(torch.__version__),
490        "numpy": np.__version__,
491        "schema": _SCHEMA,
492    }
493
494
495def _make_arena(
496    arm: str, config: dict[str, Any]
497) -> SharedMemorySegmentPool | SharedMemoryRingBuffer:
498    unit = (
499        sum(_leaf_sizes(config["size"], config["kind"], config["dtype"]))
500        * _DTYPES[config["dtype"]][1]
501        + 512
502    )
503    pinned = arm.endswith("-pinned")
504    if arm.startswith("pool-"):
505        return SharedMemorySegmentPool(
506            segment_size=unit,
507            count=config["slots"],
508            acquire_timeout=config["timeout"],
509            pin_memory=pinned,
510        )
511    return SharedMemoryRingBuffer(
512        capacity=unit * config["slots"],
513        acquire_timeout=config["timeout"],
514        pin_memory=pinned,
515    )
516
517
518def _validate_config(arm: str, config: dict[str, Any]) -> None:
519    if arm not in _ARMS:
520        raise ValueError(f"unsupported or retired runtime arm: {arm}")
521    if config["device"] not in ("cpu", "cuda"):
522        raise ValueError("device must be cpu or cuda")
523    if config["device"] == "cpu" and arm not in _CPU_ARMS:
524        raise ValueError("pinned runtime arms require a CUDA consumer")
525    if min(config[key] for key in ("size", "items", "slots", "workers")) <= 0:
526        raise ValueError("size, items, slots and workers must be positive")
527    if config["warmup_epochs"] < 1 or config["local_rank"] < 0:
528        raise ValueError("warmup_epochs must be positive and local_rank nonnegative")
529    if not math.isfinite(config["timeout"]) or config["timeout"] <= 0:
530        raise ValueError("timeout must be finite and positive")
531    if (
532        config["kind"] not in ("torch", "numpy", "nested")
533        or config["dtype"] not in _DTYPES
534    ):
535        raise ValueError("unsupported payload kind/dtype")
536    retired = set(config).intersection(
537        ("threshold", "seam", "trace_dir", "retain_items", "consumer_delay")
538    )
539    if retired:
540        raise ValueError(f"retired internal configuration fields: {sorted(retired)}")
541
542
543def run_benchmark(arm: str, config: dict[str, Any]) -> dict[str, Any]:
544    """Run an ordinary public CPU/GPU comparison in the current consumer process.
545
546    Args:
547        arm: ``pool-pageable``, ``pool-pinned``, ``ring-pageable`` or ``ring-pinned``.
548        config: ``size``, ``kind``, ``dtype``, ``items``, ``workers``, ``slots``,
549            ``warmup_epochs``, ``timeout``, ``local_rank`` and ``device``.
550
551    Returns:
552        ``public-iterate-v1`` receipts: setup, excluded checked warmup epochs,
553        one measured epoch and throughput using only measured public metrics.
554        The consumer creates one arena per producer and transfers ownership to
555        ``iterate_in_subprocess``. That public iterable handles transport and
556        teardown. Thread/CUDA setup, warmup and finalizer cleanup are untimed.
557
558    Example:
559        >>> cfg = dict(size=8192, kind='torch', dtype='fp32', items=2, workers=1,
560        ...            slots=2, warmup_epochs=1, timeout=60., local_rank=0, device='cpu')
561        >>> result = run_benchmark('pool-pageable', cfg)
562        >>> len(result['measured']['items'])
563        2
564    """
565    _validate_config(arm, config)
566    configure_process()
567    source = _source_receipt()
568    if config["device"] == "cuda":
569        os.environ["LOCAL_RANK"] = str(config["local_rank"])
570        torch.cuda.set_device(config["local_rank"])
571    sources: list[Any] = []
572    setup_start = time.perf_counter()
573    try:
574        for worker in range(config["workers"]):
575            sources.append(
576                iterate_in_subprocess(
577                    partial(produce_payloads, config, worker),
578                    initializer=configure_process,
579                    mp_context="spawn",
580                    buffer_size=config["slots"],
581                    timeout=config["timeout"],
582                    arena=_make_arena(arm, config),
583                )
584            )
585        setup_s = time.perf_counter() - setup_start
586        warmup = [run_pass(sources, config) for _ in range(config["warmup_epochs"])]
587        memory_before = _memory_sample()
588        measured = run_pass(sources, config)
589        return {
590            "schema": _SCHEMA,
591            "arm": arm,
592            "config": dict(config),
593            "source": source,
594            "setup_s": setup_s,
595            "warmup": warmup,
596            "measured": measured,
597            "items_per_s": len(measured["items"]) / measured["wall_s"],
598            "payload_gib_per_s": sum(row["payload_bytes"] for row in measured["items"])
599            / (1 << 30)
600            / measured["wall_s"],
601            "memory_before": memory_before,
602            "memory_after": _memory_sample(),
603        }
604    finally:
605        sources.clear()
606        gc.collect()
607
608
609def _benchmark_to_file(arm: str, config: dict[str, Any], path: str) -> None:
610    Path(path).write_text(json.dumps(run_benchmark(arm, config)), encoding="utf-8")
611
612
613def run_isolated(arm: str, config: dict[str, Any]) -> dict[str, Any]:
614    """Run one arm in a fresh spawned consumer, with fresh public producer boundaries.
615
616    Args:
617        arm: Supported runtime arm.
618        config: Settings accepted by ``run_benchmark``; ``timeout`` bounds the
619            complete isolated invocation, including setup/warmup/teardown.
620
621    Returns:
622        The consumer's JSON-compatible benchmark receipt. A temporary result file
623        conveys only the final report, never payloads or arena endpoint messages.
624
625    Raises:
626        RuntimeError: The consumer failed or did not write its final report.
627        TimeoutError: The invocation exceeded its configured total timeout.
628
629    Example:
630        Call ``run_isolated('pool-pageable', cfg)`` for fresh-process repetitions.
631    """
632    _validate_config(arm, config)
633    with TemporaryDirectory(prefix="spdl-public-pin-") as directory:
634        path = str(Path(directory) / "result.json")
635        process = mp.get_context("spawn").Process(
636            target=_benchmark_to_file, args=(arm, config, path)
637        )
638        process.start()
639        try:
640            process.join(config["timeout"])
641            if process.is_alive():
642                raise TimeoutError("isolated benchmark consumer timed out")
643            if process.exitcode != 0 or not Path(path).is_file():
644                raise RuntimeError(
645                    f"isolated benchmark consumer failed: exit {process.exitcode}"
646                )
647            return json.loads(Path(path).read_text(encoding="utf-8"))
648        finally:
649            if process.is_alive():
650                process.terminate()
651                process.join(10)
652                if process.is_alive():
653                    process.kill()
654                    process.join(10)
655            process.close()
656
657
658def _legend() -> dict[str, str]:
659    return {
660        "protocol": (
661            "public-iterate-v1: public iterate_in_subprocess owns transport, "
662            "restoration and teardown; no private endpoint or transfer hooks."
663        ),
664        "historical": (
665            "Seven-arm fixed-8-MiB table/figure retain original-source measurements. "
666            "ring-original is retired; the changed protocol is not remeasured."
667        ),
668        "timing": (
669            "Setup and checked warmup epochs excluded. Measured epoch includes "
670            "generation, epoch dispatch/end, public transport/restore, preparation, "
671            "synchronized public H2D if selected and entire-value verification. "
672            "next_output_s combines waiting/transport/restore. Do not sum phases."
673        ),
674        "producer_cpu": (
675            "generation_cpu_s measures generation only, not total producer CPU. "
676            "consumer_cpu_s is measured-epoch process CPU, including verification."
677        ),
678        "pinning": (
679            "Per-leaf public is_pinned status is not proof of no staging. Small "
680            "inline leaves may stay pageable. Pinning is selected only through "
681            "the public arena constructor's pin_memory option."
682        ),
683        "memory": (
684            "Before/after consumer /proc PSS and host-wide /dev/shm free space; "
685            "not peaks, producer memory or registration/reservation totals."
686        ),
687        "repeats": (
688            "Fresh consumer/producers per arm/repetition, one arena per producer "
689            "and one CPU thread per process. Arms rotate per repeat. Sample SD "
690            "is null for one repeat; these are not independent GPU allocations."
691        ),
692    }
693
694
695def _parse_args(argv: list[str] | None) -> argparse.Namespace:
696    argv = list(sys.argv[1:] if argv is None else argv)
697    parser = argparse.ArgumentParser(
698        description="Public subprocess pinned-arena comparison"
699    )
700    retired = [
701        flag
702        for flag in _RETIRED_FLAGS
703        if any(value.split("=", 1)[0] == flag for value in argv)
704    ]
705    if retired:
706        parser.error(
707            f"retired internal benchmark flags: {', '.join(retired)}; "
708            "lifecycle tests remain in the library test suite"
709        )
710    parser.add_argument(
711        "--self-test",
712        action="store_true",
713        help="bounded checked public CPU workflows, not a performance sweep",
714    )
715    parser.add_argument(
716        "--source-receipt",
717        action="store_true",
718        help="benchmark source identity only; no private library/package probes",
719    )
720    parser.add_argument("--device", choices=("cpu", "cuda"), default="cuda")
721    parser.add_argument("--arms", nargs="+", choices=_ARMS)
722    parser.add_argument(
723        "--sizes",
724        nargs="+",
725        type=int,
726        default=[8],
727        help="requested MiB; overridden by --sizes-bytes",
728    )
729    parser.add_argument("--sizes-bytes", nargs="+", type=int)
730    parser.add_argument(
731        "--kinds", nargs="+", choices=("torch", "numpy", "nested"), default=["torch"]
732    )
733    parser.add_argument("--dtypes", nargs="+", choices=tuple(_DTYPES), default=["fp32"])
734    parser.add_argument("--workers", type=int, default=1)
735    parser.add_argument("--slots", type=int, default=4)
736    parser.add_argument("--items", type=int, default=8, help="per producer per epoch")
737    parser.add_argument(
738        "--warmup-epochs",
739        type=int,
740        default=1,
741        help="complete checked epochs excluded from measurement",
742    )
743    parser.add_argument("--repeats", type=int, default=3)
744    parser.add_argument("--timeout", type=float, default=600.0)
745    parser.add_argument(
746        "--local-rank", type=int, default=int(os.environ.get("LOCAL_RANK", "0"))
747    )
748    parser.add_argument("--output", help="JSON report file, otherwise stdout")
749    args = parser.parse_args(argv)
750    if args.self_test:
751        args.device, args.arms = "cpu", list(_CPU_ARMS)
752        args.sizes_bytes, args.kinds, args.dtypes = (
753            [8192],
754            ["torch", "numpy", "nested"],
755            ["fp32"],
756        )
757        args.workers, args.slots, args.items, args.repeats, args.warmup_epochs = (
758            1,
759            2,
760            2,
761            1,
762            1,
763        )
764    if args.arms is None:
765        args.arms = list(_CPU_ARMS if args.device == "cpu" else _ARMS)
766    if min(args.workers, args.slots, args.items, args.warmup_epochs, args.repeats) <= 0:
767        parser.error(
768            "workers, slots, items, warmup-epochs and repeats must be positive"
769        )
770    if not math.isfinite(args.timeout) or args.timeout <= 0 or args.local_rank < 0:
771        parser.error("timeout must be finite/positive; local-rank must be nonnegative")
772    if any(size <= 0 for size in (args.sizes_bytes or args.sizes)):
773        parser.error("sizes must be positive")
774    if args.device == "cpu" and any(arm not in _CPU_ARMS for arm in args.arms):
775        parser.error("pinned runtime arms require --device cuda")
776    return args
777
778
779def _summaries(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
780    groups: dict[tuple[Any, ...], list[float]] = {}
781    for row in rows:
782        config = row["config"]
783        key = (
784            row["arm"],
785            config["size"],
786            config["kind"],
787            config["dtype"],
788            config["workers"],
789            config["device"],
790        )
791        groups.setdefault(key, []).append(row["items_per_s"])
792    return [
793        {
794            "arm": key[0],
795            "size": key[1],
796            "kind": key[2],
797            "dtype": key[3],
798            "workers": key[4],
799            "device": key[5],
800            "items_per_s_mean": statistics.mean(values),
801            "items_per_s_stdev": statistics.stdev(values) if len(values) > 1 else None,
802            "fresh_process_repeats": len(values),
803        }
804        for key, values in groups.items()
805    ]
806
807
808def main(argv: list[str] | None = None) -> None:
809    """Run the four-arm public comparison CLI or bounded CPU/source checks.
810
811    Args:
812        argv: CLI arguments, or ``None`` to read ``sys.argv``.
813
814    Returns:
815        None; writes structured JSON to ``--output`` or stdout. Reports include
816        raw per-item receipts, grouped throughput means/sample SD and scope legend.
817        ``--device cpu`` selects only unpinned public workflows. ``--self-test``
818        checks bounded CPU epochs without CUDA; ``--source-receipt`` hashes only
819        this benchmark, not private library modules or installed native packages.
820
821    Raises:
822        SystemExit: Unsupported/retired CLI flags or invalid configuration.
823        RuntimeError: A public consumer/producer workflow failed.
824
825    Example:
826        >>> main(['--device', 'cpu', '--arms', 'pool-pageable', '--sizes-bytes', '8192',
827        ...       '--items', '2', '--repeats', '1', '--output', 'cpu-results.json'])
828    """
829    args = _parse_args(argv)
830    if args.source_receipt:
831        report = {"schema": _SCHEMA, "source_receipt": _source_receipt()}
832    else:
833        rows = []
834        sizes = args.sizes_bytes or [size << 20 for size in args.sizes]
835        for size, kind, dtype, repeat in product(
836            sizes, args.kinds, args.dtypes, range(args.repeats)
837        ):
838            config = {
839                "size": size,
840                "kind": kind,
841                "dtype": dtype,
842                "repeat": repeat,
843                "workers": args.workers,
844                "slots": args.slots,
845                "items": args.items,
846                "warmup_epochs": args.warmup_epochs,
847                "timeout": args.timeout,
848                "local_rank": args.local_rank,
849                "device": args.device,
850            }
851            order = (
852                args.arms[repeat % len(args.arms) :]
853                + args.arms[: repeat % len(args.arms)]
854            )
855            rows.extend(run_isolated(arm, config) for arm in order)
856        report = {
857            "schema": _SCHEMA,
858            "legend": _legend(),
859            "results": rows,
860            "summary": _summaries(rows),
861        }
862    text = json.dumps(report, indent=2, allow_nan=False)
863    if args.output:
864        Path(args.output).write_text(text + "\n", encoding="utf-8")
865    else:
866        print(text)
867
868
869if __name__ == "__main__":
870    main()

API Reference

Functions

configure_process() → None[source]

Select one Torch CPU thread in each process before benchmark setup/timing.

Use this as the public subprocess initializer, not at module import time. It does not initialize CUDA or change another process’s thread count.

Example

>>> configure_process()
>>> torch.get_num_threads()
1
make_payload(size: int, kind: str, dtype: str, sequence: int) → Any[source]

Create fresh deterministic CPU data, including for independent verification.

Parameters:
  • size – Positive requested byte budget. Leaf lengths are dtype-rounded; a nested payload also includes a three-element leaf.

  • kind – torch, numpy or a nested dictionary/list/tuple tensor tree.

  • dtype – fp32, fp16 or uint8.

  • sequence – Nonnegative item identity incorporated into every leaf.

Returns:

A CPU tensor, NumPy array or nested CPU tensor tree; no CUDA storage.

Example

>>> make_payload(8192, 'torch', 'fp32', sequence=1).numel()
2048
produce_payloads(config: dict[str, Any], worker: int = 0) → Iterable[dict[str, Any]][source]

Return a pickle-safe, reiterable CPU source for a public subprocess boundary.

Parameters:
  • config – size, kind, dtype, items per epoch and workers.

  • worker – Producer index in [0, workers).

Returns:

A reiterable source, not a single-use generator. Each epoch produces fresh values and globally distinct sequence identifiers. Rows include generation wall/CPU time and the producer’s CUDA-uninitialized/thread-count receipt. Pass configure_process as the boundary’s initializer.

Example

>>> cfg = dict(size=8192, kind='torch', dtype='fp32', items=2, workers=1)
>>> source = produce_payloads(cfg)
>>> [row['sequence'] for row in source]
[0, 1]
>>> [row['sequence'] for row in source]
[2, 3]
verify_payload(batch: Any, size: int, kind: str, dtype: str, sequence: int, *, device: str = 'cpu') → None[source]

Check structure, dtype, shape, device and every value against fresh CPU data.

Parameters:
  • batch – Prepared tensor/tree to check, on CPU or CUDA.

  • size – Requested payload byte budget.

  • kind – Original torch, numpy or nested payload kind.

  • dtype – Original fp32, fp16 or uint8 dtype.

  • sequence – Expected producer item identity.

  • device – Required leaf device type, cpu or cuda.

Raises:

AssertionError – Structure, layout, device or any value differs.

Example

>>> batch = make_payload(8192, 'torch', 'fp32', 1)
>>> verify_payload(batch, 8192, 'torch', 'fp32', 1)
restore_payload(batch: Any) → tuple[Any, dict[str, float]][source]

Prepare already-restored public boundary outputs as CPU tensor leaves.

No serialized blobs, registry or reader endpoints are accepted here. Arena restoration belongs entirely to iterate_in_subprocess.

Parameters:

batch – Tensor/array/tree already yielded by the public iterable.

Returns:

(tensor_batch, phases); phases measure only NumPy-to-Torch/tree preparation, not the preceding transport or arena restoration.

Example

>>> batch, phases = restore_payload(make_payload(16, 'numpy', 'fp32', 0))
>>> batch.numel()
4
transfer_payload(batch: Any) → tuple[Any, dict[str, Any]][source]

Use only the public synchronized H2D helper, with its cache disabled.

Parameters:

batch – CPU tensor or supported tensor tree.

Returns:

(gpu_batch, phases) with measured helper completion wall/process-CPU time only. Unmeasured internal pin/copy/stream phases are omitted.

Example

On a CUDA consumer, call gpu, phases = transfer_payload(cpu_batch).

process_item(item: dict[str, Any], config: dict[str, Any]) → dict[str, Any][source]

Prepare, optionally transfer, and fully verify one public output.

Parameters:
  • item – Producer row already restored by the public subprocess iterable.

  • config – Payload settings and device (cpu or cuda).

Returns:

Scalar/list receipts only, with no retained tensors. Every arm uses this identical consumer workflow. Source pinning is reported per leaf, including small inline/pageable leaves.

Raises:

AssertionError – Producer CUDA/thread receipt or full value check fails.

Example

Use this with rows from iterate_in_subprocess(produce_factory).

run_pass(sources: Iterable[Iterable[dict[str, Any]]], config: dict[str, Any]) → dict[str, Any][source]

Run one whole checked epoch, consuming producers in round-robin order.

Parameters:
  • sources – One public subprocess iterable per producer, already initialized.

  • config – Payload, per-producer items, worker count and device settings.

Returns:

Epoch wall/consumer CPU time and per-item receipts. Wall includes epoch command dispatch, generation, public transport/restore, preparation, synchronized H2D if selected, full verification and final epoch completion. next_output_s combines waiting, transport and restoration; it is not an internal read/copy/registration timer. No payloads are retained.

Example

Call once for warmup and again on the same sources for measured work.

run_benchmark(arm: str, config: dict[str, Any]) → dict[str, Any][source]

Run an ordinary public CPU/GPU comparison in the current consumer process.

Parameters:
  • arm – pool-pageable, pool-pinned, ring-pageable or ring-pinned.

  • config – size, kind, dtype, items, workers, slots, warmup_epochs, timeout, local_rank and device.

Returns:

setup, excluded checked warmup epochs, one measured epoch and throughput using only measured public metrics. The consumer creates one arena per producer and transfers ownership to iterate_in_subprocess. That public iterable handles transport and teardown. Thread/CUDA setup, warmup and finalizer cleanup are untimed.

Return type:

public-iterate-v1 receipts

Example

>>> cfg = dict(size=8192, kind='torch', dtype='fp32', items=2, workers=1,
...            slots=2, warmup_epochs=1, timeout=60., local_rank=0, device='cpu')
>>> result = run_benchmark('pool-pageable', cfg)
>>> len(result['measured']['items'])
2
run_isolated(arm: str, config: dict[str, Any]) → dict[str, Any][source]

Run one arm in a fresh spawned consumer, with fresh public producer boundaries.

Parameters:
  • arm – Supported runtime arm.

  • config – Settings accepted by run_benchmark; timeout bounds the complete isolated invocation, including setup/warmup/teardown.

Returns:

The consumer’s JSON-compatible benchmark receipt. A temporary result file conveys only the final report, never payloads or arena endpoint messages.

Raises:
  • RuntimeError – The consumer failed or did not write its final report.

  • TimeoutError – The invocation exceeded its configured total timeout.

Example

Call run_isolated('pool-pageable', cfg) for fresh-process repetitions.

main(argv: list[str] | None = None) → None[source]

Run the four-arm public comparison CLI or bounded CPU/source checks.

Parameters:

argv – CLI arguments, or None to read sys.argv.

Returns:

None; writes structured JSON to --output or stdout. Reports include raw per-item receipts, grouped throughput means/sample SD and scope legend. --device cpu selects only unpinned public workflows. --self-test checks bounded CPU epochs without CUDA; --source-receipt hashes only this benchmark, not private library modules or installed native packages.

Raises:
  • SystemExit – Unsupported/retired CLI flags or invalid configuration.

  • RuntimeError – A public consumer/producer workflow failed.

Example

>>> main(['--device', 'cpu', '--arms', 'pool-pageable', '--sizes-bytes', '8192',
...       '--items', '2', '--repeats', '1', '--output', 'cpu-results.json'])