Skip to content

Commit 770c3a8

Browse files
committed
Address comments
Signed-off-by: Julius Berner <mail@jberner.info>
1 parent 4a20b42 commit 770c3a8

10 files changed

Lines changed: 192 additions & 44 deletions

File tree

fastgen/configs/experiments/WanT2V/config_anyflow.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@
44
"""AnyFlow flow-map pretrain config on Wan-1.3B T2V (paper Stage 1).
55
66
AnyFlow's pretrain objective is MeanFlow's with a fixed ``beta08`` per-timestep
7-
weighting, a finite-difference JVP, shifted timestep sampling, and a
8-
``consistency_ratio`` fraction of the batch pinned to ``r = 0`` — so this config
7+
weighting, a finite-difference JVP, shifted timestep sampling, and a
8+
``consistency_ratio`` fraction of the batch pinned to ``r = 0`` — so this config
99
runs``MeanFlowModel`` directly. The values below mirror the reference recipe
1010
``train_wan1b_student_shift5_81f_480p_lr5e-5_6k_b32.yml``.
1111
Known deviations from the reference: full-rank fine-tuning instead of the paper's
@@ -103,7 +103,7 @@ def create_config():
103103
config.model.sample_t_cfg.t_list = [1.0, 0.9375, 0.8333333333333334, 0.625, 0.0]
104104

105105
# ------ data / trainer ------
106-
config.dataloader_train = VideoLoaderConfig
106+
config.dataloader_train = copy.deepcopy(VideoLoaderConfig)
107107
config.dataloader_train.img_size = (config.model.input_shape[-1] * 8, config.model.input_shape[-2] * 8)
108108
config.dataloader_train.sequence_length = (config.model.input_shape[1] - 1) * 4 + 1
109109
config.dataloader_train.batch_size = 1

fastgen/configs/experiments/WanT2V/config_anyflow_onpolicy.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ def create_config():
137137
config.trainer.callbacks.ema.start_iter = 6399
138138

139139
# ------ data / trainer ------
140-
config.dataloader_train = VideoLoaderConfig
140+
config.dataloader_train = copy.deepcopy(VideoLoaderConfig)
141141
config.dataloader_train.img_size = (config.model.input_shape[-1] * 8, config.model.input_shape[-2] * 8)
142142
config.dataloader_train.sequence_length = (config.model.input_shape[1] - 1) * 4 + 1
143143
config.dataloader_train.batch_size = 1

fastgen/configs/methods/config_anyflow.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111

1212
from typing import List, Optional
1313

14-
from typing import Optional
15-
1614
import attrs
1715
from omegaconf import DictConfig
1816

@@ -61,6 +59,11 @@ class ModelConfig(DMD2ModelConfig):
6159

6260
# Prediction-side guidance fusion for the co-trained flow-map loss; see
6361
# `config_mean_flow.ModelConfig`.
62+
#
63+
# Deviates from the AnyFlow reference, which divides dF/dt by g for the whole
64+
# batch. We divide only on samples that kept their condition: a dropped sample's
65+
# fused prediction is plain `u_uncond`, so an ungated 1/g would regress it onto
66+
# a different fixed point.
6467
guidance_fuse_scale: Optional[float] = None
6568

6669
# Text dropout for the co-trained flow-map loss (reference drop_text_ratio).
@@ -70,7 +73,7 @@ class ModelConfig(DMD2ModelConfig):
7073
# Precision for autocast in the co-trained loss JVP (None = training precision).
7174
precision_amp_jvp: str | None = None
7275

73-
# MeanFlow's target-side guidance knobs. AnyFlow typixcally guides on the
76+
# MeanFlow's target-side guidance knobs. AnyFlow typically guides on the
7477
# PREDICTION side (`guidance_fuse_scale`), so these stay at their
7578
# no-op defaults
7679
guidance_mixture_ratio: Optional[float] = None

fastgen/configs/methods/config_mean_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ class LossConfig:
8383
# loss type (choice between l2 and opt_grad)
8484
loss_type: str = "opt_grad"
8585
# optional fixed per-timestep loss weighting evaluated as a function of t
86-
# ("beta08", "gaussian", "uniform"). Multiplies the adaptive norm_method
86+
# ("beta08", "gaussian", "uniform"). Multiplies the adaptive norm_method
8787
# weight above; None disables it.
8888
weight_type: Optional[str] = None
8989
# rebalance the flow-map / consistency (r < t) sample losses to the global

fastgen/methods/consistency_model/mean_flow.py

Lines changed: 32 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,9 @@ class FlowMapLossMixin:
5959
training objective; distribution-matching methods can co-train it alongside
6060
their own objective.
6161
62-
The host class must provide ``net``, ``device``, ``config``,
63-
``precision_amp``, a ``_get_velocity`` implementation, and call
64-
``_init_flow_map_loss`` from its ``__init__``.
62+
The host class must provide ``net``, ``device``, ``config`` and ``precision_amp``
63+
(plus ``teacher`` when ``loss_config.use_cd``), and call ``_init_flow_map_loss``
64+
from its ``__init__``.
6565
"""
6666

