Files
foxhunt/crates/ml-ensemble/src/cuda_streams.rs
jgrusewski 53382ad692 perf(cuda): unify dual-stream to single stream + event-based sync_all
gpu_backtest_evaluator: removed env_stream, env_event, main_event.
Sequential single-stream loop eliminates 4 event sync calls per step.
H100 forward pass occupies 100+ SMs — env_step overlap was not profitable.

cuda_streams: sync_all() reduced from N blocking cuStreamSynchronize
calls to 1 via cuEventRecord fan-in on stream[0]. 118 tests pass.

Estimated 15-20% walltime reduction in backtest evaluation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 07:50:00 +01:00

257 lines
8.7 KiB
Rust

//! CUDA stream pool for multi-stream ensemble inference.
//!
//! Provides parallel execution of multiple models on different CUDA streams.
//! Falls back to no-op on CPU devices.
//!
//! On CUDA devices, each stream can execute kernels concurrently with other streams,
//! enabling multiple model forward passes to overlap. This is the foundation for
//! `StreamAwareEnsemble` (Task 14) which assigns each model to a separate stream.
use ml_core::device::MlDevice;
use crate::MLError;
use std::sync::Arc;
#[cfg(feature = "cuda")]
type CudaStream = cudarc::driver::CudaStream;
#[cfg(feature = "cuda")]
type CudaEvent = cudarc::driver::CudaEvent;
/// Pool of CUDA streams for parallel model inference.
///
/// On CUDA devices, creates multiple streams via `CudaContext::new_stream()`.
/// On CPU, acts as a no-op (all methods succeed without doing anything).
///
/// # Usage
///
/// ```ignore
/// let pool = CudaStreamPool::new(&device, 4)?;
/// // Each model gets assigned stream index = model_index % pool.count()
/// // Sync all streams after inference:
/// pool.sync_all()?;
/// ```
pub struct CudaStreamPool {
/// Number of streams (0 for CPU)
count: usize,
/// Whether this pool is on a CUDA device
is_cuda: bool,
/// The device this pool is associated with
device: MlDevice,
/// Forked CUDA streams (empty on CPU)
#[cfg(feature = "cuda")]
streams: Vec<Arc<CudaStream>>,
#[cfg(not(feature = "cuda"))]
streams: Vec<()>,
/// Per-stream events for event-based sync (empty on CPU).
/// Used by `sync_all()` to avoid N blocking `cuStreamSynchronize` calls.
/// Instead: record event on each stream, have stream[0] wait on all events,
/// then sync stream[0] once — 1 blocking call instead of N.
#[cfg(feature = "cuda")]
events: Vec<CudaEvent>,
}
impl std::fmt::Debug for CudaStreamPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CudaStreamPool")
.field("count", &self.count)
.field("is_cuda", &self.is_cuda)
.field("device", &self.device)
.finish()
}
}
impl CudaStreamPool {
/// Create a new stream pool.
///
/// On CUDA: creates `count` streams via the device context.
/// On CPU: creates a no-op pool that passes through all operations.
pub fn new(device: &MlDevice, count: usize) -> Result<Self, MLError> {
#[cfg(feature = "cuda")]
{
if let MlDevice::Cuda { context, .. } = device {
let mut streams = Vec::with_capacity(count);
let mut events = Vec::with_capacity(count);
for i in 0..count {
let stream = context.new_stream().map_err(|e| {
MLError::DeviceError(format!(
"Failed to create CUDA stream {i}/{count}: {e}"
))
})?;
// Disable timing on sync events (pure synchronization, no profiling overhead).
let event = context.new_event(None).map_err(|e| {
MLError::DeviceError(format!(
"Failed to create CUDA event {i}/{count}: {e}"
))
})?;
streams.push(stream);
events.push(event);
}
return Ok(Self {
count,
is_cuda: true,
device: device.clone(),
streams,
events,
});
}
}
let _ = count; // suppress unused warning on non-cuda
// CPU device: no-op pool
Ok(Self {
count: 0,
is_cuda: false,
device: device.clone(),
streams: Vec::new(),
#[cfg(feature = "cuda")]
events: Vec::new(),
})
}
/// Number of streams in the pool.
pub fn count(&self) -> usize {
self.count
}
/// Whether this pool has CUDA streams.
pub fn is_cuda(&self) -> bool {
self.is_cuda
}
/// Get the device associated with this pool.
pub fn device(&self) -> &MlDevice {
&self.device
}
/// Get a CUDA stream by index.
///
/// Returns `None` on CPU or if index is out of bounds.
/// Typical usage: `pool.get_stream(model_index % pool.count())`.
#[cfg(feature = "cuda")]
pub fn get_stream(&self, index: usize) -> Option<&Arc<CudaStream>> {
self.streams.get(index)
}
/// Get a CUDA stream by index (no-op on non-CUDA builds).
#[cfg(not(feature = "cuda"))]
pub fn get_stream(&self, _index: usize) -> Option<&()> {
None
}
/// Synchronize all streams (wait for completion).
///
/// On CPU: no-op.
/// On CUDA: uses event-based synchronization to avoid N blocking host calls.
/// Records an event on each stream, has stream[0] wait on all events, then
/// synchronizes stream[0] — 1 blocking `cuStreamSynchronize` instead of N.
pub fn sync_all(&self) -> Result<(), MLError> {
#[cfg(feature = "cuda")]
{
if self.streams.len() <= 1 {
// 0 or 1 streams: direct sync is already optimal.
if let Some(s) = self.streams.first() {
s.synchronize().map_err(|e| {
MLError::DeviceError(format!("CUDA stream 0 sync failed: {e}"))
})?;
}
return Ok(());
}
// Record an event on each stream capturing all enqueued work.
for (i, (stream, event)) in self.streams.iter().zip(self.events.iter()).enumerate() {
event.record(stream).map_err(|e| {
MLError::DeviceError(format!(
"CUDA event record on stream {i} failed: {e}"
))
})?;
}
// Have stream[0] wait on events from streams[1..N].
// stream[0]'s own event is already implicitly ordered.
for (i, event) in self.events.iter().enumerate().skip(1) {
if let Some(s0) = self.streams.first() {
s0.wait(event).map_err(|e| {
MLError::DeviceError(format!(
"CUDA stream 0 wait on event {i} failed: {e}"
))
})?;
}
}
// Single blocking sync on stream[0] — completes when all work is done.
if let Some(s0) = self.streams.first() {
s0.synchronize().map_err(|e| {
MLError::DeviceError(format!(
"CUDA stream 0 sync after event join failed: {e}"
))
})?;
}
}
Ok(())
}
/// Synchronize a single stream by index.
///
/// On CPU: no-op.
/// On CUDA: synchronizes the specified stream.
/// Returns `Ok(())` if index is out of bounds (no-op for safety).
pub fn sync_stream(&self, index: usize) -> Result<(), MLError> {
#[cfg(feature = "cuda")]
if let Some(stream) = self.streams.get(index) {
stream.synchronize().map_err(|e| {
MLError::DeviceError(format!(
"CUDA stream {index} sync failed: {e}"
))
})?;
}
let _ = index; // suppress unused on non-cuda
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_stream_pool_cpu_noop() {
let device = MlDevice::Cpu;
let pool = CudaStreamPool::new(&device, 4)
.expect("CPU pool should succeed");
assert_eq!(pool.count(), 0);
assert!(!pool.is_cuda());
pool.sync_all().expect("CPU sync should succeed");
}
#[test]
fn test_stream_pool_cpu_sync_stream_noop() {
let device = MlDevice::Cpu;
let pool = CudaStreamPool::new(&device, 4)
.expect("CPU pool should succeed");
pool.sync_stream(0).expect("sync_stream on CPU should succeed");
pool.sync_stream(100).expect("out-of-bounds sync_stream should be noop");
}
#[test]
fn test_stream_pool_cpu_zero_count() {
let device = MlDevice::Cpu;
let pool = CudaStreamPool::new(&device, 0)
.expect("Zero-count CPU pool should succeed");
assert_eq!(pool.count(), 0);
pool.sync_all().expect("sync_all on empty pool should be noop");
}
#[test]
fn test_stream_pool_cpu_no_streams() {
let device = MlDevice::Cpu;
let pool = CudaStreamPool::new(&device, 4)
.expect("CPU pool should succeed");
assert!(pool.get_stream(0).is_none());
assert!(pool.get_stream(3).is_none());
assert!(pool.get_stream(4).is_none());
}
}