Skip to content

Commit 3f7114a

Browse files
authored
fix(group): ⚡️ run each rank on a non-blocking stream (#75)
The ranks of a DeviceGroup ran on CuPy's legacy null stream, where a peer copy into one GPU serializes with the copy in the other direction. Every allgather over PCIe got half the bandwidth: 19.5/11.0/6.6 GB/s per receiver at 2/4/8 GPUs, against 34.8/15.3/11.3 on non-blocking streams. Each rank now runs on a non-blocking stream of its device, one per device and reused across calls so that CuPy's pool keeps reusing its cached blocks. The stream waits for the caller's current stream on entry and is synchronized on exit. On 8x RTX PRO 6000 (PCIe, complex64, D_M = 4000, chi = 2000) the 8-GPU sweep drops from 169.3 s to 145.5 s, 63% to 73% parallel efficiency, with unchanged outputs. Assisted-by: Claude Code:claude-opus-5-5
1 parent dc6ecf6 commit 3f7114a

6 files changed

Lines changed: 85 additions & 3 deletions

File tree

‎docs/content/docs/features/large-problems.mdx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ out = src(
9797
the first one gathers the sketches and runs the QR. Everything runs in the calling
9898
process, one thread per GPU, so nothing changes for the caller: the call returns
9999
the output cores as with one GPU, errors are raised as usual, and the inputs are
100-
read once and shared.
100+
read once and shared. Each thread runs on a non-blocking CUDA stream of its own GPU,
101+
not on CuPy's legacy default stream, where a copy between two GPUs waits for the
102+
copy in the other direction and the exchanges between GPUs would get half the
103+
bandwidth. The stream first waits for the work already queued on the caller's
104+
current stream, so inputs given as CuPy arrays may still be in flight.
101105

102106
The sketch index is split: each GPU owns a contiguous block of the `chi_out`
103107
sketch columns and computes only those columns of every environment and of every

‎src/src_method/_group.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
enable_peer_access,
2929
get_xp,
3030
use_device,
31+
use_stream,
3132
)
3233

3334
if TYPE_CHECKING:
@@ -125,14 +126,14 @@ def run(self, fn: Callable[[int], T]) -> list[T]:
125126
Exception: The first error raised by a rank, after every rank stopped.
126127
"""
127128
if self.size == 1:
128-
with use_device(self.xp, self.devices[0]):
129+
with use_device(self.xp, self.devices[0]), use_stream(self.xp):
129130
return [fn(0)]
130131
first: list[BaseException] = []
131132
lock = threading.Lock()
132133

133134
def worker(rank: int) -> T:
134135
try:
135-
with use_device(self.xp, self.devices[rank]):
136+
with use_device(self.xp, self.devices[rank]), use_stream(self.xp):
136137
return fn(rank)
137138
except BaseException as err:
138139
secondary = isinstance(

‎src/src_method/utils/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
to_host_async,
2828
to_numpy,
2929
use_device,
30+
use_stream,
3031
)
3132
from .linalg import truncated_qr
3233

@@ -56,4 +57,5 @@
5657
"to_numpy",
5758
"truncated_qr",
5859
"use_device",
60+
"use_stream",
5961
]

‎src/src_method/utils/_backend.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,42 @@ def use_device(xp: ModuleType, device: int) -> Iterator[None]:
213213
yield
214214

215215

216+
# One per device, reused across calls: CuPy's pool hands a freed block only to the
217+
# stream that allocated it, so a new stream per call would strand the blocks the
218+
# previous one cached.
219+
_streams: dict[int, Any] = {}
220+
221+
222+
@contextmanager
223+
def use_stream(xp: ModuleType) -> Iterator[None]:
224+
"""Run the block on a non-blocking stream of the current device.
225+
226+
On the legacy null stream, a peer copy into one device serializes with the copy
227+
in the other direction, which halves the bandwidth of every collective over
228+
PCIe. The stream first waits for the work already queued on the previous
229+
current stream, so inputs written there are ready, and is synchronized on a
230+
normal exit, so whatever the block returns is ready on any stream. On the host
231+
this is a no-op.
232+
233+
Args:
234+
xp: The array namespace.
235+
236+
Yields:
237+
Nothing.
238+
"""
239+
if is_host(xp):
240+
yield
241+
return
242+
device = xp.cuda.Device().id
243+
stream = _streams.get(device) or _streams.setdefault(
244+
device, xp.cuda.Stream(non_blocking=True)
245+
)
246+
stream.wait_event(xp.cuda.get_current_stream().record())
247+
with stream:
248+
yield
249+
stream.synchronize()
250+
251+
216252
# cudaErrorPeerAccessAlreadyEnabled: another library, or an earlier call, did it.
217253
_PEER_ALREADY_ENABLED = 704
218254

‎tests/test_backend.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
to_device_async,
2222
to_host_async,
2323
use_device,
24+
use_stream,
2425
)
2526

2627

@@ -90,6 +91,11 @@ def test_host_devices_are_simulated():
9091
assert current_device(np) == 0
9192

9293

94+
def test_host_use_stream_is_a_no_op():
95+
with use_stream(np):
96+
assert isinstance(current_stream(np), NullStream)
97+
98+
9399
def test_copy_into_on_host():
94100
dst = np.empty((2, 3), dtype=complex)
95101

‎tests/test_gpu_backend.py‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import src_method._sweep as sweep_module
1515
from src_method import Resources, apply, compress, src
16+
from src_method._group import DeviceGroup
1617
from src_method._kernels import SiteKernels
1718
from src_method._plan import (
1819
GPU_MARGIN_FRACTION,
@@ -475,3 +476,35 @@ def test_too_many_devices_raise() -> None:
475476

476477
with pytest.raises(ValueError, match="visible"):
477478
src(*stack, chi_out=4, device="gpu", resources=Resources(devices=count + 1))
479+
480+
481+
def test_group_runs_on_a_non_blocking_stream() -> None:
482+
"""Ranks leave the null stream, keep one stream per device, and see prior work."""
483+
group = DeviceGroup("gpu", [0])
484+
data = cupy.zeros(1 << 24, dtype=cupy.float32)
485+
data += 1 # queued on the null stream before the run
486+
487+
def fn(_rank: int) -> tuple[int, float]:
488+
stream = cupy.cuda.get_current_stream()
489+
assert stream != cupy.cuda.Stream.null
490+
return stream.ptr, float((data * 2).sum())
491+
492+
((first, total),) = group.run(fn)
493+
((second, _),) = group.run(fn)
494+
495+
assert total == 2 * data.size
496+
assert first == second
497+
assert cupy.cuda.get_current_stream() == cupy.cuda.Stream.null
498+
499+
500+
@pytest.mark.usefixtures("two_gpus")
501+
def test_two_gpus_run_on_their_own_streams() -> None:
502+
"""Each rank of a group runs on a non-blocking stream of its own device."""
503+
group = DeviceGroup("gpu", 2)
504+
505+
def fn(rank: int) -> tuple[int, int]:
506+
stream = cupy.cuda.get_current_stream()
507+
assert stream != cupy.cuda.Stream.null
508+
return rank, stream.device_id
509+
510+
assert group.run(fn) == [(0, 0), (1, 1)]

0 commit comments

Comments
 (0)