Benchmark transfer overlap

Benchmark overlap between D2H transfers and foreground CUDA compute.

The intended use of spdl.io.transfer_tensor_d2h() is to call it from a background CPU thread while the foreground thread submits independent CUDA work. Raw transfer latency alone does not measure that benefit, so this benchmark compares serialized and concurrent transfer-plus-compute makespans. The PyTorch to(cpu) method is a direct-transfer reference. SPDL uses the same transfer primitive while adding nested-batch traversal and cached stream management.

The source tensors are made ready before timing. The transfer worker owns its copy stream, while the foreground thread owns a separate compute stream. Each timed block runs compute-only, transfer-only, serialized, and concurrent cases in randomized order. Concurrent cases use a CPU barrier and report CPU launch skew so delayed worker scheduling is visible in the result.

Usage:

python examples/benchmark_transfer_overlap.py         --output /tmp/transfer_overlap.csv

Use --samples-output to retain paired per-trial measurements.

Measured steady-state result

One run used a 64 MiB uint8 payload on an NVIDIA A100 MIG 3g.20gb with CUDA 13.3. After three warmup blocks, 20 paired randomized blocks produced the following SPDL medians. The foreground workload was a 2048-square torch.mm.

Tensors  Compute   Transfer  Serialized  Concurrent  Paired speedup (95% CI)
      1  7.611 ms  5.700 ms   13.325 ms     7.754 ms  1.716x [1.706, 1.725]
     32  7.568 ms  5.854 ms   13.404 ms     7.749 ms  1.730x [1.726, 1.737]
    256  7.565 ms  6.617 ms   14.241 ms     7.760 ms  1.835x [1.823, 1.842]

These are steady-state results: validation and warmup prime the pinned-memory and allocator caches before timing. Direct PyTorch transfer medians were 5.628, 5.807, and 6.599 ms for the same three cases, respectively. The SPDL wrapper therefore remained within 1.3% of the underlying transfer primitive while preserving nested-batch handling and stream reuse. These measurements do not characterize first-call or allocator-cache growth latency.

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"""Benchmark overlap between D2H transfers and foreground CUDA compute.
  9
 10The intended use of :func:`spdl.io.transfer_tensor_d2h` is to call it from a
 11background CPU thread while the foreground thread submits independent CUDA
 12work. Raw transfer latency alone does not measure that benefit, so this
 13benchmark compares serialized and concurrent transfer-plus-compute makespans.
 14The ``PyTorch to(cpu)`` method is a direct-transfer reference. SPDL uses the
 15same transfer primitive while adding nested-batch traversal and cached stream
 16management.
 17
 18The source tensors are made ready before timing. The transfer worker owns its
 19copy stream, while the foreground thread owns a separate compute stream. Each
 20timed block runs compute-only, transfer-only, serialized, and concurrent cases
 21in randomized order. Concurrent cases use a CPU barrier and report CPU launch
 22skew so delayed worker scheduling is visible in the result.
 23
 24Usage::
 25
 26    python examples/benchmark_transfer_overlap.py \
 27        --output /tmp/transfer_overlap.csv
 28
 29Use ``--samples-output`` to retain paired per-trial measurements.
 30
 31Measured steady-state result
 32----------------------------
 33
 34One run used a 64 MiB uint8 payload on an NVIDIA A100 MIG 3g.20gb with
 35CUDA 13.3. After three warmup blocks, 20 paired randomized blocks produced the
 36following SPDL medians. The foreground workload was a 2048-square ``torch.mm``.
 37
 38::
 39
 40    Tensors  Compute   Transfer  Serialized  Concurrent  Paired speedup (95% CI)
 41          1  7.611 ms  5.700 ms   13.325 ms     7.754 ms  1.716x [1.706, 1.725]
 42         32  7.568 ms  5.854 ms   13.404 ms     7.749 ms  1.730x [1.726, 1.737]
 43        256  7.565 ms  6.617 ms   14.241 ms     7.760 ms  1.835x [1.823, 1.842]
 44
 45These are steady-state results: validation and warmup prime the pinned-memory
 46and allocator caches before timing. Direct PyTorch transfer medians were
 475.628, 5.807, and 6.599 ms for the same three cases, respectively. The SPDL
 48wrapper therefore remained within 1.3% of the underlying transfer primitive
 49while preserving nested-batch handling and stream reuse. These measurements do
 50not characterize first-call or allocator-cache growth latency.
 51"""
 52
 53import argparse
 54import csv
 55import os
 56import random
 57import threading
 58import time
 59from collections.abc import Callable, Sequence
 60from concurrent.futures import Future, ThreadPoolExecutor
 61from dataclasses import dataclass, fields
 62from datetime import datetime, timezone
 63from functools import partial
 64
 65import numpy as np
 66import torch
 67from spdl.io import transfer_tensor_d2h
 68
 69_SCHEMA_VERSION = 1
 70_METHOD_NATIVE = "PyTorch to(cpu)"
 71_METHOD_SPDL = "SPDL"
 72
 73
 74@dataclass(frozen=True)
 75class BenchmarkConfig:
 76    """Configuration for an overlap benchmark run."""
 77
 78    total_mib: tuple[int, ...]
 79    num_tensors: tuple[int, ...]
 80    matrix_size: int
 81    compute_target_ms: float
 82    compute_iterations: int | None
 83    num_warmup: int
 84    num_runs: int
 85    bootstrap_resamples: int
 86    seed: int
 87
 88
 89@dataclass(frozen=True)
 90class OverlapSample:
 91    """Paired measurements from one randomized benchmark block."""
 92
 93    trial: int
 94    schedule_order: str
 95    compute_only_ms: float
 96    transfer_only_ms: float
 97    serialized_ms: float
 98    concurrent_ms: float
 99    cpu_launch_skew_ms: float
