Skip to content

Commit a98212a

Browse files
danlkvclaude
andcommitted
sampler: pinned-host d2h via cuda.bindings when available
Default numpy d2h (np.asarray on a jax.Array) lands in pageable host memory, which forces the CUDA driver to stage the transfer through internal pinned scratch before copying into the user buffer. The second hop is bound by host DRAM bandwidth and caps the effective d2h throughput well below PCIe line rate. Pinning the destination skips the staging hop. Measured throughput at 3.4 GB transfer (rotated surface code d=7, 10M shots): H100 (gen4 x16) B200 (gen5 x16) cp.asnumpy / np.asarray (pageable) 1.9 GB/s 4.1 GB/s cudaMemcpy → cudaHostAlloc (pinned) 23.4 GB/s 51.4 GB/s Translated to per-shot wall on the surface-code-noise sweep: p vanilla pre-pin (+G) post-pin (+G) 1e-6 (10M shots) 0.084µs 0.162µs 0.021µs 1e-4 (10M shots) 0.176µs 0.188µs 0.021µs 1e-2 (10K shots) 4.86µs 0.102µs 0.091µs The pinned path lifts +G from "loses to vanilla below p~1e-5" to "monotonically faster across the whole sweep." Implementation: - New tsim.utils.cuda_helpers module: _PinnedBuf (RAII over cudaHostAlloc), alloc_pinned_numpy (returns a pinned-backed ndarray with lifetime tied to the underlying region via ctypes + ndarray.base), copy_d2h (the public entry, picks pinned fast path or numpy fallback based on import of cuda.bindings). - sampler._sample_batches replaces np.asarray(combined)[:shots] with copy_d2h(combined)[:shots] and adds the matching jax.block_until_ready before the call. cuda.bindings is a soft dep — when import fails, copy_d2h falls back to np.array, preserving the prior behavior. The pyproject.toml is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f535cd1 commit a98212a

2 files changed

Lines changed: 117 additions & 1 deletion

File tree

src/tsim/sampler.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from tsim.core.graph import prepare_graph
1818
from tsim.core.types import CompiledComponent, CompiledProgram
1919
from tsim.noise.channels import ChannelSampler
20+
from tsim.utils.cuda_helpers import copy_d2h
2021

