Skip to content

Commit 1b149f7

Browse files
committed
Add compile-once-and-pad path for Qwen3-VL multimodal generation
Lets one compiled generation graph serve repeated calls with images of varying sizes. The first call JIT-compiles for the configured upper bounds; subsequent calls hit the EXLA cache and run ~2-3x faster on CPU. Featurizer changes: - Two new options, :max_patches and :max_num_images. When set, pixel_values is right-padded along the patches axis with zeros and image_grid_thw is padded with [0, 0, 0] rows. - Guard rails: :max_patches must be a multiple of merge_size**2 and must accommodate the largest image the user plans to send. Vision encoder changes: - patch_metadata derives a patch_valid mask (i < total_real_patches) from the padded grid_thw. Padded image_id values are clipped to a valid index so gather operations succeed, and the safe_grid_w guard prevents division by zero in the row/col derivation for padded positions. - The block-diagonal attention mask is ANDed with patch_valid so padded patches neither attend nor are attended to. Their embedding contributions therefore drop out of the output entirely, which is what makes the padding correctness-preserving. API: - Bumblebee.Multimodal.ImageTextToText.compile/5 configures the featurizer + tokenizer at the upper-bound shapes and returns a state struct. - run/3 takes that state plus a prompt+image and runs generation, hitting the cached compiled graph. Validation: - 4 new featurizer tests covering the padding shape, padding values, the merge_size**2 constraint, and the "too many patches" error. - Full fast suite: 289 passed, 0 regressions. - Real Qwen3-VL-2B-Instruct on COCO 39769 with greedy decode and max_patches=1024: cold call 27.3s, warm call 10.1s (2.7x speedup), both produce "A group of cats are lying on a pink blanket with remote controls." Refs #442.
1 parent bbb8dd6 commit 1b149f7

5 files changed

Lines changed: 306 additions & 22 deletions

File tree

lib/bumblebee/multimodal/image_text_to_text.ex

Lines changed: 131 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
defmodule Bumblebee.Multimodal.ImageTextToText do
22
@moduledoc """
3-
Generation helper for vision-language models like Qwen3-VL.
4-
5-
This wraps featurization, prompt expansion, and `Bumblebee.Text.Generation`
6-
in a single call. Each call recompiles the generation graph if the
7-
image or prompt produces a different total patch count or sequence
8-
length, which makes this best suited for interactive or one-shot use.
9-
For high-throughput serving with batched, varying image sizes, see
10-
the static-shape padding follow-up.
3+
Generation helpers for vision-language models like Qwen3-VL.
4+
5+
Two entry points:
6+
7+
* `generate/6` — one-shot call. Featurizes, expands the prompt
8+
placeholder, and runs generation. Each call recompiles the graph
9+
when the image or sequence length changes, so it suits
10+
interactive use.
11+
12+
* `compile/5` + `run/3` — compile the generation graph **once** for
13+
upper-bound shapes, then run repeatedly with images of varying
14+
sizes. The featurizer pads `pixel_values` and `image_grid_thw` to
15+
the configured maxima, and the vision encoder excludes padded
16+
patches from attention via `patch_valid`.
1117
"""
1218

1319
alias Bumblebee.Text
@@ -97,6 +103,123 @@ defmodule Bumblebee.Multimodal.ImageTextToText do
97103
%{text: decoded, token_ids: token_ids}
98104
end
99105

