perf: compact Stage-2 AR inference loop to active rows only
CI / Sync project version with tag (pull_request) Has been skipped
CI / Type check (ty) (pull_request) Successful in 1m2s
CI / Lint (ruff check) (pull_request) Successful in 1m6s
CI / Format (ruff format) (pull_request) Successful in 1m6s
CI / Tests (pull_request) Successful in 2m46s
CI / Bump version, tag, and update changelog on merge to master (pull_request) Has been skipped
CI / Publish package to Gitea package registry (pull_request) Has been skipped
CI / Update README badges (version, test count) (pull_request) Has been skipped

sample_secondaries_ar ran all k_max=15 slots for every row regardless of
each row's own predicted secondary count, even though the baseline
checkpoint's rollout measured only 0.382 secondaries/step — so ~97% of
stage-2 model calls generated tokens sec_valid then masked away.

Compact the loop to the still-active row set at each slot: drop a row the
moment its n_sec_pred is exhausted (or, under n_sec.mode="stop_token", the
moment its own stop logit fires), so slot k's model calls cost O(active
rows) instead of O(B). Exact — rows are independent given their own
history — verified by comparing the compacted path against a new
full_length=True escape hatch that reproduces the original uncompacted
behavior bit-for-bit under deterministic noise.

full_length=True is required by
_assemble_stage2_ar_inputs_scheduled's scheduled-sampling self-sample,
whose training contract needs a real prediction at every slot up to
k_max regardless of a row's own count, so training behavior is
unchanged.

AttentionHistory's KV cache and MarkovHistory's O(1) state are kept
aligned to the shrinking active set via a new
HistoryEncoder.select_cache / Stage2Autoregressive.select_history_cache.

Also fixes a latent bug the refactor surfaced: derived_n_sec (stop-token
mode) could be overwritten by a later spurious re-fire of the stop logit
on a row that had already stopped; now tracked via an explicit `finished`
mask so only the first stop slot is recorded, matching the documented
contract.

No architecture or checkpoint-format change — every existing v0.3.0
Stage2Autoregressive checkpoint (flow/wgan, markov/attention,
head/stop_token) picks up the speedup automatically on its next
`giant rollout`/`giant predict`, no retraining needed.

Measured (CPU, hidden_dim=512/6 blocks, k_max=15, batch 512, mean
n_sec≈0.38 matching the baseline checkpoint's own rollout): 17.6-22.9x
fewer wall-clock seconds for the AR loop alone (attention/markov history
respectively). Directional only — baseline.toml's GPU inference-cost
comment is updated accordingly, flagged stale pending a real rollout
re-measurement via eval_cost_per_step.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HPt7bVLZYFJe5cG6V7ahqC
This commit is contained in:
2026-09-03 17:56:59 +02:00
parent bf3271f09e
commit 5c576fa8f3
6 changed files with 249 additions and 59 deletions
+81
View File
@@ -341,6 +341,87 @@ def test_sample_secondaries_ar_none_n_sec_pred_without_stop_head_raises():
sample_secondaries_ar(decoder, cond_cont, cond_cat, stage1_out, None, steps=2)
# ── Row compaction: full_length=False (default, inference) must agree with
# full_length=True (the pre-compaction behaviour, still exercised by
# _assemble_stage2_ar_inputs_scheduled's training-time self-sample) ────────
def _zero_randn(*size, **kwargs):
"""Drop-in replacement for `torch.randn` that returns zeros of the same
shape — makes the ODE/WGAN noise deterministic so a compacted run and a
full_length run can be compared row-for-row regardless of how many
`torch.randn` calls each makes (compaction changes the batch size, and
therefore the RNG stream position, at every slot)."""
device = kwargs.get("device")
dtype = kwargs.get("dtype")
return torch.zeros(*size, device=device, dtype=dtype)
@pytest.mark.parametrize("history", ["markov", "attention"])
@pytest.mark.parametrize("generator", ["flow", "wgan"])
def test_sample_secondaries_ar_compaction_matches_full_length_head_mode(generator, history, monkeypatch):
B, k_max, emb_dim = 4, 5, 6
decoder = _stage2_ar("physical", generator, emb_dim=emb_dim, k_max=k_max, history=history)
cond_cont, cond_cat = _cond(B)
stage1_out = torch.randn(B, X_DIM)
n_sec_pred = torch.tensor([0, 1, 3, k_max])
monkeypatch.setattr(torch, "randn", _zero_randn)
sec_cont_c, sec_type_c, sec_valid_c = sample_secondaries_ar(
decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=2, full_length=False
)
sec_cont_f, sec_type_f, sec_valid_f = sample_secondaries_ar(
decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=2, full_length=True
)
assert torch.equal(sec_valid_c, sec_valid_f)
assert torch.equal(sec_valid_c, torch.arange(k_max).unsqueeze(0) < n_sec_pred.unsqueeze(1))
assert torch.allclose(sec_cont_c[sec_valid_c], sec_cont_f[sec_valid_f], atol=1e-4, rtol=1e-4)
assert torch.allclose(sec_type_c[sec_valid_c], sec_type_f[sec_valid_f], atol=1e-4, rtol=1e-4)
@pytest.mark.parametrize("generator", ["flow", "wgan"])
def test_sample_secondaries_ar_compaction_matches_full_length_stop_token(generator, monkeypatch):
"""`n_sec_sampling="greedy"` keeps the stop decision itself deterministic
(no `torch.rand` draw), so only `torch.randn` needs zeroing."""
B, k_max = 6, 5
decoder = _stage2_ar_stop_token("physical", generator, n_sec_sampling="greedy", k_max=k_max)
cond_cont, cond_cat = _cond(B)
stage1_out = torch.randn(B, X_DIM)
monkeypatch.setattr(torch, "randn", _zero_randn)
sec_cont_c, sec_type_c, sec_valid_c = sample_secondaries_ar(
decoder, cond_cont, cond_cat, stage1_out, None, steps=2, full_length=False
)
sec_cont_f, sec_type_f, sec_valid_f = sample_secondaries_ar(
decoder, cond_cont, cond_cat, stage1_out, None, steps=2, full_length=True
)
assert torch.equal(sec_valid_c, sec_valid_f)
assert torch.allclose(sec_cont_c[sec_valid_c], sec_cont_f[sec_valid_f], atol=1e-4, rtol=1e-4)
assert torch.allclose(sec_type_c[sec_valid_c], sec_type_f[sec_valid_f], atol=1e-4, rtol=1e-4)
def test_sample_secondaries_ar_full_length_ignores_n_sec_pred_zero_rows():
"""A row with n_sec_pred == 0 would be dropped from the active set at
slot 0 under compaction (full_length=False) — full_length=True must
still run the model for it at every slot (only masked by sec_valid at
the end), matching _assemble_stage2_ar_inputs_scheduled's contract."""
B, k_max, emb_dim = 3, 4, 6
decoder = _stage2_ar("physical", "flow", emb_dim=emb_dim, k_max=k_max)
cond_cont, cond_cat = _cond(B)
stage1_out = torch.randn(B, X_DIM)
n_sec_pred = torch.tensor([0, 0, 0])
sec_cont, sec_type, sec_valid = sample_secondaries_ar(
decoder, cond_cont, cond_cat, stage1_out, n_sec_pred, steps=2, full_length=True
)
assert not sec_valid.any()
# every slot still ran the model (not left at the zero-init default) —
# a real flow ODE output from randn-initialized noise is essentially
# never exactly zero.
assert not torch.allclose(sec_cont, torch.zeros_like(sec_cont))
# ── resolve_n_sec: n_sec.mode = "head" sampling policy (gitea #86) ──────────