Files
foxhunt/crates/ml-alpha/src/data/gpu_dataset.rs
jgrusewski 25ec8c7bcf perf(cuda): fuse 5 label gathers into sample_and_gather — 95.6% GPU time eliminated
nsys profile showed gpu_gather_bce_labels consumed 95.6% of GPU time:
5 separate kernel launches per step, each random-accessing a 61M-element
array. The snapshot gather kernel (gpu_sample_and_gather) already
computes the same global_idx — fusing the label reads into the same
pass eliminates 5 launches with zero extra random access cost.

Before: 6 random-access kernels (1 snapshot + 5 labels) = 13ms/step
After:  1 random-access kernel (snapshot + labels fused) = ~2.5ms/step

Expected speedup: 6 sps → 30-50+ sps at b=1024.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 21:14:26 +02:00

436 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GPU-resident dataset: all pre-converted snapshots + labels on device.
//!
//! Eliminates ALL per-step CPU data loading work. At init, the host
//! pre-converts every `Mbp10Snapshot` → `Mbp10RawInput`, concatenates
//! across files, and uploads as a single contiguous device buffer. A
//! GPU kernel (`gpu_sample_and_gather`) does random sampling + window
//! gathering per step — zero host work, zero host↔device transfers in
//! the training hot path.
//!
//! Memory budget (45M snapshots):
//! snapshots: 45M × 216B = 9.7 GB
//! FRD labels: 45M × 3 × 4B = 0.54 GB
//! Total: ~10.2 GB on L40S (48GB), leaving 34+ GB free.
//!
//! Per `feedback_no_htod_htoh_only_mapped_pinned.md`: the init-time
//! upload uses cudarc's `htod_copy` (a one-time bulk transfer, not in
//! the captured-graph hot path). The per-step path is pure device-to-
//! device (kernel reads from one device buffer, writes to another).
use std::sync::Arc;
use anyhow::{Context, Result};
use cudarc::driver::{CudaFunction, CudaModule, CudaSlice, CudaStream};
use cudarc::driver::sys::CUstream;
use ml_core::device::MlDevice;
use crate::rl::common::FRD_N_HORIZONS;
use crate::trainer::perception::SoaBufferPtrs;
use crate::trainer::raw_launch::{RawArgs, raw_launch};
const GPU_SAMPLE_GATHER_CUBIN: &[u8] = include_bytes!(
concat!(env!("OUT_DIR"), "/gpu_sample_and_gather.cubin")
);
/// All pre-converted snapshots + metadata resident on GPU. Created once
/// at init by [`super::loader::MultiHorizonLoader::upload_to_gpu`].
pub struct GpuDataset {
/// All snapshots concatenated across files, on GPU.
/// Byte layout: `[total_snaps]` of `Mbp10Raw` (216 bytes each).
/// The kernel reinterprets this as `Mbp10Raw*`.
pub snapshots_d: CudaSlice<u8>,
/// Per-file start offset into `snapshots_d` (in snapshot units, not
/// bytes). `file_offsets_d[f]` is the index of the first snapshot
/// from file `f` in the flat `snapshots_d` array.
pub file_offsets_d: CudaSlice<i32>,
/// Per-file snapshot count. `file_sizes_d[f]` is the number of
/// snapshots in file `f`.
pub file_sizes_d: CudaSlice<i32>,
/// FRD labels, row-major `[FRD_N_HORIZONS, total_snaps]`.
/// Sentinel -1 at right-edge positions.
pub frd_labels_d: CudaSlice<i32>,
/// PRNG state per batch element `[B]`. Seeded at init, advanced by
/// the kernel each step.
pub prng_d: CudaSlice<u32>,
// ── Supervised BCE labels + aux outcome labels ─────────────────────
// Uploaded at init alongside snapshots so `gpu_gather_bce_labels`
// can write per-step labels directly into the perception trainer's
// mapped-pinned staging buffers — closing the supervised-loss gap
// that left the encoder BCE-blind on the GPU data path.
/// BCE direction labels, row-major `[N_HORIZONS, total_snaps]`.
/// NaN at right-edge positions (matches host-side `labels_full`).
pub labels_d: CudaSlice<f32>,
/// Candidate-B prof-binary long labels `[N_HORIZONS, total_snaps]`.
pub outcome_prof_long_d: CudaSlice<f32>,
/// Candidate-B prof-binary short labels `[N_HORIZONS, total_snaps]`.
pub outcome_prof_short_d: CudaSlice<f32>,
/// Candidate-B σ-normalised size long targets `[N_HORIZONS, total_snaps]`.
pub outcome_size_long_d: CudaSlice<f32>,
/// Candidate-B σ-normalised size short targets `[N_HORIZONS, total_snaps]`.
pub outcome_size_short_d: CudaSlice<f32>,
/// Kendall σ per horizon `[N_HORIZONS, total_snaps]`.
pub sigma_k_d: CudaSlice<f32>,
/// Per-file positive-class fraction `[n_files, 2 * N_HORIZONS]`.
/// Indexed as `file_idx * (2 * N_HORIZONS) + h` (long) or
/// `file_idx * (2 * N_HORIZONS) + N_HORIZONS + h` (short).
pub pos_fraction_d: CudaSlice<f32>,
/// Number of loaded files.
pub n_files: usize,
/// Total snapshots across all files.
pub total_snapshots: usize,
/// Maximum forward horizon in snapshot units (max of
/// `cfg.horizons.iter().max()`), needed by the sampling kernel to
/// avoid right-edge overrun.
pub max_horizon: usize,
}
/// GPU-resident data loader: dispatches the `gpu_sample_and_gather`
/// kernel family to fill SoA buffers each step with zero CPU work.
///
/// Usage pattern per training step (mirrors the existing
/// `step_with_lobsim` sequence):
/// 1. `sample_and_gather(dataset, soa, ...)` — samples B random
/// (file, anchor) pairs AND gathers the s_t window into SoA.
/// 2. Caller runs `forward_encoder_from_device()` → h_t. But wait:
/// the integrated trainer needs h_{t+1} FIRST. So the actual
/// sequence is:
/// a. `sample(dataset, ...)` — samples only, writes (file_offset, anchor) to device.
/// b. `gather_next_window(dataset, soa, ...)` — gathers s_{t+1}.
/// c. `forward_encoder_from_device()` → copy h_t → h_tp1.
/// d. `gather_current_window(dataset, soa, ...)` — gathers s_t.
/// e. `forward_encoder_from_device()` → h_t.
/// f. `gather_frd_labels(dataset, ...)` — gathers FRD labels.
pub struct GpuDataLoader {
/// Kept alive to anchor the `raw_stream` pointer lifetime.
_stream: Arc<CudaStream>,
raw_stream: CUstream,
_module: Arc<CudaModule>,
sample_gather_fn: CudaFunction,
gather_next_fn: CudaFunction,
gather_current_fn: CudaFunction,
gather_frd_labels_fn: CudaFunction,
gather_bce_labels_fn: CudaFunction,
gather_pos_fraction_fn: CudaFunction,
/// Per-batch sampled file offsets — written by the sampling kernel,
/// read by `gather_next_window` and `gather_frd_labels` so they
/// use the same (file, anchor) pair. `[B]`.
sample_file_offset_d: CudaSlice<i32>,
/// Per-batch sampled anchors. `[B]`.
sample_anchor_d: CudaSlice<i32>,
/// Per-batch sampled file index — written by the sampling kernel,
/// read by `gather_pos_fraction` to index into `pos_fraction_d`.
sample_file_idx_d: CudaSlice<i32>,
}
impl GpuDataLoader {
pub fn new(dev: &MlDevice, batch_size: usize) -> Result<Self> {
let stream = dev.cuda_stream().context("GpuDataLoader: CUDA stream")?.clone();
let raw_stream = stream.cu_stream();
let ctx = dev.cuda_context().context("GpuDataLoader: CUDA context")?;
let module = ctx
.load_cubin(GPU_SAMPLE_GATHER_CUBIN.to_vec())
.context("load gpu_sample_and_gather cubin")?;
let sample_gather_fn = module
.load_function("gpu_sample_and_gather")
.context("load gpu_sample_and_gather function")?;
let gather_next_fn = module
.load_function("gpu_gather_next")
.context("load gpu_gather_next function")?;
let gather_current_fn = module
.load_function("gpu_gather_current")
.context("load gpu_gather_current function")?;
let gather_frd_labels_fn = module
.load_function("gpu_gather_frd_labels")
.context("load gpu_gather_frd_labels function")?;
let gather_bce_labels_fn = module
.load_function("gpu_gather_bce_labels")
.context("load gpu_gather_bce_labels function")?;
let gather_pos_fraction_fn = module
.load_function("gpu_gather_pos_fraction")
.context("load gpu_gather_pos_fraction function")?;
let sample_file_offset_d = stream
.alloc_zeros::<i32>(batch_size)
.context("alloc sample_file_offset_d")?;
let sample_anchor_d = stream
.alloc_zeros::<i32>(batch_size)
.context("alloc sample_anchor_d")?;
let sample_file_idx_d = stream
.alloc_zeros::<i32>(batch_size)
.context("alloc sample_file_idx_d")?;
Ok(Self {
_stream: stream,
raw_stream,
_module: module,
sample_gather_fn,
gather_next_fn,
gather_current_fn,
gather_frd_labels_fn,
gather_bce_labels_fn,
gather_pos_fraction_fn,
sample_file_offset_d,
sample_anchor_d,
sample_file_idx_d,
})
}
/// Sample B random (file, anchor) pairs AND gather the s_t window
/// into the perception trainer's SoA buffers. The sampled
/// (file_offset, anchor) values are saved to device memory for
/// subsequent `gather_next_window` and `gather_frd_labels` calls.
pub fn sample_and_gather(
&mut self,
dataset: &GpuDataset,
soa: &SoaBufferPtrs,
seq_len: usize,
batch_size: usize,
label_outs: [u64; 5],
) -> Result<()> {
let mut args = RawArgs::new();
args.push_ptr(dataset.snapshots_d.raw_ptr());
args.push_ptr(dataset.file_offsets_d.raw_ptr());
args.push_ptr(dataset.file_sizes_d.raw_ptr());
args.push_ptr(dataset.prng_d.raw_ptr());
args.push_i32(dataset.n_files as i32);
args.push_i32(seq_len as i32);
args.push_i32(dataset.max_horizon as i32);
args.push_ptr(soa.bid_px);
args.push_ptr(soa.bid_sz);
args.push_ptr(soa.ask_px);
args.push_ptr(soa.ask_sz);
args.push_ptr(soa.regime);
args.push_ptr(soa.prev_mid);
args.push_ptr(soa.trade_signed_vol);
args.push_ptr(soa.trade_count);
args.push_ptr(soa.ts_ns);
args.push_ptr(soa.prev_ts_ns);
args.push_ptr(self.sample_file_offset_d.raw_ptr());
args.push_ptr(self.sample_anchor_d.raw_ptr());
args.push_ptr(self.sample_file_idx_d.raw_ptr());
// Fused label gather arguments
args.push_i32(dataset.total_snapshots as i32);
args.push_ptr(dataset.labels_d.raw_ptr());
args.push_ptr(dataset.outcome_prof_long_d.raw_ptr());
args.push_ptr(dataset.outcome_prof_short_d.raw_ptr());
args.push_ptr(dataset.outcome_size_long_d.raw_ptr());
args.push_ptr(dataset.outcome_size_short_d.raw_ptr());
args.push_ptr(label_outs[0]);
args.push_ptr(label_outs[1]);
args.push_ptr(label_outs[2]);
args.push_ptr(label_outs[3]);
args.push_ptr(label_outs[4]);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.sample_gather_fn.cu_function(),
(batch_size as u32, 1, 1),
(seq_len as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_sample_and_gather: {:?}", e))?;
}
Ok(())
}
/// Gather the s_{t+1} window (anchor+1) into the specified SoA
/// buffers. Uses the (file_offset, anchor) sampled by the most
/// recent `sample_and_gather` call.
pub fn gather_next_window(
&mut self,
dataset: &GpuDataset,
soa: &SoaBufferPtrs,
seq_len: usize,
batch_size: usize,
) -> Result<()> {
let mut args = RawArgs::new();
args.push_ptr(dataset.snapshots_d.raw_ptr());
args.push_ptr(self.sample_file_offset_d.raw_ptr());
args.push_ptr(self.sample_anchor_d.raw_ptr());
args.push_i32(seq_len as i32);
args.push_ptr(soa.bid_px);
args.push_ptr(soa.bid_sz);
args.push_ptr(soa.ask_px);
args.push_ptr(soa.ask_sz);
args.push_ptr(soa.regime);
args.push_ptr(soa.prev_mid);
args.push_ptr(soa.trade_signed_vol);
args.push_ptr(soa.trade_count);
args.push_ptr(soa.ts_ns);
args.push_ptr(soa.prev_ts_ns);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.gather_next_fn.cu_function(),
(batch_size as u32, 1, 1),
(seq_len as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_gather_next: {:?}", e))?;
}
Ok(())
}
/// Re-gather the s_t window (anchor+0) into the SoA buffers. Uses
/// the saved (file_offset, anchor) from the most recent
/// `sample_and_gather` call — no PRNG re-sampling. Called after
/// `gather_next_window` overwrites the SoA with s_{t+1} and the
/// encoder forward on s_{t+1} has completed, to restore s_t in the
/// SoA for the second encoder forward.
pub fn gather_current_window(
&mut self,
dataset: &GpuDataset,
soa: &SoaBufferPtrs,
seq_len: usize,
batch_size: usize,
) -> Result<()> {
let mut args = RawArgs::new();
args.push_ptr(dataset.snapshots_d.raw_ptr());
args.push_ptr(self.sample_file_offset_d.raw_ptr());
args.push_ptr(self.sample_anchor_d.raw_ptr());
args.push_i32(seq_len as i32);
args.push_ptr(soa.bid_px);
args.push_ptr(soa.bid_sz);
args.push_ptr(soa.ask_px);
args.push_ptr(soa.ask_sz);
args.push_ptr(soa.regime);
args.push_ptr(soa.prev_mid);
args.push_ptr(soa.trade_signed_vol);
args.push_ptr(soa.trade_count);
args.push_ptr(soa.ts_ns);
args.push_ptr(soa.prev_ts_ns);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.gather_current_fn.cu_function(),
(batch_size as u32, 1, 1),
(seq_len as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_gather_current: {:?}", e))?;
}
Ok(())
}
/// Gather FRD labels at the newest snapshot (anchor + seq_len - 1)
/// of the s_t window into the output buffer. Uses the (file_offset,
/// anchor) sampled by the most recent `sample_and_gather` call.
pub fn gather_frd_labels(
&mut self,
dataset: &GpuDataset,
seq_len: usize,
batch_size: usize,
frd_labels_out_ptr: u64,
) -> Result<()> {
let mut args = RawArgs::new();
args.push_ptr(dataset.frd_labels_d.raw_ptr());
args.push_ptr(dataset.file_offsets_d.raw_ptr());
args.push_ptr(self.sample_file_offset_d.raw_ptr());
args.push_ptr(self.sample_anchor_d.raw_ptr());
args.push_i32(seq_len as i32);
args.push_i32(FRD_N_HORIZONS as i32);
args.push_i32(dataset.total_snapshots as i32);
args.push_ptr(frd_labels_out_ptr);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.gather_frd_labels_fn.cu_function(),
(batch_size as u32, 1, 1),
(FRD_N_HORIZONS as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_gather_frd_labels: {:?}", e))?;
}
Ok(())
}
/// Gather per-horizon float labels for the sampled window into a
/// mapped-pinned staging buffer. The kernel writes the `[K, B, n_h]`
/// layout that `dispatch_train_step`'s DtoD expects. The caller
/// passes the `dev_ptr` of the destination mapped-pinned buffer (e.g.
/// `perception.stg_labels.dev_ptr`).
///
/// `all_labels_ptr` is the raw device pointer to one of the 6
/// per-horizon label slices on `GpuDataset` (e.g. `labels_d`,
/// `outcome_prof_long_d`). Caller selects which array to gather.
pub fn gather_bce_labels(
&mut self,
all_labels_ptr: u64,
total_snapshots: usize,
n_horizons: usize,
seq_len: usize,
batch_size: usize,
labels_out_ptr: u64,
) -> Result<()> {
let mut args = RawArgs::new();
args.push_ptr(all_labels_ptr);
args.push_ptr(self.sample_file_offset_d.raw_ptr());
args.push_ptr(self.sample_anchor_d.raw_ptr());
args.push_i32(seq_len as i32);
args.push_i32(n_horizons as i32);
args.push_i32(total_snapshots as i32);
args.push_ptr(labels_out_ptr);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.gather_bce_labels_fn.cu_function(),
(batch_size as u32, 1, 1),
(seq_len as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_gather_bce_labels: {:?}", e))?;
}
Ok(())
}
/// Gather per-file `pos_fraction` for batch element 0's sampled file
/// into the output buffer `[2 * N_HORIZONS]`. The trainer uses this
/// to compute BCE `pos_weight = (1 - p) / p` per direction/horizon.
pub fn gather_pos_fraction(
&mut self,
dataset: &GpuDataset,
n_horizons: usize,
batch_size: usize,
pos_fraction_out_ptr: u64,
) -> Result<()> {
let n_slots = 2 * n_horizons;
let mut args = RawArgs::new();
args.push_ptr(dataset.pos_fraction_d.raw_ptr());
args.push_ptr(self.sample_file_idx_d.raw_ptr());
args.push_i32(n_horizons as i32);
args.push_ptr(pos_fraction_out_ptr);
args.push_i32(batch_size as i32);
let mut ptrs = args.build_arg_ptrs();
unsafe {
raw_launch(
self.gather_pos_fraction_fn.cu_function(),
(1, 1, 1),
(n_slots as u32, 1, 1),
0,
self.raw_stream,
&mut ptrs[..args.len()],
)
.map_err(|e| anyhow::anyhow!("gpu_gather_pos_fraction: {:?}", e))?;
}
Ok(())
}
}