100
101    @property
102    def speedup(self) -> float:
103        """Return serialized-to-concurrent makespan speedup."""
104        return self.serialized_ms / self.concurrent_ms
105
106    @property
107    def hidden_ms(self) -> float:
108        """Return time saved by concurrent execution."""
109        return self.serialized_ms - self.concurrent_ms
110
111    @property
112    def estimated_overlap_efficiency(self) -> float:
113        """Estimate saved time as a fraction of the paired overlap potential.
114
115        The four schedules are timed separately, so noise can produce estimates
116        outside the interval from zero to one.
117        """
118        potential_ms = min(self.compute_only_ms, self.transfer_only_ms)
119        return self.hidden_ms / potential_ms if potential_ms > 0 else 0.0
120
121
122@dataclass(frozen=True)
123class BenchmarkSummary:
124    """One summary row written to the benchmark output CSV."""
125
126    schema_version: int
127    created_utc: str
128    device_name: str
129    device_capability: str
130    torch_version: str
131    cuda_version: str
132    seed: int
133    schedule_seed: int
134    bootstrap_seed: int
135    bootstrap_resamples: int
136    num_warmup: int
137    num_runs: int
138    total_bytes: int
139    num_tensors: int
140    dtype: str
141    matrix_size: int
142    compute_target_ms: float
143    compute_iterations: int
144    method: str
145    compute_median_ms: float
146    compute_p95_ms: float
147    transfer_median_ms: float
148    transfer_p95_ms: float
149    transfer_gb_per_s: float
150    serialized_median_ms: float
151    serialized_p95_ms: float
152    concurrent_median_ms: float
153    concurrent_p95_ms: float
154    speedup_median: float
155    speedup_ci_lower: float
156    speedup_ci_upper: float
157    hidden_median_ms: float
158    estimated_overlap_efficiency_median: float
159    estimated_overlap_efficiency_ci_lower: float
160    estimated_overlap_efficiency_ci_upper: float
161    cpu_launch_skew_p95_ms: float
162
163
164@dataclass(frozen=True)
165class RawSampleRecord:
166    """One per-trial row optionally written for reproducibility."""
167
168    schema_version: int
169    created_utc: str
170    seed: int
171    schedule_seed: int
172    total_bytes: int
173    num_tensors: int
174    method: str
175    trial: int
176    schedule_order: str
177    compute_only_ms: float
178    transfer_only_ms: float
179    serialized_ms: float
180    concurrent_ms: float
181    speedup: float
182    hidden_ms: float
183    estimated_overlap_efficiency: float
184    cpu_launch_skew_ms: float
185
186
187@dataclass(frozen=True)
188class _TransferOutcome:
189    output: list[torch.Tensor]
190    started_ns: int
191
192
193@dataclass
194class _MutableTrial:
195    transfer_only_ms: float = 0.0
196    serialized_ms: float = 0.0
197    concurrent_ms: float = 0.0
198    cpu_launch_skew_ms: float = 0.0
199
200
201def _native_d2h(
202    tensors: list[torch.Tensor], stream: torch.cuda.Stream
203) -> list[torch.Tensor]:
204    """Transfer tensors directly to CPU on a dedicated stream."""
205    producer_stream = torch.cuda.current_stream(tensors[0].device)
206    if producer_stream != stream:
207        stream.wait_stream(producer_stream)
208    with torch.cuda.stream(stream):
209        output = [tensor.to("cpu", non_blocking=True) for tensor in tensors]
210    stream.synchronize()
211    return output
212
213
214def _submit_transfer(
215    executor: ThreadPoolExecutor,
216    fn: Callable[[], list[torch.Tensor]],
217    device: torch.device,
218    barrier: threading.Barrier | None = None,
219) -> Future[_TransferOutcome]:
220    """Submit one transfer to the persistent background worker."""
221
222    def _work() -> _TransferOutcome:
223        torch.cuda.set_device(device)
224        if barrier is not None:
225            barrier.wait(timeout=30)
226        started_ns = time.perf_counter_ns()
227        output = fn()
228        return _TransferOutcome(output, started_ns)
229
230    return executor.submit(_work)
231
232
233def _elapsed_ms(start_ns: int, end_ns: int) -> float:
234    """Convert a monotonic nanosecond interval to milliseconds."""
235    return (end_ns - start_ns) / 1_000_000
236
237
238def _time_compute(run_compute: Callable[[], torch.Tensor]) -> float:
239    """Measure one synchronous foreground-compute call."""
240    start_ns = time.perf_counter_ns()
241    run_compute()
242    return _elapsed_ms(start_ns, time.perf_counter_ns())
243
244
245def _time_transfer(
246    executor: ThreadPoolExecutor,
247    fn: Callable[[], list[torch.Tensor]],
248    device: torch.device,
249) -> float:
250    """Measure one end-to-end background transfer."""
251    start_ns = time.perf_counter_ns()
252    outcome = _submit_transfer(executor, fn, device).result()
253    end_ns = time.perf_counter_ns()
254    del outcome
255    return _elapsed_ms(start_ns, end_ns)
256
257
258def _time_serialized(
259    executor: ThreadPoolExecutor,
260    fn: Callable[[], list[torch.Tensor]],
261    run_compute: Callable[[], torch.Tensor],
262    device: torch.device,
263) -> float:
264    """Measure transfer followed by foreground compute."""
265    start_ns = time.perf_counter_ns()
266    outcome = _submit_transfer(executor, fn, device).result()
267    run_compute()
268    end_ns = time.perf_counter_ns()
269    del outcome
270    return _elapsed_ms(start_ns, end_ns)
271
272
273def _time_concurrent(
274    executor: ThreadPoolExecutor,
275    fn: Callable[[], list[torch.Tensor]],
276    run_compute: Callable[[], torch.Tensor],
277    device: torch.device,
278) -> tuple[float, float]:
279    """Measure barrier-coordinated transfer and foreground compute."""
280    barrier = threading.Barrier(2)
281    start_ns = time.perf_counter_ns()
282    future = _submit_transfer(executor, fn, device, barrier)
283    barrier.wait(timeout=30)
284    compute_started_ns = time.perf_counter_ns()
285    run_compute()
286    outcome = future.result()
287    end_ns = time.perf_counter_ns()
288    launch_skew_ms = _elapsed_ms(
289        min(compute_started_ns, outcome.started_ns),
290        max(compute_started_ns, outcome.started_ns),
291    )
292    del outcome
293    return _elapsed_ms(start_ns, end_ns), launch_skew_ms
294
295
296def _percentile(values: Sequence[float], percentile: float) -> float:
297    """Return one percentile as a Python float."""
298    return float(np.percentile(np.asarray(values, dtype=np.float64), percentile))
299
300
301def _bootstrap_median_ci(
302    values: Sequence[float], *, seed: int, num_resamples: int
303) -> tuple[float, float]:
304    """Return a percentile-bootstrap 95% confidence interval for the median."""
305    samples = np.asarray(values, dtype=np.float64)
306    rng = np.random.default_rng(seed)
307    indices = rng.integers(0, len(samples), size=(num_resamples, len(samples)))
308    medians = np.median(samples[indices], axis=1)
309    return (
310        float(np.percentile(medians, 2.5)),
311        float(np.percentile(medians, 97.5)),
312    )
313
314
315def _calibrate_compute_iterations(
316    run_compute: Callable[[int], torch.Tensor], target_ms: float
317) -> int:
318    """Calibrate real matrix multiplication to an independent duration target."""
319    iterations = 1
320    for _ in range(4):
321        samples = []
322        for _ in range(3):
323            start_ns = time.perf_counter_ns()
324            run_compute(iterations)
325            samples.append(_elapsed_ms(start_ns, time.perf_counter_ns()))
326        measured_ms = _percentile(samples, 50)
327        estimate = round(iterations * target_ms / max(measured_ms, 0.001))
328        estimate = max(1, min(100_000, estimate))
329        if estimate == iterations:
330            break
331        iterations = estimate
332    return iterations
333
334
335def _create_source_tensors(
336    total_bytes: int, num_tensors: int, device: torch.device
337) -> list[torch.Tensor]:
338    """Create uint8 CUDA tensors whose sizes sum to ``total_bytes``."""
339    if num_tensors > total_bytes:
340        raise ValueError(
341            f"Cannot split {total_bytes} bytes into {num_tensors} nonempty tensors."
342        )
343    size, remainder = divmod(total_bytes, num_tensors)
344    return [
345        torch.randint(
346            0,
347            256,
348            (size + (index < remainder),),
349            dtype=torch.uint8,
350            device=device,
351        )
352        for index in range(num_tensors)
353    ]
354
355
356def _validate_methods(
357    executor: ThreadPoolExecutor,
358    methods: dict[str, Callable[[], list[torch.Tensor]]],
359    tensors: list[torch.Tensor],
360    device: torch.device,
361) -> None:
362    """Prime each method on the worker and validate exact transfer results."""
363    expected = [tensor.cpu() for tensor in tensors]
364    for method in methods.values():
365        outcome = _submit_transfer(executor, method, device).result()
366        for actual, reference in zip(outcome.output, expected, strict=True):
367            torch.testing.assert_close(actual, reference)
368        del outcome
369
370
371def _collect_samples(
372    executor: ThreadPoolExecutor,
373    methods: dict[str, Callable[[], list[torch.Tensor]]],
374    run_compute: Callable[[], torch.Tensor],
375    device: torch.device,
376    *,
377    num_warmup: int,
378    num_runs: int,
379    seed: int,
380) -> dict[str, list[OverlapSample]]:
381    """Collect randomized, paired timing blocks for every transfer method."""
382    for _ in range(num_warmup):
383        run_compute()
384        for method in methods.values():
385            _time_transfer(executor, method, device)
386            _time_concurrent(executor, method, run_compute, device)
387
388    rng = random.Random(seed)
389    samples = {method: [] for method in methods}
390    cases = [("compute", "")]
391    cases.extend(
392        (method, schedule)
393        for method in methods
394        for schedule in ("transfer", "serialized", "concurrent")
395    )
396
397    for trial in range(num_runs):
398        order = list(cases)
399        rng.shuffle(order)
400        order_label = "|".join(
401            method if method == "compute" else f"{method}:{schedule}"
402            for method, schedule in order
403        )
404        compute_only_ms = 0.0
405        trial_results = {method: _MutableTrial() for method in methods}
406        for method, schedule in order:
407            if method == "compute":
408                compute_only_ms = _time_compute(run_compute)
409                continue
410
411            transfer = methods[method]
412            result = trial_results[method]
413            if schedule == "transfer":
414                result.transfer_only_ms = _time_transfer(executor, transfer, device)
415            elif schedule == "serialized":
416                result.serialized_ms = _time_serialized(
417                    executor, transfer, run_compute, device
418                )
419            else:
420                (
421                    result.concurrent_ms,
422                    result.cpu_launch_skew_ms,
423                ) = _time_concurrent(executor, transfer, run_compute, device)
424
425        for method, result in trial_results.items():
426            samples[method].append(
427                OverlapSample(
428                    trial=trial,
429                    schedule_order=order_label,
430                    compute_only_ms=compute_only_ms,
431                    transfer_only_ms=result.transfer_only_ms,
432                    serialized_ms=result.serialized_ms,
433                    concurrent_ms=result.concurrent_ms,
434                    cpu_launch_skew_ms=result.cpu_launch_skew_ms,
435                )
436            )
437    return samples
438
439
440def _summarize(
441    samples: list[OverlapSample],
442    *,
443    created_utc: str,
444    device: torch.device,
445    config: BenchmarkConfig,
446    total_bytes: int,
447    num_tensors: int,
448    compute_iterations: int,
449    method: str,
450    schedule_seed: int,
451    bootstrap_seed: int,
452) -> BenchmarkSummary:
453    """Summarize one case and method into a stable CSV schema."""
454    compute = [sample.compute_only_ms for sample in samples]
455    transfer = [sample.transfer_only_ms for sample in samples]
456    serialized = [sample.serialized_ms for sample in samples]
457    concurrent = [sample.concurrent_ms for sample in samples]
458    speedups = [sample.speedup for sample in samples]
459    hidden = [sample.hidden_ms for sample in samples]
460    efficiencies = [sample.estimated_overlap_efficiency for sample in samples]
461    launch_skews = [sample.cpu_launch_skew_ms for sample in samples]
462    speedup_ci = _bootstrap_median_ci(
463        speedups, seed=bootstrap_seed, num_resamples=config.bootstrap_resamples
464    )
465    efficiency_ci = _bootstrap_median_ci(
466        efficiencies,
467        seed=bootstrap_seed + 1,
468        num_resamples=config.bootstrap_resamples,
469    )
470    transfer_median_ms = _percentile(transfer, 50)
471    capability = torch.cuda.get_device_capability(device)
472    return BenchmarkSummary(
473        schema_version=_SCHEMA_VERSION,
474        created_utc=created_utc,
475        device_name=torch.cuda.get_device_name(device),
476        device_capability=f"{capability[0]}.{capability[1]}",
477        torch_version=str(torch.__version__),
478        cuda_version=str(torch.version.cuda or "unknown"),
479        seed=config.seed,
480        schedule_seed=schedule_seed,
481        bootstrap_seed=bootstrap_seed,
482        bootstrap_resamples=config.bootstrap_resamples,
483        num_warmup=config.num_warmup,
484        num_runs=config.num_runs,
485        total_bytes=total_bytes,
486        num_tensors=num_tensors,
487        dtype="torch.uint8",
488        matrix_size=config.matrix_size,
489        compute_target_ms=config.compute_target_ms,
490        compute_iterations=compute_iterations,
491        method=method,
492        compute_median_ms=_percentile(compute, 50),
493        compute_p95_ms=_percentile(compute, 95),
494        transfer_median_ms=transfer_median_ms,
495        transfer_p95_ms=_percentile(transfer, 95),
496        transfer_gb_per_s=total_bytes / (transfer_median_ms * 1_000_000),
497        serialized_median_ms=_percentile(serialized, 50),
498        serialized_p95_ms=_percentile(serialized, 95),
499        concurrent_median_ms=_percentile(concurrent, 50),
500        concurrent_p95_ms=_percentile(concurrent, 95),
501        speedup_median=_percentile(speedups, 50),
502        speedup_ci_lower=speedup_ci[0],
503        speedup_ci_upper=speedup_ci[1],
504        hidden_median_ms=_percentile(hidden, 50),
505        estimated_overlap_efficiency_median=_percentile(efficiencies, 50),
506        estimated_overlap_efficiency_ci_lower=efficiency_ci[0],
507        estimated_overlap_efficiency_ci_upper=efficiency_ci[1],
508        cpu_launch_skew_p95_ms=_percentile(launch_skews, 95),
509    )
510
511
512def _print_summary(summary: BenchmarkSummary) -> None:
513    """Print one compact, human-readable result row."""
514    print(
515        f"  {summary.method:<6} "
516        f"compute={summary.compute_median_ms:7.3f} ms  "
517        f"transfer={summary.transfer_median_ms:7.3f} ms  "
518        f"serialized={summary.serialized_median_ms:7.3f} ms  "
519        f"concurrent={summary.concurrent_median_ms:7.3f} ms  "
520        f"speedup={summary.speedup_median:5.2f}x  "
521        f"estimated_overlap="
522        f"{summary.estimated_overlap_efficiency_median * 100:6.1f}%"
523    )
524
525
526def _write_csv(records: Sequence[object], output_path: str, record_type: type) -> None:
527    """Write dataclass records to a self-contained CSV file."""
528    os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
529    fieldnames = [field.name for field in fields(record_type)]
530    with open(output_path, "w", encoding="utf-8", newline="") as output:
531        writer = csv.DictWriter(output, fieldnames=fieldnames)
532        writer.writeheader()
533        for record in records:
534            writer.writerow(
535                {fieldname: getattr(record, fieldname) for fieldname in fieldnames}
536            )
537
538
539def _run_benchmarks(
540    config: BenchmarkConfig, device: torch.device
541) -> tuple[list[BenchmarkSummary], list[RawSampleRecord]]:
542    """Run all configured overlap scenarios and return summary and raw rows."""
543    torch.manual_seed(config.seed)
544    torch.cuda.set_device(device)
545    current_index = torch.cuda.current_device()
546    device = torch.device("cuda", current_index)
547
548    left = torch.randn(config.matrix_size, config.matrix_size, device=device)
549    right = torch.randn(config.matrix_size, config.matrix_size, device=device)
550    compute_output = torch.empty_like(left)
551    compute_stream = torch.cuda.Stream(device=device)
552    compute_stream.wait_stream(torch.cuda.current_stream(device))
553
554    def _run_compute(iterations: int) -> torch.Tensor:
555        with torch.cuda.stream(compute_stream):
556            for _ in range(iterations):
557                torch.mm(left, right, out=compute_output)
558        compute_stream.synchronize()
559        return compute_output
560
561    _run_compute(1)
562    compute_iterations = config.compute_iterations
563    if compute_iterations is None:
564        compute_iterations = _calibrate_compute_iterations(
565            _run_compute, config.compute_target_ms
566        )
567
568    def _compute() -> torch.Tensor:
569        return _run_compute(compute_iterations)
570
571    _compute()
572    if not torch.isfinite(compute_output).all().item():
573        raise RuntimeError(
574            "Foreground matrix multiplication produced non-finite output."
575        )
576
577    created_utc = datetime.now(timezone.utc).isoformat()
578    summaries = []
579    raw_records = []
580    scenarios = [
581        (total_mib * 1024 * 1024, num_tensors)
582        for total_mib in config.total_mib
583        for num_tensors in config.num_tensors
584    ]
585    random.Random(config.seed).shuffle(scenarios)
586
587    with ThreadPoolExecutor(
588        max_workers=1, thread_name_prefix="spdl-d2h-benchmark"
589    ) as executor:
590        for scenario_index, (total_bytes, num_tensors) in enumerate(scenarios):
591            print(
592                f"\n{total_bytes / (1024 * 1024):.0f} MiB in "
593                f"{num_tensors} tensor(s), {compute_iterations} compute iteration(s)"
594            )
595            tensors = _create_source_tensors(total_bytes, num_tensors, device)
596            torch.cuda.synchronize(device)
597            native_stream = torch.cuda.Stream(device=device)
598            methods: dict[str, Callable[[], list[torch.Tensor]]] = {
599                _METHOD_NATIVE: partial(_native_d2h, tensors, native_stream),
600                _METHOD_SPDL: partial(transfer_tensor_d2h, tensors, device=device),
601            }
602            _validate_methods(executor, methods, tensors, device)
603            scenario_seed = config.seed + scenario_index * 1009
604            samples = _collect_samples(
605                executor,
606                methods,
607                _compute,
608                device,
609                num_warmup=config.num_warmup,
610                num_runs=config.num_runs,
611                seed=scenario_seed,
612            )
613            for method_index, (method, method_samples) in enumerate(samples.items()):
614                bootstrap_seed = scenario_seed + method_index * 17
615                summary = _summarize(
616                    method_samples,
617                    created_utc=created_utc,
618                    device=device,
619                    config=config,
620                    total_bytes=total_bytes,
621                    num_tensors=num_tensors,
622                    compute_iterations=compute_iterations,
623                    method=method,
624                    schedule_seed=scenario_seed,
625                    bootstrap_seed=bootstrap_seed,
626                )
627                summaries.append(summary)
628                _print_summary(summary)
629                raw_records.extend(
630                    RawSampleRecord(
631                        schema_version=_SCHEMA_VERSION,
632                        created_utc=created_utc,
633                        seed=config.seed,
634                        schedule_seed=scenario_seed,
635                        total_bytes=total_bytes,
636                        num_tensors=num_tensors,
637                        method=method,
638                        trial=sample.trial,
639                        schedule_order=sample.schedule_order,
640                        compute_only_ms=sample.compute_only_ms,
641                        transfer_only_ms=sample.transfer_only_ms,
642                        serialized_ms=sample.serialized_ms,
643                        concurrent_ms=sample.concurrent_ms,
644                        speedup=sample.speedup,
645                        hidden_ms=sample.hidden_ms,
646                        estimated_overlap_efficiency=(
647                            sample.estimated_overlap_efficiency
648                        ),
649                        cpu_launch_skew_ms=sample.cpu_launch_skew_ms,
650                    )
651                    for sample in method_samples
652                )
653            del tensors
654
655    return summaries, raw_records
656
657
658def _positive_int(value: str) -> int:
659    parsed = int(value)
660    if parsed <= 0:
661        raise argparse.ArgumentTypeError("must be positive")
662    return parsed
663
664
665def _non_negative_int(value: str) -> int:
666    parsed = int(value)
667    if parsed < 0:
668        raise argparse.ArgumentTypeError("must be non-negative")
669    return parsed
670
671
672def _at_least_two_int(value: str) -> int:
673    parsed = int(value)
674    if parsed < 2:
675        raise argparse.ArgumentTypeError("must be at least 2")
676    return parsed
677
678
679def _positive_float(value: str) -> float:
680    parsed = float(value)
681    if parsed <= 0:
682        raise argparse.ArgumentTypeError("must be positive")
683    return parsed
684
685
686def _parse_args() -> argparse.Namespace:
687    """Parse command-line arguments."""
688    parser = argparse.ArgumentParser(
689        description="Benchmark overlap between D2H transfer and CUDA compute."
690    )
691    parser.add_argument(
692        "--output",
693        type=lambda path: os.path.realpath(path),
694        required=True,
695        help="Summary CSV output path.",
696    )
697    parser.add_argument(
698        "--samples-output",
699        type=lambda path: os.path.realpath(path),
700        help="Optional per-trial CSV output path.",
701    )
702    parser.add_argument("--device", default="cuda:0", help="CUDA device to use.")
703    parser.add_argument(
704        "--total-mib",
705        type=_positive_int,
706        nargs="+",
707        default=[64],
708        help="Fixed total payload sizes in MiB.",
709    )
710    parser.add_argument(
711        "--num-tensors",
712        type=_positive_int,
713        nargs="+",
714        default=[1, 32],
715        help="Tensor counts to test for each fixed payload size.",
716    )
717    parser.add_argument(
718        "--matrix-size",
719        type=_positive_int,
720        default=2048,
721        help="Square float32 matrix size for foreground torch.mm work.",
722    )
723    parser.add_argument(
724        "--compute-ms",
725        type=_positive_float,
726        default=8.0,
727        help="Independent target duration used to calibrate foreground compute.",
728    )
729    parser.add_argument(
730        "--compute-iterations",
731        type=_positive_int,
732        help="Skip calibration and use this many torch.mm calls per sample.",
733    )
734    parser.add_argument(
735        "--num-warmup",
736        type=_non_negative_int,
737        default=5,
738        help="Warmup blocks per scenario.",
739    )
740    parser.add_argument(
741        "--num-runs",
742        type=_at_least_two_int,
743        default=30,
744        help="Paired randomized timing blocks per scenario.",
745    )
746    parser.add_argument(
747        "--bootstrap-resamples",
748        type=_positive_int,
749        default=2000,
750        help="Bootstrap resamples for paired median confidence intervals.",
751    )
752    parser.add_argument("--seed", type=int, default=0, help="Randomization seed.")
753    return parser.parse_args()
754
755
756def main() -> None:
757    """Run the overlap benchmark and write its results."""
758    args = _parse_args()
759    if not torch.cuda.is_available():
760        raise RuntimeError("CUDA is not available. This benchmark requires a GPU.")
761    device = torch.device(args.device)
762    if device.type != "cuda":
763        raise ValueError(f"Expected a CUDA device, but received {device}.")
764    config = BenchmarkConfig(
765        total_mib=tuple(args.total_mib),
766        num_tensors=tuple(args.num_tensors),
767        matrix_size=args.matrix_size,
768        compute_target_ms=args.compute_ms,
769        compute_iterations=args.compute_iterations,
770        num_warmup=args.num_warmup,
771        num_runs=args.num_runs,
772        bootstrap_resamples=args.bootstrap_resamples,
773        seed=args.seed,
774    )
775
776    print("D2H transfer / foreground CUDA compute overlap benchmark")
777    print(f"Device: {torch.cuda.get_device_name(device)}")
778    print("PyTorch to(cpu) is a naive reference; compare overlap within each method.")
779    summaries, raw_records = _run_benchmarks(config, device)
780    _write_csv(summaries, args.output, BenchmarkSummary)
781    print(f"\nSummary saved to: {args.output}")
782    if args.samples_output:
783        _write_csv(raw_records, args.samples_output, RawSampleRecord)
784        print(f"Raw samples saved to: {args.samples_output}")
785
786
787if __name__ == "__main__":
788    main()

