Skip to content

Commit c549c31

Browse files
committed
batched encode with padded videos
1 parent 2ce3866 commit c549c31

3 files changed

Lines changed: 258 additions & 59 deletions

File tree

src/maxtext/layers/embeddings.py

Lines changed: 153 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1401,44 +1401,115 @@ def __call__(
14011401
height: int,
14021402
width: int,
14031403
token_mask: Array | None = None,
1404-
valid_grid: tuple[int, int, int] | None = None,
1404+
valid_grid: Array | None = None,
14051405
) -> Array:
14061406
"""Apply rotary position embeddings directly to inputs (Q or K tensors).
14071407
14081408
Args:
1409-
inputs: Input tensor of shape [B, T*H*W, N, head_dim] (batch, sequence, heads, head_dim)
1409+
inputs: Input tensor of shape [B, S, N, head_dim] (batch, sequence, heads, head_dim)
14101410
where T=num_frames, H=height, W=width (all static)
14111411
num_frames: Number of temporal frames (static)
14121412
height: Height in patches (static)
14131413
width: Width in patches (static)
14141414
token_mask: Optional mask identifying valid tokens in the padded sequence.
1415-
valid_grid: Optional unpadded `(frames, height, width)` grid used to compute
1416-
RoPE for valid tokens.
1415+
valid_grid: Optional actual video grid with shape (batch, 3) (dynamic)
14171416
14181417
Returns:
1419-
Rotated inputs with same shape [B, T*H*W, N, head_dim]
1418+
Rotated inputs with same shape [B, S, N, head_dim]
14201419
"""
1421-
cos_emb, sin_emb = self.compute_cos_sin(num_frames, height, width)
1422-
if token_mask is not None and valid_grid is not None:
1423-
valid_cos, valid_sin = self.compute_cos_sin(*valid_grid)
1424-
valid_len = math.prod(valid_grid)
1425-
valid_indices = jnp.nonzero(token_mask[0], size=valid_len)[0]
1426-
cos_emb = jnp.ones_like(cos_emb).at[valid_indices].set(valid_cos)
1427-
sin_emb = jnp.zeros_like(sin_emb).at[valid_indices].set(valid_sin)
1428-
1429-
if len(inputs.shape) == 4:
1430-
cos_emb = cos_emb[None, :, None, :] # [1, S, 1, H]
1431-
sin_emb = sin_emb[None, :, None, :]
1432-
elif len(inputs.shape) == 3:
1433-
# For [S, N, H] case
1434-
cos_emb = cos_emb[:, None, :] # [S, 1, H]
1435-
sin_emb = sin_emb[:, None, :]
1420+
is_3d = inputs.ndim == 3
1421+
if is_3d:
1422+
inputs = inputs[None, :, :, :]
1423+
1424+
batch_size = inputs.shape[0]
1425+
max_patches = num_frames * height * width
1426+
1427+
if valid_grid is None:
1428+
valid_grid = jnp.tile(
1429+
jnp.array([[num_frames, height, width]], dtype=jnp.int32),
1430+
(batch_size, 1),
1431+
)
1432+
1433+
# Generate coordinates in block-based order
1434+
row, col = generate_block_coords(valid_grid, max_patches, self.spatial_merge_size)
1435+
1436+
# Compute frequencies dynamically using coordinates
1437+
max_hw = max(height, width)
1438+
freq_table = self._compute_freq_table(max_hw) # [max_hw, head_dim//4]
1439+
1440+
row_freqs = freq_table[row] # [B, max_patches, head_dim//4]
1441+
col_freqs = freq_table[col] # [B, max_patches, head_dim//4]
1442+
1443+
# Concatenate row and column frequencies
1444+
embeddings = jnp.concatenate([row_freqs, col_freqs], axis=-1) # [B, max_patches, head_dim//2]
1445+
1446+
# Double the embeddings to match head_dim
1447+
embeddings = jnp.concatenate([embeddings, embeddings], axis=-1) # [B, max_patches, head_dim]
1448+
1449+
cos_emb = jnp.cos(embeddings)
1450+
sin_emb = jnp.sin(embeddings)
1451+
1452+
# Mask out invalid (padded) tokens by setting cos to 1 and sin to 0
1453+
if token_mask is not None:
1454+
is_valid = token_mask[:, :, None] # [B, max_patches, 1]
1455+
cos_emb = jnp.where(is_valid, cos_emb, 1.0)
1456+
sin_emb = jnp.where(is_valid, sin_emb, 0.0)
1457+
1458+
if self.cast_as_fprop_dtype:
1459+
cos_emb = cos_emb.astype(self.fprop_dtype)
1460+
sin_emb = sin_emb.astype(self.fprop_dtype)
1461+
1462+
# Reshape for broadcasting to inputs [B, S, N, H]
1463+
cos_emb = cos_emb[:, :, None, :] # [B, S, 1, H]
1464+
sin_emb = sin_emb[:, :, None, :]
14361465

