Skip to content

Commit 36c3445

Browse files
committed
feat(v7-d03): LossScaler with static + dynamic modes for fp16
Closes V7-D03 (cppmega-mlx-4ij1): fp16 has a narrow dynamic range (~6e-5 to 6e4) so a naive fp16 train step overflows or underflows within a handful of iterations. cppmega_v4.runtime.loss_scaler.LossScaler provides the missing scaling + overflow detection. Public API: - LossScaler(mode='static'|'dynamic', init_scale, growth_factor, backoff_factor, growth_interval, min_scale, max_scale) - .scale_grads(grads) — multiply each grad tensor by scale. - .unscale_and_check(grads) → (unscaled, overflow). Overflow is True when any inf/nan in any grad entry. - .update(overflow) — dynamic mode halves scale on overflow, doubles after growth_interval consecutive clean steps, bounded by [min_scale, max_scale]. Static mode just counts overflow. - .snapshot() — dict suitable for extras / UI overlay: {mode, scale, overflow_count, clean_steps_since_overflow}. Tests (tests/v4/test_loss_scaler.py): 8/8 — static init+scaling, dynamic halve-on-overflow, doubling after growth_interval, static counts but doesn't adjust, unscale inverts scaling, NaN+inf both detected, min/max scale bounds respected, snapshot shape. Integration into stage_train is the next step (V7-D03 sub-task — hooking LossScaler into the training loop when master_dtype="fp16" and surfacing scale/overflow in extras + UI).
1 parent f7a51ce commit 36c3445

3 files changed

Lines changed: 246 additions & 0 deletions

File tree

