Codeseys's picture
Wave 7+8+9: spikes 006/007/008 — close vision-validation gaps V2/V5/V8
57af35d
Raw
History Blame Contribute Delete
11.2 kB
"""Spike 008 — DiLoCo outer-loop smoke.
Verifies the framework's DiLoCo wrapper integrates cleanly with
`torchft.local_sgd.DiLoCo`. Tests follow torchft's own test pattern
(`torchft/local_sgd_test.py::DiLoCoTest`) — single-process, mock Manager,
verify that the outer optimizer machinery actually fires, NOT that two
replicas converge in single-process (which they cannot due to the post-hook
sequencing — see below).
Cross-replica convergence test deferred to multi-process integration tests
once we have real torch.distributed in CI (post-replication phase).
Per `docs/adrs/ADR-003-diloco-impl.md`.
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import create_autospec
import pytest
import torch
import torch.nn as nn
import torch.optim as optim
HERE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(HERE))
from composer_diloco import ( # noqa: E402
_TORCHFT_AVAILABLE,
DiLoCo,
Manager,
_DummyWork,
)
pytestmark = pytest.mark.skipif(
not _TORCHFT_AVAILABLE,
reason="torchft not installed (pip install torchft-nightly)",
)
class TinyMLP(nn.Module):
def __init__(self):
super().__init__()
self.net = nn.Sequential(nn.Linear(4, 8), nn.ReLU(), nn.Linear(8, 2))
def forward(self, x):
return self.net(x)
def _make_passthrough_manager():
"""Manager whose allreduce is a no-op pass-through.
Why no-op (and not real-averaging): in single-process, replica A's
`inner_a.step()` post-hook runs prepare_sync + perform_sync to completion
BEFORE replica B's `inner_b.step()` is called. By the time replica B
arrives at allreduce, replica A's outer optimizer has already stepped
using A's local pseudogradient. There is no way to inject a true
cross-replica barrier in single-process without rewriting torchft's
internals — and since we're using upstream code, we don't.
This means single-process tests verify the *machinery* (sync fires,
outer optimizer steps, Nesterov state populates), not cross-replica
convergence. True cross-replica convergence is verified in production
by NCCL.
This is also exactly the pattern torchft uses in their own
`torchft/local_sgd_test.py::DiLoCoTest` — they do not test convergence
in single-process.
"""
mgr = create_autospec(Manager)
mgr._use_async_quorum = False
mgr.errored.return_value = None
mgr.should_commit.return_value = True
mgr.current_step.return_value = 0
def passthrough(tensor: torch.Tensor, should_quantize: bool = False):
return _DummyWork(tensor)
mgr.allreduce.side_effect = passthrough
return mgr
# ---------------------------------------------------------------------
# Acceptance test 1 — outer loop machinery fires on single replica
# ---------------------------------------------------------------------
def test_diloco_single_replica_machinery_fires():
"""Acceptance: 1 replica × 4 inner steps × 2 outer rounds.
After 2 outer rounds:
- allreduce was called once per parameter per round
- start_quorum was called once per round
- outer optimizer's Nesterov state is populated for every parameter
- parameters moved from the initial state
"""
torch.manual_seed(0)
model = TinyMLP()
initial = {n: p.detach().clone() for n, p in model.named_parameters()}
inner = optim.AdamW(model.parameters(), lr=1e-3)
outer = optim.SGD(model.parameters(), lr=0.7, momentum=0.9, nesterov=True)
mgr = _make_passthrough_manager()
SYNC_EVERY = 4
OUTER_ROUNDS = 2
n_params = len(list(model.parameters()))
with DiLoCo(mgr, [model], inner, outer, sync_every=SYNC_EVERY) as dl:
for _outer_round in range(OUTER_ROUNDS):
for _inner_step in range(SYNC_EVERY):
inner.zero_grad()
x = torch.randn(8, 4)
y = torch.randn(8, 2)
((model(x) - y) ** 2).mean().backward()
inner.step() # outer sync fires automatically inside post-hook
# 1. allreduce was called n_params × OUTER_ROUNDS times
assert mgr.allreduce.call_count == n_params * OUTER_ROUNDS, (
f"expected {n_params * OUTER_ROUNDS} allreduce calls, got {mgr.allreduce.call_count}"
)
# 2. start_quorum was called once per outer round
assert mgr.start_quorum.call_count == OUTER_ROUNDS, (
f"expected {OUTER_ROUNDS} start_quorum calls, got {mgr.start_quorum.call_count}"
)
# 3. should_commit was called once per outer round
assert mgr.should_commit.call_count == OUTER_ROUNDS
# 4. Outer optimizer holds Nesterov momentum state for every parameter
assert len(outer.state_dict()["state"]) == n_params, (
f"expected {n_params} momentum buffers, got {len(outer.state_dict()['state'])}"
)
# 5. Parameters moved from θ_initial (outer optimizer actually applied updates)
any_change = any(
not torch.equal(p, initial[n]) for n, p in model.named_parameters()
)
assert any_change, "outer optimizer did not move the parameters"
# ---------------------------------------------------------------------
# Acceptance test 2 — torchft sign convention is what we expect
# ---------------------------------------------------------------------
def test_diloco_pseudogradient_sign_convention():
"""Verify torchft computes pseudograd = θ_initial − θ_local + outer SGD math.
Setup:
- inner LR = 0 (so inner steps don't move params; only outer sync moves them)
- manually nudge params so θ_local ≠ θ_initial
- outer LR = 1, momentum = 0 (plain SGD, no Nesterov complications)
- sync_every = 2
Math:
pseudograd = θ_initial − θ_local = -nudge
restore: p.data ← θ_initial
outer step: p.data ← θ_initial - lr * pseudograd
= θ_initial - 1 * (-nudge)
= θ_initial + nudge
= θ_local_at_sync
merge(alpha=0): p.data unchanged
Expected after 1 outer round: final = θ_local_at_sync
A sign flip in pseudograd would land us at `θ_initial - nudge` (movement
in the wrong direction by 2*nudge total), which this test catches.
"""
torch.manual_seed(0)
model = TinyMLP()
inner = optim.SGD(model.parameters(), lr=0.0) # zero inner LR
outer = optim.SGD(model.parameters(), lr=1.0, momentum=0.0) # plain SGD
mgr = _make_passthrough_manager()
SYNC_EVERY = 2
NUDGE = 0.5
initial_param = next(model.parameters()).detach().clone()
with DiLoCo(mgr, [model], inner, outer, sync_every=SYNC_EVERY) as dl:
# Manually nudge AFTER the DiLoCo wrapper saved θ_initial so
# θ_local ≠ θ_initial when prepare_sync runs.
with torch.no_grad():
for p in model.parameters():
p.add_(NUDGE)
local_param_after_nudge = next(model.parameters()).detach().clone()
# Run inner steps with zero LR — the post-hook fires the outer sync
# at step `sync_every` but the inner step itself doesn't move params.
for _ in range(SYNC_EVERY):
inner.zero_grad()
x = torch.randn(8, 4)
((model(x) - torch.randn(8, 2)) ** 2).mean().backward()
inner.step()
final_param = next(model.parameters()).detach().clone()
# Per the math above: final should equal θ_local_at_sync = θ_initial + NUDGE.
expected = local_param_after_nudge
diff = (final_param - expected).abs().max().item()
# And the wrong-sign result would have been θ_initial - NUDGE
wrong_sign = initial_param - NUDGE * torch.ones_like(initial_param)
wrong_sign_diff = (final_param - wrong_sign).abs().max().item()
assert diff < 1e-5, (
f"sign convention violated. \n"
f" initial[0,0]={initial_param.flatten()[0].item():.6f}\n"
f" local_at_sync[0,0]={local_param_after_nudge.flatten()[0].item():.6f}\n"
f" final[0,0]={final_param.flatten()[0].item():.6f}\n"
f" expected[0,0]={expected.flatten()[0].item():.6f}\n"
f" max-abs-diff={diff:.6e}\n"
f" wrong-sign-diff={wrong_sign_diff:.6e} (≈0 means sign flipped)\n"
)
# ---------------------------------------------------------------------
# Acceptance test 3 — Spike 005 imports still work alongside torchft
# ---------------------------------------------------------------------
def test_no_regression_in_spike_005_imports():
"""Verify importing torchft + composer_diloco coexists with Spike 005.
This is a lightweight import-side-effects test. The 38-test Spike 005
suite runs separately and passes there.
"""
spike_005 = HERE.parent / "005-integrated-trainer-skeleton"
sys.path.insert(0, str(spike_005))
from opsd_loss import generalized_jsd_loss # noqa: F401
from teacher_replay import extract_dpo_pairs # noqa: F401
# Construct a fresh DiLoCo and verify it can be entered + exited
model = TinyMLP()
inner = optim.AdamW(model.parameters(), lr=1e-3)
outer = optim.SGD(model.parameters(), lr=0.7, momentum=0.9, nesterov=True)
mgr = _make_passthrough_manager()
with DiLoCo(mgr, [model], inner, outer, sync_every=2) as dl:
assert dl is not None
# ---------------------------------------------------------------------
# Acceptance test 4 — wrapper smoke (make_diloco_outer_loop)
# ---------------------------------------------------------------------
def test_make_diloco_outer_loop_factory():
"""The framework's `make_diloco_outer_loop()` constructs a working DiLoCo."""
from composer_diloco import make_diloco_outer_loop
model = TinyMLP()
inner = optim.AdamW(model.parameters(), lr=1e-3)
mgr = _make_passthrough_manager()
dl = make_diloco_outer_loop(
manager=mgr,
model_fragments=[model],
inner_optimizer=inner,
outer_lr=0.7,
outer_momentum=0.9,
nesterov=True,
sync_every=4,
)
# Outer optimizer was constructed with our hyperparams
assert dl._sync_every == 4
assert dl is not None
# ---------------------------------------------------------------------
# Acceptance test 5 — Streaming DiLoCo config path (deferred to v0.2 but
# importable today)
# ---------------------------------------------------------------------
def test_streaming_diloco_with_two_fragments_constructs():
"""Streaming DiLoCo accepts 2 fragments + nonzero sync delay (config path)."""
torch.manual_seed(0)
model = TinyMLP()
# Two-fragment split (each linear is its own fragment)
fragments = [model.net[0], model.net[2]]
inner = optim.AdamW(model.parameters(), lr=1e-3)
outer = optim.SGD(model.parameters(), lr=0.7, momentum=0.9, nesterov=True)
mgr = _make_passthrough_manager()
# sync_every=4, 2 fragments → effective per-fragment sync_every=2.
# fragment_sync_delay=0 = no delay (still vanilla DiLoCo per-fragment).
with DiLoCo(
mgr, fragments, inner, outer,
sync_every=4, fragment_sync_delay=0, fragment_update_alpha=0.0,
) as dl:
assert len(dl._fragments) == 2