cuda: infrastructure for whole-step CUDA graph capture

- Thread-local launch stream (xserv_cuda::stream): every kernel
  wrapper, cublasSetStream, and NCCL collective now launches on
  current_stream_raw() — the legacy null stream by default (behavior
  unchanged), or the capture stream installed via push_stream during
  graph capture. Capture is impossible on the legacy stream.
- Allocator retain mode: blocks freed inside a retain window are
  quarantined (RetainedBlocks) instead of pooled, so an instantiated
  graph keeps exclusive ownership of every intermediate buffer it
  references across replays.
- Capture mode GLOBAL -> THREAD_LOCAL: concurrent TP rank threads
  must not poison each other's captures with their own cudaMallocs.
- embedding_device_ids / rope_inplace_device_pos: variants reading
  token ids / positions from persistent device buffers, replacing the
  per-call host upload that a captured region cannot contain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 20:12:37 +08:00
parent 2a92f268a9
commit 4088f49b7d
20 changed files with 191 additions and 69 deletions

View File

@@ -111,6 +111,22 @@ pub fn cached_trim() {
/// Called from `GpuBuffer::Drop` for pooled buffers. Takes raw pointer
/// and size to avoid re-triggering Drop.
pub fn return_to_pool(ptr: *mut u8, len: usize) {
// During CUDA graph capture, buffers freed by the captured code are
// quarantined instead of pooled: the instantiated graph references their
// addresses on every replay, so they must never be handed to another
// consumer for as long as the graph lives.
let quarantined = RETAINED.with(|cell| {
let mut r = cell.borrow_mut();
if let Some(list) = r.as_mut() {
list.push((ptr, len));
true
} else {
false
}
});
if quarantined {
return;
}
ALLOCATOR.with(|cell| {
let mut alloc = cell.borrow_mut();
let bucket = bucket_size(len);
@@ -119,6 +135,41 @@ pub fn return_to_pool(ptr: *mut u8, len: usize) {
});
}
thread_local! {
static RETAINED: RefCell<Option<Vec<(*mut u8, usize)>>> = const { RefCell::new(None) };
}
/// Buffers freed while a retain window was active. Holding this keeps their
/// memory out of the pool; dropping it returns the blocks (on the owning
/// thread) for reuse.
pub struct RetainedBlocks(Vec<(*mut u8, usize)>);
impl Drop for RetainedBlocks {
fn drop(&mut self) {
for (ptr, len) in self.0.drain(..) {
return_to_pool(ptr, len);
}
}
}
/// Start quarantining buffers freed on this thread (see `return_to_pool`).
/// Must be paired with `end_retain` on the same thread; nesting unsupported.
pub fn begin_retain() {
RETAINED.with(|cell| {
let mut r = cell.borrow_mut();
assert!(r.is_none(), "begin_retain: retain window already active");
*r = Some(Vec::new());
});
}
/// Stop quarantining and hand the quarantined blocks to the caller.
pub fn end_retain() -> RetainedBlocks {
RETAINED.with(|cell| {
let list = cell.borrow_mut().take().expect("end_retain without begin_retain");
RetainedBlocks(list)
})
}
/// Round up to next power-of-2, minimum 512 bytes.
fn bucket_size(size: usize) -> usize {
let min = 512;

View File

@@ -15,6 +15,7 @@ pub const CUDA_ERROR_OUT_OF_MEMORY: i32 = 2;
/// cudaStreamCaptureMode::cudaStreamCaptureModeGlobal
pub const CUDA_STREAM_CAPTURE_MODE_GLOBAL: i32 = 0;
pub const CUDA_STREAM_CAPTURE_MODE_THREAD_LOCAL: i32 = 1;
unsafe extern "C" {
// --- Device ---

View File

@@ -50,10 +50,13 @@ impl CudaGraph {
pub fn begin_capture(&mut self, stream: &CudaStream) -> Result<()> {
// If we have an old graph, destroy it first
self.destroy_inner();
// THREAD_LOCAL: only "potentially unsafe" CUDA calls (cudaMalloc etc.)
// made by THIS thread invalidate the capture. With GLOBAL mode, TP rank
// threads capturing concurrently would poison each other's captures.
error::check(unsafe {
ffi::cudaStreamBeginCapture(
stream.as_raw(),
ffi::CUDA_STREAM_CAPTURE_MODE_GLOBAL,
ffi::CUDA_STREAM_CAPTURE_MODE_THREAD_LOCAL,
)
})
}

View File

@@ -11,4 +11,4 @@ pub use device::DeviceInfo;
pub use error::{CudaError, Result};
pub use graph::CudaGraph;
pub use memory::{GpuBuffer, PinnedBuffer};
pub use stream::CudaStream;
pub use stream::{current_stream_raw, push_stream, CudaStream, StreamGuard};

View File

@@ -31,3 +31,39 @@ impl Drop for CudaStream {
// Can move across threads, but not shared without synchronization
unsafe impl Send for CudaStream {}
// --- Thread-local launch stream -------------------------------------------
//
// Every kernel wrapper in xserv-kernels launches on `current_stream_raw()`,
// which defaults to the legacy null stream (the historical behavior). CUDA
// graph capture requires work to be issued on an explicit stream, so capture
// code installs its stream here for the duration of the captured region via
// `push_stream` / `StreamGuard`.
use std::cell::Cell;
thread_local! {
static CURRENT_STREAM: Cell<ffi::CudaStream> = const { Cell::new(std::ptr::null_mut()) };
}
/// The stream kernel launches on this thread should use (null = legacy default).
pub fn current_stream_raw() -> ffi::CudaStream {
CURRENT_STREAM.with(|c| c.get())
}
/// RAII guard that installs a launch stream for the current thread and
/// restores the previous one on drop.
pub struct StreamGuard {
prev: ffi::CudaStream,
}
pub fn push_stream(stream: &CudaStream) -> StreamGuard {
let prev = CURRENT_STREAM.with(|c| c.replace(stream.as_raw()));
StreamGuard { prev }
}
impl Drop for StreamGuard {
fn drop(&mut self) {
CURRENT_STREAM.with(|c| c.set(self.prev));
}
}