API Reference

Functions

dataclass(cls=None, /, *, init=True, repr=True, eq=True, order=False, unsafe_hash=False, frozen=False, match_args=True, kw_only=False, slots=False, weakref_slot=False)[source]

Add dunder methods based on the fields defined in the class.

Examines PEP 526 __annotations__ to determine fields.

If init is true, an __init__() method is added to the class. If repr is true, a __repr__() method is added. If order is true, rich comparison dunder methods are added. If unsafe_hash is true, a __hash__() method is added. If frozen is true, fields may not be assigned to after instance creation. If match_args is true, the __match_args__ tuple is added. If kw_only is true, then by default all fields are keyword-only. If slots is true, a new class with a __slots__ attribute is returned.

fields(class_or_instance)[source]

Return a tuple describing the fields of this dataclass.

Accepts a dataclass or an instance of one. Tuple elements are of type Field.

main() → None[source]

Run the overlap benchmark and write its results.

transfer_tensor_d2h(batch: T, /, *, device: TDevice | str | None = None, stream: torch.cuda.Stream | None = None, producer_event: torch.cuda.Event | None = None) → T[source]

Transfer PyTorch CUDA tensors to CPU through a dedicated stream.

Added in version 0.8.0.

This function transfers tensors directly to CPU with torch.Tensor.to() on a dedicated CUDA stream. Non-blocking D2H transfers produce page-locked (pinned) CPU tensors.

