Files
foxhunt/crates/ml-alpha/cuda/gpu_sample_and_gather.cu
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

400 lines
16 KiB
Plaintext
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_sample_and_gather.cu — GPU-resident batch sampler + AoS→SoA scatter
//
// Replaces the CPU-side `next_sequence_pair` + `build_sequence_at` +
// `extend_from_slice` + mapped-pinned memcpy hot path. At b=256, K=32,
// the CPU version spent ~16ms/step on 512 build_sequence_at calls with
// 7,700 heap allocations while GPU compute was 0.62ms. This kernel
// samples B random windows and gathers snapshots directly from a
// pre-uploaded GPU-resident dataset — zero CPU work per step.
//
// Architecture:
// * At init, the host pre-converts ALL Mbp10Snapshot → Mbp10RawInput
// and uploads as a single flat CudaSlice<u8> (reinterpreted as
// Mbp10Raw on device). File offsets + sizes are uploaded as i32
// arrays. Labels/regime are uploaded similarly.
// * Per step, this kernel runs with Grid=(B,1,1), Block=(K,1,1):
// - Thread 0 of each block uses device-side xorshift32 PRNG to
// sample file_idx and anchor within that file.
// - All K threads gather all_snapshots[file_offset + anchor + k]
// and scatter to SoA output buffers (same layout as
// snapshot_aos_to_soa.cu).
//
// Per feedback_no_atomicadd: block-local shared memory for sampling,
// no atomics. Per feedback_no_nvrtc: pre-compiled cubin via build.rs.
#define BOOK_LEVELS 10
#define REGIME_DIM 6
#define N_HORIZONS 3
#define N_LABEL_SOURCES 5
// Must match #[repr(C)] Mbp10RawInput in snap_features.rs (216 bytes).
struct __align__(8) Mbp10Raw {
float bid_px[BOOK_LEVELS]; // offset 0, 40 bytes
float bid_sz[BOOK_LEVELS]; // offset 40, 40 bytes
float ask_px[BOOK_LEVELS]; // offset 80, 40 bytes
float ask_sz[BOOK_LEVELS]; // offset 120, 40 bytes
float prev_mid; // offset 160, 4 bytes
float trade_signed_vol; // offset 164, 4 bytes
unsigned int trade_count; // offset 168, 4 bytes
// 4 bytes padding for u64 alignment
unsigned long long ts_ns; // offset 176, 8 bytes
unsigned long long prev_ts_ns; // offset 184, 8 bytes
float regime[REGIME_DIM]; // offset 192, 24 bytes
// total: 216 bytes
};
// Marsaglia xorshift32 — minimal state, sufficient quality for
// stochastic sampling. Each batch element carries its own seed to
// avoid inter-block contention.
__device__ __forceinline__ unsigned int xorshift32(unsigned int s) {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
return s;
}
// ── Primary kernel: sample + gather + SoA scatter ────────────────────
//
// Grid = (B, 1, 1) — one block per batch element
// Block = (K, 1, 1) — one thread per sequence position
//
// Thread 0 samples (file_idx, anchor) via xorshift32. All K threads
// then read their assigned snapshot from the flat GPU array and write
// to SoA outputs at position [b * K + k].
extern "C" __global__ void gpu_sample_and_gather(
const Mbp10Raw* __restrict__ all_snapshots, // [total_snaps]
const int* __restrict__ file_offsets, // [n_files]
const int* __restrict__ file_sizes, // [n_files]
unsigned int* __restrict__ prng_state, // [B]
int n_files,
int seq_len,
int max_horizon,
// SoA outputs (same layout as snapshot_aos_to_soa):
float* __restrict__ bid_px_soa, // [B*K * BOOK_LEVELS]
float* __restrict__ bid_sz_soa,
float* __restrict__ ask_px_soa,
float* __restrict__ ask_sz_soa,
float* __restrict__ regime_soa, // [B*K * REGIME_DIM]
float* __restrict__ prev_mid_soa, // [B*K]
float* __restrict__ tsv_soa, // [B*K]
int* __restrict__ tc_soa, // [B*K]
long long* __restrict__ ts_ns_soa, // [B*K]
long long* __restrict__ prev_ts_ns_soa, // [B*K]
// Global-memory outputs for the sampled (file_offset, anchor, file_idx)
// per batch element. Read by gpu_gather_next and gpu_gather_frd_labels
// to reuse the same sampling decision.
int* __restrict__ out_file_offset, // [B]
int* __restrict__ out_anchor, // [B]
int* __restrict__ out_file_idx, // [B]
// Fused label gather — 5 label sources (BCE, prof_long, prof_short,
// size_long, size_short), each [N_HORIZONS × total_snaps]. Reads in
// the same pass as snapshot gather to avoid 5 separate random-access
// kernel launches that consumed 95.6% of GPU time.
int total_snaps,
const float* __restrict__ labels_src_0, // [N_HORIZONS × total_snaps]
const float* __restrict__ labels_src_1,
const float* __restrict__ labels_src_2,
const float* __restrict__ labels_src_3,
const float* __restrict__ labels_src_4,
float* __restrict__ labels_out_0, // [K, B, N_HORIZONS]
float* __restrict__ labels_out_1,
float* __restrict__ labels_out_2,
float* __restrict__ labels_out_3,
float* __restrict__ labels_out_4,
int B
) {
int b = blockIdx.x;
int k = threadIdx.x;
if (b >= B || k >= seq_len) return;
// ── Thread 0: sample file + anchor via device-side PRNG ──────────
__shared__ int s_file_offset;
__shared__ int s_anchor;
if (k == 0) {
unsigned int seed = prng_state[b];
// Sample file index (uniform across files).
seed = xorshift32(seed);
int file_idx = (int)(seed % (unsigned int)n_files);
// Sample anchor within file. Must leave room for seq_len +
// max_horizon snapshots after the anchor (+ 1 for the s_{t+1}
// window gathered by gpu_gather_next).
int fsize = file_sizes[file_idx];
int usable = fsize - seq_len - max_horizon - 1;
seed = xorshift32(seed);
int anchor;
if (usable > 0) {
anchor = (int)(seed % (unsigned int)usable);
} else {
// Degenerate file too short — clamp to 0 (defensive).
anchor = 0;
}
s_file_offset = file_offsets[file_idx];
s_anchor = anchor;
// Write to global memory so downstream kernels can read the
// same (file_offset, anchor, file_idx) without re-sampling.
out_file_offset[b] = s_file_offset;
out_anchor[b] = anchor;
out_file_idx[b] = file_idx;
prng_state[b] = seed;
}
__syncthreads();
// ── All K threads: gather + SoA scatter ──────────────────────────
int global_idx = s_file_offset + s_anchor + k;
const Mbp10Raw& snap = all_snapshots[global_idx];
int n = b * seq_len + k; // output position in [B*K] flat layout
int base_book = n * BOOK_LEVELS;
int base_regime = n * REGIME_DIM;
#pragma unroll
for (int i = 0; i < BOOK_LEVELS; i++) {
bid_px_soa[base_book + i] = snap.bid_px[i];
bid_sz_soa[base_book + i] = snap.bid_sz[i];
ask_px_soa[base_book + i] = snap.ask_px[i];
ask_sz_soa[base_book + i] = snap.ask_sz[i];
}
#pragma unroll
for (int i = 0; i < REGIME_DIM; i++) {
regime_soa[base_regime + i] = snap.regime[i];
}
prev_mid_soa[n] = snap.prev_mid;
tsv_soa[n] = snap.trade_signed_vol;
tc_soa[n] = (int)snap.trade_count;
ts_ns_soa[n] = (long long)snap.ts_ns;
prev_ts_ns_soa[n] = (long long)snap.prev_ts_ns;
// Fused label gather — same global_idx, zero extra random access.
// Output layout: [K, B, N_HORIZONS] row-major.
int lbl_base = (k * B + b) * N_HORIZONS;
const float* srcs[N_LABEL_SOURCES] = {
labels_src_0, labels_src_1, labels_src_2, labels_src_3, labels_src_4
};
float* outs[N_LABEL_SOURCES] = {
labels_out_0, labels_out_1, labels_out_2, labels_out_3, labels_out_4
};
#pragma unroll
for (int s = 0; s < N_LABEL_SOURCES; s++) {
#pragma unroll
for (int h = 0; h < N_HORIZONS; h++) {
outs[s][lbl_base + h] = srcs[s][h * total_snaps + global_idx];
}
}
}
// ── Label gather kernel (STANDALONE — kept for backward compat) ─────
//
// Runs AFTER gpu_sample_and_gather. Gathers per-horizon FRD labels at
// the anchor position (rightmost K position = newest snapshot in the
// window, matching the supervised label semantics).
//
// Grid = (B, 1, 1)
// Block = (FRD_N_HORIZONS, 1, 1) — typically 3 threads
//
// For RL training, the FRD labels are the primary use case. BCE labels
// and outcome labels are handled by the supervised path and are not
// needed per-step in the RL loop.
extern "C" __global__ void gpu_gather_frd_labels(
const int* __restrict__ all_frd_labels, // [n_frd_horizons * total_snaps]
const int* __restrict__ file_offsets, // [n_files] (same as above)
const int* __restrict__ sample_file_offset,// [B] — file_offset chosen by gpu_sample_and_gather
const int* __restrict__ sample_anchor, // [B] — anchor chosen by gpu_sample_and_gather
int seq_len,
int n_frd_horizons,
int total_snaps,
int* __restrict__ frd_labels_out, // [B * n_frd_horizons]
int B
) {
int b = blockIdx.x;
int h = threadIdx.x;
if (b >= B || h >= n_frd_horizons) return;
// The FRD label is at the NEWEST snapshot in the window = anchor + seq_len - 1.
int newest_idx = sample_file_offset[b] + sample_anchor[b] + seq_len - 1;
int label_idx = h * total_snaps + newest_idx; // row-major [n_frd_horizons, total_snaps]
frd_labels_out[b * n_frd_horizons + h] = all_frd_labels[label_idx];
}
// ── Pair gather: s_{t+1} window for Bellman targets ──────────────────
//
// The RL trainer needs (s_t, s_{t+1}) consecutive windows. This kernel
// gathers the SECOND window at anchor+1 into a separate set of SoA
// buffers, reusing the same (file_offset, anchor) from the primary
// gpu_sample_and_gather call. Avoids a second sampling pass.
//
// Grid = (B, 1, 1)
// Block = (K, 1, 1)
extern "C" __global__ void gpu_gather_next(
const Mbp10Raw* __restrict__ all_snapshots, // [total_snaps]
const int* __restrict__ sample_file_offset,// [B]
const int* __restrict__ sample_anchor, // [B]
int seq_len,
// SoA outputs for s_{t+1}:
float* __restrict__ bid_px_soa,
float* __restrict__ bid_sz_soa,
float* __restrict__ ask_px_soa,
float* __restrict__ ask_sz_soa,
float* __restrict__ regime_soa,
float* __restrict__ prev_mid_soa,
float* __restrict__ tsv_soa,
int* __restrict__ tc_soa,
long long* __restrict__ ts_ns_soa,
long long* __restrict__ prev_ts_ns_soa,
int B
) {
int b = blockIdx.x;
int k = threadIdx.x;
if (b >= B || k >= seq_len) return;
// anchor + 1 for the next-step window.
int global_idx = sample_file_offset[b] + sample_anchor[b] + 1 + k;
const Mbp10Raw& snap = all_snapshots[global_idx];
int n = b * seq_len + k;
int base_book = n * BOOK_LEVELS;
int base_regime = n * REGIME_DIM;
#pragma unroll
for (int i = 0; i < BOOK_LEVELS; i++) {
bid_px_soa[base_book + i] = snap.bid_px[i];
bid_sz_soa[base_book + i] = snap.bid_sz[i];
ask_px_soa[base_book + i] = snap.ask_px[i];
ask_sz_soa[base_book + i] = snap.ask_sz[i];
}
#pragma unroll
for (int i = 0; i < REGIME_DIM; i++) {
regime_soa[base_regime + i] = snap.regime[i];
}
prev_mid_soa[n] = snap.prev_mid;
tsv_soa[n] = snap.trade_signed_vol;
tc_soa[n] = (int)snap.trade_count;
ts_ns_soa[n] = (long long)snap.ts_ns;
prev_ts_ns_soa[n] = (long long)snap.prev_ts_ns;
}
// ── Re-gather s_t: current window at anchor+0 ──────────────────────
//
// After gpu_sample_and_gather samples an anchor and gpu_gather_next
// overwrites the SoA with s_{t+1}, this kernel re-populates the SoA
// with the original s_t window. Uses the saved (file_offset, anchor)
// from gpu_sample_and_gather — no PRNG re-sampling. Identical to
// gpu_gather_next except the global_idx offset is +0 instead of +1.
//
// Grid = (B, 1, 1)
// Block = (K, 1, 1)
extern "C" __global__ void gpu_gather_current(
const Mbp10Raw* __restrict__ all_snapshots,
const int* __restrict__ sample_file_offset,
const int* __restrict__ sample_anchor,
int seq_len,
float* __restrict__ bid_px_soa,
float* __restrict__ bid_sz_soa,
float* __restrict__ ask_px_soa,
float* __restrict__ ask_sz_soa,
float* __restrict__ regime_soa,
float* __restrict__ prev_mid_soa,
float* __restrict__ tsv_soa,
int* __restrict__ tc_soa,
long long* __restrict__ ts_ns_soa,
long long* __restrict__ prev_ts_ns_soa,
int B
) {
int b = blockIdx.x;
int k = threadIdx.x;
if (b >= B || k >= seq_len) return;
int global_idx = sample_file_offset[b] + sample_anchor[b] + k;
const Mbp10Raw& snap = all_snapshots[global_idx];
int n = b * seq_len + k;
int base_book = n * BOOK_LEVELS;
int base_regime = n * REGIME_DIM;
#pragma unroll
for (int i = 0; i < BOOK_LEVELS; i++) {
bid_px_soa[base_book + i] = snap.bid_px[i];
bid_sz_soa[base_book + i] = snap.bid_sz[i];
ask_px_soa[base_book + i] = snap.ask_px[i];
ask_sz_soa[base_book + i] = snap.ask_sz[i];
}
#pragma unroll
for (int i = 0; i < REGIME_DIM; i++) {
regime_soa[base_regime + i] = snap.regime[i];
}
prev_mid_soa[n] = snap.prev_mid;
tsv_soa[n] = snap.trade_signed_vol;
tc_soa[n] = (int)snap.trade_count;
ts_ns_soa[n] = (long long)snap.ts_ns;
prev_ts_ns_soa[n] = (long long)snap.prev_ts_ns;
}
// ── BCE / aux label gather kernel ───────────────────────────────────
//
// Gathers per-horizon float labels at each of the K window positions
// (anchor + k, for k in [0, K)) into the output buffer with the
// same [K, B, N_H] row-major layout that step_batched's mapped-pinned
// staging uses. This unified kernel serves all 6 per-horizon float
// label arrays (labels, outcome_prof_{long,short}, outcome_size_{long,
// short}, sigma_k) — the caller selects which by passing the
// appropriate `all_labels` source pointer from GpuDataset.
//
// Grid = (B, 1, 1)
// Block = (K, 1, 1) where K = seq_len
//
// The output layout is:
// out[k * B * n_horizons + b * n_horizons + h] = all_labels[h * total_snaps + global_idx]
// matching the staging fill loop in perception.rs step_batched.
extern "C" __global__ void gpu_gather_bce_labels(
const float* __restrict__ all_labels, // [n_horizons, total_snaps] row-major
const int* __restrict__ sample_file_offset, // [B]
const int* __restrict__ sample_anchor, // [B]
int seq_len,
int n_horizons,
int total_snaps,
float* __restrict__ labels_out, // [K, B, n_horizons] row-major
int B
) {
int b = blockIdx.x;
int k = threadIdx.x;
if (b >= B || k >= seq_len) return;
int global_idx = sample_file_offset[b] + sample_anchor[b] + k;
int out_base = (k * B + b) * n_horizons;
for (int h = 0; h < n_horizons; h++) {
labels_out[out_base + h] = all_labels[h * total_snaps + global_idx];
}
}
// ── Per-file pos_fraction gather kernel ─────────────────────────────
//
// Reads the per-file positive-class fraction for batch element 0's
// sampled file and writes a single pos_fraction vector. The trainer
// uses per-file pos_fraction as a coarse class-balance signal; all
// batch elements within a step use the same pw value (batch 0's file).
//
// Grid = (1, 1, 1)
// Block = (2 * N_HORIZONS, 1, 1) — one thread per pos_fraction slot
extern "C" __global__ void gpu_gather_pos_fraction(
const float* __restrict__ all_pos_fraction, // [n_files, 2 * n_horizons]
const int* __restrict__ sample_file_idx, // [B]
int n_horizons,
float* __restrict__ pos_fraction_out, // [2 * n_horizons]
int B
) {
int slot = threadIdx.x;
int n_slots = 2 * n_horizons;
if (slot >= n_slots) return;
// Use batch 0's file index as the representative.
int file_idx = sample_file_idx[0];
pos_fraction_out[slot] = all_pos_fraction[file_idx * n_slots + slot];
}