Files
foxhunt/AGENT_R3_A4_GPU_OPTIMIZATION.md
jgrusewski 6da9d262db feat(ml): MAMBA-2 P0 fixes + hyperparameter optimization (13 params)
CRITICAL P0 FIXES (Validated - Loss 0.87 → 0.07):
- Add sigmoid activation to inference and training (ml/src/mamba/mod.rs:798, 1538)
- Fix config.total_decay_steps (was hardcoded 10000) (ml/src/mamba/mod.rs:2271)
- Update d_state: 16→64, 32→64 (Mamba-2 spec) (ml/src/mamba/mod.rs:178, 730)

HYPERPARAMETER OPTIMIZATION:
- Implement 13-parameter Bayesian optimization with argmin
- Add async data loading with 3-batch prefetch (+20-30% speedup)
- Create hyperopt adapter: ml/src/hyperopt/adapters/mamba2.rs
- Add example: ml/examples/hyperopt_mamba2_demo.rs

VALIDATION:
- Local test: Loss 0.07 vs 0.87 (12× improvement)
- Val loss: 0.04-0.14 vs 1.2 (27× improvement)
- Accuracy: 12-30% vs 1-5% (3-6× improvement)
- All binaries rebuilt and uploaded to Runpod S3

DEPLOYMENT:
- RTX 4090 pod active (n0fq2ikt4uk0zy)
- Training: 10 trials × 50 epochs, batch_size=256
- Expected: 1.3 days, $10.41 cost

Fixes #P0-sigmoid #P0-decay-steps #hyperopt-mamba2
2025-10-28 14:11:18 +01:00

54 KiB
Raw Blame History

AGENT R3 A4: GPU Optimization Research Report

Date: 2025-10-28 Agent: Research Agent 3, Assignment 4 Mission: Research CUDA and GPU optimization techniques for sequence models Status: COMPLETE


Executive Summary

This report provides a comprehensive analysis of GPU optimization techniques for sequence models (specifically MAMBA-2, TFT, DQN, PPO) based on industry best practices from PyTorch, NVIDIA, and academic research. The findings identify 8 actionable optimization categories with expected speedups ranging from 20% to 200% (2×).

Key Findings:

  • Mixed Precision Training: 2× speedup with minimal code changes
  • Gradient Accumulation: Simulate larger batch sizes (144 → 288 effective)
  • Async Data Loading: 20-30% speedup by eliminating CPU bottleneck
  • Kernel Fusion: 10-20% speedup via torch.compile
  • Gradient Checkpointing: 2-4× larger models/batches possible

Priority Recommendations:

  1. P0 - Async Data Loading (1-2 hours, 20-30% speedup)
  2. P1 - Mixed Precision Training (2-4 hours, 2× speedup)
  3. P1 - Gradient Accumulation (1-2 hours, better convergence)
  4. P2 - torch.compile Fusion (2-4 hours, 10-20% speedup)

Table of Contents

  1. FlashAttention & Sequence Model Optimizations
  2. Mixed Precision Training (FP16/BF16)
  3. Gradient Accumulation
  4. Kernel Fusion
  5. Memory Optimization
  6. Data Loading Optimization
  7. Multi-GPU Training
  8. Profiling Tools
  9. Implementation Priority
  10. Rust/Candle Considerations

1. FlashAttention & Sequence Model Optimizations

1.1 What is FlashAttention?

FlashAttention is a highly optimized CUDA kernel for accelerating attention computations in transformer models. It addresses the fundamental problem that attention is memory-bound, not compute-bound on modern GPUs.

Key Insights:

  • GPU Memory Hierarchy: GPUs have fast SRAM (~20 MB) and slow HBM (high-bandwidth memory, 40-80 GB)
  • Standard Attention Problem: Creates N×N score matrix in slow HBM, causing excessive memory transfers
  • FlashAttention Solution: Tiles computation to fit in fast SRAM, reducing HBM transfers by 10-20×

Technical Implementation:

Standard Attention:                  FlashAttention:
Q, K, V → HBM                       Q, K, V → tiled blocks
QK^T → HBM (N×N matrix!)           QK^T computed in SRAM tiles
Softmax(QK^T) → HBM                Softmax computed incrementally
Output = Softmax × V → HBM         Output accumulated in SRAM
                                    Only final result → HBM

Performance Gains:

  • FlashAttention-1 (2022): 2-4× speedup vs standard attention
  • FlashAttention-2 (2023): 1.5-2× faster than FA-1 (optimized work partitioning)
  • FlashAttention-3 (2024): 1.5-2× faster than FA-2 on Hopper GPUs (H100)
    • Uses asynchronous Tensor Cores + TMA (Tensor Memory Accelerator)
    • Achieves 740 TFLOPS on H100 (75% of theoretical max)
    • FP8 support with incoherent processing (reduces quantization error)

1.2 Can FlashAttention Apply to MAMBA-2?

Answer: Partially, but MAMBA-2 uses different primitives.

MAMBA-2 vs Transformers:

  • Transformers: Use attention mechanism (QK^T softmax)
  • MAMBA-2: Uses Selective State Space Models (SSMs) with linear-time inference
    • No quadratic attention mechanism
    • Uses structured state matrices (A, B, C) with selectivity
    • SSM operations are already O(N) vs attention's O(N²)

Research Findings:

  • A 2024 paper ("Characterizing the Behavior of Training Mamba-based SSM Models on GPUs") analyzed MAMBA SSM bottlenecks
  • Key Finding: SSM operators dominate 30% of execution time, but are already memory-optimized
  • MAMBA-2 Advantages:
    • Linear-time inference (vs quadratic for transformers)
    • 5× throughput gains over transformers reported in original paper
    • No KV-cache overhead (transformers store keys/values for generation)

Hybrid Models (2024 trend):

  • Nemotron-H: Replaces 92% of attention with MAMBA-2 → 3× faster throughput
  • Bamba: MAMBA-2 + MoE → 2× throughput vs transformers
  • Together AI models: Replace 75% attention with MAMBA → similar accuracy, faster inference

Recommendation: MAMBA-2 is already optimized for sequence modeling. Focus on general GPU optimizations (mixed precision, data loading) rather than attention-specific kernels.

1.3 What are Fused CUDA Kernels?

Fused kernels combine multiple operations into a single GPU kernel, reducing memory transfers and kernel launch overhead.

Example - Unfused:

// Three separate kernel launches
x = layernorm(input);    // Kernel 1: HBM → compute → HBM
y = dropout(x);          // Kernel 2: HBM → compute → HBM
z = activation(y);       // Kernel 3: HBM → compute → HBM
// Total: 6 HBM transfers!

Example - Fused:

// Single kernel launch
z = fused_ln_dropout_act(input);  // Kernel 1: HBM → compute → HBM
// Total: 2 HBM transfers (3× reduction)

Common Fusion Patterns:

  • LayerNorm + Dropout
  • Bias + Activation (e.g., bias + GELU)
  • QKV projection (fuse Q, K, V matrix multiplies)
  • Residual connections + normalization

Performance Gains: 10-20% speedup by reducing memory bandwidth bottlenecks and kernel launch overhead.