106+
@doc """
107+
Compiles the generation graph once for the given upper-bound shapes.
108+
109+
The returned struct can be passed to `run/3` repeatedly. Calls with
110+
images that produce fewer than `:max_patches` real patches or
111+
shorter than `:sequence_length` prompts are padded; the vision
112+
encoder masks the padded positions out of attention.
113+
114+
## Options
115+
116+
* `:max_patches` (required) — upper bound on total patches across
117+
all images in one call. Must be a multiple of `merge_size ** 2`.
118+
* `:max_num_images` (required) — upper bound on number of images
119+
per call.
120+
* `:sequence_length` (required) — upper bound on token count
121+
(prompt + generated).
122+
"""
123+
def compile(
124+
model_info,
125+
featurizer,
126+
tokenizer,
127+
%Text.GenerationConfig{} = generation_config,
128+
opts
129+
) do
130+
opts = Keyword.validate!(opts, [:max_patches, :max_num_images, :sequence_length])
131+
max_patches = Keyword.fetch!(opts, :max_patches)
132+
max_num_images = Keyword.fetch!(opts, :max_num_images)
133+
sequence_length = Keyword.fetch!(opts, :sequence_length)
134+
135+
%{model: model, params: params, spec: spec} = model_info
136+
137+
unless Map.has_key?(spec, :image_token_id) do
138+
raise ArgumentError,
139+
"expected a multimodal model with :image_token_id, got #{inspect(spec.__struct__)}"
140+
end
141+
142+
merge_size = spec.vision_spec.spatial_merge_size
143+
144+
featurizer =
145+
Bumblebee.configure(featurizer,
146+
max_patches: max_patches,
147+
max_num_images: max_num_images
148+
)
149+
150+
tokenizer =
151+
Bumblebee.configure(tokenizer,
152+
length: sequence_length,
153+
pad_direction: :left,
154+
return_token_type_ids: false
155+
)
156+
157+
generate_fun = Text.Generation.build_generate(model, spec, generation_config)
158+
159+
%{
160+
generate_fun: generate_fun,
161+
params: params,
162+
spec: spec,
163+
featurizer: featurizer,
164+
tokenizer: tokenizer,
165+
merge_size: merge_size,
166+
max_patches: max_patches,
167+
max_num_images: max_num_images,
168+
sequence_length: sequence_length
169+
}
170+
end
171+
172+
@doc """
173+
Runs a prompt + image through a pre-compiled generator from `compile/5`.
174+
175+
EXLA caches the compiled graph by input shape; since the featurizer
176+
pads to the upper bounds configured in `compile/5`, every call hits
177+
the same cached graph.
178+
"""
179+
def run(compiled, text, image) do
180+
%{
181+
generate_fun: generate_fun,
182+
params: params,
183+
featurizer: featurizer,
184+
tokenizer: tokenizer,
185+
merge_size: merge_size
186+
} = compiled
187+
188+
image_inputs = Bumblebee.apply_featurizer(featurizer, image)
189+
grid_thw_real = unpad_grid_thw(image_inputs["image_grid_thw"])
190+
visual_tokens = visual_tokens_for(grid_thw_real, merge_size)
191+
expanded_text = expand_marker(text, visual_tokens)
192+
193+
text_inputs = Bumblebee.apply_tokenizer(tokenizer, expanded_text)
194+
195+
inputs =
196+
text_inputs
197+
|> Map.merge(image_inputs)
198+
|> Map.put("seed", Nx.tensor([:erlang.system_time()], type: :s64))
199+
200+
%{token_ids: token_ids} = generate_fun.(params, inputs)
201+
202+
decoded =
203+
token_ids
204+
|> Nx.to_batched(1)
205+
|> Enum.map(&Bumblebee.Tokenizer.decode(tokenizer, Nx.to_flat_list(&1)))
206+
|> hd()
207+
208+
%{text: decoded, token_ids: token_ids}
209+
end
210+
211+
# Drops padding rows ([0, 0, 0]) so visual_tokens_for matches the
212+
# actual prompt expansion length.
213+
defp unpad_grid_thw(grid_thw) do
214+
grid_thw
215+
|> Nx.to_list()
216+
|> Enum.reject(fn [t, h, w] -> t == 0 and h == 0 and w == 0 end)
217+
|> case do
218+
[] -> Nx.tensor([[0, 0, 0]], type: :s64)
219+
rows -> Nx.tensor(rows, type: :s64)
220+
end
221+
end
222+
100223
defp expand_marker(text, visual_tokens) do
101224
case String.split(text, @placeholder) do
102225
[_only] ->

