-
Notifications
You must be signed in to change notification settings - Fork 70
feat(distributed): add deterministic TP8 all-reduce #310
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (c) 2026 RL-Kernel Contributors | ||
|
|
||
| from rl_engine.distributed.collectives import DeterministicCollective | ||
|
|
||
| __all__ = ["DeterministicCollective"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,234 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (c) 2026 RL-Kernel Contributors | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import socket | ||
| import threading | ||
| from types import TracebackType | ||
| from typing import Any | ||
|
|
||
| import torch | ||
| import torch.distributed as dist | ||
|
|
||
| _SUPPORTED_WORLD_SIZES = (1, 2, 4, 8) | ||
| _DEFAULT_MAX_SIZE_BYTES = 64 * 1024 * 1024 | ||
| _REDUCTION_DTYPES = (torch.float32, torch.float16, torch.bfloat16) | ||
|
|
||
|
|
||
| class DeterministicCollective: | ||
| """Correctness-first TP-invariant CUDA collectives for one eight-GPU node. | ||
|
|
||
| TP sizes 1, 2, 4, and 8 use nested prefixes of the same balanced tree. | ||
| A reduction is cross-TP bitwise invariant when every rank input is the | ||
| corresponding contiguous subtree root of one canonical finest-grained | ||
| reduction, as produced by a TBIK-compatible row-parallel kernel. Every | ||
| node evaluates the lower logical subtree before the higher one. | ||
|
|
||
| One instance owns a symmetric CUDA IPC staging buffer. All ranks must call | ||
| its methods in the same order with matching shapes and dtypes. Calls are | ||
| host-synchronizing by design; the first version prioritizes determinism and | ||
| lifetime safety over overlap or throughput. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| group: dist.ProcessGroup | None = None, | ||
| device: torch.device | str | int | None = None, | ||
| *, | ||
| max_size_bytes: int = _DEFAULT_MAX_SIZE_BYTES, | ||
| ) -> None: | ||
| if not dist.is_available() or not dist.is_initialized(): | ||
| raise RuntimeError("torch.distributed must be initialized before collectives") | ||
| if not torch.cuda.is_available(): | ||
| raise RuntimeError("deterministic collectives require CUDA") | ||
| if max_size_bytes <= 0: | ||
| raise ValueError("max_size_bytes must be positive") | ||
|
|
||
| self.group = group if group is not None else dist.group.WORLD | ||
| self.rank = dist.get_rank(group=self.group) | ||
| self.world_size = dist.get_world_size(group=self.group) | ||
| if self.world_size not in _SUPPORTED_WORLD_SIZES: | ||
| raise ValueError( | ||
| "deterministic collectives require world_size in " | ||
| f"{_SUPPORTED_WORLD_SIZES}, got {self.world_size}" | ||
| ) | ||
|
|
||
| if device is None: | ||
| normalized_device = torch.device("cuda", torch.cuda.current_device()) | ||
| elif isinstance(device, int): | ||
| normalized_device = torch.device("cuda", device) | ||
| else: | ||
| normalized_device = torch.device(device) | ||
| if normalized_device.type != "cuda": | ||
| raise ValueError(f"deterministic collectives require a CUDA device, got {device!r}") | ||
| if normalized_device.index is None: | ||
| normalized_device = torch.device("cuda", torch.cuda.current_device()) | ||
| if normalized_device.index != torch.cuda.current_device(): | ||
| raise ValueError( | ||
| "the collective device must be the current CUDA device; call " | ||
| f"torch.cuda.set_device({normalized_device.index}) first" | ||
| ) | ||
|
|
||
| try: | ||
| from rl_engine import _C | ||
| except ImportError as exc: | ||
| raise RuntimeError( | ||
| "the RL-Kernel CUDA extension is required; rebuild with " | ||
| "`pip install --no-build-isolation -e .`" | ||
| ) from exc | ||
| required_symbols = ( | ||
| "deterministic_collective_ipc_meta", | ||
| "deterministic_collective_create", | ||
| "deterministic_collective_destroy", | ||
| "deterministic_collective_stage", | ||
| "deterministic_collective_all_reduce", | ||
| ) | ||
| missing = [name for name in required_symbols if not hasattr(_C, name)] | ||
| if missing: | ||
| raise RuntimeError( | ||
| "the RL-Kernel CUDA extension lacks deterministic collectives: " | ||
| + ", ".join(missing) | ||
| ) | ||
|
|
||
| self.device = normalized_device | ||
| self.max_size_bytes = int(max_size_bytes) | ||
| self._extension = _C | ||
| self._lock = threading.Lock() | ||
| self._handle = 0 | ||
| self._staging = torch.empty( | ||
| self.max_size_bytes, | ||
| dtype=torch.uint8, | ||
| device=self.device, | ||
| ) | ||
|
|
||
| handle, offset = self._extension.deterministic_collective_ipc_meta(self._staging) | ||
| local_meta = { | ||
| "handle": handle, | ||
| "offset": int(offset), | ||
| "capacity": self.max_size_bytes, | ||
| "hostname": socket.gethostname(), | ||
| } | ||
| gathered_meta: list[dict[str, Any] | None] = [None] * self.world_size | ||
| dist.all_gather_object(gathered_meta, local_meta, group=self.group) | ||
| if any(meta is None for meta in gathered_meta): | ||
| raise RuntimeError("failed to exchange CUDA IPC metadata") | ||
| complete_meta = [meta for meta in gathered_meta if meta is not None] | ||
| hostnames = {meta["hostname"] for meta in complete_meta} | ||
| if len(hostnames) != 1: | ||
| raise ValueError("deterministic collectives require all ranks on one host") | ||
| capacities = {meta["capacity"] for meta in complete_meta} | ||
| if capacities != {self.max_size_bytes}: | ||
| raise ValueError("all ranks must use the same max_size_bytes") | ||
|
|
||
| handles = [meta["handle"] for meta in complete_meta] | ||
| offsets = [meta["offset"] for meta in complete_meta] | ||
| self._handle = self._extension.deterministic_collective_create( | ||
| self._staging, | ||
| handles, | ||
| offsets, | ||
| self.rank, | ||
| ) | ||
| self._synchronize_ranks() | ||
|
|
||
| def all_reduce( | ||
| self, | ||
| input: torch.Tensor, | ||
| *, | ||
| out: torch.Tensor | None = None, | ||
| ) -> torch.Tensor: | ||
| """Return the TBIK-compatible fixed-tree sum on every rank. | ||
|
|
||
| Supported dtypes are float32, float16, and bfloat16. ``out`` may alias | ||
| ``input``; the input is staged before the output kernel starts. Cross-TP | ||
| invariance requires inputs to follow the class-level subtree contract. | ||
| """ | ||
|
|
||
| self._check_open() | ||
| self._validate_reduction_input(input) | ||
| if out is None: | ||
| out = torch.empty_like(input) | ||
| self._validate_output(out, input) | ||
|
|
||
| with self._lock: | ||
| self._validate_matching_signature("all_reduce", input) | ||
| self._extension.deterministic_collective_stage(self._handle, input) | ||
| self._synchronize_ranks() | ||
| self._extension.deterministic_collective_all_reduce(self._handle, out) | ||
| self._synchronize_ranks() | ||
| return out | ||
|
|
||
| def close(self) -> None: | ||
| """Release imported CUDA IPC mappings after the last collective call.""" | ||
|
|
||
| handle = getattr(self, "_handle", 0) | ||
| if not handle: | ||
| return | ||
| torch.cuda.synchronize(self.device) | ||
| self._handle = 0 | ||
| self._extension.deterministic_collective_destroy(handle) | ||
|
Comment on lines
+161
to
+169
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Add an opt-in cross-rank barrier to 🛠️ Proposed fix- def close(self) -> None:
+ def close(self, *, synchronize_ranks: bool = True) -> None:
"""Release imported CUDA IPC mappings after the last collective call."""
handle = getattr(self, "_handle", 0)
if not handle:
return
+ if synchronize_ranks:
+ self._synchronize_ranks()
torch.cuda.synchronize(self.device)
self._handle = 0
self._extension.deterministic_collective_destroy(handle)
def __del__(self) -> None:
try:
- self.close()
+ self.close(synchronize_ranks=False)
except Exception:
pass🤖 Prompt for AI Agents
Comment on lines
+161
to
+169
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (Optional) The existing close() only ensures that the local GPU has finished its work. It may be safer to close the IPC resources only after ensuring that no peer GPU is still reading this GPU's staging buffer. |
||
|
|
||
| def __enter__(self) -> DeterministicCollective: | ||
| self._check_open() | ||
| return self | ||
|
|
||
| def __exit__( | ||
| self, | ||
| exc_type: type[BaseException] | None, | ||
| exc_value: BaseException | None, | ||
| traceback: TracebackType | None, | ||
| ) -> None: | ||
| self.close() | ||
|
|
||
| def __del__(self) -> None: | ||
| try: | ||
| self.close() | ||
| except Exception: | ||
| pass | ||
|
|
||
| def _check_open(self) -> None: | ||
| if not getattr(self, "_handle", 0): | ||
| raise RuntimeError("deterministic collective is closed") | ||
|
|
||
| def _validate_reduction_input(self, input: torch.Tensor) -> None: | ||
| if not input.is_cuda or input.device != self.device: | ||
| raise ValueError(f"input must be on {self.device}, got {input.device}") | ||
| if not input.is_contiguous(): | ||
| raise ValueError("input must be contiguous") | ||
| if input.dtype not in _REDUCTION_DTYPES: | ||
| raise TypeError( | ||
| "deterministic reductions support float32, float16, and bfloat16; " | ||
| f"got {input.dtype}" | ||
| ) | ||
| input_bytes = input.numel() * input.element_size() | ||
| if input_bytes > self.max_size_bytes: | ||
| raise ValueError( | ||
| f"input requires {input_bytes} bytes but max_size_bytes={self.max_size_bytes}" | ||
| ) | ||
|
|
||
| def _validate_output(self, output: torch.Tensor, input: torch.Tensor) -> None: | ||
| if output.device != input.device: | ||
| raise ValueError("out must be on the same device as input") | ||
| if output.dtype != input.dtype: | ||
| raise TypeError("out must have the same dtype as input") | ||
| if output.shape != input.shape: | ||
| raise ValueError("out must have the same shape as input") | ||
| if not output.is_contiguous(): | ||
| raise ValueError("out must be contiguous") | ||
|
|
||
| def _validate_matching_signature(self, op_name: str, input: torch.Tensor) -> None: | ||
| signature = (op_name, tuple(input.shape), str(input.dtype), input.numel()) | ||
| signatures: list[tuple[Any, ...] | None] = [None] * self.world_size | ||
| dist.all_gather_object(signatures, signature, group=self.group) | ||
| if any(peer_signature != signature for peer_signature in signatures): | ||
| raise ValueError( | ||
| f"all ranks must call {op_name} with matching shapes and dtypes; got {signatures}" | ||
| ) | ||
|
|
||
| def _synchronize_ranks(self) -> None: | ||
| torch.cuda.synchronize(self.device) | ||
| backend = dist.get_backend(self.group) | ||
| if backend == dist.Backend.NCCL or str(backend).lower() == "nccl": | ||
| dist.barrier(group=self.group, device_ids=[self.device.index]) | ||
| else: | ||
| dist.barrier(group=self.group) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -83,6 +83,7 @@ def get_extensions(): | |
| "csrc/cuda/rmsnorm.cu", | ||
| "csrc/cuda/activation.cu", | ||
| "csrc/cuda/attention/deterministic_attention.cu", | ||
| "csrc/cuda/distributed/deterministic_collective.cu", | ||
| ] | ||
|
|
||
| cc_major, cc_minor = torch.cuda.get_device_capability() | ||
|
|
@@ -147,6 +148,9 @@ def get_extensions(): | |
|
|
||
| cxx_flags = ["-O3", "-std=c++17", "-DKERNEL_ALIGN_WITH_CUDA"] | ||
| extra_link_args = list(torch_rpath) | ||
| if os.name != "nt": | ||
| # CUDA IPC metadata queries use the driver API (cuPointerGetAttribute). | ||
| extra_link_args.append("-lcuda") | ||
|
Comment on lines
+151
to
+153
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Show the link-argument handling in the build script.
fd -t f 'setup.py' --max-depth 1 --exec sed -n '130,195p'Repository: RL-Align/RL-Kernel Length of output: 3298 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- setup.py structure and imports ---'
sed -n '1,220p' setup.py
printf '%s\n' '--- Windows and CUDA build references ---'
rg -n -C 3 'os\.name|extra_link_args|cuda\.lib|-lcuda|Windows|win32|MSVC|ALLOW_UNSUPPORTED' . -g '!*.lock' -g '!*.json'
printf '%s\n' '--- extension/link argument consumers in tracked files ---'
rg -n -C 3 'CUDAExtension|extra_link_args' . -g '*.py' -g '*.md' -g '*.yml' -g '*.yaml'Repository: RL-Align/RL-Kernel Length of output: 16084 🌐 Web query:
💡 Result: The correct import library for the NVIDIA CUDA Driver API on Windows is cuda.lib [1][2][3]. While the underlying dynamic link library (DLL) installed by the NVIDIA GPU driver is named nvcuda.dll [4][5][6], applications using the Driver API must link against cuda.lib to resolve symbols (which typically have the cu prefix) [1][5][2]. Important distinctions regarding CUDA APIs on Windows: - Driver API: Applications link against cuda.lib to interface with nvcuda.dll [1][3]. - Runtime API: Applications typically link against cudart.lib, which manages the CUDA runtime [1]. In professional development environments using the CUDA Toolkit, cuda.lib is the standard import library provided for the Driver API [2][3]. Developers should not look for an import library named nvcuda.lib [3]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
python3 - <<'PY'
from pathlib import Path
text = Path("setup.py").read_text()
required = [
'if os.name != "nt":',
'extra_link_args.append("-lcuda")',
'if "-lcuda" not in extra_link_args:',
]
for item in required:
print(f"{item!r}: {text.count(item)}")
# Model the exact append conditions shown in setup.py.
for os_name in ("posix", "nt"):
for sm90, det_gemm in ((False, False), (True, False), (False, True), (True, True)):
args = ["torch-rpath"]
if os_name != "nt":
args.append("-lcuda")
if sm90 and "-lcuda" not in args:
args.append("-lcuda")
if det_gemm and "-lcuda" not in args:
args.append("-lcuda")
print(os_name, f"sm90={sm90}", f"det_gemm={det_gemm}", args)
PYRepository: RL-Align/RL-Kernel Length of output: 694 🌐 Web query:
💡 Result: When using nvcc on Windows, you generally do not need to manually call link.exe or specify cuda.lib directly if you allow nvcc to manage the link stage, as it automatically handles the necessary CUDA runtime libraries [1][2]. If you need to link against additional libraries or pass specific options to the host linker (link.exe), you can use the following methods: 1. Standard Library Linking To link against standard CUDA libraries (e.g., cuBLAS, cuFFT), use the -l option followed by the library name (without the.lib extension) and the -L option to specify the library search path [1][2]. Example: nvcc example.cu -L "C:\path\to\cuda\lib" -lcublas 2. Passing Options Directly to link.exe If you need to pass specific arguments to the Microsoft host linker (link.exe) that nvcc does not handle explicitly, use the --linker-options (or -Xlinker) flag [3][4]. Example: nvcc example.cu -Xlinker "/OPT:REF" 3. CUDA Runtime Linking By default, nvcc links against the static CUDA runtime library [1][2]. To link against the shared version, pass the --cudart=shared flag [1][2]. For scenarios requiring more complex control over the linking process, such as handling relocatable device code, use the --device-link (-dlink) option [5][6], which generates an object file containing executable device code that can then be passed to your host linker [6][7]. Citations:
🌐 Web query:
💡 Result: On Windows, PyTorch C++ extensions do not typically require manual inclusion of cuda.lib via extra_link_args in the same way Linux extensions use -lcuda [1]. Instead, the build system automatically handles linking against the necessary CUDA and PyTorch libraries if configured correctly [2][1][3]. If you are experiencing linking errors, follow these guidelines: 1. Use the correct build environment: Always use the x64 Native Tools Command Prompt for your specific Visual Studio version when running setup.py [4]. 2. Linker configuration: If you must manually specify libraries, they are passed through the libraries argument in the CUDAExtension constructor, not extra_link_args [5][6]. For example, ensure you are linking against the correct library names (e.g., cudart, c10, torch, torch_cpu) located in your PyTorch site-packages/torch/lib directory [6]. 3. CUDA paths: Ensure your CUDAHOME environment variable is correctly set to your NVIDIA GPU Computing Toolkit installation directory [5]. The build system uses this to locate the necessary include and library files [5][3]. 4. Troubleshooting missing symbols: If you encounter issues with missing symbols like warp_size, it is often due to how the linker resolves C++ symbols from the torch binaries [7]. Historically, some users have had success by adding /INCLUDE:"?warp_size@cuda@at@@yahxz" to the linker options, though this is a workaround for specific binary compatibility issues [7]. 5. Avoid manual extra_link_args if possible: The preferred approach is to let torch.utils.cpp_extension.BuildExtension handle the discovery and linking of libraries automatically [1]. If the automatic process fails, verify that your CUDA toolkit and PyTorch versions are compatible and that your environment variables are set correctly [5][6]. For complex projects, many developers prefer to use CMake to generate a Visual Studio solution, which provides more granular control over linker settings and library dependencies than the standard setuptools approach [8][9]. Citations:
🏁 Script executed: #!/bin/bash
set -eu
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL \
https://raw.githubusercontent.com/pytorch/pytorch/v2.4.1/torch/utils/cpp_extension.py \
-o "$tmp"
printf '%s\n' '--- extra_link_args handling ---'
rg -n -C 8 'extra_link_args|extra_ldflags|cuda_dlink' "$tmp" | head -240Repository: RL-Align/RL-Kernel Length of output: 10537 🏁 Script executed: #!/bin/bash
set -eu
tmp="$(mktemp)"
trap 'rm -f "$tmp"' EXIT
curl -fsSL \
https://raw.githubusercontent.com/pytorch/pytorch/v2.4.1/torch/utils/cpp_extension.py \
-o "$tmp"
printf '%s\n' '--- extension constructor ---'
rg -n -C 30 '^def CUDAExtension|^class BuildExtension|extra_link_args' "$tmp" | tail -360
printf '%s\n' '--- link command templates ---'
rg -n -C 12 'link.*cuda|cuda.*link|ldflags|linker|build\.so|build\.dll|\.pyd' "$tmp" | tail -360Repository: RL-Align/RL-Kernel Length of output: 22401 Use a platform-specific CUDA driver library. When either SM90 option is enabled on Windows, the guards append 🤖 Prompt for AI Agents |
||
|
|
||
| sm90_srcs = [ | ||
| "csrc/cuda/fused_logp_sm90.cu", | ||
|
|
@@ -162,7 +166,8 @@ def get_extensions(): | |
| cuda_sources.extend(present_sm90) | ||
| nvcc_flags.append(f"-gencode=arch=compute_{tma_arch},code=sm_{tma_arch}") | ||
| cxx_flags.append("-DKERNEL_ALIGN_WITH_SM90") | ||
| extra_link_args.append("-lcuda") | ||
| if "-lcuda" not in extra_link_args: | ||
| extra_link_args.append("-lcuda") | ||
|
|
||
| # det_gemm SM90 (mma.sync + TMA) path: independent of the fused_logp | ||
| # SM90 sources, which currently fail ptxas on CUDA 12.4 (shared::cta in | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
| # Copyright (c) 2026 RL-Kernel Contributors |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could we use a dedicated IPC-safe cudaMalloc allocation for this staging buffer instead of a PyTorch caching-allocator tensor? cudaIpcGetMemHandle is obtained from the allocation base, which may represent a larger allocator segment and can expose or mishandle unrelated memory.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
+1, This Zhihu CUDA IPC tutorial by Kaiyuan also uses
cudaMallocand thencudaIpcGetMemHandle,https://zhuanlan.zhihu.com/p/2019510762004050171, so it looks safe to follow this pattern