perf(gpu): zero-sync gradient clipping — eliminate all GPU→CPU barriers from hot path

clip_grad_norm now returns a GPU-resident Tensor instead of (f64, f64),
keeping backward → clip → optimizer.step fully pipelined on GPU with
zero cuStreamSynchronize stalls. The unconditional multiply trick
(scale = min(max_norm/(norm+eps), 1.0)) avoids the conditional branch
that previously required reading the norm to CPU.

Key changes:
- gradient_utils::clip_grad_norm: return Tensor, unconditional GPU multiply
- Handle BF16 mixed-precision via per-gradient to_dtype cast
- adam.rs: backward_step_with_monitoring returns Tensor (zero sync)
- gradient_accumulation: delegate to gradient_utils (DRY, same GPU path)
- dqn.rs: single sync boundary after ALL GPU work queued

1746 tests passing (ml-core 286, ml-dqn 388, ml-ppo 198, ml 874).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jgrusewski
2026-03-09 20:26:45 +01:00
parent db65eb56a5
commit ee12f1d9e4
4 changed files with 113 additions and 147 deletions

View File

@@ -106,41 +106,20 @@ pub fn scale_grads(grads: &mut GradStore, vars: &[Var], scale: f64) -> Result<()
Ok(())
}
/// Clip gradients by global L2 norm.
/// Clip gradients by global L2 norm — **fully GPU-resident, zero CPU sync**.
///
/// If the total L2 norm of all gradients exceeds `max_norm`, all gradients
/// are scaled down proportionally so the total norm equals `max_norm`.
/// This is equivalent to PyTorch's `torch.nn.utils.clip_grad_norm_`.
/// Delegates to `crate::gradient_utils::clip_grad_norm` which computes
/// the norm and applies `min(max_norm / (norm + eps), 1.0)` scaling
/// entirely on GPU.
///
/// Returns the original (pre-clip) gradient norm for logging.
/// Returns the pre-clip gradient norm as a scalar `f64` (single `to_scalar`
/// readback after all GPU work is complete).
pub fn clip_grads(grads: &mut GradStore, vars: &[Var], max_norm: f64) -> Result<f64, MLError> {
// Compute total L2 norm
let mut total_norm_sq = 0.0_f64;
for var in vars {
if let Some(grad) = grads.get(var) {
let norm_sq = grad
.sqr()
.map_err(|e| {
MLError::TrainingError(format!("Failed to square gradient for clipping: {}", e))
})?
.sum_all()
.map_err(|e| {
MLError::TrainingError(format!("Failed to sum gradient for clipping: {}", e))
})?
.to_vec0::<f32>()
.map_err(|e| {
MLError::TrainingError(format!("Failed to extract norm for clipping: {}", e))
})? as f64;
total_norm_sq += norm_sq;
}
}
let total_norm = total_norm_sq.sqrt();
if total_norm > max_norm && total_norm > 0.0 {
let clip_coef = max_norm / total_norm;
scale_grads(grads, vars, clip_coef)?;
}
let norm_tensor = crate::gradient_utils::clip_grad_norm(vars, grads, max_norm)
.map_err(|e| MLError::TrainingError(format!("Gradient clipping failed: {}", e)))?;
// Single GPU→CPU sync — caller typically has no further GPU work pending
let total_norm = norm_tensor.to_vec1::<f32>()
.map_err(|e| MLError::TrainingError(format!("Failed to read gradient norm: {}", e)))?[0] as f64;
Ok(total_norm)
}
@@ -151,31 +130,12 @@ pub fn clip_grads(grads: &mut GradStore, vars: &[Var], max_norm: f64) -> Result<
/// to CPU (potentially megabytes). If any element is NaN, the sum is NaN;
/// if any element is Inf, the sum is Inf.
///
/// Returns `Err` with the variable index if any gradient is non-finite.
/// Returns `Err` if any gradient is non-finite.
///
/// Accumulates sum-of-sums on GPU — single `to_scalar` at the end.
/// If ANY element is NaN the total is NaN; if Inf the total is Inf.
pub fn check_gradients_finite(grads: &GradStore, vars: &[Var]) -> Result<(), MLError> {
for (idx, var) in vars.iter().enumerate() {
if let Some(grad) = grads.get(var) {
let sum = grad
.sum_all()
.and_then(|t| t.to_vec0::<f32>())
.map_err(|e| {
MLError::TrainingError(format!(
"Failed to check gradient for param {}: {}",
idx, e
))
})?;
if !sum.is_finite() {
return Err(MLError::TrainingError(format!(
"NaN/Inf gradient detected in parameter {} (shape: {:?}). \
Halting training to prevent model corruption.",
idx,
grad.shape()
)));
}
}
}
Ok(())
crate::gradient_utils::check_gradients_finite(vars, grads)
}
#[cfg(test)]

