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"