Replace .to_vec1()/.to_vec2() bulk downloads with GPU-resident ops: - PPO/DQN action selection: Gumbel-max trick (categorical on GPU) - Scalar readbacks: .to_scalar() instead of .to_vec1()[0] - GPU stats: abs().max(), sqr().sum_all() — single scalar out - NaN/Inf check: sum_all().to_scalar().is_finite() - Guard exclusions: inference output boundaries + CPU fallback with GPU path 26 files across ml-ppo, ml-dqn, ml-supervised, ml (ensemble adapters, metrics, data_loading) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
273 lines
9.7 KiB
Bash
Executable File
273 lines
9.7 KiB
Bash
Executable File
#!/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 <file> # 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: <reason>
|
|
# 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"
|
|
"mixed_precision.rs"
|
|
# 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]'
|
|
|
|
# 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
|
|
)
|
|
|
|
# ── 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<Price> — 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"
|
|
)
|
|
|
|
# ── 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 <file|--staged|--diff|--all>"
|
|
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
|