lib/bumblebee/vision/qwen3_vl_featurizer.ex

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ defmodule Bumblebee.Vision.Qwen3VLFeaturizer do
5757
explicit maximum total pixels after smart-resize. Overrides the `:quality`
5858
preset when set.
5959
"""
60+
],
61+
max_patches: [
62+
default: nil,
63+
doc: """
64+
when set, pads `pixel_values` along the patches axis to this size with
65+
zeros. Required for compile-once-and-pad serving of variable-size
66+
images. Must be a multiple of `merge_size ** 2`.
67+
"""
68+
],
69+
max_num_images: [
70+
default: nil,
71+
doc: """
72+
when set, pads `image_grid_thw` to this many rows with `[0, 0, 0]`.
73+
Required alongside `:max_patches` for compile-once-and-pad serving.
74+
"""
6075
]
6176
]
6277

@@ -117,12 +132,71 @@ defmodule Bumblebee.Vision.Qwen3VLFeaturizer do
117132
|> Enum.map(& &1.grid_thw)
118133
|> Nx.stack()
119134

135+
{pixel_values, image_grid_thw} =
136+
maybe_pad_to_max(pixel_values, image_grid_thw, featurizer)
137+
120138
%{
121139
"pixel_values" => pixel_values,
122140
"image_grid_thw" => image_grid_thw
123141
}
124142
end
125143

144+
defp maybe_pad_to_max(pixel_values, image_grid_thw, featurizer) do
145+
pixel_values = maybe_pad_patches(pixel_values, featurizer)
146+
image_grid_thw = maybe_pad_grid_thw(image_grid_thw, featurizer)
147+
{pixel_values, image_grid_thw}
148+
end
149+
150+
defp maybe_pad_patches(pixel_values, %{max_patches: nil}), do: pixel_values
151+
152+
defp maybe_pad_patches(pixel_values, featurizer) do
153+
{num_patches, flat} = Nx.shape(pixel_values)
154+
max_patches = featurizer.max_patches
155+
merge_sq = featurizer.merge_size * featurizer.merge_size
156+
157+
unless rem(max_patches, merge_sq) == 0 do
158+
raise ArgumentError,
159+
":max_patches (#{max_patches}) must be a multiple of merge_size**2 " <>
160+
"(= #{merge_sq})"
161+
end
162+
163+
if num_patches > max_patches do
164+
raise ArgumentError,
165+
"featurizer produced #{num_patches} patches but :max_patches is " <>
166+
"#{max_patches}; raise :max_patches or lower :quality / :max_pixels"
167+
end
168+
169+
pad_rows = max_patches - num_patches
170+
171+
if pad_rows == 0 do
172+
pixel_values
173+
else
174+
padding = Nx.broadcast(Nx.tensor(0.0, type: Nx.type(pixel_values)), {pad_rows, flat})
175+
Nx.concatenate([pixel_values, padding], axis: 0)
176+
end
177+
end
178+
179+
defp maybe_pad_grid_thw(image_grid_thw, %{max_num_images: nil}), do: image_grid_thw
180+
181+
defp maybe_pad_grid_thw(image_grid_thw, featurizer) do
182+
{num_images, 3} = Nx.shape(image_grid_thw)
183+
max_num_images = featurizer.max_num_images
184+
185+
if num_images > max_num_images do
186+
raise ArgumentError,
187+
"got #{num_images} images but :max_num_images is #{max_num_images}"
188+
end
189+
190+
pad_rows = max_num_images - num_images
191+
192+
if pad_rows == 0 do
193+
image_grid_thw
194+
else
195+
padding = Nx.broadcast(Nx.tensor(0, type: Nx.type(image_grid_thw)), {pad_rows, 3})
196+
Nx.concatenate([image_grid_thw, padding], axis: 0)
197+
end
198+
end
199+
126200
defp normalize_input(input) when is_list(input), do: input
127201
defp normalize_input(%{image: _} = input), do: [input]
128202
defp normalize_input(%{video: _} = input), do: [input]

lib/bumblebee/vision/qwen3_vl_vision.ex

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,7 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
250250
src_grid_size = trunc(:math.sqrt(spec.num_position_embeddings))
251251
merge_size = spec.spatial_merge_size
252252

253-
{row_in_image, col_in_image, grid_h_per_patch, grid_w_per_patch, _image_id} =
253+
{row_in_image, col_in_image, grid_h_per_patch, grid_w_per_patch, _image_id, _patch_valid} =
254254
patch_metadata(grid_thw, total_patches, merge_size)
255255

256256
src_max_f = Nx.tensor(src_grid_size - 1, type: :f32)
@@ -317,24 +317,41 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
317317

318318
cumulative = Nx.cumulative_sum(patches_per_image)
319319
exclusive_cumulative = Nx.subtract(cumulative, patches_per_image)
320+
total_real_patches = Nx.sum(patches_per_image)
320321

321322
patch_indices = Nx.iota({total_patches}, type: :s64)
322323

323-
image_id_per_patch =
324+
# Patches beyond total_real_patches are padding slots (when the
325+
# featurizer was configured with :max_patches). Mark them invalid so
326+
# downstream attention masking can exclude them entirely.
327+
patch_valid = Nx.less(patch_indices, total_real_patches)
328+
329+
image_id_raw =
324330
patch_indices
325331
|> Nx.new_axis(-1)
326332
|> Nx.greater_equal(Nx.new_axis(cumulative, 0))
327333
|> Nx.sum(axes: [-1])
328334
|> Nx.as_type(:s64)
329335

336+
n_images = Nx.axis_size(grid_thw, 0)
337+
# Padded patches map to image_id == n_images (out of bounds). Clip so
338+
# gather operations succeed. Their derived row/col/grid values are
339+
# garbage but get masked out via `patch_valid` in the attention step.
340+
image_id_per_patch = Nx.clip(image_id_raw, 0, n_images - 1)
341+
330342
offset_per_patch = Nx.take(exclusive_cumulative, image_id_per_patch)
331343
local_index = Nx.subtract(patch_indices, offset_per_patch)
332344

333345
grid_h_per_patch = Nx.take(grid_h, image_id_per_patch)
334346
grid_w_per_patch = Nx.take(grid_w, image_id_per_patch)
335347

348+
# Padded images have grid_w == 0; guard the divisions so we don't
349+
# divide by zero. The resulting coordinates for padded patches are
350+
# arbitrary and are masked out downstream.
351+
safe_grid_w = Nx.max(grid_w_per_patch, merge_size)
352+
336353
merge_sq = merge_size * merge_size
337-
merged_w_per_patch = Nx.quotient(grid_w_per_patch, merge_size)
354+
merged_w_per_patch = Nx.quotient(safe_grid_w, merge_size)
338355

339356
block_idx = Nx.quotient(local_index, merge_sq)
340357
within = Nx.remainder(local_index, merge_sq)
@@ -346,7 +363,8 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
346363
row_in_image = block_row |> Nx.multiply(merge_size) |> Nx.add(within_h)
347364
col_in_image = block_col |> Nx.multiply(merge_size) |> Nx.add(within_w)
348365

349-
{row_in_image, col_in_image, grid_h_per_patch, grid_w_per_patch, image_id_per_patch}
366+
{row_in_image, col_in_image, grid_h_per_patch, grid_w_per_patch, image_id_per_patch,
367+
patch_valid}
350368
end
351369

352370
defp encoder(embeddings, grid_thw, spec, opts) do
@@ -365,7 +383,7 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
365383
fn embed, grid_thw_t, _opts ->
366384
{_batch, total_patches, _hidden} = Nx.shape(embed)
367385

368-
{row_in_image, col_in_image, _, _, _} =
386+
{row_in_image, col_in_image, _, _, _, _} =
369387
patch_metadata(grid_thw_t, total_patches, spec.spatial_merge_size)
370388

371389
compute_2d_rotary_from_positions(
@@ -384,10 +402,10 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
384402
fn embed, grid_thw_t, _opts ->
385403
{_batch, total_patches, _hidden} = Nx.shape(embed)
386404

387-
{_, _, _, _, image_id_per_patch} =
405+
{_, _, _, _, image_id_per_patch, patch_valid} =
388406
patch_metadata(grid_thw_t, total_patches, spec.spatial_merge_size)
389407

390-
block_diagonal_attention_mask(image_id_per_patch)
408+
block_diagonal_attention_mask(image_id_per_patch, patch_valid)
391409
end,
392410
[embeddings, grid_thw],
393411
op_name: :attention_mask
@@ -418,11 +436,13 @@ defmodule Bumblebee.Vision.Qwen3VLVision do
418436
end
419437

420438
# Returns {total_patches, total_patches} boolean tensor where True means
421-
# the two patches share an image (and are therefore allowed to attend).
422-
defnp block_diagonal_attention_mask(image_id_per_patch) do
439+
# the two patches share an image AND both are valid (not padding).
440+
defnp block_diagonal_attention_mask(image_id_per_patch, patch_valid) do
423441
a = Nx.new_axis(image_id_per_patch, -1)
424442
b = Nx.new_axis(image_id_per_patch, 0)
425-
Nx.equal(a, b)
443+
same_image = Nx.equal(a, b)
444+
valid_pair = Nx.multiply(Nx.new_axis(patch_valid, -1), Nx.new_axis(patch_valid, 0))
445+
Nx.logical_and(same_image, valid_pair)
426446
end
427447

428448
defp vision_transformer_blocks(

notebooks/qwen3_vl.livemd

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,10 +138,41 @@ Bumblebee.Multimodal.ImageTextToText.generate(
138138
#=> %{text: "A group of cats lying on a pink blanket with remote controls.", token_ids: ...}
139139
```
140140

141-
> Note: this is a single-call helper — each call recompiles the
142-
> generation graph if the image size or sequence length changes. The
143-
> follow-up static-shape padding work lets one compiled graph serve
144-
> repeated calls with varying image sizes.
141+
> Note: each `generate/6` call recompiles the generation graph when
142+
> the image size or sequence length changes. For repeated calls, use
143+
> `compile/5` + `run/3` (see below).
144+
145+
## Compile Once, Run Many
146+
147+
For serving-style use where many images of varying sizes share one
148+
compiled graph, configure upper bounds with `compile/5`, then call
149+
`run/3` repeatedly. The featurizer pads `pixel_values` and
150+
`image_grid_thw` to the maxima you set, and the vision encoder
151+
excludes the padded patches from attention.
152+
153+
```elixir
154+
compiled =
155+
Bumblebee.Multimodal.ImageTextToText.compile(
156+
model_info,
157+
featurizer,
158+
tokenizer,
159+
generation_config,
160+
max_patches: 1024,
161+
max_num_images: 1,
162+
sequence_length: 384
163+
)
164+
165+
# First call: JIT-compiles for these upper-bound shapes
166+
Bumblebee.Multimodal.ImageTextToText.run(compiled, prompt, image)
167+
168+
# Subsequent calls reuse the same compiled graph, even if the new
169+
# image produces fewer real patches — padding makes the shapes match.
170+
Bumblebee.Multimodal.ImageTextToText.run(compiled, prompt, another_image)
171+
```
172+
173+
On `Qwen3-VL-2B-Instruct` + CPU + a 640×480 COCO image, the warm
174+
call runs in ~10s while the cold (JIT-compiling) call takes ~27s — a
175+
2.7x speedup that scales with the number of repeated calls.
145176

146177
## Multiple Images in One Prompt
147178

0 commit comments

Comments
 (0)