The transfer process: 1. Traverses the batch recursively. 2. Asynchronously transfers each matching tensor directly to CPU. 3. Synchronizes the transfer stream before returning the rebuilt batch.

The returned CPU tensors remain page-locked. Retaining many transferred batches therefore retains the corresponding amount of pinned host memory.

By default, the function records an event on the calling thread’s current stream and makes the copy stream wait for that event. When dispatching the function to another CPU thread, record the event on the producer thread before dispatch and pass it as producer_event. This captures the exact readiness boundary without waiting for later foreground work. The function waits for the copy stream before returning.

Example

import torch
from spdl.io import transfer_tensor_d2h

batch = {"scores": torch.randn(32, device="cuda:0")}
cpu_batch = transfer_tensor_d2h(batch, device="cuda:0")
Parameters:
  • batch – A torch.Tensor or a composition of tensors with container types such as list, tuple, dict and dataclass.

  • device –

    Optional CUDA device to transfer data from.

    If None the source device is determined by the LOCAL_RANK environment variable. If not set, cuda:0 is used.

  • stream –

    Optional Custom CUDA stream to use for the transfer. If None, a stream is created from the device argument, and cached to a thread-local storage for future reuse.

    When stream is not None, the device argument must be provided. The stream must be on the same device.

  • producer_event – Optional Event marking when the input tensors are ready. If None, an event is recorded on the calling thread’s current stream. Callers that dispatch this function to another CPU thread must record and pass an event from the producer thread.