cppmega_v4/runtime/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""cppmega_v4 runtime helpers."""

cppmega_v4/runtime/loss_scaler.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""V7-D03: loss scaling + overflow detection for fp16 training.
2+
3+
fp16 has a narrow dynamic range (~6e-5 to 6e4). Without a loss
4+
scaler, gradients underflow silently or overflow into inf/nan
5+
within a few steps. This module provides:
6+
7+
* `LossScaler` with two modes:
8+
- static (`mode='static'`, `init_scale=2**16`): multiplies the
9+
loss by a fixed factor before the backward pass; on
10+
gradient overflow it just increments a counter without
11+
adjusting the scale.
12+
- dynamic (`mode='dynamic'`): halves the scale on overflow,
13+
doubles after `growth_interval` consecutive clean steps,
14+
bounded by `[min_scale, max_scale]`.
15+
* `unscale_and_check(grads)` divides each grad by the active scale
16+
and returns (unscaled_grads, overflow_detected).
17+
* Caller is responsible for skipping the optimizer.update() on
18+
overflow and calling `update(overflow=...)` after each step so
19+
the dynamic scaler tracks state.
20+
21+
Usage in a training loop:
22+
23+
scaler = LossScaler(mode="dynamic")
24+
for step in range(N):
25+
loss, grads = lvg(model, x, y)
26+
loss = loss * scaler.scale # scale the loss
27+
grads = scaler.scale_grads(grads) # equivalent: scale grads
28+
unscaled, overflow = scaler.unscale_and_check(grads)
29+
if not overflow:
30+
opt.update(model, unscaled)
31+
scaler.update(overflow) # adjust dynamic scale
32+
"""
33+
34+
from __future__ import annotations
35+
36+
from typing import Any, Literal
37+
38+
import mlx.core as mx
39+
import mlx.nn as nn
40+
41+
42+
class LossScaler:
43+
"""Loss / gradient scaler for fp16 training.
44+
45+
Attributes:
46+
scale: current scale factor (mx scalar).
47+
overflow_count: total overflow events observed.
48+
clean_steps_since_overflow: monotonic counter used by dynamic
49+
mode to decide when to grow the scale.
50+
"""
51+
52+
def __init__(
53+
self,
54+
*,
55+
mode: Literal["static", "dynamic"] = "dynamic",
56+
init_scale: float = 2.0 ** 16,
57+
growth_factor: float = 2.0,
58+
backoff_factor: float = 0.5,
59+
growth_interval: int = 200,
60+
min_scale: float = 1.0,
61+
max_scale: float = 2.0 ** 24,
62+
) -> None:
63+
if mode not in ("static", "dynamic"):
64+
raise ValueError(f"mode must be static|dynamic, got {mode}")
65+
if init_scale <= 0:
66+
raise ValueError("init_scale must be > 0")
67+
if growth_factor <= 1.0 or backoff_factor >= 1.0:
68+
raise ValueError(
69+
"growth_factor must be > 1 and backoff_factor must be < 1"
70+
)
71+
self.mode = mode
72+
self._scale = float(init_scale)
73+
self.growth_factor = growth_factor
74+
self.backoff_factor = backoff_factor
75+
self.growth_interval = max(1, int(growth_interval))
76+
self.min_scale = float(min_scale)
77+
self.max_scale = float(max_scale)
78+
self.overflow_count = 0
79+
self.clean_steps_since_overflow = 0
80+
81+
@property
82+
def scale(self) -> float:
83+
return self._scale
84+
85+
def scale_grads(self, grads: Any) -> Any:
86+
"""Multiply each grad tensor by self.scale (in-place equivalent
87+
for tree). Used when scaling AFTER the backward pass — the
88+
more common pattern is to multiply the loss directly."""
89+
return nn.utils.tree_map(
90+
lambda g: g * self._scale if hasattr(g, "shape") else g,
91+
grads,
92+
)
93+
94+
def unscale_and_check(self, grads: Any) -> tuple[Any, bool]:
95+
"""Divide each grad by self.scale and check for inf/nan.
96+
97+
Returns (unscaled_grads, overflow). When overflow is True the
98+
caller MUST skip the optimizer update for this step.
99+
"""
100+
flat = dict(nn.utils.tree_flatten(grads))
101+
overflow = False
102+
unscaled: dict[str, Any] = {}
103+
for k, g in flat.items():
104+
if not hasattr(g, "shape"):
105+
unscaled[k] = g
106+
continue
107+
ug = g / self._scale
108+
# mlx has no isfinite — use the abs<inf trick.
109+
if bool(mx.any(mx.isnan(ug)).item()) or bool(
110+
mx.any(mx.isinf(ug)).item()):
111+
overflow = True
112+
unscaled[k] = ug
113+
unscaled_tree = nn.utils.tree_unflatten(list(unscaled.items()))
114+
return unscaled_tree, overflow
115+
116+
def update(self, overflow: bool) -> None:
117+
"""Adjust the scale based on this step's overflow outcome."""
118+
if overflow:
119+
self.overflow_count += 1
120+
self.clean_steps_since_overflow = 0
121+
if self.mode == "dynamic":
122+
self._scale = max(
123+
self.min_scale, self._scale * self.backoff_factor)
124+
return
125+
self.clean_steps_since_overflow += 1
126+
if (self.mode == "dynamic"
127+
and self.clean_steps_since_overflow
128+
>= self.growth_interval):
129+
self._scale = min(
130+
self.max_scale, self._scale * self.growth_factor)
131+
self.clean_steps_since_overflow = 0
132+
133+
def snapshot(self) -> dict[str, Any]:
134+
"""Reportable state — meant for extras / UI overlay."""
135+
return {
136+
"mode": self.mode,
137+
"scale": float(self._scale),
138+
"overflow_count": int(self.overflow_count),
139+
"clean_steps_since_overflow":
140+
int(self.clean_steps_since_overflow),
141+
}
142+
143+
144+
__all__ = ["LossScaler"]

