Skip to content

feat(cpu): add Qwen3.5 0.8B multi-image support - #696

Merged
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-multi-image-production
Aug 10, 2026
Merged

feat(cpu): add Qwen3.5 0.8B multi-image support#696
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-multi-image-production

Conversation

@Aharrypotter

@Aharrypotter Aharrypotter commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Extends the existing Qwen3.5-0.8B single-image multimodal CPU path with
ordered multi-image input:

  • repeated --image_path arguments in the desktop and Android runner
  • independent smart resize and vision encoding for each image
  • ordered placeholder expansion, image grids, visual spans, and 3-axis MRoPE
  • distinct visual-embedding injection for every image span
  • single-image compatibility plus focused multi-image correctness coverage

Each image runs through the vision tower independently before its embeddings
are concatenated in prompt order. This preserves per-image attention isolation
and avoids dense vision attention over the total patch count of all images.

End-to-end multi-image demo

Image 1 Image 2
Two cats lying on a pink surface mllm inference framework diagram

Prompt

Describe the first image and the second image separately in one short sentence each.

Qwen3.5-0.8B Multimodal on OnePlus 13T

The first image shows two cats lying on a pink surface, while the second image displays an AI inference framework with algorithms, accelerator, and GPU components.

The auxiliary Pixel 9 Pro XL run produced the same response. Both runs verified
the model, tokenizer, images, runner, and loaded candidate runtime libraries.

Review guide

Area Main files What to review
Runner and template main.cpp, Argparse.hpp, tokenization_qwen3_5.hpp repeated image arguments and one ordered marker per image
Preprocessing image_preprocessor_qwen3_5.hpp independent resize, patch concatenation, and {N, 3} grids
Vision and integration modeling_qwen3_5_vision.hpp, modeling_qwen3_5.hpp, multimodal_qwen3_5.hpp per-image tower execution, span validation, MRoPE, and embedding injection
Tests and docs Qwen35MultimodalTest.cpp, README.md multi-geometry/order coverage, single-image regression, errors, and CLI usage

Suggested review order: template/preprocessing → vision execution → MRoPE and
embedding injection → tests and runner
.

Current-head validation

Candidate HEAD: bc64f0abecca9cf1c150de5affffbc76fb33118d
Merged-main base: cc86c4ca8b2233edbf137f6b3e53bfdd11017d31

Gate Result
macOS arm64 Release runner and affected tests built; 37/37 Qwen3.5 C++ tests passed
Pinned Transformers oracle Exact two-image template, grid geometry, token count, and image spans matched
Android cross-build NDK r28b/API 28 arm64-v8a build plus AArch64 ELF, Build ID, dependency, and bundle-hash audits passed
OnePlus 13T primary 14/14 multimodal tests passed; single-image regression and the displayed two-image generation passed
Pixel 9 Pro XL auxiliary 14/14 multimodal tests passed; the same two-image response and candidate-library load proof passed
Full validation details
Evidence Result
Static clang-format and git diff --check passed; temporary debug/dump interfaces were absent
Existing Qwen3.5 regressions tokenizer 4/4, config 6/6, GDN 7/7, GDN Conv 6/6, multimodal 14/14
Transformers two-image oracle grids [[1,14,20],[1,22,46]]; 354 input tokens; image spans [4,74) and [76,329)
Android artifact identities focused test Build ID 694ad3b15a73fdb82752a679ae6009f36a0571ec; runner e76e40394419e1979391175edb63c178a4f69b9e
Android runtime libraries libMllmRT.so Build ID b0d780d6df31db5c9f0158b4815828566e897657; libMllmCPUBackend.so b712bfd2b6eeb1e8289f9dedbe2440ac22905d64
OnePlus single-image regression This image shows two tabby cats lying on a bright pink surface, appearing to be asleep or resting.
Device scope OnePlus 13T is the primary Android product device; Pixel 9 Pro XL is an auxiliary portability check

The device artifacts were built and validated from source bytes identical to
the final commit; creating the commit did not modify the tested files. These
runs establish the declared correctness and integration paths, not formal
image-quality, numerical-parity, or performance claims.

How to use it

Repeat --image_path in the intended prompt order:

mllm-qwen3-5-runner \
  --model_path /path/to/model.mllm \
  --model_version v2 \
  --tokenizer_path /path/to/tokenizer.json \
  --config_path examples/qwen3_5/config_0.8B_multimodal_w4a32_kai.json \
  --image_path /path/to/first.jpg \
  --image_path /path/to/second.png \
  --prompt "Describe the first and second images."