Returns:

An object of the same type as the input, but the PyTorch CUDA tensors on the specified device are transferred to CPU.

If there is no PyTorch tensor in the input, the input is returned as-is.

If there is no CUDA device available, the input is returned as-is.

Raises:
  • ValueError – If device is not a CUDA device, or a custom stream does not match the requested device.

  • RuntimeError – If the resolved CUDA device index is unavailable.

Classes

class BenchmarkConfig(total_mib: tuple[int, ...], num_tensors: tuple[int, ...], matrix_size: int, compute_target_ms: float, compute_iterations: int | None, num_warmup: int, num_runs: int, bootstrap_resamples: int, seed: int)[source]

Configuration for an overlap benchmark run.

bootstrap_resamples: int
compute_iterations: int | None
compute_target_ms: float
matrix_size: int
num_runs: int
num_tensors: tuple[int, ...]
num_warmup: int
seed: int
total_mib: tuple[int, ...]
class BenchmarkSummary(schema_version: int, created_utc: str, device_name: str, device_capability: str, torch_version: str, cuda_version: str, seed: int, schedule_seed: int, bootstrap_seed: int, bootstrap_resamples: int, num_warmup: int, num_runs: int, total_bytes: int, num_tensors: int, dtype: str, matrix_size: int, compute_target_ms: float, compute_iterations: int, method: str, compute_median_ms: float, compute_p95_ms: float, transfer_median_ms: float, transfer_p95_ms: float, transfer_gb_per_s: float, serialized_median_ms: float, serialized_p95_ms: float, concurrent_median_ms: float, concurrent_p95_ms: float, speedup_median: float, speedup_ci_lower: float, speedup_ci_upper: float, hidden_median_ms: float, estimated_overlap_efficiency_median: float, estimated_overlap_efficiency_ci_lower: float, estimated_overlap_efficiency_ci_upper: float, cpu_launch_skew_p95_ms: float)[source]

