File size: 8,711 Bytes
bcc8d35 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 | """Audit 2026-10-14 (G4-rl-batch) regressions for `align/rl/batch_source.py`.
U04-1 fewer live rows than world x accum slots killed the run (RuntimeError after the
rollout was paid for);
U04-2 row quantisation threw away up to ~50% of live rows and toggled the step size 2x;
U04-3 peers waited for rank 0's rollout on a collective bounded by the process-group
timeout, and a rank-0 failure left them blocked.
"""
from __future__ import annotations
import os
import tempfile
import time
import pytest
import torch
from test_grpo_module import _batch, _module
from test_grpo_driver import FakeClient, _source
from align.rl.batch_source import GrpoBatchSource, RolloutMicroHandle
def _payload(B: int, T: int = 5) -> dict[str, torch.Tensor]:
"""B group-contiguous rows; row i is identifiable by input_ids[i, 0] == i + 1."""
ids = torch.zeros((B, T), dtype=torch.int64)
ids[:, 0] = torch.arange(1, B + 1)
rm = torch.zeros((B, T), dtype=torch.int64)
rm[:, 2:] = 1
return {
"input_ids": ids,
"attention_mask": torch.ones((B, T), dtype=torch.int64),
"response_mask": rm,
"advantages": torch.ones(B),
"vllm_logprobs": torch.zeros((B, T - 1)),
"group_ids": torch.arange(B) // 16,
"reward::reward/total": torch.ones(B),
}
def _bare(world: int, accum: int, rank: int = 0, prompts: int = 32, g: int = 16) -> GrpoBatchSource:
src = GrpoBatchSource.__new__(GrpoBatchSource)
src.world_size, src.grad_accum, src.rank = world, accum, rank
src.prompts_per_step, src.n_samples_per_prompt = prompts, g
src.dropped_remainder_rows = 0
return src
def _real_rows(slices) -> list[int]:
"""Row ids carried by real (non-padding) rows of one rank's slices."""
out = []
for s in slices:
for i in range(s["input_ids"].shape[0]):
if int(s["response_mask"][i].sum()) > 0:
out.append(int(s["input_ids"][i, 0]))
return out
def _all_ranks(world, accum, B):
per_rank = []
for r in range(world):
src = _bare(world, accum, rank=r)
per_rank.append(src._slice_rank(_payload(B)))
return per_rank
def test_u04_1_fewer_live_rows_than_slots_still_trains_every_row():
"""World 3 x accum 85 = 255 slots; 240 live rows (17 of 32 groups dead) used to raise."""
per_rank = _all_ranks(3, 85, 240)
for slices in per_rank:
assert len(slices) == 85
assert all(s["input_ids"].shape[0] >= 1 for s in slices), "a rank would run an empty micro"
used = sorted(i for slices in per_rank for i in _real_rows(slices))
assert used == list(range(1, 241)), "every live row must reach exactly one micro"
def test_u04_1_padding_rows_contribute_nothing_and_are_flagged():
per_rank = _all_ranks(3, 85, 240)
pad_rows = [
s
for slices in per_rank
for s in slices
if int(s["response_mask"].sum()) == 0
]
assert len(pad_rows) == 255 - 240
for s in pad_rows:
assert float(s["advantages"].abs().sum()) == 0.0
assert s["input_ids"].shape[0] == 1
def test_u04_2_one_dead_group_no_longer_discards_half_the_rows():
"""B=496 (one dead group of 32x16, 3 ranks, accum 85) used 255 rows and dropped 241."""
per_rank = _all_ranks(3, 85, 496)
used = sorted(i for slices in per_rank for i in _real_rows(slices))
assert used == list(range(1, 497))
src = _bare(3, 85)
src._slice_rank(_payload(496))
assert src.dropped_remainder_rows == 0
def test_u04_2_full_step_shape_is_unchanged():
"""The nominal full-live shape keeps its per-micro row cap (memory budget): 512 rows over 255
slots is 2 rows per micro, 2 rows dropped and counted -- exactly the previous behaviour."""
src = _bare(3, 85)
slices = src._slice_rank(_payload(512))
assert all(s["input_ids"].shape[0] == 2 for s in slices)
assert src.dropped_remainder_rows == 2
def test_u04_2_micro_sizes_never_exceed_the_nominal_cap_and_ranks_stay_balanced():
for B in (255, 300, 400, 496, 510, 512):
per_rank = _all_ranks(3, 85, B)
for m in range(85):
sizes = [per_rank[r][m]["input_ids"].shape[0] for r in range(3)]
assert max(sizes) <= 2 and max(sizes) - min(sizes) <= 1, (B, m, sizes)
def test_u04_1_pad_micro_runs_through_the_real_module_with_zero_loss_and_live_graph():
mod, model = _module(grad_accum=1)
src = _bare(1, 4, prompts=1, g=8)
src.device = "cpu"
src.old_logprob_fn = lambda ids, attn: mod.compute_logprobs_nograd(ids, attn)
b = _batch(model, B=2, T=10)
payload = {
"input_ids": b.input_ids,
"attention_mask": b.attention_mask,
"response_mask": b.response_mask,
"advantages": b.advantages,
"vllm_logprobs": b.vllm_logprobs,
"group_ids": b.group_ids,
}
slices = src._slice_rank(payload) # 2 rows over 4 slots: two micros are padding
pads = [s for s in slices if int(s["response_mask"].sum()) == 0]
assert len(pads) == 2
batch = src._finalize(model, pads[0])
out = mod._grpo_loss_adapter(model, batch)
assert float(out.loss) == 0.0
out.loss.backward()
assert all(p.grad is not None and torch.isfinite(p.grad).all() for p in model.parameters())
def test_u04_1_tripwire_is_not_fed_from_a_padding_micro():
"""A padding micro reports entropy 0 / length 0; observing it would fake a collapse."""
seen = []
class _Wire:
fired = False
fire_reason = None
def observe(self, step, entropy, length):
seen.append((step, entropy, length))
return False
src = _source(g=8, accum=2, steps=1)
src.collapse_tripwire = _Wire()
src._on_step_boundary(0, {"entropy": 0.0, "lengths/response_mean": 0.0}, padded=True)
assert seen == []
src._on_step_boundary(0, {"entropy": 1.0, "lengths/response_mean": 3.0})
assert seen == [(0, 1.0, 3.0)]
# ---------------------------------------------------------------------------- U04-3 (2 real ranks)
def _wait_worker(rank, world, init_file, mode, q):
import datetime
import torch.distributed as dist
os.environ["ALIGN_PG_TIMEOUT_S"] = "3"
os.environ["WORLD_SIZE"] = str(world)
os.environ["RANK"] = str(rank)
os.environ["ALIGN_ROLLOUT_WAIT_TIMEOUT_S"] = "60"
dist.init_process_group(
"gloo", init_method=f"file://{init_file}", rank=rank, world_size=world,
timeout=datetime.timedelta(seconds=3),
)
class _Src(GrpoBatchSource):
def _produce_step(self, optimizer_step):
if mode == "slow":
time.sleep(7) # longer than the 3 s PG timeout, far shorter than the rollout budget
return _payload(4)
raise RuntimeError("rollout engine died (test)")
src = _Src(
[{"problem": "p", "answer": "1"}], prompts_per_step=1, n_samples_per_prompt=4,
max_response_len=4, temperature=1.0, optimizer_steps=1, grad_accum=1, seed=0,
prompt_builder=lambda p: p,
)
src.bind(client=None, reward=None, verifier=None, old_logprob_fn=lambda i, a: None,
weights_dir_fn=lambda: "w", rank=rank, world_size=world)
src.old_logprob_fn = lambda ids, attn: torch.zeros(ids.shape[0], ids.shape[1] - 1)
t0 = time.time()
try:
batch = src._materialize_micro(None, RolloutMicroHandle(0, 0))
q.put((rank, "ok", int(batch.input_ids.shape[0]), time.time() - t0))
except Exception as e: # noqa: BLE001
q.put((rank, "err", str(e), time.time() - t0))
def _run_two_ranks(mode):
import torch.multiprocessing as mp
ctx = mp.get_context("spawn")
q = ctx.Queue()
with tempfile.TemporaryDirectory(dir="/apps/development/rlhf/align/.tmp/audit_2026-10-14/scratch_g4_rl_batch") as d:
f = os.path.join(d, "store")
ps = [ctx.Process(target=_wait_worker, args=(r, 2, f, mode, q)) for r in range(2)]
for p in ps:
p.start()
res = {}
for _ in range(2):
r = q.get(timeout=90)
res[r[0]] = r
for p in ps:
p.join(30)
return res
def test_u04_3_peers_survive_a_rank0_rollout_longer_than_the_pg_timeout():
res = _run_two_ranks("slow")
assert res[0][1] == "ok" and res[1][1] == "ok", res
assert res[1][2] == 2 # 4 rows over 2 ranks
def test_u04_3_a_rank0_failure_reaches_the_peers_as_a_named_error_not_a_timeout():
res = _run_two_ranks("fail")
assert res[0][1] == "err" and "rollout engine died (test)" in res[0][2], res
assert res[1][1] == "err" and "rollout engine died (test)" in res[1][2] and "rank 0" in res[1][2], res
assert res[1][3] < 30, "the peer must be told promptly, not wait out a timeout"
|