6767
def _init_flow_map_loss(self) -> None:
@@ -70,7 +70,8 @@ def _init_flow_map_loss(self) -> None:
7070
self.sample_r_cfg = self.config.sample_r_cfg
7171
self.loss_config = self.config.loss_config
7272

73-
# Precision for JVP
73+
# Drop the JVP autocast when it matches the outer one, since a nested region in the
74+
# same dtype is a no-op.
7475
if self.config.precision_amp_jvp is None or self.config.precision_amp_jvp == self.precision_amp:
7576
self.precision_amp_jvp = None
7677
else:
@@ -89,28 +90,29 @@ def _init_flow_map_loss(self) -> None:
8990
grid = shift * grid / (1 + (shift - 1) * grid)
9091
self._timestep_weight_scale = float(num_steps / self._timestep_weight_raw(grid).sum())
9192

92-
def _drop_condition(self, condition: Any, neg_condition: Any) -> Tuple[Any, Optional[torch.Tensor]]:
93+
def _drop_condition(
94+
self, condition: Any, neg_condition: Any, batch_size: int, device: torch.device
95+
) -> Tuple[Any, torch.Tensor]:
9396
"""Replace the condition with neg_condition for a per-sample subset.
9497
95-
Returns ``(condition, keep)``; ``keep`` is the ``[B]`` bool mask (None if
96-
no dropout), so callers can reuse the same subset. Keys in
97-
``cond_keys_no_dropout`` are never replaced.
98+
Returns ``(condition, keep)``; ``keep`` is the ``[B]`` bool mask of the
99+
samples that stayed conditional, so callers can reuse the same subset.
98100
99101
``deterministic_buckets`` decides whether an index carries bucket
100102
information: if so the buckets are cut on the GLOBAL index and an
101103
index-based rule would only hit flow matching on rank 0, so draw per
102104
sample; otherwise drop the first ``num_to_drop``.
103105
"""
106+
# Dropout disabled, or no negative condition to swap in: every sample
107+
# stays conditional.
104108
if self.config.cond_dropout_prob is None or neg_condition is None:
105-
return condition, None
109+
return condition, torch.ones(batch_size, dtype=torch.bool, device=device)
106110

107-
ref = neg_condition if isinstance(neg_condition, torch.Tensor) else next(iter(neg_condition.values()))
108-
batch_size = ref.shape[0]
109111
if self.sample_t_cfg.deterministic_buckets:
110-
keep = torch.rand(batch_size, device=ref.device) >= self.config.cond_dropout_prob
112+
keep = torch.rand(batch_size, device=device) >= self.config.cond_dropout_prob
111113
else:
112-
num_to_drop = (torch.rand(batch_size, device=ref.device) < self.config.cond_dropout_prob).sum()
113-
keep = torch.arange(batch_size, device=ref.device) >= num_to_drop
114+
num_to_drop = (torch.rand(batch_size, device=device) < self.config.cond_dropout_prob).sum()
115+
keep = torch.arange(batch_size, device=device) >= num_to_drop
114116