One summary row written to the benchmark output CSV.

bootstrap_resamples: int
bootstrap_seed: int
compute_iterations: int
compute_median_ms: float
compute_p95_ms: float
compute_target_ms: float
concurrent_median_ms: float
concurrent_p95_ms: float
cpu_launch_skew_p95_ms: float
created_utc: str
cuda_version: str
device_capability: str
device_name: str
dtype: str
estimated_overlap_efficiency_ci_lower: float
estimated_overlap_efficiency_ci_upper: float
estimated_overlap_efficiency_median: float
hidden_median_ms: float
matrix_size: int
method: str
num_runs: int
num_tensors: int
num_warmup: int
schedule_seed: int
schema_version: int
seed: int
serialized_median_ms: float
serialized_p95_ms: float
speedup_ci_lower: float
speedup_ci_upper: float
speedup_median: float
torch_version: str
total_bytes: int
transfer_gb_per_s: float
transfer_median_ms: float
transfer_p95_ms: float
class Callable
class Future[source]

Represents the result of an asynchronous computation.

add_done_callback(fn)[source]

Attaches a callable that will be called when the future finishes.

Parameters:

fn – A callable that will be called with this future as its only argument when the future completes or is cancelled. The callable will always be called by a thread in the same process in which it was added. If the future has already completed or been cancelled then the callable will be called immediately. These callables are called in the order that they were added.