tests/v4/test_loss_scaler.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"""V7-D03: LossScaler unit + integration tests."""
2+
3+
from __future__ import annotations
4+
5+
import mlx.core as mx
6+
import pytest
7+
8+
from cppmega_v4.runtime.loss_scaler import LossScaler
9+
10+
11+
def _grads_finite() -> dict:
12+
return {"w": mx.array([[1.0, 2.0], [3.0, 4.0]], dtype=mx.float32)}
13+
14+
15+
def _grads_with_inf() -> dict:
16+
return {"w": mx.array([[float("inf"), 0.0], [0.0, 0.0]],
17+
dtype=mx.float32)}
18+
19+
20+
def _grads_with_nan() -> dict:
21+
return {"w": mx.array([[float("nan"), 0.0], [0.0, 0.0]],
22+
dtype=mx.float32)}
23+
24+
25+
def test_v7_d03_static_scaler_init_and_scale():
26+
s = LossScaler(mode="static", init_scale=128.0)
27+
assert s.scale == 128.0
28+
grads = _grads_finite()
29+
scaled = s.scale_grads(grads)
30+
# Each entry multiplied by 128.
31+
assert float(scaled["w"][0, 0].item()) == pytest.approx(128.0)
32+
33+
34+
def test_v7_d03_dynamic_scaler_halves_on_overflow():
35+
s = LossScaler(mode="dynamic", init_scale=512.0,
36+
backoff_factor=0.5, min_scale=1.0)
37+
assert s.scale == 512.0
38+
_, overflow = s.unscale_and_check(_grads_with_inf())
39+
assert overflow is True
40+
s.update(overflow=True)
41+
assert s.scale == 256.0
42+
assert s.overflow_count == 1
43+
44+
45+
def test_v7_d03_dynamic_scaler_doubles_after_growth_interval():
46+
s = LossScaler(mode="dynamic", init_scale=8.0,
47+
growth_factor=2.0, growth_interval=3,
48+
max_scale=1024.0)
49+
for _ in range(3):
50+
s.update(overflow=False)
51+
assert s.scale == 16.0
52+
for _ in range(3):
53+
s.update(overflow=False)
54+
assert s.scale == 32.0
55+
56+
57+
def test_v7_d03_static_mode_counts_overflow_but_does_not_adjust():
58+
s = LossScaler(mode="static", init_scale=64.0)
59+
s.update(overflow=True)
60+
assert s.scale == 64.0 # unchanged
61+
assert s.overflow_count == 1
62+
63+
64+
def test_v7_d03_unscale_inverts_scaling():
65+
s = LossScaler(mode="static", init_scale=4.0)
66+
grads = _grads_finite()
67+
scaled = s.scale_grads(grads)
68+
unscaled, overflow = s.unscale_and_check(scaled)
69+
assert overflow is False
70+
assert mx.allclose(unscaled["w"], grads["w"], atol=1e-6)
71+
72+
73+
def test_v7_d03_nan_grads_also_detected_as_overflow():
74+
s = LossScaler(mode="dynamic", init_scale=128.0)
75+
_, overflow = s.unscale_and_check(_grads_with_nan())
76+
assert overflow is True
77+
78+
79+
def test_v7_d03_dynamic_scaler_respects_min_max_bounds():
80+
s = LossScaler(mode="dynamic", init_scale=2.0,
81+
backoff_factor=0.5, min_scale=1.0,
82+
growth_factor=2.0, max_scale=4.0,
83+
growth_interval=1)
84+
# Slam against min via repeated overflow.
85+
for _ in range(10):
86+
s.update(overflow=True)
87+
assert s.scale == 1.0
88+
# Slam against max via repeated clean steps.
89+
for _ in range(10):
90+
s.update(overflow=False)
91+
assert s.scale == 4.0
92+
93+
94+
def test_v7_d03_snapshot_shape():
95+
s = LossScaler(mode="dynamic", init_scale=128.0)
96+
snap = s.snapshot()
97+
for k in ("mode", "scale", "overflow_count",
98+
"clean_steps_since_overflow"):
99+
assert k in snap
100+
assert snap["mode"] == "dynamic"
101+
assert snap["scale"] == 128.0

0 commit comments

Comments
 (0)