2. Mixed Precision Training (FP16/BF16)

2.1 How It Works

Mixed Precision Training uses FP16 (half-precision) for most operations while keeping FP32 (single-precision) for numerically sensitive operations.

Precision Formats:

FP32 (32-bit): 1 sign bit, 8 exponent bits, 23 mantissa bits
  Range: ±3.4×10^38
  Precision: ~7 decimal digits
  Memory: 4 bytes

FP16 (16-bit): 1 sign bit, 5 exponent bits, 10 mantissa bits
  Range: ±6.5×10^4 (VERY LIMITED!)
  Precision: ~3 decimal digits
  Memory: 2 bytes

BF16 (16-bit): 1 sign bit, 8 exponent bits, 7 mantissa bits
  Range: ±3.4×10^38 (same as FP32!)
  Precision: ~2 decimal digits
  Memory: 2 bytes

Key Advantages:

  • 2× speedup: FP16/BF16 ops are 2× faster on Tensor Cores (V100+, RTX series, A100+)
  • 2× memory reduction: Can fit 2× larger models or 2× larger batch sizes
  • 2× memory bandwidth: Less data to transfer between GPU memory and compute units

Three Key Techniques:

  1. Automatic Mixed Precision (AMP): PyTorch automatically selects FP16 vs FP32 per operation

    • FP16: Matrix multiplies, convolutions (compute-bound ops)
    • FP32: Softmax, LayerNorm, loss functions (numerically sensitive)
  2. Loss Scaling: Prevents gradient underflow in FP16

    • FP16 smallest representable value: ~6×10^-5
    • Gradients often < 10^-5 → become zero!
    • Solution: Scale loss by 2^16, compute gradients, then unscale
  3. Master Weights: Optimizer maintains FP32 copy of weights

    • Training uses FP16 weights (fast compute)
    • Optimizer updates FP32 weights (precise accumulation)
    • FP32 weights → FP16 weights for next forward pass

2.2 PyTorch Implementation

Standard Training (FP32):

model = MyModel().cuda()
optimizer = optim.Adam(model.parameters(), lr=1e-3)

for epoch in range(epochs):
    for inputs, labels in dataloader:
        inputs, labels = inputs.cuda(), labels.cuda()

        optimizer.zero_grad()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

Mixed Precision Training (FP16):

from torch.cuda.amp import autocast, GradScaler

model = MyModel().cuda()
optimizer = optim.Adam(model.parameters(), lr=1e-3)
scaler = GradScaler()  # Loss scaling for FP16

for epoch in range(epochs):
    for inputs, labels in dataloader:
        inputs, labels = inputs.cuda(), labels.cuda()

        optimizer.zero_grad()

        # Forward pass in FP16
        with autocast(device_type='cuda', dtype=torch.float16):
            outputs = model(inputs)
            loss = criterion(outputs, labels)

        # Backward with gradient scaling
        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()

Changes: Only 3 lines added!

  1. scaler = GradScaler()
  2. with autocast(...): around forward pass
  3. scaler.scale(loss).backward() instead of loss.backward()
  4. scaler.step(optimizer) instead of optimizer.step()
  5. scaler.update() after step

2.3 Stability Tricks

Common Issues:

  1. Gradient underflow: Gradients become zero in FP16

    • Solution: GradScaler automatically adjusts scaling factor
    • Starts at 2^16, increases if no NaN/Inf, decreases if detected
  2. Loss divergence: Training becomes unstable

    • Solution: Keep normalization layers (BatchNorm, LayerNorm) in FP32
    • Solution: Use BF16 instead of FP16 (wider dynamic range)
  3. NaN/Inf in loss:

    • Solution: GradScaler detects NaN/Inf, skips optimizer step, reduces scale
    • Solution: Gradient clipping (scaler.unscale_() before clipping)

Gradient Clipping with AMP:

scaler.scale(loss).backward()

# Unscale gradients before clipping
scaler.unscale_(optimizer)
torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

scaler.step(optimizer)
scaler.update()

BF16 vs FP16:

  • FP16: Faster on older GPUs (V100, RTX 2000/3000 series), but less stable
  • BF16: Same range as FP32, more stable, supported on Ampere+ (A100, RTX 3090+, RTX 4000+)
  • Recommendation: Use BF16 if available (RTX 3050 Ti supports it!)

2.4 Implementation for Rust/Candle

Candle Status (as of 2024):

  • Candle supports FP16 operations via DType::F16
  • No automatic mixed precision system like PyTorch's AMP
  • Manual dtype casting required

Manual FP16 Example:

use candle_core::{DType, Device, Tensor};

let device = Device::cuda_if_available(0)?;

// Create model weights in FP16
let weight = Tensor::randn(0f32, 1., (512, 512), &device)?
    .to_dtype(DType::F16)?;

// Forward pass in FP16
let input = input.to_dtype(DType::F16)?;
let output = input.matmul(&weight)?;

// Convert back to FP32 for loss (numerically sensitive)
let output_fp32 = output.to_dtype(DType::F32)?;
let loss = mse_loss(&output_fp32, &target)?;

Challenges:

  • No automatic loss scaling (GradScaler equivalent)
  • No automatic op selection (FP16 vs FP32)
  • Manual gradient clipping required

Recommendation: Implement basic FP16 support first (P2 priority), then add loss scaling if stability issues arise.


3. Gradient Accumulation

3.1 Problem Statement

Our Issue: Optimizer wants batch size 201, but GPU memory limits us to 144.

Traditional Solution: Reduce batch size → worse convergence, longer training

Better Solution: Gradient accumulation simulates larger batch sizes without increasing memory.

3.2 How It Works

Standard Training (BS=144):

for batch in dataloader:  # Each batch has 144 samples
    optimizer.zero_grad()
    loss = model(batch)
    loss.backward()       # Compute gradients
    optimizer.step()      # Update weights immediately

Gradient Accumulation (Effective BS=288):

accumulation_steps = 2  # Simulate BS = 144 × 2 = 288

for i, batch in enumerate(dataloader):  # Each batch has 144 samples
    loss = model(batch)
    loss = loss / accumulation_steps  # Scale loss!
    loss.backward()  # Accumulate gradients (don't zero!)

    if (i + 1) % accumulation_steps == 0:
        optimizer.step()      # Update weights every 2 batches
        optimizer.zero_grad()  # Zero gradients after update

Key Points:

  1. Gradients accumulate: Don't call zero_grad() between batches
  2. Scale loss: Divide by accumulation_steps for correct gradient magnitude
  3. Update periodically: Call optimizer.step() every N batches

3.3 Memory vs Compute Trade-off

Memory Usage:

  • Forward pass: Only one batch (144 samples) in memory at a time
  • Backward pass: Gradients accumulate in parameter .grad buffers (fixed size)
  • Result: Same memory usage as BS=144!

Compute Time:

  • Standard (BS=288): 1 forward + 1 backward = 2 ops per 288 samples
  • Accumulated (BS=288): 2 forwards + 2 backwards = 4 ops per 288 samples
  • Result: 2× slower per effective batch, but enables larger effective batches