cancel()[source]

Cancel the future if possible.

Returns True if the future was cancelled, False otherwise. A future cannot be cancelled if it is running or has already completed.

cancelled()[source]

Return True if the future was cancelled.

done()[source]

Return True if the future was cancelled or finished executing.

exception(timeout=None)[source]

Return the exception raised by the call that the future represents.

Parameters:

timeout – The number of seconds to wait for the exception if the future isn’t done. If None, then there is no limit on the wait time.

Returns:

The exception raised by the call that the future represents or None if the call completed without raising.

Raises:
  • CancelledError – If the future was cancelled.

  • TimeoutError – If the future didn’t finish executing before the given timeout.

result(timeout=None)[source]

Return the result of the call that the future represents.

Parameters:

timeout – The number of seconds to wait for the result if the future isn’t done. If None, then there is no limit on the wait time.

Returns:

The result of the call that the future represents.

Raises:
  • CancelledError – If the future was cancelled.

  • TimeoutError – If the future didn’t finish executing before the given timeout.

  • Exception – If the call raised then that exception will be raised.

running()[source]

Return True if the future is currently executing.

set_exception(exception)[source]

Sets the result of the future as being the given exception.

Should only be used by Executor implementations and unit tests.

set_result(result)[source]

Sets the return value of work associated with the future.

Should only be used by Executor implementations and unit tests.

set_running_or_notify_cancel()[source]

Mark the future as running or process any cancel notifications.

Should only be used by Executor implementations and unit tests.

If the future has been cancelled (cancel() was called and returned True) then any threads waiting on the future completing (though calls to as_completed() or wait()) are notified and False is returned.

If the future was not cancelled then it is put in the running state (future calls to running() will return True) and True is returned.

This method should be called by Executor implementations before executing the work associated with this future. If this method returns False then the work should not be executed.