14371466
rotated = inputs * cos_emb + self._rotate_half(inputs) * sin_emb
14381467

1468+
if is_3d:
1469+
rotated = rotated[0]
1470+
14391471
return rotated
14401472

14411473

1474+
def generate_block_coords(video_grid_thw: Array, max_patches: int, merge_size: int) -> tuple[Array, Array]:
1475+
"""Generate row and col coordinates in block-based order for padded video.
1476+
1477+
Args:
1478+
video_grid_thw: Actual video grid with shape (batch, 3), in Qwen grid units.
1479+
max_patches: Maximum number of patches (static).
1480+
merge_size: Spatial merge block size (static).
1481+
1482+
Returns:
1483+
Tuple of (row, col) each of shape [batch, max_patches]
1484+
"""
1485+
V_T = video_grid_thw[:, 0:1] # [B, 1]
1486+
V_H = video_grid_thw[:, 1:2]
1487+
V_W = video_grid_thw[:, 2:3]
1488+
1489+
merged_w = V_W // merge_size
1490+
V_len = V_T * V_H * V_W
1491+
1492+
idx = jnp.arange(max_patches, dtype=jnp.int32)[None, :] # [1, max_patches]
1493+
is_valid = idx < V_len # [B, max_patches]
1494+
1495+
stride_lh = V_H * V_W
1496+
safe_stride_lh = jnp.maximum(stride_lh, 1)
1497+
s_idx = idx % safe_stride_lh
1498+
1499+
intra_col = s_idx % merge_size
1500+
intra_row = (s_idx // merge_size) % merge_size
1501+
1502+
safe_merged_w = jnp.maximum(merged_w, 1)
1503+
block_elements = merge_size * merge_size
1504+
block_col = (s_idx // block_elements) % safe_merged_w
1505+
block_row = s_idx // (safe_merged_w * block_elements)
1506+
1507+
row = jnp.where(is_valid, block_row * merge_size + intra_row, 0)
1508+
col = jnp.where(is_valid, block_col * merge_size + intra_col, 0)
1509+
1510+
return row, col
1511+
1512+
14421513
def qwen3omnimoe_vision_pos_embed_interpolate_as_linen(
14431514
*,
14441515
num_position_embeddings: int,
@@ -1596,46 +1667,83 @@ def _interpolate_single(self, t: int, h: int, w: int) -> tuple[Array, Array]:
15961667

15971668
return indices, weights
15981669

1599-
def __call__(self, num_frames: int, height: int, width: int) -> Array:
1670+
def __call__(
1671+
self,
1672+
num_frames: int,
1673+
height: int,
1674+
width: int,
1675+
video_grid_thw: Array | None = None,
1676+
attention_mask: Array | None = None,
1677+
) -> Array:
16001678
"""Interpolate positional embeddings for given static grid dimensions.
16011679
16021680
Args:
16031681
num_frames: Number of temporal frames (static)
16041682
height: Height in patches (static)
16051683
width: Width in patches (static)
1684+
video_grid_thw: Optional actual video grid with shape (batch, 3) (dynamic)
1685+
attention_mask: Optional attention mask with shape (batch, max_patches) (dynamic)
16061686
16071687
Returns:
1608-
Interpolated positional embeddings of shape [num_frames * height * width, hidden_size]
1688+
Interpolated positional embeddings of shape [batch, num_frames * height * width, hidden_size]
16091689
"""
1610-
# Get interpolation indices and weights
1611-
indices, weights = self._interpolate_single(num_frames, height, width) # [4, h*w], [4, h*w]
1690+
if video_grid_thw is not None:
1691+
batch_size = video_grid_thw.shape[0]
1692+
elif attention_mask is not None:
1693+
batch_size = attention_mask.shape[0]
1694+
else:
1695+
batch_size = 1
16121696

1613-
# Lookup embeddings for all 4 corners
1614-
corner_embeds = self.pos_embed.value[indices] # [4, h*w, hidden_size]
1697+
max_patches = num_frames * height * width
16151698

1616-
# Apply bilinear weights and sum
1617-
weighted_embeds = corner_embeds * weights[:, :, None] # [4, h*w, hidden_size]
1618-
interpolated = jnp.sum(weighted_embeds, axis=0) # [h*w, hidden_size]
1699+
if video_grid_thw is None:
1700+
video_grid_thw = jnp.tile(
1701+
jnp.array([[num_frames, height, width]], dtype=jnp.int32),
1702+
(batch_size, 1),
1703+
)
1704+
if attention_mask is None:
1705+
attention_mask = jnp.ones((batch_size, max_patches), dtype=jnp.int32)
16191706

1620-
# Repeat for temporal frames
1621-
if num_frames > 1:
1622-
interpolated = jnp.tile(interpolated, (num_frames, 1)) # [t*h*w, hidden_size]
1707+
# Generate coordinates in block-based order
1708+
row, col = generate_block_coords(video_grid_thw, max_patches, self.spatial_merge_size)
16231709

1624-
# Apply spatial merge permutation
1625-
# Reshape to [t, h, w, hidden_size] then permute for block-based processing
1626-
merge_size = self.spatial_merge_size
1627-
merged_h = height // merge_size
1628-
merged_w = width // merge_size
1710+
# Normalize coordinates to [0, 1] range based on valid grid sizes
1711+
V_H = video_grid_thw[:, 1:2]
1712+
V_W = video_grid_thw[:, 2:3]
1713+
row_norm = row / jnp.maximum(V_H - 1, 1)
1714+
col_norm = col / jnp.maximum(V_W - 1, 1)
1715+
1716+
# Interpolate from N x N grid
1717+
N = self.num_grid_per_side
1718+
table = self.pos_embed.value.reshape(N, N, self.hidden_size)
1719+
1720+
y = row_norm * (N - 1)
1721+
x = col_norm * (N - 1)
1722+
1723+
y0 = jnp.floor(y).astype(jnp.int32)
1724+
x0 = jnp.floor(x).astype(jnp.int32)
1725+
y1 = jnp.minimum(y0 + 1, N - 1)
1726+
x1 = jnp.minimum(x0 + 1, N - 1)
1727+
1728+
dy = y - y0
1729+
dx = x - x0
16291730

1630-
# Reshape: [t*h*w, hidden_size] -> [t, h, w, hidden_size]
1631-
interpolated = interpolated.reshape(num_frames, height, width, self.hidden_size)
1731+
# Gather 4 corners
1732+
embed_00 = table[y0, x0]
1733+
embed_01 = table[y0, x1]
1734+
embed_10 = table[y1, x0]
1735+
embed_11 = table[y1, x1]
1736+
1737+
# Apply bilinear weights
1738+
dy = dy[:, :, None]
1739+
dx = dx[:, :, None]
1740+
1741+
interpolated = (
1742+
(1.0 - dy) * (1.0 - dx) * embed_00 + (1.0 - dy) * dx * embed_01 + dy * (1.0 - dx) * embed_10 + dy * dx * embed_11
1743+
)
16321744

1633-
# Permute for spatial merging: [t, merged_h, merge_size, merged_w, merge_size, hidden_size]
1634-
interpolated = interpolated.reshape(num_frames, merged_h, merge_size, merged_w, merge_size, self.hidden_size)
1635-
# -> [t, merged_h, merged_w, merge_size, merge_size, hidden_size]
1636-
interpolated = jnp.transpose(interpolated, (0, 1, 3, 2, 4, 5))
1637-
# Flatten back to [t*merged_h*merged_w*merge_size*merge_size, hidden_size]
1638-
interpolated = interpolated.reshape(-1, self.hidden_size)
1745+
# Mask out invalid (padded) tokens by setting their embeddings to zero
1746+
interpolated = interpolated * attention_mask[:, :, None]
16391747

16401748
if self.cast_as_fprop_dtype:
16411749
interpolated = interpolated.astype(self.fprop_dtype)

src/maxtext/models/qwen3.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2136,22 +2136,17 @@ def __call__(
21362136

21372137
x, _ = self.patch_embed(hidden_states)
21382138
x = x.reshape(batch_size, -1, self.config.hidden_size_for_vit)
2139-
valid_grid = None
21402139
if attention_mask is not None and video_grid_thw is None:
21412140
raise ValueError("video_grid_thw is required when video_mask is provided.")
2142-
if attention_mask is not None and batch_size != 1:
2143-
raise ValueError("Padded Qwen3-Omni vision encoding currently supports batch size one.")
2144-
if video_grid_thw is not None:
2145-
grid = video_grid_thw[0] if getattr(video_grid_thw, "ndim", 1) == 2 else video_grid_thw
2146-
valid_grid = tuple(int(dim) for dim in grid)
2147-
pos = self.pos_embed_interpolate(num_frames, height, width)
2148-
if attention_mask is not None and valid_grid is not None:
2149-
valid_pos = self.pos_embed_interpolate(*valid_grid)
2150-
valid_indices = jnp.nonzero(attention_mask[0], size=math.prod(valid_grid))[0]
2151-
pos = jnp.zeros_like(pos).at[valid_indices].set(valid_pos)
2152-
2153-
pos = pos[jnp.newaxis, :, :]
2141+
pos = self.pos_embed_interpolate(
2142+
num_frames,
2143+
height,
2144+
width,
2145+
video_grid_thw=video_grid_thw,
2146+
attention_mask=attention_mask,
2147+
)
21542148
x = x + pos
2149+
valid_grid = video_grid_thw
21552150

21562151
h_traj = []
21572152
for i in range(self.depth):

tests/unit/qwen3_omni_layers_test.py

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -645,6 +645,9 @@ def _copy_weights_and_test(self, num_frames, height, width):
645645
pos_embed_jax = self.jax_model(num_frames, height, width)
646646
pos_embed_torch = self.torch_encoder.fast_pos_embed_interpolate(grid_thw_torch)
647647

648+
if pos_embed_jax.ndim == 3 and pos_embed_torch.ndim == 2:
649+
pos_embed_jax = pos_embed_jax[0]
650+
648651
assert_all_close_jax_torch(pos_embed_jax, pos_embed_torch, rtol=1e-2, atol=1e-2)
649652

650653
def test_pos_embed_interpolate_matches_torch(self):
@@ -762,7 +765,7 @@ def test_padded_video_valid_outputs_match_unpadded(self):
762765

763766
encoder = JaxQwen3OmniMoeVisionEncoder(config=config, mesh=self.mesh, rngs=nnx.Rngs(42))
764767
raw_output, _ = encoder(raw_video)
765-
padded_output, _ = encoder(padded_video, video_mask=video_mask, video_grid_thw=raw_grid)
768+
padded_output, _ = encoder(padded_video, video_mask=video_mask, video_grid_thw=jnp.array([raw_grid], dtype=jnp.int32))
766769
patch_mask = np.asarray(video_mask).reshape(1, -1, temporal_patch_size * patch_size * patch_size).max(-1).astype(bool)
767770

768771
np.testing.assert_allclose(
@@ -772,6 +775,99 @@ def test_padded_video_valid_outputs_match_unpadded(self):
772775
atol=1e-4,
773776
)
774777

778+
def test_padded_video_valid_outputs_match_unpadded_batched(self):
779+
"""Padded tokens do not change valid outputs anywhere in the ViT for batched inputs."""
780+
raw_grid_1 = (2, 2, 2)
781+
raw_grid_2 = (1, 4, 4)
782+
max_grid = (3, 4, 4)
783+
config = pyconfig.initialize(
784+
["", base_config_path],
785+
model_name="qwen3-omni-30b-a3b",
786+
attention="dot_product",
787+
attention_type="full",
788+
dtype="float32",
789+
dtype_mm="float32",
790+
weight_dtype="float32",
791+
override_model_config=True,
792+
attention_for_vit="dot_product",
793+
hidden_size_for_vit=16,
794+
num_attention_heads_for_vit=2,
795+
intermediate_size_for_vit=32,
796+
num_hidden_layers_for_vit=1,
797+
deepstack_visual_indexes_for_vit=[],
798+
video_max_grid_t=max_grid[0],
799+
video_max_grid_h=max_grid[1],
800+
video_max_grid_w=max_grid[2],
801+
)
802+
patch_size = config.patch_size_for_vit
803+
temporal_patch_size = config.temporal_patch_size_for_vit
804+
805+
# Generate raw videos
806+
raw_shape_1 = (
807+
1,
808+
config.num_channels_for_vit,
809+
raw_grid_1[0] * temporal_patch_size,
810+
raw_grid_1[1] * patch_size,
811+
raw_grid_1[2] * patch_size,
812+
)
813+
raw_shape_2 = (
814+
1,
815+
config.num_channels_for_vit,
816+
raw_grid_2[0] * temporal_patch_size,
817+
raw_grid_2[1] * patch_size,
818+
raw_grid_2[2] * patch_size,
819+
)
820+
raw_video_1, _ = create_random_jax_torch(*raw_shape_1)
821+
raw_video_2, _ = create_random_jax_torch(*raw_shape_2)
822+
823+
# Pad them individually
824+
padded_video_1, _, video_mask_1 = maybe_pad_video_values_to_max_grid(
825+
np.asarray(raw_video_1), np.asarray([raw_grid_1]), config
826+
)
827+
padded_video_2, _, video_mask_2 = maybe_pad_video_values_to_max_grid(
828+
np.asarray(raw_video_2), np.asarray([raw_grid_2]), config
829+
)
830+
831+
# Batch them
832+
batched_padded_video = jnp.concatenate([padded_video_1, padded_video_2], axis=0)
833+
batched_video_mask = jnp.concatenate([video_mask_1, video_mask_2], axis=0)
834+
batched_grid_thw = jnp.array([raw_grid_1, raw_grid_2], dtype=jnp.int32)
835+
836+
encoder = JaxQwen3OmniMoeVisionEncoder(config=config, mesh=self.mesh, rngs=nnx.Rngs(42))
837+
838+
# Run individual unpadded
839+
raw_output_1, _ = encoder(raw_video_1)
840+
raw_output_2, _ = encoder(raw_video_2)
841+
842+
# Run batched padded
843+
batched_padded_output, _ = encoder(
844+
batched_padded_video,
845+
video_mask=batched_video_mask,
846+
video_grid_thw=batched_grid_thw,
847+
)
848+
849+
# Verify equivalence for batch item 0
850+
patch_mask_1 = (
851+
np.asarray(video_mask_1).reshape(1, -1, temporal_patch_size * patch_size * patch_size).max(-1).astype(bool)
852+
)
853+
np.testing.assert_allclose(
854+
np.asarray(raw_output_1).reshape(-1, config.hidden_size_for_vit),
855+
np.asarray(batched_padded_output[0])[np.asarray(patch_mask_1)[0]],
856+
rtol=1e-4,
857+
atol=1e-4,
858+
)
859+
860+
# Verify equivalence for batch item 1
861+
patch_mask_2 = (
862+
np.asarray(video_mask_2).reshape(1, -1, temporal_patch_size * patch_size * patch_size).max(-1).astype(bool)
863+
)
864+
np.testing.assert_allclose(
865+
np.asarray(raw_output_2).reshape(-1, config.hidden_size_for_vit),
866+
np.asarray(batched_padded_output[1])[np.asarray(patch_mask_2)[0]],
867+
rtol=1e-4,
868+
atol=1e-4,
869+
)
870+
775871

776872
class TestDeepstackProcess(unittest.TestCase):
777873
"""Tests for deepstack_process.

0 commit comments

Comments
 (0)