Skip to content

Commit 9ff00c8

Browse files
authored
[None][fix] handle rank-3 Fp4 and non-contiguous Ulysses shard in LTX-2 NVFP4 fused paths (#15718)
Signed-off-by: Yiyun Lu <55233584+luyiyun1021@users.noreply.github.com>
1 parent b155a51 commit 9ff00c8

6 files changed

Lines changed: 276 additions & 12 deletions

File tree

tensorrt_llm/_torch/modules/gated_mlp.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,15 @@ def _fused_gate_up_swiglu(self, x, fp4_out=False):
216216
if not isinstance(x, (tuple, Fp4QuantizedTensor)) and x.dim() > 2:
217217
original_shape = x.shape
218218
x = x.reshape(-1, x.shape[-1])
219+
elif isinstance(x, Fp4QuantizedTensor) and x.fp4_tensor.dim() > 2:
220+
# Fused GEMM needs a 2D mat1: flatten a rank-3 fp4 activation's data
221+
# to [M, D/2] (its SF is already flat-M, so reuse it); unflatten below.
222+
original_shape = x.fp4_tensor.shape
223+
x = Fp4QuantizedTensor(
224+
fp4_tensor=x.fp4_tensor.reshape(-1, x.fp4_tensor.shape[-1]),
225+
scaling_factor=x.scaling_factor,
226+
is_sf_swizzled=x.is_sf_swizzled,
227+
)
219228

220229
# Get quantized inputs from Linear's NVFP4 pipeline
221230
act_fp4, act_sf, alpha = module.quant_method._input_prepare(module, x)
@@ -271,11 +280,10 @@ def forward(
271280
if torch.compiler.is_compiling():
272281
fp4_out = True
273282
else:
274-
if isinstance(x, (tuple, Fp4QuantizedTensor)):
275-
m = x[0].shape[0] if isinstance(x, tuple) else x.shape[0]
276-
else:
277-
m = x.reshape(
278-
-1, x.shape[-1]).shape[0] if x.dim() > 2 else x.shape[0]
283+
t = x.fp4_tensor if isinstance(x, Fp4QuantizedTensor) else (
284+
x[0] if isinstance(x, tuple) else x)
285+
m = t.reshape(
286+
-1, t.shape[-1]).shape[0] if t.dim() > 2 else t.shape[0]
279287
fp4_out = m >= GatedMLP._FP4OUT_MIN_M
280288
h2 = self._fused_gate_up_swiglu(x, fp4_out=fp4_out)
281289
elif self._can_fuse_gate_up_swiglu():

tensorrt_llm/_torch/modules/mlp.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,12 @@ def _gelu_fusion_eligibility(self) -> Tuple[bool, bool]:
180180

181181
@staticmethod
182182
def _token_count(x: Union[torch.Tensor, tuple, Fp4QuantizedTensor]) -> int:
183-
if isinstance(x, (tuple, Fp4QuantizedTensor)):
184-
return x[0].shape[0] if isinstance(x, tuple) else x.shape[0]
185-
return x.reshape(-1,
186-
x.shape[-1]).shape[0] if x.dim() > 2 else x.shape[0]
183+
# Row count M = product of leading dims (B*S for [B, S, ...]); keeps the
184+
# fp4-out switch correct for rank-3 / pre-quantized inputs.
185+
t = x.fp4_tensor if isinstance(
186+
x, Fp4QuantizedTensor) else (x[0] if isinstance(x, tuple) else x)
187+
return t.reshape(-1,
188+
t.shape[-1]).shape[0] if t.dim() > 2 else t.shape[0]
187189

188190
def _fused_gelu(
189191
self,
@@ -205,6 +207,15 @@ def _fused_gelu(
205207
if not isinstance(x, (tuple, Fp4QuantizedTensor)) and x.dim() > 2:
206208
original_shape = x.shape
207209
x = x.reshape(-1, x.shape[-1])
210+
elif isinstance(x, Fp4QuantizedTensor) and x.fp4_tensor.dim() > 2:
211+
# Fused GEMM needs a 2D mat1: flatten a rank-3 fp4 activation's data
212+
# to [M, D/2] (its SF is already flat-M, so reuse it); unflatten below.
213+
original_shape = x.fp4_tensor.shape
214+
x = Fp4QuantizedTensor(
215+
fp4_tensor=x.fp4_tensor.reshape(-1, x.fp4_tensor.shape[-1]),
216+
scaling_factor=x.scaling_factor,
217+
is_sf_swizzled=x.is_sf_swizzled,
218+
)
208219

209220
act_fp4, act_sf, alpha = module.quant_method._input_prepare(module, x)
210221

tensorrt_llm/_torch/visual_gen/models/ltx2/transformer_ltx2.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,8 +477,11 @@ def forward_async(
477477
and self.qk_norm
478478
)
479479

480-
# SEPARATE_QKV self-attn 3x fp4_quantize dedup; see Attention.forward_async.
481-
if self._maybe_share_qkv_quantize and getattr(self.to_q, "input_scale", None) is not None:
480+
# SEPARATE_QKV self-attn shares ONE input quant across to_q/to_k/to_v (mirrors
481+
# Attention.forward_async): reuse x if already fp4, else quantize once.
482+
if isinstance(x, Fp4QuantizedTensor):
483+
qkv_input = x
484+
elif self._maybe_share_qkv_quantize and getattr(self.to_q, "input_scale", None) is not None:
482485
x_2d = x.reshape(-1, x.shape[-1])
483486
fp4, sf = torch.ops.trtllm.tunable_fp4_quantize(
484487
x_2d, self.to_q.input_scale, self.to_q.scaling_vector_size, False

tensorrt_llm/_torch/visual_gen/utils.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ def shard(
198198
start = self._rank * chunk
199199
idx = [slice(None)] * tensor.ndim
200200
idx[dim] = slice(start, start + chunk)
201-
return tensor[tuple(idx)]
201+
# A dim>=1 block slice is non-contiguous when a leading dim is >1 (e.g.
202+
# batched CFG B=2); fused DiT kernels need a dense buffer. No-op at B==1.
203+
return tensor[tuple(idx)].contiguous()
202204

203205
def shard_rope(
204206
self,

tests/unittest/_torch/thop/parallel/test_dense_gemm_act_fusion.py

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,3 +228,57 @@ def _spy(x, fp4_out=False):
228228
assert seen["fp4_out"] == expected, (
229229
f"m={m}: expected fp4_out={expected}, got {seen['fp4_out']}"
230230
)
231+
232+
233+
# ---------------------------------------------------------------------------
234+
# The fused MLP flattens a rank-3 Fp4QuantizedTensor to 2D before the (2D-only)
235+
# GEMM. A fused rmsnorm hands it [B, S, D/2]; the path previously flattened only
236+
# plain tensors, tripping `assert inputs[0].dim() == 2`.
237+
# ---------------------------------------------------------------------------
238+
@pytest.mark.parametrize("kind", ["gelu", "swiglu"])
239+
def test_fused_act_flattens_3d_fp4_input(kind):
240+
"""Fused NVFP4 MLP flattens a rank-3 Fp4QuantizedTensor to 2D before the GEMM."""
241+
import types
242+
243+
from tensorrt_llm._torch.utils import Fp4QuantizedTensor, gelu_tanh
244+
245+
B, S, K = 2, 8, 64
246+
if kind == "gelu":
247+
from tensorrt_llm._torch.modules.mlp import MLP
248+
249+
mod = MLP(hidden_size=K, intermediate_size=128, bias=False, activation=gelu_tanh)
250+
proj = mod.up_proj
251+
run = lambda x: mod._fused_gelu(x) # noqa: E731
252+
else:
253+
from tensorrt_llm._torch.modules.gated_mlp import GatedMLP
254+
255+
mod = GatedMLP(hidden_size=K, intermediate_size=128, bias=False)
256+
proj = mod.gate_up_proj
257+
run = lambda x: mod._fused_gate_up_swiglu(x) # noqa: E731
258+
259+
captured = {}
260+
261+
class _Stop(Exception):
262+
pass
263+
264+
def fake_input_prepare(module, x):
265+
captured["dim"] = x.fp4_tensor.dim() if isinstance(x, Fp4QuantizedTensor) else x.dim()
266+
captured["shape"] = (
267+
tuple(x.fp4_tensor.shape) if isinstance(x, Fp4QuantizedTensor) else tuple(x.shape)
268+
)
269+
raise _Stop # short-circuit before the real (HW-only) GEMM
270+
271+
proj.quant_method = types.SimpleNamespace(_input_prepare=fake_input_prepare)
272+
273+
fp4_3d = Fp4QuantizedTensor(
274+
fp4_tensor=torch.zeros(B, S, K // 2, dtype=torch.uint8),
275+
scaling_factor=torch.zeros(B * S, K // 16, dtype=torch.uint8),
276+
)
277+
with pytest.raises(_Stop):
278+
run(fp4_3d)
279+
280+
assert captured["dim"] == 2, (
281+
f"{kind}: rank-3 Fp4QuantizedTensor not flattened before the GEMM "
282+
f"(kernel would see rank {captured['dim']})"
283+
)
284+
assert captured["shape"] == (B * S, K // 2)

tests/unittest/_torch/visual_gen/test_ltx2_transformer.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,45 @@ def _init_all_weights(model: torch.nn.Module, std: float = 0.02):
7474
torch.nn.init.normal_(p, mean=0.0, std=std)
7575

7676

77+
# float32 quant-scale params (filled to 1.0, not normal_, so dequant is well-behaved).
78+
_FP4_FLOAT_SCALE_SUFFIXES = (
79+
".input_scale",
80+
".inv_input_scale",
81+
".weight_scale_2",
82+
".alpha",
83+
".kv_scales",
84+
".inv_kv_scales",
85+
)
86+
87+
88+
def _init_weights_quant_safe(model: torch.nn.Module, std: float = 0.02):
89+
"""Like ``_init_all_weights`` but safe for NVFP4 modules.
90+
91+
NVFP4 Linears hold a packed-fp4 weight (``float4_e2m1x2``) and an FP8 scale
92+
factor (``weight_scale``) whose dtypes ``torch.nn.init.normal_`` cannot fill.
93+
Fill standard float params (norm weights and quant scales to 1.0, the rest
94+
random); for the sub-byte/FP8 quant tensors write a benign finite byte.
95+
"""
96+
with torch.no_grad():
97+
for name, p in model.named_parameters():
98+
if p.numel() == 0:
99+
continue
100+
if p.dtype in (torch.float32, torch.float16, torch.bfloat16):
101+
if any(name.endswith(s) for s in _FP4_FLOAT_SCALE_SUFFIXES):
102+
p.fill_(1.0)
103+
elif "norm" in name and "weight" in name:
104+
p.fill_(1.0)
105+
else:
106+
torch.nn.init.normal_(p, mean=0.0, std=std)
107+
else:
108+
# Packed-fp4 weight / FP8 scale factor: normal_ is undefined on
109+
# these dtypes, so write a benign finite byte via a uint8 view.
110+
try:
111+
p.view(torch.uint8).fill_(1)
112+
except Exception:
113+
pass
114+
115+
77116
def _make_video_positions(
78117
batch: int, n_patches: int, n_frames: int, grid_h: int, grid_w: int, device: torch.device
79118
) -> torch.Tensor:
@@ -390,6 +429,153 @@ def test_video_only_input_to_audio_video_model(self):
390429
)
391430

392431

432+
# AudioVideo config with real fused-AdaLN hidden dims (video/audio both 32*64=2048,
433+
# in _FUSED_ADALN_SUPPORTED_DIMS). Smaller dims (e.g. 128) take the eager bf16 fallback,
434+
# so no rank-3 Fp4QuantizedTensor is ever produced and the smoke would be a trivial pass.
435+
FP4_SMOKE_AV_CONFIG = dict(
436+
num_attention_heads=32,
437+
attention_head_dim=64, # video inner dim = 2048
438+
in_channels=32, # NVFP4 GEMM needs K (= in_channels patchify) divisible by 32
439+
out_channels=32,
440+
num_layers=1,
441+
cross_attention_dim=2048, # text cross-attn context_dim must equal inner_dim (32*64)
442+
caption_channels=64,
443+
norm_eps=1e-6,
444+
positional_embedding_max_pos=[4, 32, 32],
445+
timestep_scale_multiplier=1000,
446+
use_middle_indices_grid=True,
447+
audio_num_attention_heads=32,
448+
audio_attention_head_dim=64, # audio inner dim = 2048
449+
audio_in_channels=32,
450+
audio_out_channels=32,
451+
audio_cross_attention_dim=2048, # must equal audio inner_dim (32*64)
452+
audio_positional_embedding_max_pos=[4],
453+
av_ca_timestep_scale_multiplier=1,
454+
)
455+
456+
457+
class TestLTX2NVFP4ForwardSmoke(unittest.TestCase):
458+
"""NVFP4 forward smoke: a static-NVFP4 AudioVideo model must run end-to-end with
459+
fabricated weights/inputs without raising.
460+
461+
Guards the rank-3 ``Fp4QuantizedTensor`` bug class (PR #15718): the fused
462+
AdaLN+NVFP4 norm emits a rank-3 ``[B, S, D/2]`` fp4 activation that flows into the
463+
fused MLP epilogue and the attention projections; consumers that ``reshape``/assert
464+
2D crash on it. The forward-pre-hook below asserts the fp4 path is actually
465+
exercised, so the test fails (rather than silently passing) if the model falls back
466+
to bf16.
467+
468+
Requires Blackwell (NVFP4 fused kernels are SM100+) and a hidden dim in {2048, 4096}.
469+
"""
470+
471+
@pytest.mark.skipif(
472+
not torch.cuda.is_available() or torch.cuda.get_device_capability() < (10, 0),
473+
reason="NVFP4 fused kernels require Blackwell (SM100+)",
474+
)
475+
def test_audio_video_nvfp4_forward_smoke(self):
476+
from types import SimpleNamespace
477+
478+
from tensorrt_llm._torch.utils import Fp4QuantizedTensor
479+
from tensorrt_llm._torch.visual_gen.models.ltx2.ltx2_core.modality import Modality
480+
from tensorrt_llm._torch.visual_gen.models.ltx2.transformer_ltx2 import (
481+
LTXModel,
482+
LTXModelType,
483+
)
484+
from tensorrt_llm.quantization.mode import QuantAlgo
485+
486+
torch.manual_seed(42)
487+
device = "cuda"
488+
dtype = torch.bfloat16
489+
490+
quant_config = QuantConfig(quant_algo=QuantAlgo.NVFP4, group_size=16)
491+
model_config = DiffusionModelConfig(
492+
pretrained_config=SimpleNamespace(),
493+
quant_config=quant_config,
494+
mapping=Mapping(),
495+
attention=AttentionConfig(backend="VANILLA"),
496+
skip_create_weights_in_init=False,
497+
)
498+
# NVFP4 model: device-only .to() (a dtype cast would corrupt the packed fp4 weights).
499+
model = (
500+
LTXModel(
501+
model_type=LTXModelType.AudioVideo,
502+
model_config=model_config,
503+
**FP4_SMOKE_AV_CONFIG,
504+
)
505+
.to(device)
506+
.eval()
507+
)
508+
_init_weights_quant_safe(model)
509+
510+
batch = 1
511+
v_frames, v_h, v_w = 1, 4, 4
512+
v_patches = v_frames * v_h * v_w
513+
a_patches = 8
514+
in_channels = FP4_SMOKE_AV_CONFIG["in_channels"]
515+
audio_in_channels = FP4_SMOKE_AV_CONFIG["audio_in_channels"]
516+
caption_channels = FP4_SMOKE_AV_CONFIG["caption_channels"]
517+
text_len = 8
518+
519+
v_context = (
520+
torch.randn(batch, text_len, caption_channels, device=device, dtype=dtype) * 0.02
521+
)
522+
a_context = (
523+
torch.randn(batch, text_len, caption_channels, device=device, dtype=dtype) * 0.02
524+
)
525+
v_positions = _make_video_positions(batch, v_patches, v_frames, v_h, v_w, device)
526+
a_positions = _make_audio_positions(batch, a_patches, device)
527+
528+
video_modality = Modality(
529+
latent=torch.randn(batch, v_patches, in_channels, device=device, dtype=dtype) * 0.02,
530+
timesteps=torch.tensor([0.5], device=device),
531+
positions=v_positions,
532+
context=v_context,
533+
)
534+
audio_modality = Modality(
535+
latent=torch.randn(batch, a_patches, audio_in_channels, device=device, dtype=dtype)
536+
* 0.02,
537+
timesteps=torch.tensor([0.5], device=device),
538+
positions=a_positions,
539+
context=a_context,
540+
)
541+
text_cache = model.prepare_text_cache(
542+
video_context=v_context,
543+
video_positions=v_positions,
544+
audio_context=a_context,
545+
audio_positions=a_positions,
546+
dtype=dtype,
547+
)
548+
549+
# Self-validation: record whether a rank-3 Fp4QuantizedTensor reaches any module,
550+
# so a silent bf16 fallback (e.g. unsupported dim) fails the test instead of passing.
551+
saw_rank3_fp4 = []
552+
553+
def _record(mod, args):
554+
for a in args:
555+
if isinstance(a, Fp4QuantizedTensor) and a.fp4_tensor.dim() > 2:
556+
saw_rank3_fp4.append(type(mod).__name__)
557+
558+
handles = [m.register_forward_pre_hook(_record) for m in model.modules()]
559+
try:
560+
with torch.no_grad():
561+
video_out, audio_out = model(
562+
video=video_modality, audio=audio_modality, text_cache=text_cache
563+
)
564+
finally:
565+
for h in handles:
566+
h.remove()
567+
568+
self.assertEqual(video_out.shape, (batch, v_patches, FP4_SMOKE_AV_CONFIG["out_channels"]))
569+
self.assertEqual(
570+
audio_out.shape, (batch, a_patches, FP4_SMOKE_AV_CONFIG["audio_out_channels"])
571+
)
572+
self.assertTrue(
573+
saw_rank3_fp4,
574+
"no rank-3 Fp4QuantizedTensor reached any module -- the fused AdaLN+NVFP4 "
575+
"path was not exercised (check hidden dim in {2048, 4096} and the NVFP4 build).",
576+
)
577+
578+
393579
class TestLTX2QuantExcludeModuleRemapping(unittest.TestCase):
394580
"""Tests for _remap_exclude_modules and _apply_quant_config_exclude_modules.
395581

0 commit comments

Comments
 (0)