When to Use:

  • Optimizer requires larger batch sizes for convergence
  • GPU memory is the bottleneck (can't fit larger batches)
  • Training time is not critical (acceptable 2× slowdown)

3.4 Implementation with Mixed Precision

Combined AMP + Gradient Accumulation:

from torch.cuda.amp import autocast, GradScaler

scaler = GradScaler()
accumulation_steps = 2

for i, (inputs, labels) in enumerate(dataloader):
    with autocast(device_type='cuda', dtype=torch.float16):
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss = loss / accumulation_steps  # Scale loss

    # Accumulate scaled gradients
    scaler.scale(loss).backward()

    if (i + 1) % accumulation_steps == 0:
        # Optional: gradient clipping
        scaler.unscale_(optimizer)
        torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0)

        scaler.step(optimizer)
        scaler.update()
        optimizer.zero_grad()

3.5 Rust/Candle Implementation

Candle Gradient Accumulation:

let accumulation_steps = 2;
let mut accumulated_loss = 0.0;

for (i, batch) in dataloader.enumerate() {
    let output = model.forward(&batch.input)?;
    let loss = mse_loss(&output, &batch.target)?;
    let scaled_loss = loss / (accumulation_steps as f64);

    // Backward pass (gradients accumulate automatically)
    grads = scaled_loss.backward()?;
    accumulated_loss += loss.to_scalar::<f64>()?;

    if (i + 1) % accumulation_steps == 0 {
        // Update weights after N batches
        optimizer.step(&grads)?;
        optimizer.zero_grad()?;

        println!("Accumulated loss: {:.4}", accumulated_loss / accumulation_steps as f64);
        accumulated_loss = 0.0;
    }
}

3.6 Expected Improvement

For Our Use Case (BS=144 → BS=288):

  • Memory: Same (still 144 per forward pass)
  • Training Time: ~2× slower (acceptable for 30 min → 60 min training)
  • Convergence: Potentially better with larger effective batch size
  • Hyperopt: Can test batch sizes up to 288+ without OOM

Recommendation: Implement gradient accumulation (P1) to test hyperopt's BS=201 recommendation.


4. Kernel Fusion

4.1 Overview

Kernel fusion combines multiple GPU operations into a single kernel, reducing:

  1. Memory bandwidth: Fewer HBM read/write operations
  2. Kernel launch overhead: Single launch instead of multiple
  3. Intermediate storage: No need to materialize intermediate tensors

4.2 PyTorch torch.compile

torch.compile (PyTorch 2.0+) automatically fuses operations via Triton code generation.

Basic Usage:

import torch

# Define model
model = MyModel().cuda()

# Compile model (one-line change!)
model = torch.compile(model)

# Train as usual - torch.compile fuses ops automatically
for inputs, labels in dataloader:
    outputs = model(inputs)  # Fused kernels generated automatically
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

What torch.compile Does:

  1. Traces PyTorch operations during first forward pass
  2. Generates fused Triton kernels for common patterns
  3. Caches compiled kernels for subsequent runs
  4. Falls back to eager mode if tracing fails

Common Fusion Patterns:

  • Pointwise ops: Element-wise add, mul, activation functions
  • Reductions: Softmax, LayerNorm (fuse exp + sum + div)
  • Matmul + Bias + Activation: Fuse linear layer with activation
  • Attention patterns: QKV projection, softmax, output projection

4.3 Triton Custom Kernels

Triton is a Python-based GPU programming language that compiles to efficient CUDA/ROCm kernels.

Example - Fused LayerNorm + Dropout:

import triton
import triton.language as tl

@triton.jit
def fused_layernorm_dropout_kernel(
    x_ptr, out_ptr, mean_ptr, rstd_ptr,
    dropout_mask_ptr, dropout_prob, eps,
    N, BLOCK_SIZE: tl.constexpr
):
    pid = tl.program_id(0)
    block_start = pid * BLOCK_SIZE
    offsets = block_start + tl.arange(0, BLOCK_SIZE)
    mask = offsets < N

    # Load input
    x = tl.load(x_ptr + offsets, mask=mask)

    # Compute mean and variance
    mean = tl.sum(x, axis=0) / N
    x_centered = x - mean
    var = tl.sum(x_centered * x_centered, axis=0) / N
    rstd = 1.0 / tl.sqrt(var + eps)

    # Normalize
    x_norm = x_centered * rstd

    # Apply dropout
    dropout_mask = tl.rand(offsets) > dropout_prob
    x_dropout = tl.where(dropout_mask, x_norm / (1 - dropout_prob), 0.0)

    # Store output
    tl.store(out_ptr + offsets, x_dropout, mask=mask)
    tl.store(mean_ptr + pid, mean)
    tl.store(rstd_ptr + pid, rstd)
    tl.store(dropout_mask_ptr + offsets, dropout_mask, mask=mask)

Usage:

def fused_layernorm_dropout(x, dropout_prob=0.1, eps=1e-5):
    N = x.shape[-1]
    BLOCK_SIZE = 1024

    out = torch.empty_like(x)
    mean = torch.empty(x.shape[0], device=x.device)
    rstd = torch.empty(x.shape[0], device=x.device)
    dropout_mask = torch.empty_like(x, dtype=torch.bool)

    grid = lambda meta: (triton.cdiv(N, meta['BLOCK_SIZE']),)
    fused_layernorm_dropout_kernel[grid](
        x, out, mean, rstd, dropout_mask, dropout_prob, eps, N, BLOCK_SIZE
    )

    return out

4.4 torch.compile vs Triton vs CUDA

Approach Ease of Use Performance Flexibility Recommendation
torch.compile (1 line) (10-20% speedup) (automatic) Start here
Triton (Python-like) (20-50% speedup) (custom kernels) Advanced optimization
CUDA C++ (C++/CUDA) (50%+ speedup) (full control) Expert-level only

Recommendation: Start with torch.compile (P2 priority). If profiling shows specific bottlenecks, consider Triton kernels (P3).

4.5 Expected Speedup

From Research:

  • torch.compile: 10-20% speedup on typical models
  • Mirage (advanced compiler): 1.2-2.5× speedup on LLMs/GenAI
  • Custom Triton kernels: 20-50% speedup for specific patterns

For Our Models:

  • MAMBA-2: 10-15% speedup (SSM ops are already optimized)
  • TFT: 15-20% speedup (many pointwise ops, attention patterns)
  • DQN/PPO: 10-15% speedup (smaller models, less fusion opportunity)

5. Memory Optimization

5.1 Gradient Checkpointing (Activation Checkpointing)

Problem: Forward pass stores all intermediate activations for backward pass → high memory usage.

Solution: Recompute activations during backward pass instead of storing them.

Trade-off:

  • Memory: 50-80% reduction (only store checkpointed activations)
  • Compute: 30-50% slowdown (extra forward pass during backward)
  • Result: Can train 2-4× larger models or batch sizes!

5.2 How It Works

Standard Backpropagation:

Forward:  x → act1 → act2 → act3 → output
          ↓     ↓      ↓      ↓
        Store  Store  Store  Store  (High memory!)

Backward: output → act3 → act2 → act1 → x
          (Use stored activations)

Gradient Checkpointing:

