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 simple reference path, not a staging- and allocation-matched replacement for SPDL.

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 1g.10gb with CUDA 13.3. After five warmup blocks, 30 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.262 ms  9.800 ms   17.012 ms    11.852 ms  1.471x [1.266, 1.670]
     32  7.310 ms 11.443 ms   17.757 ms    11.626 ms  1.625x [1.428, 1.693]

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

Transfer PyTorch CUDA tensors to CPU through a dedicated stream.

Added in version 0.7.0.

This function performs efficient GPU to CPU data transfer using page-locked (pinned) memory and a dedicated CUDA stream. The page-locked memory is cached and reused across calls.

The transfer process: 1. Gathers all tensors from the batch. 2. Allocates (or reuses cached) page-locked memory. 3. Asynchronously transfers data from GPU to page-locked memory. 4. Copies data from page-locked memory to new CPU tensors. 5. Rebuilds the batch structure with CPU tensors.

The copy stream waits for work already submitted to the caller’s current stream, and this function waits for the copy stream before returning. If a tensor was produced on another non-current stream, the caller must first establish an ordering dependency with the current stream. When called from a background CPU thread, the transfer can overlap with later GPU work submitted independently by a foreground thread. It is intended for offloading nested results before CPU post-processing or serialization.

If any transferred tensor requires gradients, the function uses a synchronous per-tensor transfer on the caller’s current stream to preserve autograd.

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.

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.