View File

@@ -3,66 +3,76 @@
//! Provides gradient clipping, NaN/Inf detection, and other gradient-related
//! operations that are missing from candle_nn::optim.
//!
//! **GPU hot-path rule**: All norm/sum computations accumulate on a GPU-resident
//! tensor. Only ONE `to_scalar()` call per function invocation — never inside
//! a loop over parameter tensors.
//! **GPU hot-path rule**: ZERO `to_scalar()` / `to_vec*()` calls in any
//! function on this module's critical path. All norm/sum computations
//! stay on GPU-resident tensors. Callers that need a CPU scalar must
//! call `Tensor::to_scalar()` themselves — typically AFTER `optimizer.step()`
//! so the readback piggybacks on an already-flushed pipeline.
use candle_core::{backprop::GradStore, DType, Error, Tensor, Var};
use crate::MLError;
/// Clip gradients by global L2 norm (similar to PyTorch's clip_grad_norm_)
/// Clip gradients by global L2 norm — **fully GPU-resident, zero CPU sync**.
///
/// Computes the L2 norm of all gradients entirely on GPU, with a single
/// `to_scalar()` at the end. Previous implementation called `to_scalar()`
/// per parameter tensor (~16-20 GPU→CPU sync barriers per step).
/// Computes the L2 norm of all gradients on GPU, then unconditionally
/// multiplies every gradient by `min(max_norm / (norm + eps), 1.0)`.
/// When the norm is already below `max_norm` the scale factor is clamped
/// to 1.0, making the multiply a no-op in terms of gradient values.
///
/// # Arguments
/// * `vars` - Slice of Var containing model parameters
/// * `grads` - Mutable reference to GradStore from loss.backward()
/// * `max_norm` - Maximum allowed gradient norm
/// **Why unconditional multiply?** A conditional `if norm > max_norm`
/// requires reading the norm to CPU (`to_scalar()`), which inserts a
/// `cuStreamSynchronize` barrier between backward() and optimizer.step().
/// On an H100 this costs ~5-20µs of pure stall per training step — the
/// GPU cannot overlap the two kernel launches. The unconditional GPU
/// multiply costs < 1µs and keeps the pipeline fully saturated.
///
/// # Returns
/// A tuple of (actual_norm, clipped_norm) where:
/// - actual_norm: The total gradient norm before clipping
/// - clipped_norm: The gradient norm after clipping (≤ max_norm)
/// A `[1]`-shaped F32 tensor containing the pre-clip gradient norm,
/// resident on the **same device** as the gradients. The caller can
/// read it with `to_scalar::<f32>()` at a convenient sync boundary
/// (e.g. after `optimizer.step()` + `loss.to_scalar()`).
pub fn clip_grad_norm(
vars: &[Var],
grads: &mut GradStore,
max_norm: f64,
) -> Result<(f64, f64), Error> {
) -> Result<Tensor, Error> {
// Determine device from first gradient tensor (fall back to CPU)
let device = vars.iter()
.find_map(|v| grads.get(v).map(|g| g.device().clone()))
.unwrap_or(candle_core::Device::Cpu);
// Accumulate squared norms on a GPU-resident scalar tensor.
// ZERO to_scalar() calls in this loop — all arithmetic stays on device.
let mut norm_sq_acc = Tensor::zeros(&[], DType::F32, &device)?;
// ZERO to_scalar() calls — all arithmetic stays on device.
let mut norm_sq_acc = Tensor::zeros(&[1], DType::F32, &device)?;
for var in vars.iter() {
if let Some(grad) = grads.get(var) {
let partial = grad.sqr()?.sum_all()?.to_dtype(DType::F32)?;
let partial = grad.sqr()?.sum_all()?.to_dtype(DType::F32)?.reshape(&[1])?;
norm_sq_acc = (norm_sq_acc + partial)?;
}
}
// Single GPU→CPU sync: read the accumulated norm²
let total_norm_sq = norm_sq_acc.to_scalar::<f32>()? as f64;
let total_norm = total_norm_sq.sqrt();
// GPU-resident norm (never leaves the device)
let norm = norm_sq_acc.sqrt()?;
// Scale gradients if the norm exceeds the maximum
if total_norm > max_norm {
let scale_factor = max_norm / (total_norm + 1e-6);
for var in vars.iter() {
if let Some(grad) = grads.remove(var) {
let scaled_grad = (grad * scale_factor)?;
grads.insert(var, scaled_grad);
}
// scale = min(max_norm / (norm + eps), 1.0) — entirely on GPU
let max_norm_t = Tensor::new(&[max_norm as f32], &device)?;
let eps_t = Tensor::new(&[1e-6_f32], &device)?;
let scale = (&max_norm_t / (&norm + &eps_t)?)?
.clamp(0.0_f64, 1.0)?;
// Unconditionally multiply every gradient by `scale`.
// When norm ≤ max_norm, scale ≈ 1.0 so values are unchanged.
// Cast scale to each gradient's dtype to handle mixed-precision (BF16) models.
for var in vars.iter() {
if let Some(grad) = grads.remove(var) {
let scale_cast = scale.to_dtype(grad.dtype())?;
let scaled_grad = grad.broadcast_mul(&scale_cast)?;
grads.insert(var, scaled_grad);
}
Ok((total_norm, max_norm))
} else {
Ok((total_norm, total_norm))
}
Ok(norm)
}
/// Check if any gradient in the GradStore contains NaN or Inf values.
@@ -182,9 +192,13 @@ mod tests {
let mut grads = loss.backward().expect("backward");
// grad = [2, 4, 6], norm = sqrt(4+16+36) = sqrt(56) ≈ 7.48
let (actual, clipped) = clip_grad_norm(&[var], &mut grads, 100.0).expect("clip");
let norm_tensor = clip_grad_norm(&[var.clone()], &mut grads, 100.0).expect("clip");
let actual = norm_tensor.to_vec1::<f32>().expect("read")[0] as f64;
assert!((actual - 7.483_f64).abs() < 0.01);
assert!((clipped - actual).abs() < 1e-6, "Should not clip");
// Gradients should be unchanged (norm < max_norm → scale ≈ 1.0)
let grad = grads.get(&var).expect("grad exists");
let g_vec = grad.to_vec1::<f32>().expect("read");
assert!((g_vec[0] - 2.0).abs() < 0.02, "grad[0] should be ~2.0, got {}", g_vec[0]);
}
#[test]
@@ -198,8 +212,13 @@ mod tests {
let mut grads = loss.backward().expect("backward");
// grad = [2, 4, 6], norm ≈ 7.48, clip to 1.0
let (actual, clipped) = clip_grad_norm(&[var], &mut grads, 1.0).expect("clip");
let norm_tensor = clip_grad_norm(&[var.clone()], &mut grads, 1.0).expect("clip");
let actual = norm_tensor.to_vec1::<f32>().expect("read")[0] as f64;
assert!(actual > 1.0);
assert!((clipped - 1.0).abs() < 1e-6, "Should clip to max_norm");
// Gradients should be scaled down: new_norm ≈ 1.0
let grad = grads.get(&var).expect("grad exists");
let g_vec = grad.to_vec1::<f32>().expect("read");
let new_norm = (g_vec[0] * g_vec[0] + g_vec[1] * g_vec[1] + g_vec[2] * g_vec[2]).sqrt();
assert!((new_norm - 1.0).abs() < 0.05, "Clipped norm should be ~1.0, got {}", new_norm);
}
}

View File

@@ -124,48 +124,32 @@ impl Adam {
///
/// # Returns
///
/// Returns `Ok(gradient_norm)` on success with the actual gradient norm (before clipping),
/// or `Err(MLError::TrainingError)` on failure
/// Returns `Ok(norm_tensor)` — a GPU-resident `[1]` F32 tensor with the
/// pre-clip gradient norm. The caller reads it with `to_scalar::<f32>()`
/// at a convenient sync boundary (typically after `loss.to_scalar()`).
///
/// **Zero GPU→CPU sync** inside this function: backward → clip → opt.step
/// all stay on device, allowing the GPU to pipeline them without stalls.
pub fn backward_step_with_monitoring(
&mut self,
loss: &Tensor,
max_norm: f64,
) -> Result<f64, MLError> {
// Bug fix: Single backward pass to prevent gradient accumulation
// Root cause: Two backward passes caused 1.5x gradient amplification
// Solution: Compute gradients once, then scale loss before optimizer step if needed
// 1. Compute gradients via backward pass
) -> Result<Tensor, MLError> {
// 1. Compute gradients via backward pass (GPU-only)
let mut grads = loss
.backward()
.map_err(|e| MLError::TrainingError(format!("Backward pass failed: {}", e)))?;
// 2. Apply gradient clipping IN-PLACE (Bug #32 fix - gradient explosion)
// This modifies the grads directly before optimizer step
let (actual_grad_norm, clipped_grad_norm) = crate::gradient_utils::clip_grad_norm(&self.vars, &mut grads, max_norm)
// 2. Clip gradients entirely on GPU — returns norm tensor, zero to_scalar
let norm_tensor = crate::gradient_utils::clip_grad_norm(&self.vars, &mut grads, max_norm)
.map_err(|e| MLError::TrainingError(format!("Gradient clipping failed: {}", e)))?;
// Log gradient norms at trace level (use RUST_LOG=trace to see)
tracing::trace!(
"Gradient norm before clipping: {:.4}, after: {:.4}, max_norm: {:.4}",
actual_grad_norm,
clipped_grad_norm,
max_norm
);
// 3. Apply optimizer step with clipped gradients
// 3. Apply optimizer step with clipped gradients (GPU-only)
Optimizer::step(&mut self.optimizer, &grads)
.map_err(|e| MLError::TrainingError(format!("Optimizer step failed: {}", e)))?;
if actual_grad_norm > max_norm {
tracing::debug!(
"Gradient clipping: {:.4} -> {:.4}",
actual_grad_norm,
clipped_grad_norm
);
}
Ok(clipped_grad_norm)
// norm_tensor stays on GPU — caller decides when to read it
Ok(norm_tensor)
}
/// Perform backward pass with gradient clipping but WITHOUT an optimizer step.
@@ -193,18 +177,18 @@ impl Adam {
&self,
loss: &Tensor,
max_norm: f64,
) -> Result<(GradStore, f64), MLError> {
// Compute gradients via backward pass
) -> Result<(GradStore, Tensor), MLError> {
// Compute gradients via backward pass (GPU-only)
let mut grads = loss
.backward()
.map_err(|e| MLError::TrainingError(format!("Backward pass failed: {}", e)))?;
// Apply gradient clipping in-place
let (_actual_norm, clipped_norm) =
// Clip gradients entirely on GPU — returns norm tensor, zero to_scalar
let norm_tensor =
crate::gradient_utils::clip_grad_norm(&self.vars, &mut grads, max_norm)
.map_err(|e| MLError::TrainingError(format!("Gradient clipping failed: {}", e)))?;
Ok((grads, clipped_norm))
Ok((grads, norm_tensor))
}
/// Apply pre-computed gradients to the optimizer.

View File

@@ -3290,15 +3290,13 @@ impl DQN {
let result = self.compute_loss_internal(batch)?;
// Backward pass with gradient monitoring (Adam provides natural stabilization)
let grad_norm = if let Some(ref mut optimizer) = self.optimizer {
let norm = optimizer
// Returns GPU-resident norm tensor — zero CPU sync until loss readback below.
let grad_norm_tensor = if let Some(ref mut optimizer) = self.optimizer {
optimizer
.backward_step_with_monitoring(&result.loss_tensor, self.gradient_clip_norm)
.map_err(|e| {
MLError::TrainingError(format!("Backward step with monitoring failed: {}", e))
})?;
tracing::debug!("Gradient norm: {:.4}", norm);
norm as f32
})?
} else {
return Err(MLError::TrainingError(
"Optimizer not initialized".to_owned(),
@@ -3329,23 +3327,26 @@ impl DQN {
// Step beta annealing for PER
self.memory.step();
// Q-value monitoring every 500 steps (GPU→CPU sync each call)
if self.training_steps % 500 == 0 {
self.log_q_values(&result.states_tensor)?;
}
// Dead neuron detection every 1000 steps
if self.training_steps % 1000 == 0 {
self.log_diagnostics(grad_norm)?;
}
// Update target network with cosine-annealed EMA or hard updates
self.update_target_networks()?;
// Deferred loss readback: AFTER backward pass, the GPU pipeline is already
// flushed by grad_norm readback — this to_scalar() is near-free.
// === Single GPU→CPU sync boundary ===
// All GPU work (forward → backward → clip → opt.step → target update)
// was queued without any CPU sync. This to_scalar() flushes the entire
// pipeline in one shot. The grad_norm readback piggybacks for free.
let loss_value = result.loss_f32_tensor
.to_scalar::<f32>()
.map_err(|e| MLError::TrainingError(format!("Deferred loss readback: {}", e)))?;
let grad_norm = grad_norm_tensor.to_vec1::<f32>()
.map_err(|e| MLError::TrainingError(format!("Deferred grad norm readback: {}", e)))?[0];
// Amortized monitoring (AFTER the sync boundary — these reads are free)
if self.training_steps % 500 == 0 {
self.log_q_values(&result.states_tensor)?;
}
if self.training_steps % 1000 == 0 {
self.log_diagnostics(grad_norm)?;
}
Ok((loss_value, grad_norm))
}
@@ -3372,8 +3373,8 @@ impl DQN {
let result = self.compute_loss_internal(batch)?;
// Backward + clip WITHOUT optimizer step
let (grads, grad_norm) = if let Some(ref optimizer) = self.optimizer {
// Backward + clip WITHOUT optimizer step — zero GPU→CPU sync
let (grads, grad_norm_tensor) = if let Some(ref optimizer) = self.optimizer {
optimizer.backward_and_clip(&result.loss_tensor, self.gradient_clip_norm)?
} else {
return Err(MLError::TrainingError(
@@ -3381,14 +3382,16 @@ impl DQN {
));
};
// Deferred loss readback: AFTER backward_and_clip, GPU pipeline is flushed.
// Single GPU→CPU sync boundary — reads both loss and grad_norm
let loss_value = result.loss_f32_tensor
.to_scalar::<f32>()
.map_err(|e| MLError::TrainingError(format!("Deferred loss readback: {}", e)))?;
let grad_norm = grad_norm_tensor.to_vec1::<f32>()
.map_err(|e| MLError::TrainingError(format!("Deferred grad norm readback: {}", e)))?[0];
Ok(GradientResult {
loss: loss_value,
grad_norm: grad_norm as f32,
grad_norm,
grads,
td_errors: result.td_errors,
indices: result.indices,