115117
if isinstance(condition, torch.Tensor):
116118
return torch.where(expand_like(keep, condition), condition, neg_condition), keep
@@ -133,8 +135,9 @@ def _get_velocity(
133135
t: torch.Tensor,
134136
condition: Optional[Any] = None,
135137
neg_condition: Optional[Any] = None,
136-
) -> Tuple[Any, torch.Tensor]:
137-
"""Regression target for the flow-map loss, plus the condition it was built from.
138+
) -> Tuple[Any, torch.Tensor, torch.Tensor]:
139+
"""Regression target for the flow-map loss, the condition it was built from,
140+
and the ``[B]`` mask of samples that stayed conditional.
138141
139142
Two independent choices:
140143
@@ -148,7 +151,9 @@ def _get_velocity(
148151
fuse_scale = self.config.guidance_fuse_scale
149152
if fuse_scale is not None:
150153
assert fuse_scale > 0, f"guidance_fuse_scale must be > 0, got {fuse_scale} (None disables fusion)"
151-
condition, _ = self._drop_condition(condition, neg_condition)
154+
condition, keep = self._drop_condition(condition, neg_condition, x.shape[0], x.device)
155+
else:
156+
keep = torch.ones(x.shape[0], dtype=torch.bool, device=x.device)
152157

153158
x_t = self.net.noise_scheduler.forward_process(x, z, t)
154159

@@ -201,13 +206,12 @@ def _get_velocity(
201206
)
202207

203208
self.net.train()
204-
condition, keep = self._drop_condition(condition, neg_condition)
205-
if keep is not None:
206-
# Same subset: a kept sample is conditional + guided, a
207-
# dropped one unconditional + unguided.
208-
dxt_dt = torch.where(expand_like(keep, dxt_dt), guided_dxt_dt, dxt_dt)
209+
condition, keep = self._drop_condition(condition, neg_condition, x_t.shape[0], x_t.device)
210+
# Same subset: a kept sample is conditional + guided, a dropped one
211+
# unconditional + unguided.
212+
dxt_dt = torch.where(expand_like(keep, dxt_dt), guided_dxt_dt, dxt_dt)
209213

210-
return condition, dxt_dt
214+
return condition, dxt_dt, keep
211215

212216
def _estimate_jvp_finite_difference(
213217
self,
@@ -573,7 +577,7 @@ def _compute_mf_loss(
573577
z = torch.randn_like(real_data)
574578
x_t = self.net.noise_scheduler.forward_process(real_data, z, t)
575579

576-
condition, dxt_dt = self._get_velocity(real_data, z, t, condition=condition, neg_condition=neg_condition)
580+
condition, dxt_dt, keep = self._get_velocity(real_data, z, t, condition=condition, neg_condition=neg_condition)
577581
# prevent JVP to use cached conversions (which can break the computational graph) that were created in the no_grad context of _get_velocity
578582
torch.clear_autocast_cache()
579583
u_theta_jvp = self._jvp(x_t, t, r, dxt_dt, condition=condition)
@@ -594,9 +598,10 @@ def _compute_mf_loss(
594598
# Guidance distillation on the PREDICTION side (see `_get_velocity`): the
595599
# conditional output learns the guided flow directly, so only the prediction
596600
# changes. The uncond branch is queried at the SAME (t, r) flow-map slice,
597-
# giving (u_cond + (g - 1) * u_uncond) / g; dF/dt is then the conditional
598-
# finite difference over g, with the unconditional derivative dropped.
599-
u_theta_jvp = u_theta_jvp / guidance_fuse_scale
601+
# giving (u_cond + (g - 1) * u_uncond) / g; dF/dt is then the finite
602+
# difference over g on conditional samples, with the unconditional
603+
# derivative dropped.
604+
u_theta_jvp = torch.where(expand_like(keep, u_theta_jvp), u_theta_jvp / guidance_fuse_scale, u_theta_jvp)
600605
with torch.no_grad():
601606
u_uncond = self.net(x_t, t, r=r, condition=neg_condition, fwd_pred_type="flow")
602607
u_theta = (u_theta + (guidance_fuse_scale - 1.0) * u_uncond) / guidance_fuse_scale

fastgen/methods/distribution_matching/anyflow.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ class AnyFlowModel(FlowMapLossMixin, DMD2Model):
3838
"""AnyFlow on-policy stage: DMD2 with a flow-map rollout student.
3939
4040
``FlowMapLossMixin`` supplies the co-trained Stage-1 objective and the
41-
flow-map validation sample loop, which integrates with ``r = t_next`` —
42-
FastGenModel's default x0-prediction loop never passes ``r``.
41+
flow-map validation sample loop, which integrates with ``r = t_next``.
4342
"""
4443

4544
def __init__(self, config: ModelConfig):
@@ -133,7 +132,7 @@ def gen_data_from_net(
133132
grad_step = self._broadcast_choice(num_steps)
134133
t_list = self._rollout_t_list(num_steps)
135134

136-
# The leading jump exists only for grad_step > 0 and the trailing one
135+
# The leading jump exists only for grad_step > 0 and the trailing one
137136
# only for grad_step + 1 < num_steps.
138137
seg_t = [t_list[0]] if grad_step > 0 else []
139138
seg_t += [t_list[grad_step], t_list[grad_step + 1]]

fastgen/networks/Wan/utils.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,13 @@ def remap_anyflow_keys(state_dict: Mapping[str, Any]) -> Mapping[str, Any]:
129129
# [transformer.]condition_embedder.delta_embedder.linear_1.weight
130130
# -> [transformer.]r_embedder.time_embedder.linear_1.weight
131131
prefix, _, suffix = k.partition(delta_marker)
132-
new_sd[f"{prefix}r_embedder.time_embedder.{suffix}"] = new_sd.pop(k)
132+
target = f"{prefix}r_embedder.time_embedder.{suffix}"
133+
if target in new_sd:
134+
raise ValueError(
135+
f"remap_anyflow_keys: rewriting {k!r} would overwrite the existing {target!r}. "
136+
"This checkpoint carries both the AnyFlow and the FastGen r-pathway layouts; "
137+
"drop one of them before loading."
138+
)
139+
new_sd[target] = new_sd.pop(k)
133140
logger.info(f"remap_anyflow_keys: rewrote {len(delta_keys)} delta_embedder tensors into r_embedder.")
134141
return new_sd

tests/test_anyflowmodel.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -343,8 +343,10 @@ def counting_forward(*args, **kwargs):
343343
return orig_forward(*args, **kwargs)
344344

345345
model.net.forward = counting_forward
346-
gen = model.gen_data_from_net(input_student, t_student, condition=cond)
347-
model.net.forward = orig_forward
346+
try:
347+
gen = model.gen_data_from_net(input_student, t_student, condition=cond)
348+
finally:
349+
model.net.forward = orig_forward
348350

349351
assert len(calls) <= 3, f"rollout must compress to <= 3 forwards, got {len(calls)}"
350352
assert gen.requires_grad

tests/test_meanflowmodel.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,137 @@ def test_single_train_step_update_fp32_jvp():
120120
assert "mf_loss" in loss_map
121121
assert "gen_rand" in outputs
122122
assert isinstance(outputs["gen_rand"], Callable)
123+
124+
125+
@pytest.mark.parametrize("cond_dropout_prob, expect_guided", [(None, True), (0.0, True), (1.0, False)])
126+
def test_target_side_guidance_applies_when_no_cond_dropout(cond_dropout_prob, expect_guided):
127+
"""`guidance_scale` must reach the target for every conditional sample.
128+
129+
No dropout means every sample is conditional, so it must guide exactly like
130+
`p=0.0`. It used not to: `_drop_condition` returned `keep=None` when
131+
`cond_dropout_prob is None` and the caller skipped the update entirely --
132+
computing `guided_dxt_dt` at the cost of an extra forward pass and then
133+
discarding it, so `guidance_scale` was silently inert on that path.
134+
(Pre-existing: `_mix_condition` returned early on `cond_dropout_prob is None`
135+
before the AnyFlow work.) `_drop_condition` now always returns a mask -- all-True
136+
here -- so the caller has no special case left to forget.
137+
138+
The net's forward is stubbed to depend only on the condition, so the guided
139+
velocity is exact rather than initialization-dependent -- EDM's `SongUNet`
140+
zero-inits `out_conv`, which would otherwise make the cond and uncond passes
141+
bitwise identical and guidance a provable no-op.
142+
"""
143+
config = create_config()
144+
instance = config.model
145+
opts = ["-", "img_resolution=2", "channel_mult=[1]", "channel_mult_noise=1", "r_timestep=True"]
146+
instance.net = override_config_with_opts(instance.net, opts)
147+
instance.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
148+
instance.precision = "float32"
149+
instance.pretrained_model_path = ""
150+
instance.input_shape = [3, 2, 2]
151+
instance.cond_dropout_prob = cond_dropout_prob
152+
instance.guidance_scale = 2.0
153+
instance.guidance_fuse_scale = None
154+
model = MeanFlowModel(instance)
155+
model.on_train_begin()
156+
157+
batch_size = 4
158+
real = torch.randn(batch_size, 3, 2, 2, device=model.device, dtype=torch.float32)
159+
z = torch.randn_like(real)
160+
t = torch.full((batch_size,), 0.5, device=model.device, dtype=model.net.noise_scheduler.t_precision)
161+
condition = torch.nn.functional.one_hot(torch.arange(batch_size) % 10, num_classes=10)
162+
condition = condition.to(model.device, torch.float32)
163+
neg_condition = torch.zeros(batch_size, 10, device=model.device, dtype=torch.float32)
164+
165+
# Depends ONLY on the condition: the all-zero neg pass returns exactly 0.
166+
def condition_only_forward(x_t, t, **kwargs):
167+
val = kwargs["condition"].sum(dim=1).reshape(-1, 1, 1, 1).to(x_t.dtype)
168+
return torch.ones_like(x_t) * val
169+
170+
orig_forward = model.net.forward
171+
model.net.forward = condition_only_forward
172+
try:
173+
cond_out, dxt_dt, _ = model._get_velocity(real, z, t, condition=condition, neg_condition=neg_condition)
174+
finally:
175+
model.net.forward = orig_forward
176+
177+
# neg_dxt_dt == 0, so guided == neg + scale * (plain - neg) == 2 * plain.
178+
plain = model.net.noise_scheduler.cond_velocity(x=real, eps=z, t=t)
179+
dropped = (cond_out == neg_condition).all(dim=1)
180+
assert bool(dropped.all()) is not expect_guided
181+
182+
expected = 2.0 * plain if expect_guided else plain
183+
assert torch.allclose(dxt_dt, expected), (dxt_dt - expected).abs().max()
184+
185+
186+
def test_fused_jvp_scaling_is_gated_on_kept_samples():
187+
"""Under `guidance_fuse_scale`, dF/dt must be divided by g only for samples that
188+
stayed conditional.
189+
190+
A dropped sample's fused prediction collapses to plain `u_uncond` (the fusion
191+
self-cancels), so scaling its derivative too would regress it onto
192+
`v - (t - r) * d(u_uncond)/dt / g` instead of the unconditional MeanFlow identity.
193+
The AnyFlow reference scales the whole batch
194+
(`compute_central_difference(..., guidance)` in
195+
`far/trainers/trainer_wan_anyflow_pretrain.py`); we gate on `keep`.
196+
197+
The net is stubbed to ignore `condition`, so dropping it changes nothing about the
198+
raw derivative -- making the 1/g gate the ONLY difference between the two runs.
199+
"""
200+
g = 3.0
201+
config = create_config()
202+
instance = config.model
203+
opts = ["-", "img_resolution=2", "channel_mult=[1]", "channel_mult_noise=1", "r_timestep=True"]
204+
instance.net = override_config_with_opts(instance.net, opts)
205+
instance.device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
206+
instance.precision = "float32"
207+
instance.pretrained_model_path = ""
208+
instance.input_shape = [3, 2, 2]
209+
instance.guidance_fuse_scale = g
210+
instance.loss_config.use_jvp_finite_diff = True
211+
instance.sample_t_cfg.deterministic_buckets = False
212+
model = MeanFlowModel(instance)
213+
model.on_train_begin()
214+
215+
batch_size = 4
216+
real = torch.randn(batch_size, 3, 2, 2, device=model.device, dtype=torch.float32)
217+
t_prec = model.net.noise_scheduler.t_precision
218+
t = torch.full((batch_size,), 0.6, device=model.device, dtype=t_prec)
219+
r = torch.full((batch_size,), 0.2, device=model.device, dtype=t_prec)
220+
condition = torch.nn.functional.one_hot(torch.arange(batch_size) % 10, num_classes=10)
221+
condition = condition.to(model.device, torch.float32)
222+
neg_condition = torch.zeros(batch_size, 10, device=model.device, dtype=torch.float32)
223+
224+
# `+ 0.0 * param.sum()` leaves the value untouched but ties the output to the
225+
# autograd graph: `_mf_pred_to_loss` asserts
226+
# `u_theta.requires_grad is torch.is_grad_enabled()`. The JVP runs under `_jvp`'s
227+
# `@torch.no_grad()`, so `u_theta_jvp` stays grad-free as that code also asserts.
228+
param = next(model.net.parameters())
229+
230+
def condition_independent_forward(x_t, t, **kwargs):
231+
tt = t.reshape(-1, *([1] * (x_t.ndim - 1))).to(x_t.dtype)
232+
return x_t * (1.0 + tt) + 0.0 * param.sum().to(x_t.dtype)
233+
234+
orig_forward = model.net.forward
235+
236+
def run(cond_dropout_prob):
237+
model.config.cond_dropout_prob = cond_dropout_prob
238+
model.net.forward = condition_independent_forward
239+
try:
240+
torch.manual_seed(11)
241+
return model._compute_mf_loss(
242+
real_data=real,
243+
t=t,
244+
r=r,
245+
iteration=0,
246+
condition=condition,
247+
neg_condition=neg_condition,
248+
)[2]
249+
finally:
250+
model.net.forward = orig_forward
251+
252+
jvp_kept = run(0.0) # every sample conditional -> every derivative divided by g
253+
jvp_dropped = run(1.0) # every sample unconditional -> none divided
254+
255+
assert torch.allclose(jvp_dropped, g * jvp_kept, atol=1e-5, rtol=1e-4), (jvp_dropped - g * jvp_kept).abs().max()
256+
assert not torch.allclose(jvp_dropped, jvp_kept, atol=1e-6)

0 commit comments

Comments
 (0)