121
// ------------------------------------------------------------------
122
// Step C — Choose compute device
123
// ------------------------------------------------------------------
124
+ // Training runs on CPU even on macOS: candle 0.10.2's Metal *backward*
125
+ // pass produces garbage gradient magnitudes (1e6–1e29 at pristine weights,
126
+ // where the CPU backward yields norms of ~40–65 for identical losses), and
127
+ // with Accelerate the CPU backward is also ~2x faster per step. Metal
128
+ // remains opt-in via ONDE_FINETUNE_METAL for re-testing on newer candle.
129
+ // Forward-only paths (inference, merge, export) are unaffected.
130
#[cfg(target_os = "macos")]
125
- let device = Device::new_metal(0).unwrap_or(Device::Cpu);
131
+ let device = if std::env::var("ONDE_FINETUNE_METAL").is_ok() {
132
+ Device::new_metal(0).unwrap_or(Device::Cpu)
133
+ } else {
134
+ Device::Cpu
135
+ };
136
#[cfg(not(target_os = "macos"))]
137
let device = Device::Cpu;
138
307
// AdamW moment estimates, corrupting every weight from that step on.
308
// Replace non-finite elements with zero so the remaining finite
309
// gradient still trains those parameters this step.
300
- let mut total_norm_sq: f64 = 0.0;
310
+ // Pass 1 — sanitize each gradient and find the largest |g| element
311
+ // across all tensors. sanitize_grad admits elements up to 1e30, but
312
+ // squaring anything above ~1.8e19 overflows the f32 sum below, so the
313
+ // norm must be computed in a scaled space.
314
+ let mut cleaned: Vec<(&Var, Tensor)> = Vec::with_capacity(lora_vars.len());
315
+ let mut global_max_abs: f64 = 0.0;
316
for var in &lora_vars {
317
if let Some(grad) = grads.get(var.as_tensor()) {
318
let clean = sanitize_grad(grad).context("sanitizing gradient")?;
304
- let norm_sq = clean
305
- .sqr()
319
+ let max_abs = clean
320
+ .abs()
321
+ .and_then(|t| t.max_all())
322
+ .and_then(|t| t.to_scalar::<f32>())
323
+ .unwrap_or(0.0) as f64;
324
+ global_max_abs = global_max_abs.max(max_abs);
325
+ cleaned.push((var, clean));
326
+ }
327
+ }
328
+
329
+ // Pass 2 — L2 norm of the gradients scaled by 1/global_max_abs.
330
+ // Scaled elements are ≤ 1 so the f32 accumulation cannot overflow;
331
+ // the true norm is recovered in f64. Exploding-but-finite gradients
332
+ // thus get a finite norm and are tamed by clipping below instead of
333
+ // forcing the whole step to be skipped.
334
+ let mut scaled_norm_sq: f64 = 0.0;
335
+ if global_max_abs > 0.0 {
336
+ let inv = 1.0 / global_max_abs;
337
+ for (_, clean) in &cleaned {
338
+ let norm_sq = (clean * inv)
339
+ .and_then(|t| t.sqr())
340
.and_then(|t| t.sum_all())
341
.and_then(|t| t.to_scalar::<f32>())
342
.unwrap_or(0.0) as f64;
309
- total_norm_sq += norm_sq;
310
- grads.insert(var.as_tensor(), clean);
343
+ scaled_norm_sq += norm_sq;
344
}
345
}
313
- let total_norm = total_norm_sq.sqrt();
346
+ let total_norm = global_max_abs * scaled_norm_sq.sqrt();
347
+ if std::env::var("ONDE_FINETUNE_GRAD_DEBUG").is_ok() {
348
+ eprintln!(
349
+ "[finetune] grad-debug epoch {} step {} seq_len {} loss {:.4} max_abs {:.3e} norm {:.3e}",
350
+ epoch + 1,
351
+ step + 1,
352
+ seq_len,
353
+ loss_val,
354
+ global_max_abs,
355
+ total_norm
356
+ );
357
+ }
358
+ for (var, clean) in cleaned {
359
+ grads.insert(var.as_tensor(), clean);
360
+ }
361
362
// Nothing finite to learn from this step — skip the update so AdamW
363
// state stays clean.
364
if total_norm == 0.0 || !total_norm.is_finite() {
365
+ eprintln!(
366
+ "[finetune] WARNING: global grad norm is {} at epoch {} step {} (seq_len {}), skipping update",
367
+ total_norm,
368
+ epoch + 1,
369
+ step + 1,
370
+ seq_len
371
+ );
372
continue;
373
}
374