Description
While running DWQ (mlx_lm.quant.dwq) on models larger than ~1B parameters on an M-series Mac (M5 Max, 137 GB), the fused Metal KL-divergence kernel's backward pass overflows to -3.39e38 (≈ -FLT_MAX) on certain training batches. The initial validation loss computes fine; the first training step corrupts the parameters, and training diverges immediately.
Environment
- mlx-lm 0.31.3, macOS (Apple Silicon M5 Max, 137 GB unified memory)
- Observed during DWQ distillation (8-bit teacher → 4-bit student), e.g. Qwen2.5-1.5B-Instruct; smaller (<~1B) models unaffected on the same setup
Where
mlx_lm/tuner/losses.py:
_make_kl_backward_kernel() — the fused backward kernel
_kl_div_loss.vjp — dispatches it as grid=(1024, cotangent.size, 1), threadgroup=(1024, 1, 1), templated on ("V", logits_q.shape[-1])
can_run_metal() — returns mx.default_device() == mx.gpu and mx.metal.is_available(); kl_div_loss() falls back to the nn.losses.kl_div_loss reference path when it is false
Reproduction signals
- Triggered by the default fused path; also triggered independently by
--grad-checkpoint and by --batch-size > 1.
- Forcing the numerically stable reference KL path avoids it entirely:
import mlx_lm.tuner.losses as _losses
_losses.can_run_metal = lambda: False # use reference KL path (stable)
from mlx_lm.quant.dwq import main
main()
With that one-line monkeypatch, DWQ completes normally and the resulting weights pass our perplexity gate (teacher 8.34 < DWQ 9.45 < plain-affine 9.86 on our pilot).
Possible mechanism (hypothesis — not isolated)
Offered only as a starting point; we have not confirmed this and would not want it taken as a diagnosis.
Both KL kernels pad the tail block with a -1e30 sentinel:
vals_q[j] = (offset + j < V) ? logits_q[offset + j] : -1e30;
and later rescale partial sums with metal::fast::exp(prev_max_q - max_q). For a thread whose whole slice falls in the padding region, that argument is ≈ -1e30. Metal's metal::fast:: math functions are only defined over a limited input domain, so if fast::exp does not return exactly 0 there, sum_exp_q — and therefore lse_q = max_q + fast::log(sum_exp_q) — is garbage, and the per-element write
out[offset + j] = static_cast<T>(c * (fast::exp(vals_q[j] - lse_q) - fast::exp(vals_p[j] - lse_p)));
saturates. That would also explain why the forward pass looks fine: it reduces to one scalar per row, whereas the backward writes a full V-wide gradient, so a corrupted lse is far more consequential.
The same padding-and-rescale pattern appears in the JS kernels in this file, if the theory holds up.
Expected
The fused Metal KL kernel's backward should match the reference implementation numerically (or clamp/guard against the overflow), for models above ~1B params.
Notes
Happy to provide the exact DWQ invocation and gate script — we've documented the workaround in our internal recipe and can share timings/configs. We can also test candidate patches on the M5 Max setup where this reproduces.
Description
While running DWQ (
mlx_lm.quant.dwq) on models larger than ~1B parameters on an M-series Mac (M5 Max, 137 GB), the fused Metal KL-divergence kernel's backward pass overflows to-3.39e38(≈-FLT_MAX) on certain training batches. The initial validation loss computes fine; the first training step corrupts the parameters, and training diverges immediately.Environment
Where
mlx_lm/tuner/losses.py:_make_kl_backward_kernel()— the fused backward kernel_kl_div_loss.vjp— dispatches it asgrid=(1024, cotangent.size, 1),threadgroup=(1024, 1, 1), templated on("V", logits_q.shape[-1])can_run_metal()— returnsmx.default_device() == mx.gpu and mx.metal.is_available();kl_div_loss()falls back to thenn.losses.kl_div_lossreference path when it is falseReproduction signals
--grad-checkpointand by--batch-size > 1.With that one-line monkeypatch, DWQ completes normally and the resulting weights pass our perplexity gate (teacher 8.34 < DWQ 9.45 < plain-affine 9.86 on our pilot).
Possible mechanism (hypothesis — not isolated)
Offered only as a starting point; we have not confirmed this and would not want it taken as a diagnosis.
Both KL kernels pad the tail block with a
-1e30sentinel:and later rescale partial sums with
metal::fast::exp(prev_max_q - max_q). For a thread whose whole slice falls in the padding region, that argument is ≈-1e30. Metal'smetal::fast::math functions are only defined over a limited input domain, so iffast::expdoes not return exactly 0 there,sum_exp_q— and thereforelse_q = max_q + fast::log(sum_exp_q)— is garbage, and the per-element writesaturates. That would also explain why the forward pass looks fine: it reduces to one scalar per row, whereas the backward writes a full
V-wide gradient, so a corruptedlseis far more consequential.The same padding-and-rescale pattern appears in the JS kernels in this file, if the theory holds up.
Expected
The fused Metal KL kernel's backward should match the reference implementation numerically (or clamp/guard against the overflow), for models above ~1B params.
Notes
Happy to provide the exact DWQ invocation and gate script — we've documented the workaround in our internal recipe and can share timings/configs. We can also test candidate patches on the M5 Max setup where this reproduces.