#!/bin/bash # GPU Hot-Path Guard — detects CPU roundtrip leaks and GPU perf killers. # # Production HFT system on H100 GPUs. Every microsecond matters. # This guard catches ALL patterns that cause GPU pipeline stalls, # unnecessary CPU↔GPU memory transfers, or heap allocations in hot paths. # # Usage: # scripts/gpu-hotpath-guard.sh # check single file # scripts/gpu-hotpath-guard.sh --staged # check all staged .rs files # scripts/gpu-hotpath-guard.sh --diff # check unstaged changes only # scripts/gpu-hotpath-guard.sh --all # check all hot-path .rs files # # Exit codes: # 0 = clean (no leaks, or file not in hot path) # 1 = GPU perf violation detected in hot-path file # # Suppress reviewed boundary points with: // gpu-ok: # NOTE: New code should NEVER use // gpu-ok: — eliminate the transfer instead. set -euo pipefail # ── Hot-path file patterns ────────────────────────────────────────────── # Only files matching these patterns are checked. Everything else is ignored. HOT_PATHS=( # DQN training hot loop (entire trainer directory) "trainers/dqn/" "cuda_pipeline/" "gpu_action_selector" "gpu_portfolio" "gpu_replay_buffer" "gpu_experience_collector" "gpu_backtest_evaluator" "gpu_training_guard" "gpu_weights" # DQN crate — all modules (inference, networks, replay, regularization) "ml-dqn/src/" # PPO training + inference "trainers/ppo" "ml-ppo/src/" # Supervised model forward passes "ml-supervised/src/" # Hyperopt backtest inner loops "hyperopt/adapters/" "hyperopt/walk_forward.rs" # Evaluation binaries (GPU inference paths) "examples/evaluate_baseline.rs" "examples/train_baseline" # Ensemble inference "ensemble/" # Feature extraction (GPU-resident) "ml-features/src/" # Flash attention (GPU-only computation) "flash_attention/" ) # ── GPU perf violation patterns ──────────────────────────────────────── # Each pattern is a grep -E regex. Lines with "// gpu-ok:" are excluded. # Doc comments (/// or //!) and Storage match arms (error guards) are excluded. LEAK_PATTERNS=( # ─── GPU→CPU bulk transfers (tensor array → CPU memory) ─── # Each of these forces a CUDA stream synchronize + PCIe DMA transfer. # On H100: ~5-15μs per transfer, blocks entire GPU pipeline. # NOTE: .to_scalar() is NOT flagged — it reads a single value (4-8 bytes), # equivalent to the "final metric readback at epoch boundary" pattern. '\.to_vec1' '\.to_vec2' '\.to_vec3' '\.to_device\(&Device::Cpu\)' '\.to_device\(&candle_core::Device::Cpu\)' '\.get\([0-9]+\)\?\.to_vec' # ─── CPU device usage (creating anything on CPU in GPU code) ─── '&Device::Cpu' # ─── Dead GPU transfers (underscore-hidden variables suppress clippy) ─── # let _foo = tensor.to_vec1 — forced GPU→CPU sync, result discarded. 'let _\w+\s*=.*\.to_vec[123]' # ─── Dead CPU fallback paths ─── # #[cfg(not(feature = "cuda"))] blocks are dead code — project is CUDA-only. # They add maintenance burden and hide the real code path. '#\[cfg\(not\(feature\s*=\s*"cuda"\)\)\]' # ─── CPU-side batch data mutation (GPU data wrongly processed on CPU) ─── # Functions taking mutable Vec batch arrays indicate CPU-side processing # that should be a GPU kernel or Candle tensor ops. # Caught: CPU HER relabeling, CPU normalization, CPU reward shaping, etc. 'mut states: Vec' 'mut next_states: Vec' 'mut rewards: Vec' # ─── Candle tensor wrapping in per-step hot path ─── # Creating Candle Tensors from CudaSlice in the training loop adds # Rust-side allocation + dispatch overhead. Use raw CUDA kernels instead. # Tensor::new() creates a CPU scalar → GPU copy (HtoD for 4 bytes). # CudaStorage::wrap_cuda_slice takes ownership — blocks reuse patterns. 'CudaStorage::wrap_cuda_slice' # NOTE: Tensor::from_vec/from_slice are NOT flagged — they are CPU→GPU # uploads (correct direction). Inner-loop abuse is caught by code review. # # NOTE: memcpy_dtoh/htod and .synchronize() are not checked here — # they are CUDA implementation primitives in cuda_pipeline/. # Audit those separately with: scripts/cuda-perf-audit.sh # # NOTE: storage_and_layout() is NOT flagged — it's a zero-cost pointer # unwrap for extracting CudaSlice refs from existing Tensors. ) # ── Excluded paths ──────────────────────────────────────────────────── EXCLUDE_PATHS=( # One-time data loading at startup, not per-step hot path "trainers/dqn/data_loading.rs" # Smoke tests (not training hot path) "trainers/dqn/smoke_tests/" # Legacy non-branching QNetwork — CPU API boundary, not production hot path "ml-dqn/src/network.rs" "ml-dqn/src/ensemble_network.rs" # Model inference boundaries — return CPU types by design (EnsemblePrediction) # Training hot path uses GPU-resident tensors directly "ml-supervised/src/tft/varmap_quantization.rs" # tensor_to_predictions() returns Vec — inference output boundary "ml-supervised/src/tft/hft_optimizations.rs" # evaluate_cpu() is slow fallback before precompute_gpu_grid(); GPU path exists "ml-supervised/src/kan/spline.rs" # SearchSorted/CumsumOp CustomOp2 impls must return CudaStorage (Candle API boundary) # Runs once per PER sampling batch, not inside per-step training kernel loop "ml-dqn/src/gpu_replay_buffer.rs" ) # ── Test module exclusion ───────────────────────────────────────────── # Unit tests (#[cfg(test)] modules) are excluded — assertions inherently # need scalar readbacks. Use .to_scalar() in tests (not flagged). # Integration tests (crates/ml/tests/) ARE checked via HOT_PATHS. find_test_module_start() { local file="$1" grep -n '^\s*#\[cfg(test)\]' "$file" 2>/dev/null | head -1 | cut -d: -f1 } is_hot_path() { local file="$1" # Check exclusions first for excl in "${EXCLUDE_PATHS[@]}"; do if [[ "$file" == *"$excl"* ]]; then return 1 fi done for pattern in "${HOT_PATHS[@]}"; do if [[ "$file" == *"$pattern"* ]]; then return 0 fi done return 1 } check_file() { local file="$1" local found=0 if ! is_hot_path "$file"; then return 0 fi # Find where #[cfg(test)] starts — skip all lines at or after that point local test_start test_start=$(find_test_module_start "$file") for pattern in "${LEAK_PATTERNS[@]}"; do # Find matches, exclude: doc comments, Storage match arms, regular comments local hits hits=$(grep -nE "$pattern" "$file" 2>/dev/null \ | grep -v '[0-9]*:\s*///' \ | grep -v '[0-9]*:\s*//!' \ | grep -v '[0-9]*:\s*//' \ | grep -v 'Storage::Cpu' \ | grep -v 'Storage::Metal' \ || true) # Filter out lines inside #[cfg(test)] module if [ -n "$test_start" ] && [ -n "$hits" ]; then hits=$(echo "$hits" | while IFS= read -r line; do local lineno lineno=$(echo "$line" | cut -d: -f1) if [ "$lineno" -lt "$test_start" ]; then echo "$line" fi done) fi # Filter out #[cfg(not(feature = "cuda"))] guarded code (dead code with CUDA). # Tracks brace depth to skip entire functions/blocks, not just 2 lines. if [ -n "$hits" ]; then local skip_ranges skip_ranges=$(python3 -c " import re, sys lines = open(sys.argv[1]).readlines() ranges = [] for i, line in enumerate(lines): if re.search(r'#\[cfg\(not\(feature.*cuda', line): start = i depth = 0 found_open = False for j in range(i, len(lines)): for ch in lines[j]: if ch == '{': depth += 1 found_open = True elif ch == '}': depth -= 1 if found_open and depth == 0: ranges.append((start + 1, j + 1)) # 1-indexed break else: # Single expression (no braces): skip attribute + next line ranges.append((start + 1, min(start + 2, len(lines)))) for s, e in ranges: print(f'{s} {e}') " "$file" 2>/dev/null || true) if [ -n "$skip_ranges" ]; then hits=$(echo "$hits" | while IFS= read -r line; do local lineno lineno=$(echo "$line" | cut -d: -f1) local skip=false while IFS=' ' read -r range_start range_end; do if [ "$lineno" -ge "$range_start" ] && [ "$lineno" -le "$range_end" ]; then skip=true break fi done <<< "$skip_ranges" if [ "$skip" = false ]; then echo "$line" fi done) fi fi if [ -n "$hits" ]; then if [ "$found" -eq 0 ]; then echo "GPU HOT-PATH LEAK: $file" found=1 fi echo "$hits" | while IFS= read -r line; do echo " $line" done fi done return $found } # ── Entrypoint ────────────────────────────────────────────────────────── FILES=() MODE="file" if [ "${1:-}" = "--staged" ]; then MODE="staged" while IFS= read -r f; do FILES+=("$f") done < <(git diff --cached --name-only --diff-filter=ACM | grep '\.rs$' || true) elif [ "${1:-}" = "--diff" ]; then MODE="diff" while IFS= read -r f; do FILES+=("$f") done < <(git diff --name-only | grep '\.rs$' || true) elif [ "${1:-}" = "--all" ]; then MODE="all" while IFS= read -r f; do FILES+=("$f") done < <(find crates -name '*.rs' -type f 2>/dev/null) elif [ -n "${1:-}" ]; then FILES=("$1") else echo "Usage: $0 " exit 0 fi LEAKED=0 LEAK_COUNT=0 for file in "${FILES[@]}"; do if [ -f "$file" ]; then if ! check_file "$file"; then LEAKED=1 LEAK_COUNT=$((LEAK_COUNT + 1)) fi fi done if [ "$LEAKED" -eq 1 ]; then echo "" echo "⛔ $LEAK_COUNT file(s) with GPU perf violations in hot paths." echo " MUST be eliminated — do not suppress with // gpu-ok:" echo " Move transfers out of hot paths or keep data GPU-resident." exit 1 fi exit 0