Forward:  x → act1 → act2 → act3 → output
          ↓                        ↓
        Store                    Store  (Low memory!)

Backward: output → [recompute act3, act2] → act1 → x
          (Recompute missing activations on-the-fly)

5.3 PyTorch Implementation

Basic Usage:

from torch.utils.checkpoint import checkpoint

class MyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.layer1 = nn.Linear(1024, 1024)
        self.layer2 = nn.Linear(1024, 1024)
        self.layer3 = nn.Linear(1024, 1024)

    def forward(self, x):
        # Checkpoint layer1 (recompute during backward)
        x = checkpoint(self.layer1, x, use_reentrant=False)
        x = torch.relu(x)

        # Checkpoint layer2
        x = checkpoint(self.layer2, x, use_reentrant=False)
        x = torch.relu(x)

        # No checkpoint for final layer
        x = self.layer3(x)
        return x

Checkpoint Modules (PyTorch 2.1+):

from torch.utils.checkpoint import checkpoint_sequential

class MyModel(nn.Module):
    def __init__(self):
        super().__init__()
        self.layers = nn.Sequential(
            nn.Linear(1024, 1024),
            nn.ReLU(),
            nn.Linear(1024, 1024),
            nn.ReLU(),
            nn.Linear(1024, 1024),
            nn.ReLU(),
        )

    def forward(self, x):
        # Checkpoint every 2 layers
        x = checkpoint_sequential(self.layers, segments=3, input=x)
        return x

5.4 Advanced: Selective Activation Checkpointing (SAC)

Standard AC: Recomputes ALL operations in checkpointed region Selective AC: Saves specific operations (e.g., matmuls), recomputes others (e.g., activations)

Policy 1 - Don't Recompute Matmuls:

# Save matmul outputs, recompute activations only
# Matmuls are expensive, activations are cheap

Policy 2 - Memory vs Compute Trade-off:

# For memory-critical: Save less, recompute more
# For compute-critical: Save more, recompute less

PyTorch 2.4+ Support:

from torch.utils.checkpoint import selective_checkpoint_context_fn

# Define policy: which ops to save vs recompute
policy = SelectiveCheckpointingPolicy(
    save_ops=['matmul', 'conv2d'],
    recompute_ops=['relu', 'gelu', 'softmax']
)

with selective_checkpoint_context_fn(policy):
    output = model(input)

5.5 When to Use

