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