2122
if TYPE_CHECKING:
2223
from jax import Array as PRNGKey
@@ -341,7 +342,8 @@ def _sample_batches(
341342
# output. For big bool tensors (e.g. 500k shots × 528 detector bits)
342343
# the host memcpy alone was ~1 s on top of the PCIe transfer.
343344
combined = batches[0] if len(batches) == 1 else jnp.concatenate(batches, axis=0)
344-
result = np.asarray(combined)[:shots]
345+
jax.block_until_ready(combined)
346+
result = copy_d2h(combined)[:shots]
345347

346348
if compute_reference:
347349
assert reference is not None

src/tsim/utils/cuda_helpers.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""CUDA-runtime helpers used by the sampler hot path.
2+
3+
Lazy-imports ``cuda.bindings``. When the import succeeds, host-pinned
4+
allocations + a direct ``cudaMemcpy`` replace numpy's default pageable d2h:
5+
the pageable path forces the driver to stage the transfer through internal
6+
pinned scratch before copying into the user buffer (host-DRAM-bandwidth
7+
bound), while a pinned destination skips the staging hop and lets d2h reach
8+
PCIe line rate. When the import fails, ``copy_d2h`` transparently falls
9+
back to ``numpy.array``.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import ctypes
15+
16+
import numpy as np
17+
18+
try:
19+
from cuda.bindings import runtime as cudart
20+
_CUDA_BINDINGS_AVAILABLE = True
21+
except Exception:
22+
_CUDA_BINDINGS_AVAILABLE = False
23+
24+
25+
class _PinnedBuf:
26+
"""RAII wrapper for a ``cudaHostAlloc``'d region.
27+
28+
The Python instance owns the lifetime; ``cudaFreeHost`` runs in
29+
``__del__`` when no references remain (typically when the wrapping
30+
numpy view is garbage-collected).
31+
"""
32+
33+
__slots__ = ("ptr", "nbytes")
34+
35+
def __init__(self, nbytes: int):
36+
err, ptr = cudart.cudaHostAlloc(nbytes, cudart.cudaHostAllocDefault)
37+
if err != cudart.cudaError_t.cudaSuccess:
38+
raise RuntimeError(f"cudaHostAlloc({nbytes}) failed: {err}")
39+
self.ptr = int(ptr)
40+
self.nbytes = nbytes
41+
42+
def __del__(self):
43+
if self.ptr:
44+
try:
45+
cudart.cudaFreeHost(self.ptr)
46+
except Exception:
47+
# cudart may be torn down at interpreter exit.
48+
pass
49+
self.ptr = 0
50+
51+
52+
def alloc_pinned_numpy(nbytes: int, dtype, shape) -> np.ndarray:
53+
"""Allocate a pinned host region and return it as an ndarray view.
54+
55+
The returned array's ``base`` chain pins the underlying ``_PinnedBuf``
56+
alive until the array and all derived views are dropped; only then does
57+
``cudaFreeHost`` run.
58+
59+
Args:
60+
nbytes: Size of the underlying allocation in bytes. Must be at least
61+
``prod(shape) * dtype.itemsize``.
62+
dtype: numpy-compatible dtype for the returned view.
63+
shape: Shape of the returned view.
64+
65+
Returns:
66+
ndarray of the requested shape and dtype, backed by pinned memory.
67+
68+
Raises:
69+
RuntimeError: if cuda.bindings is unavailable, or the underlying
70+
``cudaHostAlloc`` fails.
71+
"""
72+
if not _CUDA_BINDINGS_AVAILABLE:
73+
raise RuntimeError(
74+
"cuda.bindings not importable; install 'cuda-bindings' or use "
75+
"copy_d2h() for a transparent fallback."
76+
)
77+
buf = _PinnedBuf(nbytes)
78+
carr = (ctypes.c_uint8 * nbytes).from_address(buf.ptr)
79+
carr._owner = buf # arr.base = carr; carr._owner = buf → buf stays alive
80+
return np.frombuffer(carr, dtype=np.uint8).view(dtype).reshape(shape)
81+
82+
83+
def copy_d2h(src, *, dst: np.ndarray | None = None) -> np.ndarray:
84+
"""Device-to-host copy, pinned-destination fast path when available.
85+
86+
Args:
87+
src: Single-device contiguous array-like exposing
88+
``unsafe_buffer_pointer()``, ``nbytes``, ``shape``, and ``dtype``.
89+
The caller must sync to the source's stream before invocation
90+
(``jax.block_until_ready(src)`` for a jax.Array).
91+
dst: Optional pre-allocated pinned ndarray to write into. Must have
92+
at least ``src.nbytes`` bytes.
93+
94+
Returns:
95+
ndarray with the same shape and dtype as ``src``.
96+
"""
97+
if not _CUDA_BINDINGS_AVAILABLE:
98+
# np.asarray(jax.Array) is a read-only zero-copy view; callers
99+
# mutate the return (e.g. XOR detectors with the reference sample)
100+
# so allocate fresh and copy.
101+
out = np.empty(src.shape, dtype=src.dtype)
102+
out[:] = src
103+
return out
104+
if dst is None:
105+
dst = alloc_pinned_numpy(src.nbytes, src.dtype, src.shape)
106+
err = cudart.cudaMemcpy(
107+
dst.ctypes.data,
108+
src.unsafe_buffer_pointer(),
109+
src.nbytes,
110+
cudart.cudaMemcpyKind.cudaMemcpyDeviceToHost,
111+
)[0]
112+
if err != cudart.cudaError_t.cudaSuccess:
113+
raise RuntimeError(f"cudaMemcpy d2h failed: {err}")
114+
return dst

0 commit comments

Comments
 (0)