Gradient Checkpointing is Beneficial When:

  • GPU memory is bottleneck (OOM errors, can't increase batch size)
  • Model has many layers (transformers, deep CNNs)
  • Training time is acceptable (30-50% slowdown OK)
  • Can't use smaller model (accuracy requirements)

Not Recommended When:

  • GPU memory is plentiful (< 50% utilization)
  • Model is shallow (< 10 layers)
  • Training time is critical (production deadlines)

5.6 Expected Improvement

Memory Savings:

  • Standard AC: 50-80% memory reduction
  • Selective AC: 30-50% memory reduction (less recomputation)

Compute Overhead:

  • Standard AC: 30-50% slower training
  • Selective AC: 10-20% slower training

For Our Use Case:

  • Current GPU usage: 840-865 MB / 4 GB (21%)
  • With Gradient Checkpointing: Could fit 2-4× larger models/batches
  • Recommendation: Not critical now (plenty of memory), but useful for future larger models

6. Data Loading Optimization

6.1 Current Problem

Observation from CLAUDE.md:

"CPU at 7% (data loading is synchronous)"

Root Cause: Data loading happens on CPU, blocking GPU training.

Typical Timeline (Current):

Iteration 1:
  CPU: Load batch 1 (10ms) → idle
  GPU: idle → Train on batch 1 (50ms)

Iteration 2:
  CPU: Load batch 2 (10ms) → idle
  GPU: idle → Train on batch 2 (50ms)

Total: 60ms per iteration
GPU idle time: 10ms (16.7% of time wasted!)

With Async Loading (Target):

Iteration 1:
  CPU: Load batch 1 (10ms) → Load batch 2 (10ms) → Load batch 3 (10ms)
  GPU: Train on batch 1 (50ms)

Iteration 2:
  CPU: Load batch 3 (10ms) → Load batch 4 (10ms)
  GPU: Train on batch 2 (50ms) (already loaded!)

Total: 50ms per iteration
GPU idle time: 0ms (20% speedup!)

6.2 PyTorch DataLoader Optimization

Unoptimized DataLoader:

dataloader = DataLoader(
    dataset,
    batch_size=32,
    num_workers=0,       # Single-threaded loading (SLOW!)
    pin_memory=False,    # No memory pinning
)

Optimized DataLoader:

dataloader = DataLoader(
    dataset,
    batch_size=32,
    num_workers=4,           # 4 worker processes (parallel loading)
    pin_memory=True,         # Pin memory for faster CPU→GPU transfer
    prefetch_factor=2,       # Prefetch 2 batches ahead
    persistent_workers=True, # Keep workers alive between epochs
)

Parameter Explanations:

  1. num_workers (P0 - Critical):

    • 0: Single-threaded loading on main process (SLOW)
    • 4-8: Multiple worker processes load data in parallel
    • Rule of thumb: num_workers = min(4, num_cpus // 2)
    • Impact: 20-30% speedup by eliminating CPU bottleneck
  2. pin_memory (P0 - Critical):

    • False: CPU memory is pageable (slow CPU→GPU transfer)
    • True: CPU memory is pinned (non-pageable, fast DMA transfer)
    • Impact: 10-20% faster CPU→GPU transfer
    • Note: Uses more CPU memory (minor concern)
  3. prefetch_factor (P1):

    • None: No prefetching (default when num_workers=0)
    • 2: Each worker prefetches 2 batches ahead
    • Impact: Hides data loading latency behind GPU compute
    • Trade-off: Uses more CPU memory
  4. persistent_workers (P1):

    • False: Workers are recreated every epoch (slow startup)
    • True: Workers stay alive between epochs
    • Impact: Eliminates 1-2s worker startup overhead per epoch
    • Recommended: For multi-epoch training

6.3 Custom Memory Pinning

For custom data types (non-Tensor), implement pin_memory() method:

class CustomBatch:
    def __init__(self, data):
        self.inputs = data[0]
        self.labels = data[1]

    def pin_memory(self):
        self.inputs = self.inputs.pin_memory()
        self.labels = self.labels.pin_memory()
        return self

def custom_collate(batch):
    return CustomBatch(batch)

dataloader = DataLoader(
    dataset,
    batch_size=32,
    collate_fn=custom_collate,
    pin_memory=True,  # Now works with custom types!
    num_workers=4,
)

6.4 Async Data Transfer

Use non_blocking=True for async CPU→GPU transfer:

for inputs, labels in dataloader:
    # Async transfer (doesn't block CPU)
    inputs = inputs.to('cuda', non_blocking=True)
    labels = labels.to('cuda', non_blocking=True)

    # GPU kernel launches immediately
    # Data transfer happens in parallel with compute!
    outputs = model(inputs)
    loss = criterion(outputs, labels)
    loss.backward()
    optimizer.step()

How It Works:

Without non_blocking=True:
  CPU: Transfer batch to GPU (5ms, BLOCKING)
  GPU: Idle → Train (50ms)

With non_blocking=True:
  CPU: Initiate transfer (0.1ms) → Continue to next batch
  GPU: Transfer (5ms) + Train (50ms) in parallel

Result: Data transfer is hidden behind GPU compute!

6.5 Rust/Candle Implementation

Candle currently lacks DataLoader equivalent. Manual implementation required:

use rayon::prelude::*;

// Parallel data loading with rayon
struct ParallelDataLoader {
    data: Vec<DataSample>,
    batch_size: usize,
    num_workers: usize,
}

impl ParallelDataLoader {
    fn iter_batches(&self) -> impl Iterator<Item = Vec<DataSample>> + '_ {
        self.data
            .par_chunks(self.batch_size)  // Parallel chunking
            .map(|chunk| {
                // Each worker processes one batch
                chunk.iter()
                    .map(|sample| preprocess(sample))
                    .collect()
            })
            .collect::<Vec<_>>()
            .into_iter()
    }
}

// Usage
let dataloader = ParallelDataLoader {
    data: dataset,
    batch_size: 32,
    num_workers: 4,
};

for batch in dataloader.iter_batches() {
    let input_tensor = Tensor::from_slice(&batch, &device)?;
    let output = model.forward(&input_tensor)?;
    // ... training loop
}

Limitations:

  • No built-in pin_memory equivalent
  • No prefetch_factor
  • Manual batch management

Recommendation:

  • Short-term (P0): Implement num_workers via rayon (1-2 hours)
  • Medium-term (P2): Build proper DataLoader abstraction (1 week)

6.6 Expected Improvement

For Our Use Case (CPU at 7%):

  • Current: CPU bottleneck → GPU idle time
  • With num_workers=4 + pin_memory=True: 20-30% speedup
  • With prefetch_factor=2: Additional 5-10% speedup
  • Total Expected: 25-40% training speedup

Effort vs Reward:

  • Effort: 1-2 hours (PyTorch), 4-6 hours (Rust/Candle)
  • Reward: 25-40% speedup
  • Priority: P0 (highest ROI)

7. Multi-GPU Training

7.1 Parallelism Strategies

Four Main Approaches:

  1. Data Parallelism (DP/DDP):

    • Model: Replicated on each GPU
    • Data: Split across GPUs
    • Use case: Model fits on single GPU
    • Speedup: Near-linear (0.9-0.95× per GPU)
  2. Model Parallelism (MP):

    • Model: Split across GPUs (layers 1-5 on GPU0, layers 6-10 on GPU1)
    • Data: Full batch on each stage
    • Use case: Model doesn't fit on single GPU
    • Speedup: Limited (sequential pipeline)
  3. Tensor Parallelism (TP):

    • Model: Each layer split across GPUs (matmul dimensions partitioned)
    • Data: Full batch on all GPUs
    • Use case: Very large layers (transformers, LLMs)
    • Speedup: Good for large layers
  4. Pipeline Parallelism (PP):

    • Model: Split into stages, pipelined execution
    • Data: Micro-batches flow through pipeline
    • Use case: Large models, minimize bubble time
    • Speedup: High efficiency (0.85-0.9×)

7.2 Distributed Data Parallel (DDP)

PyTorch DDP is the recommended approach for multi-GPU training when the model fits on a single GPU.

How It Works:

  1. Initialize: Each GPU gets a full copy of the model
  2. Forward: Each GPU processes different data batch
  3. Backward: Each GPU computes gradients on its batch
  4. All-Reduce: Gradients are averaged across all GPUs
  5. Update: All GPUs update model with averaged gradients

Implementation:

import torch.distributed as dist
from torch.nn.parallel import DistributedDataParallel as DDP

def train(rank, world_size):
    # Initialize process group
    dist.init_process_group("nccl", rank=rank, world_size=world_size)

    # Create model on this GPU
    model = MyModel().to(rank)
    ddp_model = DDP(model, device_ids=[rank])

    # Create distributed sampler (ensures no data overlap)
    sampler = DistributedSampler(dataset, num_replicas=world_size, rank=rank)
    dataloader = DataLoader(dataset, batch_size=32, sampler=sampler)

    # Training loop
    for inputs, labels in dataloader:
        inputs, labels = inputs.to(rank), labels.to(rank)

        outputs = ddp_model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()
        optimizer.zero_grad()

# Launch with torchrun
# torchrun --nproc_per_node=2 train.py

Key Points:

  • NCCL Backend: Optimized for NVIDIA GPUs (fastest)
  • DistributedSampler: Ensures each GPU sees different data
  • Gradient Synchronization: Automatic via DDP
  • Speedup: ~0.9-0.95× per GPU (2 GPUs → 1.8-1.9× speedup)

7.3 Runpod Multi-GPU Pricing

Current Setup: Single RTX A4000 (16 GB)

Multi-GPU Options:

Configuration Total VRAM Cost/hr Speedup Effective Cost/hr
1× RTX A4000 16 GB $0.25 1.0× $0.25
2× RTX A4000 32 GB $0.50 1.8× $0.28 (12% more)
4× RTX A4000 64 GB $1.00 3.4× $0.29 (16% more)
1× RTX A6000 48 GB $0.25 1.0× $0.25
2× RTX A6000 96 GB $0.50 1.8× $0.28 (12% more)
1× A100 40GB 40 GB $1.39 1.5× $0.93 (3.7× more!)
2× A100 40GB 80 GB $2.78 2.7× $1.03 (4.1× more)

Analysis:

  1. Best Value: 2× RTX A4000 ($0.50/hr)

    • 2× VRAM (32 GB total)
    • 1.8× speedup
    • Only 12% more cost per unit work
    • Use case: Train larger models or 2× batch size
  2. Max Throughput: 4× RTX A4000 ($1.00/hr)

    • 4× VRAM (64 GB total)
    • 3.4× speedup
    • 16% more cost per unit work
    • Use case: Hyperopt with 4 parallel trials
  3. Premium Option: A100 (not recommended)

    • 3.7× more expensive per unit work
    • Better for large-scale LLM training (not our use case)
    • Our models fit comfortably on RTX A4000

7.4 Multi-GPU Recommendation

Current Status:

  • MAMBA-2: 164 MB GPU (< 1% of 16 GB)
  • TFT: 550 MB GPU (3.4% of 16 GB)
  • DQN: 6 MB GPU (< 0.1% of 16 GB)
  • PPO: 145 MB GPU (< 1% of 16 GB)

Recommendation: Do NOT use multi-GPU for current models

Rationale:

  1. GPU underutilized: All models fit comfortably on single GPU
  2. Communication overhead: DDP synchronization (10-20 ms per batch) would dominate training time
  3. Code complexity: Additional 50-100 lines of distributed code
  4. Better alternatives: Focus on P0/P1 optimizations (async data loading, mixed precision)

When to Consider Multi-GPU:

  • Scenario 1: Hyperopt with 4+ parallel trials → Use 4× RTX A4000 pods
  • Scenario 2: Train models > 8 GB (50% of single GPU) → Use DDP
  • Scenario 3: Batch size > 512 (memory-bound) → Use DDP

Priority: P3 (Low) - Research only, not implementation


8. Profiling Tools

8.1 PyTorch Profiler

PyTorch Profiler provides detailed CPU/GPU/memory profiles with TensorBoard visualization.

Basic Usage:

import torch.profiler as profiler

model = MyModel().cuda()

with profiler.profile(
    activities=[
        profiler.ProfilerActivity.CPU,
        profiler.ProfilerActivity.CUDA,
    ],
    record_shapes=True,
    profile_memory=True,
    with_stack=True,
) as prof:
    for i, (inputs, labels) in enumerate(dataloader):
        if i >= 10:  # Profile first 10 batches
            break

        outputs = model(inputs)
        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

# Print summary
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=10))