Known limits

  • Qwen3.5-0.8B, still images, and batch size 1 only
  • no video input, deep-stack visual features, MTP, or multimodal benchmark mode
  • Qwen3.5-4B multimodal remains a separate follow-up
  • no performance claim is made

Extends the single-image multimodal support merged in #695.

Summary by CodeRabbit

  • New Features
    • Added support for multiple still images in Qwen3.5 prompts.
    • The --image_path option can now be repeated to provide images in order.
    • Multi-image prompts support image preprocessing, placement, and interleaved text correctly.
  • Bug Fixes
    • Added validation for missing, mismatched, or invalid image data.
  • Documentation
    • Updated usage examples and interactive-mode guidance.
    • Clarified that video input is not supported.
  • Tests
    • Added coverage for multi-image processing, ordering, geometry, and invalid inputs.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Qwen3.5 now supports multiple ordered still images per prompt. The change updates CLI parsing, tokenization, preprocessing, vision processing, embedding injection, position IDs, validation, documentation, and CPU tests.

Changes

Qwen3.5 multi-image flow

Layer / File(s) Summary
Multi-image input contracts
examples/qwen3_5/main.cpp, mllm/utils/Argparse.hpp, mllm/models/qwen3_5/tokenization_qwen3_5.hpp, examples/qwen3_5/README.md
Repeated --image_path values are stored in order and passed through interactive inference. Message conversion inserts image markers for each path.
Image preparation and placeholder expansion
mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp, mllm/models/qwen3_5/multimodal_qwen3_5.hpp, mllm/models/qwen3_5/tokenization_qwen3_5.hpp
Multiple images are preprocessed into concatenated patches and per-image grids. Placeholder expansion emits matching image token spans and token types.
Vision processing and embedding injection
mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp, mllm/models/qwen3_5/multimodal_qwen3_5.hpp, mllm/models/qwen3_5/modeling_qwen3_5.hpp
The vision model processes each image independently and concatenates features. The model injects features into image-token positions.
Interleaved position IDs and validation
mllm/models/qwen3_5/multimodal_qwen3_5.hpp, mllm/models/qwen3_5/modeling_qwen3_5.hpp, tests/cpu/Qwen35MultimodalTest.cpp
Position IDs support interleaved text and image spans. Tests cover preprocessing, placeholder expansion, embedding injection, geometry, ordering, and invalid inputs.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Qwen35Runner
  participant Qwen3_5Tokenizer
  participant Qwen3_5ImagePreprocessor
  participant Qwen3_5VisionModel
  participant Qwen3_5Model
  Qwen35Runner->>Qwen3_5Tokenizer: pass ordered image_paths
  Qwen3_5Tokenizer->>Qwen3_5ImagePreprocessor: preprocess image_paths
  Qwen3_5ImagePreprocessor-->>Qwen3_5Tokenizer: concatenated patches and image grids
  Qwen3_5Tokenizer->>Qwen3_5VisionModel: process image patch ranges
  Qwen3_5VisionModel-->>Qwen3_5Model: concatenated image features
  Qwen3_5Model->>Qwen3_5Model: inject features and generate position IDs
Loading

Possibly related PRs

Suggested reviewers: oreomaker, yirongjie, chenghuawang

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the primary change: adding Qwen3.5 0.8B multi-image support for CPU.
Description check ✅ Passed The description is complete and directly covers the implementation, usage, limitations, review areas, and validation results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Aharrypotter
Aharrypotter marked this pull request as ready for review August 9, 2026 14:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (5)
mllm/utils/Argparse.hpp (1)

82-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Document the append semantics of the vector specialization.

Argument<std::vector<std::string>>::parse appends one value per occurrence of the flag. This differs from every other type, which overwrites. Add a short comment so callers understand that repeated flags accumulate in argument order.

The coding guidelines require clear comments for public APIs.