Returns:

False if the Future was cancelled, True otherwise.

Raises:

RuntimeError – if this method was already called or if set_result() or set_exception() was called.

class OverlapSample(trial: int, schedule_order: str, compute_only_ms: float, transfer_only_ms: float, serialized_ms: float, concurrent_ms: float, cpu_launch_skew_ms: float)[source]

Paired measurements from one randomized benchmark block.

compute_only_ms: float
concurrent_ms: float
cpu_launch_skew_ms: float
property estimated_overlap_efficiency: float

Estimate saved time as a fraction of the paired overlap potential.

The four schedules are timed separately, so noise can produce estimates outside the interval from zero to one.

property hidden_ms: float

Return time saved by concurrent execution.

schedule_order: str
serialized_ms: float
property speedup: float

Return serialized-to-concurrent makespan speedup.

transfer_only_ms: float
trial: int
class RawSampleRecord(schema_version: int, created_utc: str, seed: int, schedule_seed: int, total_bytes: int, num_tensors: int, method: str, trial: int, schedule_order: str, compute_only_ms: float, transfer_only_ms: float, serialized_ms: float, concurrent_ms: float, speedup: float, hidden_ms: float, estimated_overlap_efficiency: float, cpu_launch_skew_ms: float)[source]

One per-trial row optionally written for reproducibility.

compute_only_ms: float
concurrent_ms: float
cpu_launch_skew_ms: float
created_utc: str
estimated_overlap_efficiency: float
hidden_ms: float
method: str
num_tensors: int
schedule_order: str
schedule_seed: int
schema_version: int
seed: int
serialized_ms: float
speedup: float
total_bytes: int
transfer_only_ms: float
trial: int
class Sequence

All the operations on a read-only sequence.

Concrete subclasses must override __new__ or __init__, __getitem__, and __len__.

abstract __getitem__(index)
__iter__()
count(value) → integer -- return number of occurrences of value
index(value[, start[, stop]]) → integer -- return first index of value.

Raises ValueError if the value is not present.

Supporting start and stop arguments is optional, but recommended.

class ThreadPoolExecutor(max_workers=None, thread_name_prefix='', initializer=None, initargs=())[source]
shutdown(wait=True, *, cancel_futures=False)[source]

Clean-up the resources associated with the Executor.

It is safe to call this method several times. Otherwise, no other methods can be called after this one.

Parameters:
  • wait – If True then shutdown will not return until all running futures have finished executing and the resources used by the executor have been reclaimed.

  • cancel_futures – If True then shutdown will cancel all pending futures. Futures that are completed or running will not be cancelled.

submit(fn, /, *args, **kwargs)[source]

Submits a callable to be executed with the given arguments.

Schedules the callable to be executed as fn(*args, **kwargs) and returns a Future instance representing the execution of the callable.

Returns:

A Future representing the given call.

class datetime(year, month, day[, hour[, minute[, second[, microsecond[, tzinfo]]]]])

The year, month and day arguments are required. tzinfo may be None, or an instance of a tzinfo subclass. The remaining arguments may be ints.

astimezone()

tz -> convert to local time in new timezone tz

combine()

date, time -> datetime with same date and time fields

ctime()

Return ctime() style string.

date()

Return date object with same year, month and day.

dst()

Return self.tzinfo.dst(self).

fold
fromisoformat()

string -> datetime from a string in most ISO 8601 formats

fromtimestamp()

timestamp[, tz] -> tz’s local time from POSIX timestamp.

hour
isoformat()

[sep] -> string in ISO 8601 format, YYYY-MM-DDT[HH[:MM[:SS[.mmm[uuu]]]]][+HH:MM]. sep is used to separate the year from the time, and defaults to ‘T’. The optional argument timespec specifies the number of additional terms of the time to include. Valid options are ‘auto’, ‘hours’, ‘minutes’, ‘seconds’, ‘milliseconds’ and ‘microseconds’.

max = datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)
microsecond
min = datetime.datetime(1, 1, 1, 0, 0)
minute
now()

Returns new datetime object representing current time local to tz.

tz

Timezone object.

If no tz is specified, uses local timezone.

replace()

Return datetime with new specified fields.

resolution = datetime.timedelta(microseconds=1)
second
strptime()

string, format -> new datetime parsed from a string (like time.strptime()).

time()

Return time object with same time but with tzinfo=None.

timestamp()

Return POSIX timestamp as float.

timetuple()

Return time tuple, compatible with time.localtime().

timetz()

Return time object with same time and tzinfo.

tzinfo
tzname()

Return self.tzinfo.tzname(self).

utcfromtimestamp()

Construct a naive UTC datetime from a POSIX timestamp.

utcnow()

Return a new datetime representing UTC day and time.

utcoffset()

Return self.tzinfo.utcoffset(self).

utctimetuple()

Return UTC time tuple, compatible with time.localtime().

class partial[source]

partial(func, *args, **keywords) - new function with partial application of the given arguments and keywords.

args

tuple of arguments to future partial calls

func

function object to use in future partial calls

keywords

dictionary of keyword arguments to future partial calls

class timezone

Fixed offset from UTC implementation of tzinfo.

dst()

Return None.

fromutc()

datetime in UTC -> datetime in local time.

max = datetime.timezone(datetime.timedelta(seconds=86340))
min = datetime.timezone(datetime.timedelta(days=-1, seconds=60))
tzname()

If name is specified when timezone is created, returns the name. Otherwise returns offset as ‘UTC(+|-)HH:MM’.

utc = datetime.timezone.utc
utcoffset()

Return fixed offset.