# Export for TensorBoard
prof.export_chrome_trace("trace.json")

Analyze in TensorBoard:

# Install TensorBoard
pip install tensorboard torch-tb-profiler

# Launch TensorBoard
tensorboard --logdir=./logs

# View in browser: http://localhost:6006

What to Look For:

  1. GPU Utilization: Should be > 80% (if < 50%, CPU bottleneck)
  2. Kernel Time: Identify expensive operations (e.g., matmul, conv)
  3. Memory Allocation: Detect memory leaks or excessive allocations
  4. Data Loading Time: Should be < 10% of total time

8.2 NVIDIA Nsight Systems

Nsight Systems provides system-level profiling with CUDA kernel timelines.

Usage:

# Profile training script
nsys profile -w true -t cuda,nvtx,osrt,cudnn,cublas -s cpu \
  --capture-range=cudaProfilerApi \
  --cudabacktrace=true \
  -o my_profile \
  python train.py

# View in Nsight Systems GUI
nsys-ui my_profile.nsys-rep

Annotate Code with NVTX:

import torch.cuda.nvtx as nvtx

for epoch in range(epochs):
    nvtx.range_push(f"Epoch {epoch}")

    for i, (inputs, labels) in enumerate(dataloader):
        nvtx.range_push("data_loading")
        inputs, labels = inputs.cuda(), labels.cuda()
        nvtx.range_pop()

        nvtx.range_push("forward")
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        nvtx.range_pop()

        nvtx.range_push("backward")
        loss.backward()
        nvtx.range_pop()

        nvtx.range_push("optimizer_step")
        optimizer.step()
        optimizer.zero_grad()
        nvtx.range_pop()

    nvtx.range_pop()

What to Look For:

  1. GPU Idle Time: Large gaps between kernels → CPU bottleneck
  2. Kernel Launch Overhead: Many small kernels → fusion opportunity
  3. Memory Transfer Time: Large cudaMemcpy → pin_memory issue
  4. Synchronization Points: Blocking calls → async opportunity

8.3 NVIDIA Nsight Compute

Nsight Compute provides detailed per-kernel profiling (SM utilization, memory throughput, etc.).

Usage:

# Profile specific kernel
ncu --set full --target-processes all -o kernel_profile python train.py

# View in Nsight Compute GUI
ncu-ui kernel_profile.ncu-rep

What to Look For:

  1. SM Utilization: Should be > 60% (if < 40%, launch more threads)
  2. Memory Throughput: Identify memory-bound kernels
  3. Warp Efficiency: Detect divergence issues
  4. Register/Shared Memory Usage: Identify resource bottlenecks

8.4 Simple CPU/GPU Monitoring

nvidia-smi for real-time GPU monitoring:

# Watch GPU utilization every 1 second
watch -n 1 nvidia-smi

# Log GPU stats to file
nvidia-smi dmon -s pucvmet -o TD > gpu_stats.log &

Python In-Training Monitoring:

import time
import torch

def profile_training_loop(model, dataloader, num_batches=100):
    model.cuda()
    start = time.time()

    for i, (inputs, labels) in enumerate(dataloader):
        if i >= num_batches:
            break

        batch_start = time.time()

        # Data transfer
        transfer_start = time.time()
        inputs, labels = inputs.cuda(), labels.cuda()
        transfer_time = time.time() - transfer_start

        # Forward
        forward_start = time.time()
        outputs = model(inputs)
        loss = criterion(outputs, labels)
        forward_time = time.time() - forward_start

        # Backward
        backward_start = time.time()
        loss.backward()
        backward_time = time.time() - backward_start

        # Optimizer
        optim_start = time.time()
        optimizer.step()
        optimizer.zero_grad()
        optim_time = time.time() - optim_start

        batch_time = time.time() - batch_start

        if i % 10 == 0:
            print(f"Batch {i}: Total={batch_time*1000:.2f}ms, "
                  f"Transfer={transfer_time*1000:.2f}ms ({transfer_time/batch_time*100:.1f}%), "
                  f"Forward={forward_time*1000:.2f}ms ({forward_time/batch_time*100:.1f}%), "
                  f"Backward={backward_time*1000:.2f}ms ({backward_time/batch_time*100:.1f}%), "
                  f"Optim={optim_time*1000:.2f}ms ({optim_time/batch_time*100:.1f}%)")

    total_time = time.time() - start
    print(f"\nTotal time: {total_time:.2f}s, Avg per batch: {total_time/num_batches*1000:.2f}ms")
    print(f"GPU Memory: {torch.cuda.max_memory_allocated()/1e9:.2f} GB")

8.5 Rust/Candle Profiling

Candle Profiling (limited support):

use std::time::Instant;

fn profile_training() -> Result<()> {
    let device = Device::cuda_if_available(0)?;

    let start = Instant::now();

    for i in 0..100 {
        let batch_start = Instant::now();

        // Forward
        let forward_start = Instant::now();
        let output = model.forward(&input)?;
        let forward_time = forward_start.elapsed();

        // Backward
        let backward_start = Instant::now();
        let grads = loss.backward()?;
        let backward_time = backward_start.elapsed();

        // Optimizer
        let optim_start = Instant::now();
        optimizer.step(&grads)?;
        let optim_time = optim_start.elapsed();

        let batch_time = batch_start.elapsed();

        if i % 10 == 0 {
            println!("Batch {}: Total={:.2}ms, Forward={:.2}ms ({:.1}%), Backward={:.2}ms ({:.1}%), Optim={:.2}ms ({:.1}%)",
                i,
                batch_time.as_secs_f64() * 1000.0,
                forward_time.as_secs_f64() * 1000.0,
                forward_time.as_secs_f64() / batch_time.as_secs_f64() * 100.0,
                backward_time.as_secs_f64() * 1000.0,
                backward_time.as_secs_f64() / batch_time.as_secs_f64() * 100.0,
                optim_time.as_secs_f64() * 1000.0,
                optim_time.as_secs_f64() / batch_time.as_secs_f64() * 100.0,
            );
        }
    }

    let total_time = start.elapsed();
    println!("\nTotal time: {:.2}s, Avg per batch: {:.2}ms",
        total_time.as_secs_f64(),
        total_time.as_secs_f64() / 100.0 * 1000.0
    );

    Ok(())
}

8.6 Profiling Checklist

