Files
hao-ai-lab-FastVideo/v2/tests/test_weight_sync_live.py
T
SolitaryThinker 9489c6c1dd [feat] Five more stress tests: LTX-2 A/V, weight-sync, served reward, cache-dit, nested workflows
The remaining design_v3 probes (all except 8b speculative decoding). All fit with no new runtime
primitive (148 tests pass).

#6 LTX-2 joint audio+video (§9.11) — LTX-2 declared an audio_vae required_for t2vs but never used
   it; now a single 2-stage denoise carries a synchronized audio latent (conditioned on video),
   applies per-modality CFG (guidance_per_modality), and decodes via video VAE + audio VAE → video +
   audio. Gated on requesting audio, so the T2V path is byte-identical (existing tests untouched).
   ToyAudioVAE; build_ltx2_av_program. 5 tests.

#4 Live weight-sync under in-flight serving (§9.14) — WeightSyncController makes the freeze → drain →
   transfer → bump version + invalidate → resume lifecycle explicit. Tests: a mid-flight swap corrupts
   (the hazard); draining first leaves the in-flight request bit-identical to baseline while a
   post-sync request reflects new weights; transformer-only sync, so the frozen text-encoder cache
   survives. The RL flywheel's hardest correctness. 3 tests.

#5 Reward-model-as-a-served-card (§9.15) — a reward model is a card (scorer + a score loop emitting
   REWARD_BATCH units); ServedRewardScorer drop-in-replaces the numpy scorer so any RL method becomes
   RLHF/RLAIF with no method change. ToyRewardModel; models/reward/. 4 tests.

#7 Content-adaptive control flow (§9.12) — CacheDiTDenoiseLoop (isolated WanDenoiseLoop subclass)
   reuses the cached velocity when predictions barely change (cache-dit skip) and early-exits on
   convergence — variable step count; interleave parity holds across ragged loops. models/adaptive/. 4 tests.

#8a Nested workflows (§9.13) — a workflow stage can invoke another workflow (engine.run routes ids);
   requires/validate recurse; cycles caught at registration + a run-time guard (engine._wf_running).
   build_t2i_i2v_extend_workflow. 5 tests.

designv4 §9.11–§9.15 + falsifier/layout/closing updates (148 tests, 25 files). Also removed a
pre-existing unused import in ltx2/loop.py.
2026-07-04 17:15:34 +00:00

90 lines
4.9 KiB
Python

"""Live weight-sync under in-flight serving — the RL flywheel's hardest correctness (designv4 §9.14).
Collocated RL serves rollouts and receives weight updates on the same instance. The hazard: if new
weights are swapped in *while a denoise loop is mid-flight*, that request's trajectory becomes a
half-and-half of two policies — its captured log-probs describe a rollout that never happened, and
training silently corrupts. These tests prove the lifecycle (freeze → drain → transfer → publish →
resume) makes it correct:
* **Hazard:** a mid-flight swap *does* corrupt the in-flight request (shown, as the thing to avoid).
* **Lifecycle:** draining the in-flight request first (it finishes on its START weights) leaves it
bit-identical to the no-sync baseline; a request admitted *after* the sync reflects the new weights.
* **Per-component versioning:** the sync bumps only the transformer's version + invalidates only its
caches — the frozen text-encoder's feature cache survives (so a shared prompt still reuses).
"""
from __future__ import annotations
import numpy as np
from v2._enums import ExecutionProfile
from v2.loop.driver import LoopRunner
from v2.models.backend import ToyDiT
from v2.models.common import cached_text_encode, text_encode_node_fn
from v2.models.wan21 import build_wan21_card
from v2.request import DiffusionParams, TaskType, make_request
from v2.request.streams import Stream
from v2.runtime import Engine
from v2.runtime.context import RuntimeLoopContext
from v2.training.methods.base import new_instance
from v2.training.weight_sync import WeightSyncController
_HUB = Engine() # borrow its observer/interceptor hubs for the loop context
def _drive_denoise(inst, prompt="a fox", seed=1, *, steps=8, swap_at=None, new_dit=None):
"""Drive a denoise loop step-by-step; optionally swap the resident transformer at step ``swap_at``
(the mid-flight hazard). Returns the final latent."""
req = make_request(TaskType.T2V, inst.card.model_id, prompt,
diffusion=DiffusionParams(num_steps=steps, seed=seed))
slots: dict = {}
text_encode_node_fn(inst, slots, req, None)
ctx = RuntimeLoopContext(inst, observers=_HUB.observers, interceptors=_HUB.interceptors,
slots=slots, stream=Stream(req.request_id), cancel_scope=None,
profile=ExecutionProfile.SERVE, metrics={}, request_id=req.request_id)
runner = LoopRunner(inst.loop("diffusion_denoise"), ctx, req, inst)
n = 0
while not runner.done:
if swap_at is not None and n == swap_at and new_dit is not None:
inst.component("transformer").copy_from(new_dit) # MID-FLIGHT swap
runner.step()
n += 1
return np.asarray(runner.result.outputs["latents"])
def _fresh():
return new_instance(build_wan21_card())
def test_baseline_then_mid_flight_swap_corrupts():
"""The hazard the lifecycle exists to prevent: swapping weights mid-denoise changes the output."""
baseline = _drive_denoise(_fresh())
corrupted = _drive_denoise(_fresh(), swap_at=3, new_dit=ToyDiT(seed=999))
assert not np.array_equal(corrupted, baseline) # a half-and-half rollout
def test_drain_then_sync_does_not_corrupt_in_flight():
"""The lifecycle: freeze admission, let the in-flight request DRAIN (finish on its start weights),
then sync. The in-flight output is bit-identical to baseline; a post-sync request uses new weights."""
baseline = _drive_denoise(_fresh())
inst = _fresh()
ctrl = WeightSyncController(inst)
ctrl.freeze() # no new admissions during the sync
assert not ctrl.can_admit()
in_flight = _drive_denoise(inst) # drains on OLD weights
assert np.array_equal(in_flight, baseline) # uncorrupted — finished on start weights
ctrl.sync(ToyDiT(seed=999)) # transfer + publish, then resume
assert ctrl.can_admit() and ctrl.synced == 1
post = _drive_denoise(inst) # admitted after the sync
assert not np.array_equal(post, baseline) # reflects the NEW weights
def test_sync_versions_and_invalidates_only_the_synced_component():
inst = _fresh()
cached_text_encode(inst, "a shared prompt") # warm the text-encoder feature cache
hits0 = inst.caches.stats()["feature"]["hits"]
WeightSyncController(inst).sync(ToyDiT(seed=7)) # transformer-only sync
assert inst.version_of("transformer") != "v0" # synced component bumped
assert inst.version_of("text_encoder") == "v0" # frozen encoder untouched (§7.1)
cached_text_encode(inst, "a shared prompt") # same prompt after the sync
assert inst.caches.stats()["feature"]["hits"] == hits0 + 1 # text cache SURVIVED the transformer sync