Refactor: decouple segment tracking from comm registration (#21392)

Signed-off-by: wangfakang <fakangwang@gmail.com>
This commit is contained in:
sky
2026-05-06 17:07:58 +08:00
committed by GitHub
parent a858fda708
commit c8bc23522f
2 changed files with 348 additions and 23 deletions
@@ -0,0 +1,210 @@
"""
Benchmark for comparing CPU overhead of segment tracking methods:
1. nccl_allocator_register_segments_with_comm() - C++ registration with index tracking
2. torch.cuda.memory.memory_snapshot() - PyTorch memory snapshot
Usage:
python benchmark/bench_pynccl_allocator/bench_segment_tracking.py --num-segments 50 --num-iters 1000
"""
import argparse
import time
import warnings
from typing import List
import torch
warnings.filterwarnings("ignore")
def setup_segments(num_segments: int, segment_size: int = 1024 * 1024):
"""
Allocate a specified number of segments using the NCCL allocator.
"""
import os
import torch.distributed as dist
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
get_nccl_mem_pool,
)
# Initialize distributed if not already done
if not dist.is_initialized():
os.environ.setdefault("MASTER_ADDR", "localhost")
os.environ.setdefault("MASTER_PORT", "29500")
dist.init_process_group(
backend="nccl",
rank=0,
world_size=1,
device_id=torch.device(f"cuda:{torch.cuda.current_device()}"),
)
mem_pool = get_nccl_mem_pool()
# Allocate segments in the pool
tensors: List[torch.Tensor] = []
with torch.cuda.use_mem_pool(mem_pool):
for _ in range(num_segments):
t = torch.empty(segment_size, dtype=torch.uint8, device="cuda")
tensors.append(t)
# Keep tensors alive by returning them (caller should hold reference)
return tensors, mem_pool
def bench_register_segments_with_comm(
nccl_lib, comm_ptr: int, num_iters: int = 10000
) -> float:
"""
Benchmark nccl_allocator_register_segments_with_comm() function.
Args:
nccl_lib: The loaded NCCL allocator library
comm_ptr: The communicator pointer value
num_iters: Number of iterations
Returns:
Average time per call in microseconds.
"""
import ctypes
# Setup the C function signature
register_func = nccl_lib.nccl_allocator_register_segments_with_comm
register_func.restype = ctypes.c_int
register_func.argtypes = [ctypes.c_uint64]
# Warmup
for _ in range(100):
register_func(comm_ptr)
# Benchmark
start = time.perf_counter()
for _ in range(num_iters):
register_func(comm_ptr)
end = time.perf_counter()
avg_us = (end - start) / num_iters * 1e6
return avg_us
def bench_mempool_snapshot(
mem_pool: torch.cuda.MemPool, num_iters: int = 10000
) -> float:
"""
Benchmark torch.cuda.MemPool.snapshot() function.
Returns:
Average time per call in microseconds.
"""
# Warmup
for _ in range(100):
mem_pool.snapshot()
# Benchmark
start = time.perf_counter()
for _ in range(num_iters):
mem_pool.snapshot()
end = time.perf_counter()
avg_us = (end - start) / num_iters * 1e6
return avg_us
def bench_with_various_segment_counts(
segment_counts: List[int],
num_iters: int = 10000,
segment_size: int = 1024 * 1024, # 1MB per segment
):
"""
Run benchmarks with various numbers of tracked segments.
"""
print("=" * 80)
print("Benchmark: Segment Registration CPU Overhead")
print("=" * 80)
print(f"Segment size: {segment_size / 1024 / 1024:.2f} MB")
print(f"Iterations per measurement: {num_iters}")
print()
print(
f"{'Segments':<12} {'register_segments (µs)':<30} {'snapshot (µs)':<20} {'Speedup':<10}"
)
print("-" * 80)
all_tensors = [] # Keep all tensors alive
comm_ptr = 0 # Use dummy comm_ptr for benchmarking (no actual NCCL registration)
for num_segments in segment_counts:
# Clean up previous segments
all_tensors = []
# Allocate segments (this initializes _nccl_allocator_lib via get_nccl_mem_pool)
tensors, mem_pool = setup_segments(num_segments, segment_size)
all_tensors.extend(tensors)
# Sync to ensure allocations are complete
torch.cuda.synchronize()
# Import _nccl_allocator_lib after setup_segments (ensures library is loaded)
from sglang.srt.distributed.device_communicators.pynccl_allocator import (
_nccl_allocator_lib,
)
# Run benchmarks
time_register = bench_register_segments_with_comm(
_nccl_allocator_lib, comm_ptr, num_iters
)
time_snapshot = bench_mempool_snapshot(mem_pool, num_iters)
speedup = time_snapshot / time_register if time_register > 0 else float("inf")
print(
f"{num_segments:<12} {time_register:<30.3f} {time_snapshot:<20.3f} {speedup:<10.2f}x"
)
print("-" * 80)
print()
def main():
parser = argparse.ArgumentParser(
description="Benchmark segment tracking methods in pynccl_allocator"
)
parser.add_argument(
"--num-segments",
type=int,
nargs="+",
default=[10, 50, 100, 200, 500, 1000],
help="Number of segments to track (can specify multiple values)",
)
parser.add_argument(
"--num-iters",
type=int,
default=10000,
help="Number of iterations for each measurement",
)
parser.add_argument(
"--segment-size",
type=int,
default=1024 * 1024, # 1MB
help="Size of each segment in bytes",
)
args = parser.parse_args()
# Check CUDA availability
if not torch.cuda.is_available():
print("Error: CUDA is not available. This benchmark requires a GPU.")
return
# Initialize CUDA context by creating a small tensor
_ = torch.zeros(1, device="cuda")
# Run benchmarks
bench_with_various_segment_counts(
segment_counts=args.num_segments,
num_iters=args.num_iters,
segment_size=args.segment_size,
)
if __name__ == "__main__":
main()
@@ -1,3 +1,4 @@
import ctypes
import logging import logging
import os import os
import tempfile import tempfile
@@ -19,9 +20,23 @@ from sglang.srt.utils.common import torch_release
after_2_8_0 = torch_release >= (2, 8) after_2_8_0 = torch_release >= (2, 8)
# C++ source for the NCCL allocator plugin
# Key design:
# 1. nccl_alloc_plug: Allocates memory via ncclMemAlloc and TRACKS the segment
# (ptr, size). Does NOT register with any comm at allocation time.
# 2. nccl_free_plug: Frees memory via ncclMemFree and UNTRACKS the segment.
# Each segment is tracked only during its lifetime (from alloc to free).
# 3. Segment tracking uses thread-safe std::vector + unordered_map for O(1) operations.
# 4. Registration via nccl_allocator_register_segments_with_comm: Registers all
# tracked segments with a given comm, using index-based tracking to avoid
# re-registration. Registration state is maintained per-communicator in C++.
nccl_allocator_source = """ nccl_allocator_source = """
#include <cuda_runtime.h> #include <cuda_runtime.h>
#include <mutex>
#include <vector>
#include <unordered_map>
#include <utility>
extern "C" { extern "C" {
@@ -35,13 +50,16 @@ typedef enum { ncclSuccess = 0,
ncclRemoteError = 6, ncclRemoteError = 6,
ncclInProgress = 7, ncclInProgress = 7,
ncclNumResults = 8 } ncclResult_t; ncclNumResults = 8 } ncclResult_t;
// NCCL symmetric memory window flags
#define NCCL_WIN_COLL_SYMMETRIC 0x01
typedef struct ncclComm* ncclComm_t; typedef struct ncclComm* ncclComm_t;
typedef struct ncclWindow_vidmem* ncclWindow_t; typedef struct ncclWindow_vidmem* ncclWindow_t;
ncclResult_t ncclCommWindowRegister(ncclComm_t comm, void* buff, size_t size, ncclWindow_t* win, int winFlags);
#define NCCL_WIN_COLL_SYMMETRIC 0x01
ncclResult_t ncclMemAlloc(void** ptr, size_t size); ncclResult_t ncclMemAlloc(void** ptr, size_t size);
ncclResult_t ncclMemFree(void *ptr); ncclResult_t ncclMemFree(void *ptr);
ncclResult_t ncclCommWindowRegister(ncclComm_t comm, void* buff, size_t size, ncclWindow_t* win, int winFlags);
const char* ncclGetErrorString(ncclResult_t result); const char* ncclGetErrorString(ncclResult_t result);
#define NCCLCHECK(cmd) do { \ #define NCCLCHECK(cmd) do { \
@@ -53,23 +71,77 @@ const char* ncclGetErrorString(ncclResult_t result);
} \ } \
} while(0) } while(0)
// Segment information structure
struct Segment {
void* ptr;
size_t size;
Segment(void* p, size_t s) : ptr(p), size(s) {}
};
// Thread-safe segment tracking
// Segment tracking using std::vector for FIFO order.
// g_segments is maintained in insertion order (oldest first).
static std::vector<Segment> g_segments;
static std::mutex g_segment_mutex;
// Track which segments have been registered with each communicator.
// Key: comm_ptr, Value: the next segment index to register for this comm.
static std::unordered_map<uintptr_t, size_t> g_comm_registration_index;
// Add a segment to the tracking (appends to end, maintaining FIFO order)
static void track_segment(void* ptr, size_t size) {
std::lock_guard<std::mutex> lock(g_segment_mutex);
g_segments.emplace_back(ptr, size);
}
void* nccl_alloc_plug(size_t size, int device, void* stream) { void* nccl_alloc_plug(size_t size, int device, void* stream) {
void* ptr; void* ptr;
NCCLCHECK(ncclMemAlloc(&ptr, size)); NCCLCHECK(ncclMemAlloc(&ptr, size));
const char *str_val = getenv("SGLANG_TMP_NCCL_COMM_VALUE"); // Track the segment but do NOT register with any comm
char *endptr; // Registration will be done at context exit via register_segments_with_comm
void* int_val = (void *)strtoull(str_val, &endptr, 0); track_segment(ptr, size);
ncclComm_t comm = (ncclComm_t)(int_val); return ptr;
ncclWindow_t win;
NCCLCHECK(ncclCommWindowRegister(comm, ptr, size, &win, NCCL_WIN_COLL_SYMMETRIC));
return ptr;
} }
void nccl_free_plug(void* ptr, size_t size, int device, void* stream) { void nccl_free_plug(void* ptr, size_t size, int device, void* stream) {
ncclResult_t err = ncclMemFree(ptr); ncclResult_t err = ncclMemFree(ptr);
// NOTE: We assume that no individual allocation will be freed until the
// entire memory pool is destroyed. If this assumption does not hold,
// we will encounter asymmetry issues between GPUs. For now, we clear
// all tracking state when the pool is destroyed.
std::lock_guard<std::mutex> lock(g_segment_mutex);
g_segments = std::vector<Segment>();
g_comm_registration_index = std::unordered_map<uintptr_t, size_t>();
}
// Register all tracked segments with a communicator.
// Uses an index-based approach to avoid re-registering already-registered segments.
// Returns 0 on success, non-zero on failure.
int nccl_allocator_register_segments_with_comm(uintptr_t comm_ptr) {
std::lock_guard<std::mutex> lock(g_segment_mutex);
ncclComm_t comm = reinterpret_cast<ncclComm_t>(comm_ptr);
// Get the starting index for this communicator
size_t start_index = g_comm_registration_index[comm_ptr];
// Register all segments from start_index to the current end
for (size_t i = start_index; i < g_segments.size(); ++i) {
const Segment& seg = g_segments[i];
ncclWindow_t win;
ncclResult_t res = ncclCommWindowRegister(comm, seg.ptr, seg.size, &win, NCCL_WIN_COLL_SYMMETRIC);
if (res != ncclSuccess) {
fprintf(stderr, "ERROR: NCCL symmetric memory registration failed. '%s'\\n", ncclGetErrorString(res));
return res;
}
}
// Update the registration index for this communicator
g_comm_registration_index[comm_ptr] = g_segments.size();
return ncclSuccess;
} }
} }
@@ -81,6 +153,9 @@ _graph_pool_id = None
_cur_device = None _cur_device = None
_active_symmetric_memory_context = None _active_symmetric_memory_context = None
# Reference to the C registration function (with arg types set)
_register_func = None
def is_symmetric_memory_enabled(): def is_symmetric_memory_enabled():
try: try:
@@ -107,9 +182,15 @@ def restore_symmetric_memory_context(saved_context):
saved_context.__enter__() saved_context.__enter__()
def get_nccl_mem_pool(): def get_nccl_mem_pool() -> torch.cuda.MemPool:
global _allocator, _mem_pool, _cur_device """
if _mem_pool is None: Get the shared MemPool for all groups.
All groups share the same pool to avoid memory fragmentation.
Comm registration is handled at context exit time.
"""
global _allocator, _mem_pool, _cur_device, _register_func
if _allocator is None:
import torch.utils.cpp_extension import torch.utils.cpp_extension
out_dir = os.path.join(tempfile.gettempdir(), "symm_allocator") out_dir = os.path.join(tempfile.gettempdir(), "symm_allocator")
@@ -124,7 +205,7 @@ def get_nccl_mem_pool():
torch.distributed.barrier() torch.distributed.barrier()
nccl_allocator_libname = "nccl_allocator" nccl_allocator_libname = "nccl_allocator"
torch.utils.cpp_extension.load_inline( lib_path = torch.utils.cpp_extension.load_inline(
name=nccl_allocator_libname, name=nccl_allocator_libname,
cpp_sources=nccl_allocator_source, cpp_sources=nccl_allocator_source,
with_cuda=True, with_cuda=True,
@@ -133,6 +214,7 @@ def get_nccl_mem_pool():
is_python_module=False, is_python_module=False,
build_directory=out_dir, build_directory=out_dir,
) )
nccl_allocator_lib = ctypes.CDLL(lib_path)
_allocator = CUDAPluggableAllocator( _allocator = CUDAPluggableAllocator(
f"{out_dir}/{nccl_allocator_libname}.so", f"{out_dir}/{nccl_allocator_libname}.so",
"nccl_alloc_plug", "nccl_alloc_plug",
@@ -140,6 +222,12 @@ def get_nccl_mem_pool():
).allocator() ).allocator()
_mem_pool = torch.cuda.MemPool(_allocator) _mem_pool = torch.cuda.MemPool(_allocator)
_cur_device = torch.cuda.current_device() _cur_device = torch.cuda.current_device()
# Setup the C function for registration with correct arg types
_register_func = nccl_allocator_lib.nccl_allocator_register_segments_with_comm
_register_func.restype = ctypes.c_int
_register_func.argtypes = [ctypes.c_uint64]
return _mem_pool return _mem_pool
@@ -151,6 +239,14 @@ class SymmetricMemoryContext:
by `ncclMemAlloc` and registered by `ncclCommWindowRegister`. Due to this, we introduce by `ncclMemAlloc` and registered by `ncclCommWindowRegister`. Due to this, we introduce
this context manager. All tensors created under this context will be correctly this context manager. All tensors created under this context will be correctly
allocated and registered with a custom allocator. allocated and registered with a custom allocator.
Key design:
- All groups share a single MemPool to avoid memory fragmentation.
- At allocation time, ptrs are tracked but NOT registered with any comm.
- At context exit time, nccl_allocator_register_segments_with_comm is called
to register all tracked segments with the current comm. The C++ layer
tracks per-comm registration state using index-based tracking to avoid
re-registration of already-registered segments.
""" """
def __init__( def __init__(
@@ -162,10 +258,14 @@ class SymmetricMemoryContext:
self._device_index = torch.cuda.current_device() self._device_index = torch.cuda.current_device()
self.is_graph_capture = torch.cuda.is_current_stream_capturing() self.is_graph_capture = torch.cuda.is_current_stream_capturing()
# Get comm ptr for tracking registrations
# Use the comm pointer value as unique identifier
self._comm_ptr = self.group_coordinator.pynccl_comm.comm.value
def __enter__(self): def __enter__(self):
assert ( assert (
self.group_coordinator.pynccl_comm is not None self.group_coordinator.pynccl_comm is not None
), f"Symmetric memory requires pynccl to be enabled in group '{self.group_coordinator.group_name}'" ), f"Symmetric memory requires pynccl to be enabled in group '{self.group_coordinator.unique_name}'"
if self.is_graph_capture: if self.is_graph_capture:
assert ( assert (
@@ -181,11 +281,6 @@ class SymmetricMemoryContext:
_cuda_beginAllocateCurrentThreadToPool(self._device_index, self._pool_id) _cuda_beginAllocateCurrentThreadToPool(self._device_index, self._pool_id)
# Set the env var to pass this argument to the C functions.
os.environ["SGLANG_TMP_NCCL_COMM_VALUE"] = str(
self.group_coordinator.pynccl_comm.comm.value
)
global _active_symmetric_memory_context global _active_symmetric_memory_context
_active_symmetric_memory_context = self _active_symmetric_memory_context = self
@@ -194,6 +289,9 @@ class SymmetricMemoryContext:
def __exit__(self, exc_type, exc_val, exc_tb): def __exit__(self, exc_type, exc_val, exc_tb):
_cuda_endAllocateToPool(self._device_index, self._pool_id) _cuda_endAllocateToPool(self._device_index, self._pool_id)
_cuda_releasePool(self._device_index, self._pool_id) _cuda_releasePool(self._device_index, self._pool_id)
# Register all unregistered segments
# with the current comm
self._register_segments_for_comm()
if self.is_graph_capture: if self.is_graph_capture:
if after_2_8_0: if after_2_8_0:
@@ -206,6 +304,23 @@ class SymmetricMemoryContext:
global _active_symmetric_memory_context global _active_symmetric_memory_context
_active_symmetric_memory_context = None _active_symmetric_memory_context = None
def _register_segments_for_comm(self):
"""
Register all tracked segments with the current comm.
Delegates to C++ layer which handles:
1. Tracking which segments have been registered with each comm
2. Only registering new segments (avoiding re-registration)
3. Thread-safe access to the segment registry
"""
# Call C++ API to register all segments with this comm
# C++ layer tracks per-comm registration state internally
result = _register_func(self._comm_ptr)
assert (
result == 0
), f"nccl_allocator_register_segments_with_comm failed with return code: {result}"
def use_symmetric_memory(group_coordinator: GroupCoordinator, disabled: bool = False): def use_symmetric_memory(group_coordinator: GroupCoordinator, disabled: bool = False):
disabled = ( disabled = (