♻️ Proposed comment
     } else {
+      // Vector arguments accumulate: each occurrence of the flag appends one
+      // value, preserving command-line order. Scalar types overwrite instead.
       if constexpr (std::is_same_v<T, std::vector<std::string>>) {
         value_.push_back(value);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/utils/Argparse.hpp` around lines 82 - 84, Add a concise public-API
comment adjacent to the std::vector<std::string> branch in Argument::parse,
documenting that each repeated flag appends one value and preserves argument
order, unlike scalar types that overwrite.

Source: Coding guidelines

mllm/models/qwen3_5/tokenization_qwen3_5.hpp (1)

365-376: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the magic offset and the unused loop index.

Line 375 adds 5 to the match position. The value is tied to the length of "user\n". If the marker changes, the insertion point silently moves into the wrong place. Bind the offset to the marker length. The loop at line 372 also does not use i, and the 48 reserve is a second magic value.

♻️ Proposed refactor
     const bool has_image = !message.image_paths.empty();
     auto applied_string = Qwen3_5Message::message_template;
     if (has_image) {
-      const auto user_content = applied_string.find("user\n");
+      static constexpr std::string_view kUserMarker = "user\n";
+      static constexpr std::string_view kImageMarker = "<|vision_start|><|image_pad|><|vision_end|>";
+      const auto user_content = applied_string.find(kUserMarker);
       if (user_content == std::string::npos) { throw std::runtime_error("Qwen3.5 message template is malformed"); }
       std::string image_markers;
-      image_markers.reserve(message.image_paths.size() * 48);
-      for (size_t i = 0; i < message.image_paths.size(); ++i) {
-        image_markers += "<|vision_start|><|image_pad|><|vision_end|>";
-      }
-      applied_string.insert(user_content + 5, image_markers);
+      image_markers.reserve(message.image_paths.size() * kImageMarker.size());
+      for (size_t i = 0; i < message.image_paths.size(); ++i) { image_markers.append(kImageMarker); }
+      applied_string.insert(user_content + kUserMarker.size(), image_markers);
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp` around lines 365 - 376, Update
the image-marker insertion in Qwen3_5Message handling to derive the insertion
offset from the matched "user\n" marker length instead of the literal 5. Replace
the unused index-based loop with iteration over the image paths, and derive the
reserve size from the marker string rather than the literal 48.
tests/cpu/Qwen35MultimodalTest.cpp (1)

97-122: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Make the image fixtures collision-safe and clean them up on failure.

The test writes two files with fixed names into the shared temporary directory. Two concurrent test processes use the same paths, so a parallel run can read a half-written fixture. If an assertion throws, lines 120-121 do not run and the files stay on disk.

Use a unique per-run subdirectory and remove it with a scope guard.

♻️ Proposed change
 TEST_F(Qwen35MultimodalTest, PreprocessesAndConcatenatesDifferentImagesInOrder) {
-  const auto temp_dir = std::filesystem::temp_directory_path();
+  const auto temp_dir = std::filesystem::temp_directory_path()
+                        / ("mllm_qwen35_multi_image_" + std::to_string(::getpid()));
+  std::filesystem::create_directories(temp_dir);
+  struct Cleanup {
+    std::filesystem::path dir;
+    ~Cleanup() { std::error_code ec; std::filesystem::remove_all(dir, ec); }
+  } cleanup{temp_dir};
-  const auto first_path = temp_dir / "mllm_qwen35_multi_image_first.ppm";
-  const auto second_path = temp_dir / "mllm_qwen35_multi_image_second.ppm";
+  const auto first_path = temp_dir / "first.ppm";
+  const auto second_path = temp_dir / "second.ppm";
@@
   EXPECT_FLOAT_EQ(patches.ptr<float>()[4 * 1536], 1.0F);
-
-  std::filesystem::remove(first_path);
-  std::filesystem::remove(second_path);
 }

::getpid() needs <unistd.h> on POSIX. For a portable identifier, use std::to_string(std::hash<std::thread::id>{}(std::this_thread::get_id())) or a static counter combined with std::chrono::steady_clock::now().time_since_epoch().count(). The coding guidelines require portability across Linux and Windows.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cpu/Qwen35MultimodalTest.cpp` around lines 97 - 122, Update the
PreprocessesAndConcatenatesDifferentImagesInOrder test to create a unique
per-run subdirectory under the temporary directory using a portable identifier,
then place both image fixtures inside it. Add a scope guard that removes the
entire subdirectory on all exits, including assertion failures, and remove the
direct cleanup calls.

Source: Coding guidelines

mllm/models/qwen3_5/multimodal_qwen3_5.hpp (1)

199-199: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Document why the advance ignores grid_t.

Line 199 advances the shared position by max(grid_h, grid_w) / spatial_merge_size. The reference rule advances by the maximum over all three axes, including the temporal axis. The two rules agree only while grid_t == 1. Still images always satisfy that, and the model rejects video tokens upstream, so the current behavior is correct. Add a comment that records this assumption. Video support will need grid_t in the maximum.

♻️ Proposed comment
+    // Still images always have grid_t == 1, so the temporal axis never sets the
+    // maximum. Video input must include grid_t in this maximum.
     current_position += std::max(grid_h, grid_w) / spatial_merge_size;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp` at line 199, Add a concise
comment immediately above the position update in the relevant multimodal
processing code, documenting that the temporal dimension is intentionally
omitted because video tokens are rejected upstream and supported inputs have
grid_t == 1; note that video support must include grid_t in the maximum. Leave
the existing current_position calculation unchanged.
mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp (1)

72-83: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Validate the rank before you read shape()[1], and document the overload.

Line 80 reads processed.first.shape()[1] before line 81 checks shape().size() != 2. The current single-image operator always returns a rank-2 tensor, so the read is safe today. If that contract changes, the read goes out of bounds. Swap the two statements.

The coding guidelines require a comment on public APIs. Add a short comment that states the return contract: concatenated patches in prompt order plus one grid row per image.

♻️ Proposed change
+  // Preprocesses every image in prompt order. Returns concatenated patches in
+  // [total_patches, patch_features] and one [t,h,w] grid row per image.
   std::pair<Tensor, Tensor> operator()(const std::vector<std::string>& image_paths) const {
     if (image_paths.empty()) { throw std::invalid_argument("Qwen3.5 image path list must not be empty"); }
     std::vector<std::pair<Tensor, Tensor>> processed_images;
     processed_images.reserve(image_paths.size());
     int32_t total_patches = 0;
     int32_t patch_features = -1;
     for (const auto& image_path : image_paths) {
       auto processed = (*this)(image_path);
+      if (processed.first.shape().size() != 2) {
+        throw std::runtime_error("Qwen3.5 preprocessed images have incompatible patch features");
+      }
       if (patch_features < 0) patch_features = processed.first.shape()[1];
-      if (processed.first.shape().size() != 2 || processed.first.shape()[1] != patch_features) {
+      if (processed.first.shape()[1] != patch_features) {
         throw std::runtime_error("Qwen3.5 preprocessed images have incompatible patch features");
       }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp` around lines 72 - 83, In
the vector overload of operator(), validate processed.first.shape().size() == 2
before reading shape()[1], then enforce the existing patch-feature compatibility
check. Add a brief public-API comment documenting that it returns concatenated
patches in prompt order plus one grid row per image.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp`:
- Around line 72-83: In the vector overload of operator(), validate
processed.first.shape().size() == 2 before reading shape()[1], then enforce the
existing patch-feature compatibility check. Add a brief public-API comment
documenting that it returns concatenated patches in prompt order plus one grid
row per image.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp`:
- Line 199: Add a concise comment immediately above the position update in the
relevant multimodal processing code, documenting that the temporal dimension is
intentionally omitted because video tokens are rejected upstream and supported
inputs have grid_t == 1; note that video support must include grid_t in the
maximum. Leave the existing current_position calculation unchanged.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp`:
- Around line 365-376: Update the image-marker insertion in Qwen3_5Message
handling to derive the insertion offset from the matched "user\n" marker length
instead of the literal 5. Replace the unused index-based loop with iteration
over the image paths, and derive the reserve size from the marker string rather
than the literal 48.

In `@mllm/utils/Argparse.hpp`:
- Around line 82-84: Add a concise public-API comment adjacent to the
std::vector<std::string> branch in Argument::parse, documenting that each
repeated flag appends one value and preserves argument order, unlike scalar
types that overwrite.

In `@tests/cpu/Qwen35MultimodalTest.cpp`:
- Around line 97-122: Update the
PreprocessesAndConcatenatesDifferentImagesInOrder test to create a unique
per-run subdirectory under the temporary directory using a portable identifier,
then place both image fixtures inside it. Add a scope guard that removes the
entire subdirectory on all exits, including assertion failures, and remove the
direct cleanup calls.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: d03d5dce-cf1c-480c-a6ff-ba0375b85e23

📥 Commits

Reviewing files that changed from the base of the PR and between cc86c4c and bc64f0a.

📒 Files selected for processing (9)
  • examples/qwen3_5/README.md
  • examples/qwen3_5/main.cpp
  • mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp
  • mllm/models/qwen3_5/modeling_qwen3_5.hpp
  • mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp
  • mllm/models/qwen3_5/multimodal_qwen3_5.hpp
  • mllm/models/qwen3_5/tokenization_qwen3_5.hpp
  • mllm/utils/Argparse.hpp
  • tests/cpu/Qwen35MultimodalTest.cpp

@chenghuaWang chenghuaWang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@chenghuaWang
chenghuaWang merged commit d1bc93b into UbiquitousLearning:main Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants