[perf] FastH3 two-Spark: gather packed FP4 inputs before head projections

Opt-in h3_vsa_fp4.sp_input_gather (default false; requires sp_projection_overlap) gathers the
already-packed FP4 inputs and rotary tables, then computes each rank's assigned Q/K/V heads locally;
the gate projection overlaps the smaller transfer. Supported on the measured GB10/cuDNN shape only;
other shapes keep the existing exchange. Enabled in the Spark-pair example.
T2AV on 2 Sparks: 19.35 -> 18.76 s/clip (merge gate check, same SP reduction order rules).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Junda Chen
2026-10-24 02:16:48 +00:00
co-authored by Claude Opus 5.5
parent 6397822a5b
commit be8a13b749
7 changed files with 308 additions and 9 deletions
@@ -162,6 +162,14 @@ projection is packed, overlapping traffic with the remaining projection GEMMs. I
QKV/gate projections with shared input quantization. Other configurations use the ordinary
head-group exchange. Packing arithmetic and FP8 transport precision are unchanged.
The optional `h3_vsa_fp4.sp_input_gather: true` gathers the existing packed FP4 inputs and
rotary tables before computing each rank's assigned Q/K/V heads. The gate projection overlaps
that smaller input transfer. It defaults to `false`, requires `sp_projection_overlap: true`,
and currently supports the measured GB10/cuDNN shape: 7430 local rows, 5376 input channels,
56 heads and bias-free NVFP4 QKV projections. Other shapes use the existing exchange path.
All projections, normalization, rotary operations and FP8 packet rounding remain present;
no steps or tokens are omitted. Validate generated output when changing this GEMM shape.
The optional `h3_vsa_fp4.sp_pooled_key_mean: true` reuses tile statistics for key smoothing
on the grouped tile-64 path with FP8 prefix attention and `smooth_k: true`. It defaults to
`false`. All key rows still contribute, including zero padding in the normalization length,
@@ -52,6 +52,7 @@ generator:
sp_pooled_key_mean: true
sp_direct_keys: true
sp_projection_overlap: true
sp_input_gather: true
VSA_sparsity: 0.8
VSA_tile_size: 64
h3_sequential_load: false
@@ -99,3 +99,22 @@ class ProjectionExchange:
return [(PendingInput(self.payload[c], self.remote[c], self.rank,
self.completion), PendingScale(self.scale[c], self.received_scale[c], self.completion))
for c in range(self.payload.shape[0])]
def completed_projection_packets(payload: torch.Tensor, scale: torch.Tensor,
rank: int) -> list[tuple[PendingInput, PendingScale]]:
"""Expose already source-ordered QKV packets through the usual consumers.
Input is [head groups, source ranks, QKV, rows, heads, 128]. An event orders
consumers on other CUDA streams after production; handles retain both
source slices and scales throughout their consumption.
"""
if (payload.ndim != 6 or payload.shape[1:3] != (2, 3) or payload.shape[-1] != 128 or rank not in (0, 1)
or not payload.is_cuda or not payload.is_contiguous() or payload.dtype != torch.float8_e4m3fn
or scale.shape != payload.shape[:-1] or scale.device != payload.device or scale.dtype != torch.float32
or not scale.is_contiguous()):
raise ValueError("Completed projection packets require two contiguous source-rank slices")
completion = Completion(payload.device)
completion.ready.record()
return [(PendingInput(payload[c], payload[c, 1 - rank].view(torch.uint8), rank,
completion), PendingScale(scale[c], scale[c], completion)) for c in range(payload.shape[0])]
+2 -2
View File
@@ -448,12 +448,12 @@ class FastVideoArgs:
self.attention_backend = env_backend
allowed_fp4 = {
"enabled", "prefix_precision", "two_level_p", "smooth_k", "sp_head_chunks", "sp_pooled_key_mean",
"sp_projection_overlap", "sp_direct_keys"
"sp_projection_overlap", "sp_direct_keys", "sp_input_gather"
}
if not isinstance(self.h3_vsa_fp4, dict) or self.h3_vsa_fp4.keys() - allowed_fp4:
raise ValueError(f"h3_vsa_fp4 must be a mapping with keys from {sorted(allowed_fp4)}")
for key in ("enabled", "two_level_p", "smooth_k", "sp_pooled_key_mean", "sp_projection_overlap",
"sp_direct_keys"):
"sp_direct_keys", "sp_input_gather"):
if key in self.h3_vsa_fp4 and not isinstance(self.h3_vsa_fp4[key], bool):
raise ValueError(f"h3_vsa_fp4.{key} must be a boolean")
head_chunks = self.h3_vsa_fp4.get("sp_head_chunks", 1)
@@ -113,7 +113,15 @@ def attention_pipelined(attn, hidden_states, rotary_emb, meta, sp_group, chunks,
and hidden_states.is_cuda and hidden_states.dtype == torch.bfloat16
and not torch.is_grad_enabled() and not torch.compiler.is_compiling()
and base._shared_qkv_gate_scale(attn, hidden_states) is not None)
if early_sends:
input_gather = False
if early_sends and options.get('sp_input_gather', False):
from .sp_input_gather import supports_input_gather
input_gather = supports_input_gather(attn, hidden_states)
if input_gather:
from .sp_input_gather import project_gathered_inputs
with base.STAGES.span('pipe_project_pack'):
packets, gate = project_gathered_inputs(attn, hidden_states, rotary_emb, sp_group, chunks, pre_quantized)
elif early_sends:
from .sp_projection import project_and_exchange
with base.STAGES.span('pipe_project_pack'):
packets, gate_pre = project_and_exchange(attn, hidden_states, rotary_emb, sp_group.device_group,
@@ -142,12 +150,13 @@ def attention_pipelined(attn, hidden_states, rotary_emb, meta, sp_group, chunks,
chunks,
)
packets = [_start_exchange(payload[chunk], scale[chunk], sp_group.device_group) for chunk in range(chunks)]
with base.STAGES.span('gate_proj'):
if gate_pre is None:
gate, _ = attn.to_gate_compress(hidden_states)
else:
linear = attn.to_gate_compress
gate = linear.quant_method.apply(linear, hidden_states, linear.bias, pre_quantized=gate_pre)
if not input_gather:
with base.STAGES.span('gate_proj'):
if gate_pre is None:
gate, _ = attn.to_gate_compress(hidden_states)
else:
linear = attn.to_gate_compress
gate = linear.quant_method.apply(linear, hidden_states, linear.bias, pre_quantized=gate_pre)
vbs = meta.variable_block_sizes
api = base._api()
pending = []
@@ -0,0 +1,141 @@
# SPDX-License-Identifier: Apache-2.0
"""Gather exact packed inputs before head-sharded two-rank H3 projections.
The packed inputs already carry the shared unit activation scale. Only weight
views are sharded; each row/head computes the same Q/K/V projection, norm/RoPE,
and transport FP8 rounding as the sequence-sharded producer. Scale reassembly
handles the unaligned join between independently padded 128-row scale tiles.
"""
import torch
import triton
import triton.language as tl
from fastvideo.distributed.async_collectives import all_gather_tensor_async
from fastvideo.distributed.projection_exchange import completed_projection_packets
from fastvideo.layers.quantization.nvfp4_config import _mm_fp4, _mm_fp4_backend
from .sp_projection import allocate
from .sp_qkv_ordered import _normalize
@triton.jit
def _join_scale_rows(Source, Out, ROWS: tl.constexpr, PADDED: tl.constexpr, GROUPS: tl.constexpr,
TOTAL_PADDED: tl.constexpr, BLOCK: tl.constexpr):
index = tl.program_id(0) * BLOCK + tl.arange(0, BLOCK)
row, group = index // GROUPS, index % GROUPS
rank, local_row = row // ROWS, row % ROWS
local_tile = (local_row // 128) * (GROUPS // 4) + group // 4
local_index = ((local_tile * 32 + local_row % 32) * 4 + (local_row // 32) % 4) * 4 + group % 4
value = tl.load(Source + rank * PADDED * GROUPS + local_index, row < 2 * ROWS, other=0)
tile = (row // 128) * (GROUPS // 4) + group // 4
destination = ((tile * 32 + row % 32) * 4 + (row // 32) % 4) * 4 + group % 4
tl.store(Out + destination, value, index < TOTAL_PADDED * GROUPS)
def join_scale_rows(source: torch.Tensor, rows: int) -> torch.Tensor:
assert source.ndim == 3 and source.shape[0] == 2 and source.is_contiguous()
assert source.dtype == torch.uint8 and source.shape[2] % 4 == 0
padded, groups = source.shape[1:]
assert padded == triton.cdiv(rows, 128) * 128
total_padded = triton.cdiv(rows * 2, 128) * 128
output = torch.empty((total_padded, groups), device=source.device, dtype=source.dtype)
_join_scale_rows[(triton.cdiv(total_padded * groups, 512), )](
source, output, rows, padded, groups, total_padded, 512, num_warps=4)
return output
@triton.jit
def _pack_gathered(X, Weight, Cos, Sin, Payload, Scale, LOCAL_ROWS: tl.constexpr, HEADS: tl.constexpr,
CHUNKS: tl.constexpr, ROTARY: tl.constexpr, EPS: tl.constexpr, PROJECTION: tl.constexpr,
BLOCK_ROWS: tl.constexpr):
row_head = (tl.program_id(0) * BLOCK_ROWS + tl.arange(0, BLOCK_ROWS)).to(tl.int64)
row, head = row_head // HEADS, row_head % HEADS
column = tl.arange(0, 128)
valid = row[:, None] < LOCAL_ROWS * 2
if PROJECTION == 2:
values = tl.load(X + row_head[:, None] * 128 + column[None, :], valid, other=0.0).to(tl.float32)
else:
values = _normalize(X, Weight, Cos, Sin, row, head, HEADS, LOCAL_ROWS * 2, 128, ROTARY, EPS, 128)
maximum = tl.max(tl.abs(values), 1)
scale = tl.maximum(maximum * (1.0 / 448.0), 1e-12)
quantized = (values / scale[:, None]).to(tl.float8e4nv)
chunk_heads: tl.constexpr = HEADS // CHUNKS
chunk = head // chunk_heads
source = row // LOCAL_ROWS
destination = ((
(chunk * 2 + source) * 3 + PROJECTION) * LOCAL_ROWS + row % LOCAL_ROWS) * chunk_heads + head % chunk_heads
tl.store(Payload + destination[:, None] * 128 + column[None, :], quantized, valid)
tl.store(Scale + destination, scale, row < LOCAL_ROWS * 2)
def pack_gathered(x, payload, scale, projection, weight=None, cosine=None, sine=None, epsilon=0.0) -> None:
chunks, world, projections, rows, heads, dim = payload.shape
assert world == 2 and projections == 3 and dim == 128
assert x.shape == (2 * rows, chunks * heads * dim) and x.is_contiguous()
assert payload.is_contiguous() and scale.is_contiguous()
if projection == 2:
weight, cosine, sine = x, x, x
rotary = 0
else:
assert weight.shape == (128, ) and cosine.shape == sine.shape
assert cosine.shape[0] == rows * 2 and cosine.is_contiguous() and sine.is_contiguous()
rotary = cosine.shape[1]
block_rows = 16 if projection == 2 else 8
_pack_gathered[(triton.cdiv(2 * rows * chunks * heads, block_rows), )](
x, weight, cosine, sine, payload, scale, rows, chunks * heads, chunks,
rotary, epsilon, projection, block_rows, num_warps=4 if projection == 2 else 8)
def supports_input_gather(attn, hidden: torch.Tensor) -> bool:
"""Keep other shapes/backends on the established projection exchange."""
if (not hidden.is_cuda or hidden.dtype != torch.bfloat16
or hidden.shape != (1, 7430, 5376) or attn.num_attention_heads != 56
or torch.cuda.get_device_capability(hidden.device) != (12, 1)
or _mm_fp4_backend() != "cudnn"):
return False
for linear in (attn.to_q, attn.to_k, attn.to_v):
weight = getattr(linear, "_nvfp4_weight", None)
scale = getattr(linear, "_nvfp4_weight_scale", None)
if (weight is None or scale is None or weight.shape != (7168, 2688)
or scale.shape != (7168, 336) or linear.bias is not None
or weight.dtype != torch.uint8 or scale.dtype != torch.uint8
or weight.device != hidden.device or scale.device != hidden.device
or not weight.is_contiguous() or not scale.is_contiguous()):
return False
return True
def project_gathered_inputs(attn, hidden, rotary, sp_group, chunks, pre_quantized=None):
"""Return completed head packets and the gate computed while inputs travel.
The caller checks eager, two-rank, head-128 inference and the common unit
activation-scale contract through _shared_qkv_gate_scale. No activations
or packets survive this call except through the returned consumer handles.
"""
linears = (attn.to_q, attn.to_k, attn.to_v)
pre = pre_quantized if pre_quantized is not None else linears[0].quant_method.quantize_input(hidden)
a, sa, input_scale = pre
a, sa = a.reshape(-1, a.shape[-1]), sa.reshape(-1, sa.shape[-1])
cosine, sine = (item.to(hidden.dtype) for item in rotary)
pending = [all_gather_tensor_async(value, sp_group.device_group, 2) for value in (a, sa, cosine, sine)]
linear = attn.to_gate_compress
gate = linear.quant_method.apply(linear, hidden, linear.bias, pre_quantized=pre)
gathered_a, gathered_sa, gathered_cosine, gathered_sine = (item.wait() for item in pending)
full_a = gathered_a.flatten(0, 1)
full_sa = join_scale_rows(gathered_sa, a.shape[0])
full_cosine, full_sine = gathered_cosine.flatten(0, 1), gathered_sine.flatten(0, 1)
rows, heads, rank = hidden.shape[1], attn.num_attention_heads, sp_group.rank_in_group
payload, scale = allocate(rows, heads, 2, chunks, hidden.device)
for projection, linear in enumerate(linears):
half = linear._nvfp4_weight.shape[0] // 2
weight = linear._nvfp4_weight.narrow(0, rank * half, half)
weight_scale = linear._nvfp4_weight_scale.narrow(0, rank * half, half)
alpha = (linear._nvfp4_alpha / input_scale if hasattr(linear, "_nvfp4_alpha")
else 1.0 / (input_scale * linear._weight_global_sf))
value = _mm_fp4(full_a, weight.T, full_sa, weight_scale.T, alpha, torch.bfloat16, None, backend="cudnn")
if projection == 2:
pack_gathered(value, payload, scale, projection)
else:
norm = attn.norm_q if projection == 0 else attn.norm_k
pack_gathered(value, payload, scale, projection, norm.weight, full_cosine, full_sine, norm.eps)
return completed_projection_packets(payload, scale, rank), gate
@@ -0,0 +1,121 @@
# SPDX-License-Identifier: Apache-2.0
"""Exact FP4 input gathering and source-ordered head packets on Blackwell."""
from types import SimpleNamespace
import pytest
import torch
pytestmark = pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA and Triton")
def _swizzle(logical):
rows, groups = logical.shape
return logical.reshape(rows // 128, 4, 32, groups // 4, 4).permute(0, 3, 2, 1, 4).contiguous().reshape(rows, groups)
@pytest.mark.parametrize("rows", [1, 127, 128, 129, 7430])
@pytest.mark.parametrize("groups", [4, 336])
def test_join_scale_rows_preserves_unaligned_sources_and_zeros_padding(rows, groups):
from fastvideo.models.dits.minimax_h3_fusions.sp_input_gather import join_scale_rows
torch.manual_seed(217)
padded, total = (rows + 127) // 128 * 128, (2 * rows + 127) // 128 * 128
logical = torch.randint(0, 256, (2, padded, groups), dtype=torch.uint8, device="cuda")
source = torch.stack([_swizzle(part) for part in logical])
expected = torch.zeros((total, groups), dtype=torch.uint8, device="cuda")
expected[:2 * rows] = logical[:, :rows].reshape(2 * rows, groups)
assert torch.equal(join_scale_rows(source, rows), _swizzle(expected))
@pytest.mark.parametrize("rows", [1, 127, 7430])
@pytest.mark.parametrize("projection", [0, 1, 2])
@pytest.mark.parametrize("rank", [0, 1])
def test_gathered_head_packets_match_original_exchange(rows, projection, rank):
from fastvideo.models.dits.minimax_h3_fusions.sp_input_gather import pack_gathered
from fastvideo.models.dits.minimax_h3_fusions.sp_projection import allocate, pack_one, pack_value
if torch.cuda.get_device_capability()[0] < 10:
pytest.skip("ordered QK packing requires Blackwell")
torch.manual_seed(29176)
values = torch.randn((2, rows, 56, 128), device="cuda", dtype=torch.bfloat16)
values[:, ::127] = 0
weight = torch.randn((128,), device="cuda", dtype=torch.bfloat16)
weight[::17] = 0
cosine, sine = [torch.randn((2, rows, 96), device="cuda", dtype=torch.bfloat16) for _ in range(2)]
gathered = values[:, :, rank * 28:(rank + 1) * 28].contiguous().reshape(2 * rows, 3584)
payload, scale = allocate(rows, 56, 2, 7, values.device)
pack_gathered(gathered, payload, scale, projection, weight, cosine.flatten(0, 1), sine.flatten(0, 1), 1e-5)
for source in range(2):
expected, expected_scale = allocate(rows, 56, 2, 7, values.device)
if projection == 2:
pack_value(values[source], expected, expected_scale)
else:
pack_one(values[source], weight, cosine[source], sine[source], expected, expected_scale, 1e-5, projection)
assert torch.equal(payload[:, source, projection].contiguous().view(torch.uint8),
expected[:, rank, projection].contiguous().view(torch.uint8))
assert torch.equal(scale[:, source, projection], expected_scale[:, rank, projection])
def test_gathered_input_head_gemm_matches_sequence_projection():
from flashinfer import SfLayout, nvfp4_quantize
from fastvideo.layers.quantization.nvfp4_config import _mm_fp4
from fastvideo.models.dits.minimax_h3_fusions.sp_input_gather import join_scale_rows
if torch.cuda.get_device_capability() != (12, 1):
pytest.skip("production route is restricted to GB10")
torch.manual_seed(62071)
rows, inner, columns = 7430, 5376, 7168
unit = torch.ones((), device="cuda", dtype=torch.float32)
x = torch.randn((2, rows, inner), device="cuda", dtype=torch.bfloat16)
x[1] *= 8
x[:, ::127] = 0
w = torch.randn((columns, inner), device="cuda", dtype=torch.bfloat16) / inner**.5
global_scale = torch.full((), 13721.5, device="cuda", dtype=torch.float32)
b, sb = nvfp4_quantize(w, global_scale, sfLayout=SfLayout.layout_128x4, do_shuffle=False)
inputs = [nvfp4_quantize(part, unit, sfLayout=SfLayout.layout_128x4, do_shuffle=False) for part in x]
full_a = torch.cat([p[0] for p in inputs])
full_sa = join_scale_rows(torch.stack([p[1] for p in inputs]), rows)
expected = [_mm_fp4(a, b.T, sa, sb.T, 1.0 / global_scale, torch.bfloat16, None, backend="cudnn")
for a, sa in inputs]
for rank in (0, 1):
start, stop = rank * 3584, (rank + 1) * 3584
output = _mm_fp4(full_a, b[start:stop].T, full_sa, sb[start:stop].T,
1.0 / global_scale, torch.bfloat16, None, backend="cudnn")
target = torch.cat([part[:, start:stop] for part in expected])
assert torch.equal(output.view(torch.int16), target.view(torch.int16))
@pytest.mark.parametrize("rank", [0, 1])
def test_completed_packets_order_other_stream_and_retain_source(rank):
from fastvideo.distributed.projection_exchange import completed_projection_packets
producer, consumer = torch.cuda.Stream(), torch.cuda.Stream()
with torch.cuda.stream(producer):
payload = torch.empty((7, 2, 3, 127, 4, 128), device="cuda", dtype=torch.float8_e4m3fn)
scale = torch.empty(payload.shape[:-1], device="cuda", dtype=torch.float32)
torch.cuda._sleep(5_000_000)
payload.view(torch.uint8).fill_(37)
scale.fill_(2.5)
packets = completed_projection_packets(payload, scale, rank)
with torch.cuda.stream(consumer):
received = [(p.wait().materialize(), s.wait().clone()) for p, s in packets]
consumer.synchronize()
del payload, scale
for payload, scale in received:
assert torch.all(payload.view(torch.uint8) == 37)
assert torch.all(scale == 2.5)
def test_input_gather_guard_rejects_other_shapes_and_backend(monkeypatch):
from fastvideo.models.dits.minimax_h3_fusions import sp_input_gather
if torch.cuda.get_device_capability() != (12, 1):
pytest.skip("production route is restricted to GB10")
hidden = torch.empty((1, 7430, 5376), device="cuda", dtype=torch.bfloat16)
linear = SimpleNamespace(_nvfp4_weight=torch.empty((7168, 2688), dtype=torch.uint8, device="cuda"),
_nvfp4_weight_scale=torch.empty((7168, 336), dtype=torch.uint8, device="cuda"),
bias=None)
attn = SimpleNamespace(num_attention_heads=56, to_q=linear, to_k=linear, to_v=linear)
monkeypatch.setattr(sp_input_gather, "_mm_fp4_backend", lambda: "cudnn")
assert sp_input_gather.supports_input_gather(attn, hidden)
assert not sp_input_gather.supports_input_gather(attn, hidden[:, :-1])
monkeypatch.setattr(sp_input_gather, "_mm_fp4_backend", lambda: "cutlass")
assert not sp_input_gather.supports_input_gather(attn, hidden)
monkeypatch.setattr(sp_input_gather, "_mm_fp4_backend", lambda: "cudnn")
linear.bias = torch.zeros((7168,), device="cuda")
assert not sp_input_gather.supports_input_gather(attn, hidden)