Before Optimization:

  1. Run PyTorch Profiler (10 batches)
  2. Check GPU utilization (should be > 80%)
  3. Identify top 5 expensive operations
  4. Measure data loading time (should be < 10%)

After Each Optimization:

  1. Re-run profiler with same settings
  2. Compare before/after metrics
  3. Verify speedup matches expectations
  4. Check for regression in accuracy

9. Implementation Priority

9.1 Priority Matrix

Optimization Effort Speedup Memory Priority ETA
Async Data Loading 1-2h 25-40% 0% P0 1 day
Mixed Precision (FP16) 2-4h 100% (2×) 50% P1 2 days
Gradient Accumulation 1-2h 0% (better convergence) 0% P1 1 day
torch.compile Fusion 2-4h 10-20% 0% P2 3 days
Gradient Checkpointing 2-4h -30% (slower) 50-80% P2 3 days
Multi-GPU (DDP) 1 week 80% per GPU 0% P3 1 week
Custom Triton Kernels 2-4 weeks 20-50% 0% P3 1 month

9.2 Implementation Roadmap

Phase 1: Quick Wins (Week 1) - Total Expected: 2.5-3× speedup

  1. Day 1 - Async Data Loading (P0):

    • PyTorch: Add num_workers=4, pin_memory=True, prefetch_factor=2
    • Rust/Candle: Implement rayon-based parallel loading
    • Expected: 25-40% speedup
    • Validation: Profile data loading time (should be < 5%)
  2. Day 2-3 - Mixed Precision (P1):

    • PyTorch: Add autocast + GradScaler
    • Rust/Candle: Implement manual FP16 casting
    • Expected: 2× speedup + 50% memory reduction
    • Validation: Compare loss/accuracy vs FP32
  3. Day 4 - Gradient Accumulation (P1):

    • Implement accumulation loop (2× effective batch size)
    • Test with hyperopt's BS=201 recommendation
    • Expected: Better convergence, same memory
    • Validation: Compare final loss vs BS=144

Phase 2: Medium Gains (Week 2-3) - Total Expected: 3-3.5× speedup

  1. Day 5-7 - torch.compile Fusion (P2):

    • Add torch.compile(model) (PyTorch only)
    • Profile before/after kernel times
    • Expected: 10-20% additional speedup
    • Validation: Check GPU utilization (should be > 85%)
  2. Day 8-10 - Gradient Checkpointing (P2):

    • Add checkpoint() to large models (TFT, MAMBA-2)
    • Test with 2× larger batch sizes
    • Expected: 50-80% memory reduction
    • Validation: Verify 30-50% compute overhead acceptable

Phase 3: Advanced (Optional, Month 2+) - Research only

  1. Week 5-8 - Multi-GPU DDP (P3):

    • Only if training time > 2 hours
    • Only if model > 50% single GPU memory
    • Expected: 1.8× speedup per 2 GPUs
    • Cost: +12% effective cost/hr
  2. Month 2+ - Custom Triton Kernels (P3):

    • Only if profiler shows specific bottlenecks
    • Requires CUDA expertise
    • Expected: 20-50% speedup for specific ops
    • Effort: 2-4 weeks per kernel

9.3 Success Metrics

Phase 1 Targets (Week 1):

  • Training time: ~2 min → ~45 sec (2.7× speedup)
  • GPU utilization: 60% → 85%+
  • CPU utilization: 7% → 40-60%
  • GPU memory: 840 MB → 420 MB (FP16)
  • Accuracy: Within 1% of FP32 baseline

Phase 2 Targets (Week 2-3):

  • Training time: ~45 sec → ~35 sec (3.4× total speedup)
  • GPU utilization: 85% → 90%+
  • Batch size: 144 → 288 (via gradient accumulation)
  • Memory headroom: 50% available for larger models

Long-Term Targets (Month 2+):

  • Training time: ~35 sec → ~20 sec (6× total speedup)
  • Multi-GPU scaling: 1.8× per 2 GPUs
  • Production-ready: < 30 sec training time for hyperopt

10. Rust/Candle Considerations

10.1 Candle Limitations (as of 2024)

Compared to PyTorch:

Feature PyTorch Candle Impact
Mixed Precision (AMP) Full support ⚠️ Manual FP16 casting Medium
Gradient Accumulation Built-in Manual implementation Low
torch.compile Automatic fusion No equivalent High
DataLoader Full-featured Manual implementation High
Gradient Checkpointing Built-in No equivalent Medium
DDP Multi-GPU NCCL support ⚠️ Limited support High
Profiling PyTorch Profiler ⚠️ Manual timing Medium

10.2 Candle Performance vs PyTorch

From Community Reports:

  • Inference: Candle competitive with PyTorch (within 10%)
  • Training: Candle 20-50% slower (less optimization)
  • Memory: Candle similar to PyTorch (no AMP = higher memory)

Performance Comparison (Llama-7B, M1 Mac):

Generation Speed:
1. Llama.cpp: Fastest
2. Candle: 10-20% slower than Llama.cpp
3. MLX: 20-30% slower than Candle

10.3 Candle Optimization Strategy

Short-Term (Phase 1):

  1. Async Data Loading: Implement with rayon (1-2 days)
  2. Manual FP16: Convert weights to FP16, profile stability (2-3 days)
  3. Gradient Accumulation: Implement loop (1 day)

Medium-Term (Phase 2): 4. Custom DataLoader: Build proper abstraction (1 week) 5. Loss Scaling: Implement GradScaler equivalent (1 week)

Long-Term (Phase 3): 6. Contribute to Candle: Submit PRs for missing features 7. Monitor Candle Roadmap: AMP, checkpointing may be added

10.4 Recommendation: Hybrid Approach

Option 1: PyTorch for Training, Candle for Inference

  • Use PyTorch AMP, torch.compile for fast training
  • Export models to safetensors
  • Use Candle for fast Rust inference
  • Best for: Production systems requiring Rust inference

Option 2: Full PyTorch Stack

  • Leverage mature PyTorch ecosystem
  • All optimizations available (AMP, DDP, torch.compile)
  • Better debugging tools
  • Best for: Research, rapid iteration

Option 3: Full Candle Stack (Current)

  • ⚠️ Manual implementation required for many optimizations
  • ⚠️ 20-50% slower training vs PyTorch
  • Single-language codebase (Rust)
  • Best for: Rust-first teams, inference-focused

Recommendation for Foxhunt:

  • Short-term: Stay with Candle, implement P0/P1 optimizations manually (1-2 weeks)
  • Medium-term: Evaluate PyTorch for training if Candle performance is insufficient (Week 4)
  • Long-term: Use Candle for inference, PyTorch for training (hybrid stack)

11. Action Items

11.1 Immediate Next Steps (This Week)

Day 1 (Today): Research complete

  • Review this report
  • Prioritize optimizations based on business needs

Day 2: Implement P0 - Async Data Loading

// Rust/Candle implementation
// File: ml/src/data/parallel_loader.rs

use rayon::prelude::*;

pub struct ParallelDataLoader {
    data: Vec<DataSample>,
    batch_size: usize,
    num_workers: usize,
}

impl ParallelDataLoader {
    pub fn new(data: Vec<DataSample>, batch_size: usize, num_workers: usize) -> Self {
        Self { data, batch_size, num_workers }
    }

