Files
AstrAI/tests/parallel/test_broadcast_state_dict.py
ViperEkura 55ee258e95 style: fix ruff lint warnings
- Remove unused local variable b in attention_backend.py
- Remove unused variable rank0_sd in test_broadcast_state_dict.py
- Remove unused imports across test files
2026-08-07 14:17:48 +08:00

169 lines
5.1 KiB
Python

"""Tests for :func:`broadcast_state_dict` and distributed ``create_ref_model``.
Uses ``spawn_parallel_fn`` with the ``gloo`` backend to simulate a
multi-rank environment without requiring multiple GPUs.
"""
import torch
from astrai.model.transformer import AutoRegressiveLM
from astrai.parallel import get_rank, spawn_parallel_fn
from astrai.parallel.executor import broadcast_state_dict, create_ref_model
from astrai.trainer.strategy import GRPOStrategy
from tests.helpers import make_rollout_config
def _broadcast_worker():
"""Rank-0 builds a state_dict; all ranks verify they receive it."""
rank = get_rank()
if rank == 0:
sd = {
"layer.weight": torch.randn(4, 8),
"layer.bias": torch.randn(4),
}
expected = {k: v.clone() for k, v in sd.items()}
else:
sd = None
expected = None
received = broadcast_state_dict(sd, src=0)
assert received is not None, f"rank {rank}: received None"
assert set(received.keys()) == {"layer.weight", "layer.bias"}
if rank == 0:
# rank-0 already had the data
for k in received:
assert torch.equal(received[k], expected[k])
# tensors preserve the source device (cpu here since gloo test)
for k, v in received.items():
assert v.device.type == "cpu", f"rank {rank}: {k} on {v.device}"
def test_broadcast_state_dict():
spawn_parallel_fn(_broadcast_worker, world_size=2, backend="gloo")
def _create_ref_model_worker():
"""Verify create_ref_model works when unwrap_model returns None on non-rank-0."""
class FakeFSDPExecutor:
"""Simulates FSDP: unwrap_model returns state_dict on rank-0, None elsewhere."""
use_distributed = True
def unwrap_model(self, model):
if get_rank() == 0:
return model.state_dict()
return None
rank = get_rank()
config = make_rollout_config()
model = AutoRegressiveLM(config).to("cpu")
# Give each rank distinct weights so we can verify broadcast overwrites them
with torch.no_grad():
for p in model.parameters():
p.add_(float(rank))
executor = FakeFSDPExecutor()
ref = create_ref_model(
model_fn=lambda: AutoRegressiveLM(config),
executor=executor,
model=model,
device="cpu",
)
assert ref is not None, f"rank {rank}: ref model is None"
# Broadcast rank-0's original weights for comparison
if rank == 0:
expected_sd = {k: v.clone() for k, v in model.state_dict().items()}
else:
expected_sd = None
expected_sd = broadcast_state_dict(expected_sd, src=0)
ref_sd = ref.state_dict()
for k in ref_sd:
assert torch.equal(ref_sd[k], expected_sd[k]), f"rank {rank}: mismatch at {k}"
# ref model should be frozen and in eval mode
assert not ref.training
for p in ref.parameters():
assert not p.requires_grad
def test_create_ref_model_distributed():
spawn_parallel_fn(_create_ref_model_worker, world_size=2, backend="gloo")
def _sync_old_model_worker():
"""Verify that sync_old_model broadcasts weights to all ranks."""
rank = get_rank()
config = make_rollout_config()
model = AutoRegressiveLM(config).to("cpu")
old_model = AutoRegressiveLM(config).to("cpu")
ref_model = AutoRegressiveLM(config).to("cpu")
# Give model rank-distinct weights
with torch.no_grad():
for p in model.parameters():
p.add_(float(rank) * 10)
class _DistExecutor:
use_distributed = True
def unwrap_model(self, m):
if get_rank() == 0:
return m.state_dict()
return None
strategy = GRPOStrategy(
model=model,
device="cpu",
old_model=old_model,
ref_model=ref_model,
executor=_DistExecutor(),
)
# Capture rank-0's policy weights for comparison
if rank == 0:
expected = {k: v.clone() for k, v in model.state_dict().items()}
else:
expected = None
expected = broadcast_state_dict(expected, src=0)
strategy.sync_old_model()
old_sd = strategy.old_model.state_dict()
for k in old_sd:
assert torch.equal(old_sd[k], expected[k]), f"rank {rank}: mismatch at {k}"
def test_sync_old_model_distributed():
spawn_parallel_fn(_sync_old_model_worker, world_size=2, backend="gloo")
def test_broadcast_state_dict_single_process():
"""When dist is not initialized, broadcast_state_dict is a no-op."""
sd = {"w": torch.randn(3, 3), "b": torch.randn(3)}
result = broadcast_state_dict(sd)
assert result is sd
def test_create_ref_model_single_process():
"""create_ref_model still works without an executor (explicit state_dict)."""
config = make_rollout_config()
model = AutoRegressiveLM(config)
sd = model.state_dict()
ref = create_ref_model(
model_fn=lambda: AutoRegressiveLM(config),
state_dict=sd,
device="cpu",
)
assert ref is not None
assert not ref.training
for p in ref.parameters():
assert not p.requires_grad
for k in sd:
assert torch.equal(ref.state_dict()[k], sd[k])