Skip to content

Commit bc64f0a

Browse files
committed
feat(cpu): add Qwen3.5 0.8B multi-image support
1 parent cc86c4c commit bc64f0a

9 files changed

Lines changed: 326 additions & 104 deletions

File tree

examples/qwen3_5/README.md

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,17 @@ mllm-qwen3-5-runner \
129129
--prompt "Describe the image." \
130130
--max_new_tokens 32
131131

132+
# Qwen3.5-0.8B multi-image (image order is preserved)
133+
mllm-qwen3-5-runner \
134+
--model_path /path/to/qwen3.5-0.8b-multimodal-w4a32-kai.mllm \
135+
--model_version v2 \
136+
--tokenizer_path /path/to/Qwen3.5-0.8B/tokenizer.json \
137+
--config_path examples/qwen3_5/config_0.8B_multimodal_w4a32_kai.json \
138+
--image_path /path/to/first.jpg \
139+
--image_path /path/to/second.jpg \
140+
--prompt "Compare the first and second images." \
141+
--max_new_tokens 32
142+
132143
# Qwen3.5-4B text-only
133144
mllm-qwen3-5-runner \
134145
--model_path /path/to/qwen3.5-4b-w4a32-kai.mllm \
@@ -139,6 +150,8 @@ mllm-qwen3-5-runner \
139150
--max_new_tokens 32
140151
```
141152

142-
Omit `--prompt` for the interactive loop. The same optional image is used for
143-
each independent prompt in that process. Omit `--image_path` to run text-only
144-
inference with either the text-only or multimodal 0.8B model.
153+
Omit `--prompt` for the interactive loop. Repeat `--image_path` to attach
154+
multiple still images in order; the same ordered image list is used for each
155+
independent prompt in that process. Omit `--image_path` to run text-only
156+
inference with either the text-only or multimodal 0.8B model. Video input is
157+
not supported.

examples/qwen3_5/main.cpp

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#include <fmt/core.h>
2+
#include <algorithm>
23
#include <chrono>
34
#include <cstdio>
45
#include <filesystem>
@@ -30,8 +31,9 @@ MLLM_MAIN({
3031
auto& config_path = Argparse::add<std::string>("-c|--config_path").help("Config path").required(true);
3132
auto& prompt = Argparse::add<std::string>("-p|--prompt").help("Run one prompt non-interactively").required(false);
3233
auto& prompt_file = Argparse::add<std::string>("--prompt_file").help("Read one benchmark prompt from a file").required(false);
33-
auto& image_path =
34-
Argparse::add<std::string>("-i|--image_path").help("Optional still image for single-image inference").required(false);
34+
auto& image_paths = Argparse::add<std::vector<std::string>>("-i|--image_path")
35+
.help("Optional still image; repeat in prompt order for multi-image inference")
36+
.required(false);
3537
auto& max_new_tokens = Argparse::add<int>("-g|--max_new_tokens").help("Maximum generated tokens per prompt").required(false);
3638
auto& print_token_ids = Argparse::add<bool>("--print_token_ids").help("Print generated token IDs to stderr").required(false);
3739
auto& benchmark_warmup =
@@ -76,6 +78,7 @@ MLLM_MAIN({
7678
}
7779

7880
auto cfg = mllm::models::qwen3_5::Qwen3_5Config(config_path.get());
81+
const auto configured_image_paths = image_paths.get();
7982
int generation_limit = max_new_tokens.isSet() ? max_new_tokens.get() : 64;
8083
if (generation_limit <= 0 || generation_limit > cfg.max_cache_length) {
8184
throw std::invalid_argument("max_new_tokens must be between 1 and max_cache_length");
@@ -85,11 +88,15 @@ MLLM_MAIN({
8588
|| benchmark_warmup.isSet() || require_device_telemetry.isSet();
8689
if (prompt.isSet() && prompt_file.isSet()) { throw std::invalid_argument("prompt and prompt_file are mutually exclusive"); }
8790
if (prompt.isSet() && prompt.get().empty()) { throw std::invalid_argument("prompt must not be empty"); }
88-
if (image_path.isSet() && image_path.get().empty()) { throw std::invalid_argument("image_path must not be empty"); }
89-
if (image_path.isSet() && !cfg.vision_enabled) {
91+
if (image_paths.isSet() && std::any_of(configured_image_paths.begin(), configured_image_paths.end(), [](const auto& path) {
92+
return path.empty();
93+
})) {
94+
throw std::invalid_argument("image_path must not be empty");
95+
}
96+
if (image_paths.isSet() && !cfg.vision_enabled) {
9097
throw std::invalid_argument("image_path requires a config with vision_config");
9198
}
92-
if (image_path.isSet() && benchmark_mode) { throw std::invalid_argument("image_path is not supported in benchmark mode"); }
99+
if (image_paths.isSet() && benchmark_mode) { throw std::invalid_argument("image_path is not supported in benchmark mode"); }
93100
if (benchmark_mode) {
94101
if (!prompt_file.isSet() || !benchmark_samples.isSet() || !benchmark_jsonl.isSet() || !benchmark_variant.isSet()
95102
|| !benchmark_source_sha.isSet() || !expected_prompt_tokens.isSet()) {
@@ -237,8 +244,7 @@ MLLM_MAIN({
237244
// KV cache and every GDN recurrent/conv state must start empty.
238245
model.resetState();
239246
fmt::print("Processing...\n");
240-
auto inputs =
241-
tokenizer.convertMessage({.prompt = prompt_text, .image_path = image_path.isSet() ? image_path.get() : ""});
247+
auto inputs = tokenizer.convertMessage({.prompt = prompt_text, .image_paths = configured_image_paths});
242248
const auto prompt_length = inputs.at("sequence").shape()[1];
243249
if (prompt_length + generation_limit - 1 > cfg.max_cache_length) {
244250
throw std::invalid_argument(fmt::format("prompt token count ({}) plus max_new_tokens ({}) exceeds "

mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
#include <stdexcept>
1010
#include <string>
1111
#include <utility>
12+
#include <vector>
1213

1314
#include "mllm/core/Tensor.hpp"
1415
#include "mllm/preprocessor/visual/Image.hpp"
@@ -68,6 +69,35 @@ class Qwen3_5ImagePreprocessor {
6869
return flattenNormalizedPatches(image.tensor());
6970
}
7071

72+
std::pair<Tensor, Tensor> operator()(const std::vector<std::string>& image_paths) const {
73+
if (image_paths.empty()) { throw std::invalid_argument("Qwen3.5 image path list must not be empty"); }
74+
std::vector<std::pair<Tensor, Tensor>> processed_images;
75+
processed_images.reserve(image_paths.size());
76+
int32_t total_patches = 0;
77+
int32_t patch_features = -1;
78+
for (const auto& image_path : image_paths) {
79+
auto processed = (*this)(image_path);
80+
if (patch_features < 0) patch_features = processed.first.shape()[1];
81+
if (processed.first.shape().size() != 2 || processed.first.shape()[1] != patch_features) {
82+
throw std::runtime_error("Qwen3.5 preprocessed images have incompatible patch features");
83+
}
84+
total_patches += processed.first.shape()[0];
85+
processed_images.push_back(std::move(processed));
86+
}
87+
88+
auto patches = Tensor::empty({total_patches, patch_features}, kFloat32, kCPU).alloc();
89+
auto grids = Tensor::empty({static_cast<int32_t>(image_paths.size()), 3}, kInt32, kCPU).alloc();
90+
int64_t patch_offset = 0;
91+
for (size_t image_index = 0; image_index < processed_images.size(); ++image_index) {
92+
const auto& [image_patches, image_grid] = processed_images[image_index];
93+
std::copy(image_patches.ptr<float>(), image_patches.ptr<float>() + image_patches.numel(),
94+
patches.ptr<float>() + patch_offset);
95+
std::copy(image_grid.ptr<int32_t>(), image_grid.ptr<int32_t>() + 3, grids.ptr<int32_t>() + image_index * 3);
96+
patch_offset += image_patches.numel();
97+
}
98+
return {patches, grids};
99+
}
100+
71101
// Exposed for focused tests and reference-oracle comparisons. Input is an
72102
// already-resized float32 RGB tensor in [H,W,3] with values in [0,255].
73103
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& image_hwc) const {

mllm/models/qwen3_5/modeling_qwen3_5.hpp

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -634,8 +634,8 @@ class Qwen3_5ForCausalLM : public ARGeneration, public nn::Module {
634634
position_ids = input.at("position_ids");
635635
if (seq_len == 1) { position_ids = advanceQwen3_5PositionIds(position_ids); }
636636
} else if (has_pixel_values) {
637-
position_ids = makeQwen3_5SingleImagePositionIds(input.at("mm_token_type_ids"), input.at("image_grid_thw"),
638-
vision_spatial_merge_size_);
637+
position_ids =
638+
makeQwen3_5ImagePositionIds(input.at("mm_token_type_ids"), input.at("image_grid_thw"), vision_spatial_merge_size_);
639639
} else {
640640
position_ids = Tensor::empty({batch_size, seq_len}, kInt64, kCPU).alloc();
641641
auto position_ids_ptr = position_ids.ptr<int64_t>();
@@ -659,29 +659,25 @@ class Qwen3_5ForCausalLM : public ARGeneration, public nn::Module {
659659
}
660660
const auto* input_ids = sequence.ptr<int64_t>();
661661
const auto* types = token_types.ptr<int32_t>();
662-
int32_t image_begin = -1;
663662
int32_t image_count = 0;
664663
for (int32_t s = 0; s < seq_len; ++s) {
665664
if (input_ids[s] == video_token_id_ || types[s] == 2) {
666-
throw std::invalid_argument("Qwen3.5 CPU single-image support does not accept video tokens");
665+
throw std::invalid_argument("Qwen3.5 CPU image support does not accept video tokens");
667666
}
668667
if ((input_ids[s] == image_token_id_) != (types[s] == 1)) {
669668
throw std::invalid_argument("Qwen3.5 image token IDs and modality token types disagree");
670669
}
671-
if (types[s] == 1) {
672-
if (image_begin < 0) image_begin = s;
673-
++image_count;
674-
}
670+
if (types[s] == 1) { ++image_count; }
675671
}
676672

677673
auto input_embeddings = llm.embed(sequence);
678674
auto image_embeddings = llm.encodeImage(input.at("pixel_values"), input.at("image_grid_thw"));
679-
if (image_begin < 0 || image_embeddings.shape().size() != 2 || image_embeddings.shape()[0] != image_count
675+
if (image_count <= 0 || image_embeddings.shape().size() != 2 || image_embeddings.shape()[0] != image_count
680676
|| input_embeddings.shape().size() != 3 || image_embeddings.shape()[1] != input_embeddings.shape()[2]
681677
|| image_embeddings.dtype() != input_embeddings.dtype()) {
682678
throw std::invalid_argument("Qwen3.5 image features and image placeholders do not match");
683679
}
684-
image_embeddings.copy2(input_embeddings[{kAll, {image_begin, image_begin + image_count}, kAll}]);
680+
injectQwen3_5ImageEmbeddings(input_embeddings, image_embeddings, token_types);
685681
sequence = llm.forwardEmbeddings(input_embeddings, llm_embedding_sin, llm_embedding_cos, AnyValue(&kv_cache_));
686682
} else {
687683
sequence = llm(sequence, llm_embedding_sin, llm_embedding_cos, AnyValue(&kv_cache_))[0];

mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -327,20 +327,48 @@ class Qwen3_5VisionModel final : public nn::Module {
327327
std::vector<Tensor> forward(const std::vector<Tensor>& inputs, const std::vector<AnyValue>& args) override {
328328
const auto& pixel_values = inputs[0];
329329
const auto& grid_thw = inputs[1];
330+
if (pixel_values.shape().size() != 2 || grid_thw.dtype() != kInt32 || grid_thw.device() != kCPU
331+
|| grid_thw.shape().size() != 2 || grid_thw.shape()[0] <= 0 || grid_thw.shape()[1] != 3) {
332+
throw std::invalid_argument("Qwen3.5 vision model requires patches and one grid row per image");
333+
}
334+
const auto* grids = grid_thw.ptr<int32_t>();
335+
int32_t patch_offset = 0;
336+
std::vector<Tensor> image_outputs;
337+
image_outputs.reserve(grid_thw.shape()[0]);
338+
for (int32_t image_index = 0; image_index < grid_thw.shape()[0]; ++image_index) {
339+
const auto* grid = grids + image_index * 3;
340+
if (grid[0] <= 0 || grid[1] <= 0 || grid[2] <= 0) {
341+
throw std::invalid_argument("Qwen3.5 vision image grids must be positive");
342+
}
343+
const int32_t patch_count = grid[0] * grid[1] * grid[2];
344+
if (patch_count > pixel_values.shape()[0] - patch_offset) {
345+
throw std::invalid_argument("Qwen3.5 image grids exceed the supplied patch rows");
346+
}
347+
auto image_pixels = pixel_values[{{patch_offset, patch_offset + patch_count}, kAll}].contiguous();
348+
auto image_grid = grid_thw[{{image_index, image_index + 1}, kAll}].contiguous();
349+
image_outputs.push_back(forwardImage(image_pixels, image_grid));
350+
patch_offset += patch_count;
351+
}
352+
if (patch_offset != pixel_values.shape()[0]) {
353+
throw std::invalid_argument("Qwen3.5 supplied patch rows exceed the image grids");
354+
}
355+
return {image_outputs.size() == 1 ? image_outputs[0] : nn::functional::concat(image_outputs, 0)};
356+
}
357+
358+
private:
359+
Tensor forwardImage(const Tensor& pixel_values, const Tensor& grid_thw) {
330360
auto hidden_states = patch_embed_(pixel_values)[0];
331361
auto position_embedding = makeQwen3_5VisionBilinearPositionEmbedding(pos_embed_.weight(), grid_thw, spatial_merge_size_);
332362
if (hidden_states.shape() != position_embedding.shape()) {
333363
throw std::invalid_argument("Qwen3.5 patch embeddings do not match the image grid");
334364
}
335365
hidden_states = hidden_states + position_embedding;
336-
337366
auto position_ids = makeQwen3_5VisionPositionIds(grid_thw, spatial_merge_size_);
338367
auto [sin, cos] = makeQwen3_5VisionRotaryEmbedding(position_ids, hidden_size_ / num_heads_);
339368
for (auto& block : blocks_.list()) { hidden_states = block(hidden_states, sin, cos)[0]; }
340-
return {merger_(hidden_states)[0]};
369+
return merger_(hidden_states)[0];
341370
}
342371

343-
private:
344372
int32_t hidden_size_ = 768;
345373
int32_t num_heads_ = 12;
346374
int32_t spatial_merge_size_ = 2;

0 commit comments

Comments
 (0)