    pub fn iter_batches(&self) -> impl Iterator<Item = Vec<Tensor>> + '_ {
        let pool = rayon::ThreadPoolBuilder::new()
            .num_threads(self.num_workers)
            .build()
            .unwrap();

        pool.install(|| {
            self.data
                .par_chunks(self.batch_size)
                .map(|chunk| preprocess_batch(chunk))
                .collect::<Vec<_>>()
        })
        .into_iter()
    }
}

Day 3-4: Implement P1 - Mixed Precision

// Rust/Candle manual FP16
// File: ml/src/trainers/mixed_precision.rs

pub struct MixedPrecisionTrainer {
    model: Box<dyn Model>,
    loss_scale: f32,
}

impl MixedPrecisionTrainer {
    pub fn train_step(&mut self, batch: &Batch) -> Result<f32> {
        // Convert input to FP16
        let input_fp16 = batch.input.to_dtype(DType::F16)?;

        // Forward in FP16
        let output_fp16 = self.model.forward(&input_fp16)?;

        // Convert to FP32 for loss
        let output_fp32 = output_fp16.to_dtype(DType::F32)?;
        let loss = mse_loss(&output_fp32, &batch.target)?;

        // Scale loss for gradient stability
        let scaled_loss = loss * self.loss_scale;

        // Backward (gradients in FP32)
        let grads = scaled_loss.backward()?;

        // Unscale gradients
        let unscaled_grads = grads.iter()
            .map(|g| g / self.loss_scale)
            .collect();

        Ok(loss.to_scalar()?)
    }
}

Day 5: Implement P1 - Gradient Accumulation

// File: ml/src/trainers/gradient_accumulation.rs

pub fn train_with_accumulation(
    model: &mut dyn Model,
    dataloader: &ParallelDataLoader,
    accumulation_steps: usize,
) -> Result<()> {
    let mut accumulated_loss = 0.0;

    for (i, batch) in dataloader.iter_batches().enumerate() {
        let loss = model.forward(&batch)?;
        let scaled_loss = loss / (accumulation_steps as f64);

        // Backward (gradients accumulate)
        let grads = scaled_loss.backward()?;
        accumulated_loss += loss.to_scalar::<f64>()?;

        if (i + 1) % accumulation_steps == 0 {
            optimizer.step(&grads)?;
            optimizer.zero_grad()?;

            println!("Accumulated loss: {:.4}",
                accumulated_loss / accumulation_steps as f64);
            accumulated_loss = 0.0;
        }
    }

    Ok(())
}

11.2 Testing Plan

Performance Validation:

  1. Baseline metrics (before optimizations)
  2. After P0 (async loading): 25-40% speedup
  3. After P1 (mixed precision): 2× speedup (cumulative 2.5-3×)
  4. After P1 (gradient accumulation): Convergence improvement

Accuracy Validation:

  1. FP32 baseline accuracy
  2. FP16 accuracy (should be within 1%)
  3. Gradient accumulation accuracy (should match or improve)

Stability Testing:

  1. No NaN/Inf in loss (check every 10 batches)
  2. Gradient magnitudes in reasonable range (1e-5 to 1e5)
  3. Memory usage stable (no leaks)

12. Conclusion

12.1 Summary

This research report identifies 8 GPU optimization categories with actionable implementations for the Foxhunt HFT trading system. The highest ROI optimizations are:

  1. Async Data Loading (P0): 25-40% speedup, 1-2 hours effort
  2. Mixed Precision (P1): 2× speedup + 50% memory reduction, 2-4 hours effort
  3. Gradient Accumulation (P1): Better convergence, 1-2 hours effort

Combined Expected Speedup: 2.5-3× (150-200%) with minimal code changes.

12.2 Key Insights

FlashAttention:

  • Transforms attention from O(N²) to O(N) memory
  • 2-4× speedup for transformers
  • Not directly applicable to MAMBA-2 (uses SSMs, not attention)
  • MAMBA-2 already optimized for sequence modeling

Mixed Precision:

  • Industry standard for GPU training (2× speedup)
  • PyTorch AMP: 3 lines of code
  • Rust/Candle: Manual implementation required
  • BF16 recommended over FP16 (better stability, same speed)

Data Loading:

  • Currently CPU-bound (7% CPU utilization)
  • num_workers + pin_memory = 25-40% speedup
  • Highest ROI optimization for our use case

Multi-GPU:

  • Not recommended for current models (< 1 GB each)
  • Only beneficial for models > 8 GB or batch size > 512
  • 2× RTX A4000 = best value if needed ($0.50/hr, 1.8× speedup)

12.3 Foxhunt-Specific Recommendations

Immediate Actions (Week 1):

  1. Implement async data loading (P0)
  2. Implement mixed precision (P1)
  3. Implement gradient accumulation (P1)
  4. Profile before/after for validation

Medium-Term (Week 2-4):

  1. Add torch.compile fusion (PyTorch only)
  2. Evaluate gradient checkpointing for larger models
  3. Monitor Candle roadmap for AMP support

Long-Term (Month 2+):

  1. Evaluate hybrid PyTorch (training) + Candle (inference)
  2. Consider multi-GPU for hyperopt parallelism
  3. Contribute AMP implementation to Candle project

12.4 Final Thoughts

The optimization journey is iterative:

  1. Measure: Profile current performance (bottlenecks, GPU utilization)
  2. Optimize: Implement highest ROI optimizations first
  3. Validate: Verify speedup and accuracy
  4. Repeat: Move to next optimization

Don't optimize blindly:

  • Profile first, optimize second
  • Focus on bottlenecks (Amdahl's Law)
  • Premature optimization is the root of all evil

With P0/P1 optimizations, we expect:

  • Training time: 2 min → 45 sec (2.7× speedup)
  • GPU memory: 840 MB → 420 MB (2× capacity)
  • GPU utilization: 60% → 85%+ (better hardware usage)
  • Throughput: 3-4× more training runs per hour

This positions Foxhunt for:

  • Faster hyperparameter tuning (3-4× more trials)
  • Larger models (2× capacity via FP16)
  • Better convergence (gradient accumulation)
  • Production-ready training times (< 1 min)

References

Academic Papers

  1. Dao et al., "FlashAttention: Fast and Memory-Efficient Exact Attention with IO-Awareness", NeurIPS 2022
  2. Dao et al., "FlashAttention-2: Faster Attention with Better Parallelism and Work Partitioning", ICLR 2023
  3. Shah et al., "FlashAttention-3: Fast and Accurate Attention with Asynchrony and Low-precision", 2024
  4. Gu et al., "Mamba: Linear-Time Sequence Modeling with Selective State Spaces", arXiv:2312.00752, 2023
  5. Micikevicius et al., "Mixed Precision Training", ICLR 2018
  6. Chen et al., "Training Deep Nets with Sublinear Memory Cost", arXiv:1604.06174, 2016

Documentation

Industry Resources


Report Complete Next Step: Review with team, prioritize P0/P1 implementations Expected Timeline: Week 1 (async loading + mixed precision) Expected Outcome: 2.5-3× training speedup