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.
- 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.Tensoror a composition of tensors with container types such aslist,tuple,dictanddataclass.device –
Optional CUDA device to transfer data from.
If
Nonethe source device is determined by theLOCAL_RANKenvironment variable. If not set,cuda:0is used.stream –
Optional Custom CUDA stream to use for the transfer. If
None, a stream is created from thedeviceargument, and cached to a thread-local storage for future reuse.When stream is not
None, thedeviceargument 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
deviceis 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.
- 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.
- 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.
- 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.
- 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.
- 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.
Return time saved by concurrent execution.
- 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.
- 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.
- 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.