Skip to content

feat(cpu): add Qwen3.5 0.8B video support - #697

Merged
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-video-multimodal
Aug 12, 2026
Merged

feat(cpu): add Qwen3.5 0.8B video support#697
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-video-multimodal

Conversation

@Aharrypotter

@Aharrypotter Aharrypotter commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Extends the existing Qwen3.5-0.8B image/multi-image CPU path with bounded
short-video input:

  • an explicit none/portable decoder-backend boundary; the optional
    portable H.264 MP4 reference backend uses externally supplied pinned
    minimp4/OpenH264 dependencies
  • deterministic source-frame sampling, temporal padding, smart resize, and
    Qwen3-VL-compatible video patchification
  • timestamped nested video markers, token type 2, video-aware 3-axis MRoPE,
    and visual-embedding injection
  • fail-closed limits for input bytes, sampled frames, decoded/selected pixels,
    and projected visual tokens
  • CTest registration, focused video/reset/error coverage, and CLI documentation

The decoder backend defaults to none and no codec source or binary is
vendored or downloaded by the build. Qwen3.5 model correctness consumes
decoded RGB frames and remains independent of the optional codec backend. A
future production Android backend can use Media NDK without changing temporal
patching, video token types, or MRoPE.

End-to-end video demo

A red square moving from left to right while a blue square stays still

Download the exact 192x128, 4 fps H.264 MP4 fixture.

Prompt

What happens in this video? Answer in one short sentence.

Qwen3.5-0.8B Multimodal on OnePlus 13T

The red squares appear one by one from left to right.

This exact-head run verified the reused model receipt, tokenizer, runtime
configuration, sampled frames, and the actually loaded runtime libraries.

Review guide

Area Main files What to review
Decoder and bounds video_decoder.cpp, video_decoder.hpp, main.cpp portable MP4/H.264 validation, deterministic sampling, allocation ceilings, backend boundary
Preprocessing/template video_preprocessor_qwen3_5.hpp, tokenization_qwen3_5.hpp resize/patch layout, timestamps, nested markers, type-2 expansion
Model integration multimodal_qwen3_5.hpp, modeling_qwen3_5.hpp temporal-span MRoPE, placeholder/feature checks, video embedding injection, reset/decode
Build/tests/docs CMake files, Qwen35MultimodalTest.cpp, README.md codec-independent correctness, opt-in portable decoder smoke, regression/error coverage, user-facing limits

Suggested review order: decoder/bounds → preprocessing/template → MRoPE and
embedding injection → tests and runner
.

Current-head validation

Candidate HEAD: aa8b2ebbde76a1cd2f6f3f98f0b3b0e49d81fcad
Merged-main base: d1bc93b8a171bc30cf47b134f12b3798b5e9049a

Gate Result
GitHub Actions exact-head build-android, build-x86, and build-macos passed
macOS arm64 none/portable builds passed; focused + portable CTest 2/2 passed; default-path rejection and invalid-backend configure checks passed
Pinned Transformers oracle sampled indices [0,2,5,7], grid [2,4,4], patch shape [32,1536], timestamps/token types/MRoPE matched
H20 Linux exact committed-source offline build 305/305, focused + portable CTest 2/2, and ELF/dependency audit passed
Android cross-build NDK r28b/API 28 arm64-v8a; AArch64 ELF/dependency audit and 7-artifact bundle hash passed
OnePlus 13T primary exact-head focused 25/25, portable MP4 smoke, runtime-map proof, and full-model video response passed
Pixel 9 Pro XL auxiliary earlier semantic/portability coverage retained; not used as exact-head closure for the decoder-backend refactor
Full validation details
Evidence Result
Static clang-format and git diff --check passed; 15-path PR diff and 1,880-file committed-source manifest verified
Reproducible demo assets MP4 94f33a39...; GIF 72052c91...; both stored under bench_assets/
MP4 fixture 8 frames, 4 fps, 64x48; default 2-fps selection [0,2,5,7]; projected video tokens 8
Selected RGB macOS, H20, OnePlus, and Pixel byte-identical: ad43f44...
Float patches OnePlus/Pixel byte-identical; desktop max abs error 1.1920929e-7, PASS at rtol=1e-6, atol=1e-6
Displayed demo 16 frames, 4 fps, 192x128; selection [0,2,4,6,9,11,13,15]; grid [4,8,12]; patch shape [384,1536]; exact-head response shown above
Android artifact closure runner, portable decoder smoke, focused test, RT, CPU backend, NDK AArch64 libomp.so, and fixture; manifest 5f959cb4...
Runtime load proof OnePlus process maps load libMllmRT.so, libMllmCPUBackend.so, and libomp.so from the exact-head device bundle
Model identities converted model 4244d758...; tokenizer 5f9e4d49...; runtime config 26b1eb70...
Fail-closed limits one-above-ceiling frames/bytes/tokens/decoded pixels/selected pixels plus two fixture-specific one-pixel limits all reject before model loading
Device scope OnePlus 13T is primary; Pixel 9 Pro XL is auxiliary; printed timings are correctness-smoke output, not performance evidence

Failed predecessor roots were retained while the final H20 run used fixed
offline stdexec/RAPIDS/CPM inputs. The accepted Linux and Android roots both
carry rc=0 plus atomic done receipts and match the tested source manifest.

How to use it

Build the optional portable/reference backend with separately supplied
dependencies:

cmake -S . -B build-video \
  -DMLLM_QWEN35_VIDEO_DECODER_BACKEND=portable \
  -DMLLM_QWEN35_MINIMP4_INCLUDE_DIR=/path/to/minimp4 \
  -DMLLM_QWEN35_OPENH264_SOURCE_DIR=/path/to/openh264 \
  -DMLLM_QWEN35_OPENH264_LIBRARIES=/path/to/libopenh264.a
cmake --build build-video --target mllm-qwen3-5-runner

Then run one local video:

mllm-qwen3-5-runner \
  --model_path /path/to/qwen3.5-0.8b-multimodal-w4a32-kai.mllm \
  --model_version v2 \
  --tokenizer_path /path/to/Qwen3.5-0.8B/tokenizer.json \
  --config_path examples/qwen3_5/config_0.8B_multimodal_w4a32_kai.json \
  --video_path /path/to/video.mp4 \
  --prompt "What happens in this video? Answer in one short sentence." \
  --max_new_tokens 48

Known limits

  • Qwen3.5-0.8B, batch size 1, and one finite local H.264 MP4 only
  • image and video inputs are mutually exclusive in one request
  • audio is ignored; no URLs, cameras, streaming, multiple videos, or long-video claim
  • decoder backend defaults to none; the portable reference backend requires user-supplied pinned dependencies
  • Android Media NDK decoding is a follow-up backend, not part of this PR
  • Qwen3.5-4B multimodal remains a separate follow-up after this PR merges
  • no accuracy-benchmark, codec-patent, or performance claim is made

Extends the ordered multi-image support merged in #696.

Summary by CodeRabbit

  • New Features

    • Added Qwen3.5 support for bounded short-video and multi-image inference.
    • Added optional portable H.264/MP4 video decoding with configurable frame sampling and resource limits.
    • Video inputs now support frame metadata, preprocessing, multimodal embeddings, and interleaved text/image/video content.
    • Added validation for unsupported configurations, malformed media, token limits, and incompatible image/video combinations.
  • Documentation

    • Added setup instructions, usage examples, video constraints, and smoke-test guidance.
  • Tests

    • Expanded multimodal coverage for video decoding, preprocessing, sampling, and model integration.

@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Adds bounded short-video inference to Qwen3.5. The change includes portable H.264/MP4 decoding, video preprocessing, multimodal token and embedding support, CLI options, CMake configuration, documentation, smoke tests, and focused CPU tests.

Changes

Qwen3.5 video inference

Layer / File(s) Summary
Video preprocessing and tokenization
mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp, mllm/models/qwen3_5/tokenization_qwen3_5.hpp, tests/cpu/Qwen35MultimodalTest.cpp
Adds I420 conversion, frame sampling, timestamp markers, bicubic resizing, temporal padding, normalized patchification, video metadata, and video tokenization.
Multimodal video embedding and positions
mllm/models/qwen3_5/multimodal_qwen3_5.hpp, mllm/models/qwen3_5/modeling_qwen3_5.hpp, tests/cpu/Qwen35MultimodalTest.cpp
Adds video placeholder expansion, video embedding injection, multimodal position IDs, video encoding, and image/video input validation.
Portable MP4 decoder and smoke test
examples/qwen3_5/video_decoder.*, examples/qwen3_5/video_decoder_smoke.cpp, examples/qwen3_5/CMakeLists.txt
Adds bounded minimp4/OpenH264 decoding, RGB frame output, fixture and oracle checks, patch serialization, and optional CTest registration.
Runner wiring and project test registration
examples/qwen3_5/main.cpp, examples/qwen3_5/README.md, CMakeLists.txt, tests/cpu/CMakeLists.txt
Adds video CLI options, validation, decoding, resource limits, message construction, usage documentation, and CTest registration.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI as Qwen3.5 runner
  participant Decoder as Portable video decoder
  participant Preprocessor as Qwen3_5VideoPreprocessor
  participant Tokenizer as Qwen3_5Tokenizer
  participant Model as Qwen3_5Model
  CLI->>Decoder: Decode bounded MP4 input
  Decoder-->>CLI: Return selected RGB frames and metadata
  CLI->>Preprocessor: Resize and patchify frames
  Preprocessor-->>Tokenizer: Return video tensors and grid metadata
  Tokenizer->>Model: Provide video tokens and multimodal inputs
  Model-->>CLI: Run multimodal inference
Loading

Possibly related PRs

Suggested reviewers: chenghuawang, oreomaker, yirongjie

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 2.22% 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 describes the main change: Qwen3.5 0.8B video support.
Description check ✅ Passed The description is complete, relevant, and documents implementation scope, validation, usage, and known limits.
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 force-pushed the feat/qwen35-0.8b-video-multimodal branch from c1966ec to aa8b2eb Compare August 11, 2026 09:27
@Aharrypotter
Aharrypotter marked this pull request as ready for review August 12, 2026 04:18

@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.

Actionable comments posted: 5

🧹 Nitpick comments (5)
mllm/models/qwen3_5/multimodal_qwen3_5.hpp (2)

253-259: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Consider routing makeQwen3_5ImagePositionIds through the new multimodal walker.

makeQwen3_5MultimodalPositionIds is a superset of makeQwen3_5ImagePositionIds at Lines 187-251. Both walk contiguous same-type spans, assign sequential text positions, map grid rows to t/h/w offsets, and advance current_position by std::max(grid_h, grid_w) / spatial_merge_size. The image-only version now differs only by rejecting type 2 and by its error strings.

Two near-identical walkers mean a future fix to the span or offset logic must land twice. makeQwen3_5ImagePositionIds can delegate to the multimodal walker with a nil video_grid_thw, which preserves its current behavior including the rejection of type-2 tokens.

Keep this deferrable. The existing image tests at tests/cpu/Qwen35MultimodalTest.cpp Lines 355-398 and 448-458 pin the image behavior and would catch a regression.

🤖 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` around lines 253 - 259, Refactor
makeQwen3_5ImagePositionIds to delegate to makeQwen3_5MultimodalPositionIds with
a nil video_grid_thw tensor, preserving image-only validation, type-2 rejection,
position calculations, and existing error behavior. Keep the current image tests
passing and avoid maintaining a second span/offset walker.

56-86: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Share one expansion helper between the image and video paths.

expandQwen3_5VideoPlaceholders duplicates expandQwen3_5ImagePlaceholders at Lines 18-48. The bodies differ only in the assigned token type (2 versus 1) and three message strings.

This PR already generalized the injection path into injectQwen3_5ModalityEmbeddings with thin injectQwen3_5ImageEmbeddings and injectQwen3_5VideoEmbeddings wrappers. Apply the same pattern to expansion so a future fix to the expansion logic lands in one place.

♻️ Proposed shared expansion helper
+inline auto expandQwen3_5ModalityPlaceholders(const std::vector<int64_t>& token_ids, int64_t modality_token_id,
+                                              const std::vector<int32_t>& modality_token_counts, int32_t modality_type,
+                                              const char* over_supply_message, const char* under_supply_message)
+    -> std::pair<std::vector<int64_t>, std::vector<int32_t>> {
+  if (modality_token_counts.empty()
+      || std::any_of(modality_token_counts.begin(), modality_token_counts.end(), [](int32_t count) { return count <= 0; })) {
+    throw std::invalid_argument("Qwen3.5 modality token counts must be non-empty and positive");
+  }
+  std::vector<int64_t> expanded;
+  std::vector<int32_t> token_types;
+  expanded.reserve(token_ids.size() + std::accumulate(modality_token_counts.begin(), modality_token_counts.end(), 0)
+                   - modality_token_counts.size());
+  token_types.reserve(expanded.capacity());
+  size_t modality_index = 0;
+  for (const auto token_id : token_ids) {
+    if (token_id != modality_token_id) {
+      expanded.push_back(token_id);
+      token_types.push_back(0);
+      continue;
+    }
+    if (modality_index >= modality_token_counts.size()) { throw std::invalid_argument(over_supply_message); }
+    expanded.insert(expanded.end(), modality_token_counts[modality_index], modality_token_id);
+    token_types.insert(token_types.end(), modality_token_counts[modality_index], modality_type);
+    ++modality_index;
+  }
+  if (modality_index != modality_token_counts.size()) { throw std::invalid_argument(under_supply_message); }
+  return {expanded, token_types};
+}
+
 inline auto expandQwen3_5VideoPlaceholders(const std::vector<int64_t>& token_ids, int64_t video_token_id,
                                            const std::vector<int32_t>& video_token_counts)
     -> std::pair<std::vector<int64_t>, std::vector<int32_t>> {
-  if (video_token_counts.empty()
-      || std::any_of(video_token_counts.begin(), video_token_counts.end(), [](int32_t count) { return count <= 0; })) {
-    throw std::invalid_argument("Qwen3.5 video token counts must be non-empty and positive");
-  }
-  std::vector<int64_t> expanded;
-  std::vector<int32_t> token_types;
-  expanded.reserve(token_ids.size() + std::accumulate(video_token_counts.begin(), video_token_counts.end(), 0)
-                   - video_token_counts.size());
-  token_types.reserve(expanded.capacity());
-  size_t video_index = 0;
-  for (const auto token_id : token_ids) {
-    if (token_id != video_token_id) {
-      expanded.push_back(token_id);
-      token_types.push_back(0);
-      continue;
-    }
-    if (video_index >= video_token_counts.size()) {
-      throw std::invalid_argument("Qwen3.5 template contains more video placeholders than temporal patches");
-    }
-    expanded.insert(expanded.end(), video_token_counts[video_index], video_token_id);
-    token_types.insert(token_types.end(), video_token_counts[video_index], 2);
-    ++video_index;
-  }
-  if (video_index != video_token_counts.size()) {
-    throw std::invalid_argument("Qwen3.5 template contains fewer video placeholders than temporal patches");
-  }
-  return {expanded, token_types};
+  return expandQwen3_5ModalityPlaceholders(
+      token_ids, video_token_id, video_token_counts, 2,
+      "Qwen3.5 template contains more video placeholders than temporal patches",
+      "Qwen3.5 template contains fewer video placeholders than temporal patches");
 }
🤖 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` around lines 56 - 86, Refactor
expandQwen3_5VideoPlaceholders and expandQwen3_5ImagePlaceholders to use one
shared modality-placeholder expansion helper, parameterized by token type and
modality-specific validation/error text. Keep the existing image and video
wrappers as thin adapters, matching the shared-helper pattern used by
injectQwen3_5ModalityEmbeddings and its modality wrappers.
tests/cpu/Qwen35MultimodalTest.cpp (2)

400-446: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a video position case with an asymmetric grid.

Both video position tests use the grid {2, 4, 4}, where grid_h == grid_w. That makes std::max(grid_h, grid_w) / spatial_merge_size at mllm/models/qwen3_5/multimodal_qwen3_5.hpp Line 339 indistinguishable from using either dimension alone.

The image test at Lines 378-398 does cover an asymmetric grid, but it runs through makeQwen3_5ImagePositionIds, not the video branch. The video branch has separate temporal-slice bookkeeping at Lines 306-316 of that file, so it needs its own asymmetric case.

Add one video case with grid_h != grid_w, for example {2, 2, 4}.

🤖 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 400 - 446, Add an
asymmetric-grid video position test alongside
BuildsTimestampSeparatedVideoPositions or
MatchesPinnedTransformersVideoMropeOracle, using video_grid dimensions such as
{2, 2, 4}. Update the token layout and expected temporal, height, and width
position arrays to validate the video branch’s temporal-slice bookkeeping and
max-dimension spatial offset when grid_h differs from grid_w.

135-142: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases that exercise the pixel-budget correction branches.

The three positive cases at Lines 137-139 all produce a rounded_pixels value inside [min_pixels_, max_pixels_], so none of them reaches the shrink branch or the grow branch of smartResize. Those branches contain the correction arithmetic I flagged in mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp at Lines 241-252.

Add one case above max_pixels_ and one below min_pixels_, and use an odd num_frames for at least one of them. An odd frame count is what makes padded_frames differ from num_frames and exposes the asymmetry.

🤖 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 135 - 142, Extend
Qwen35MultimodalTest.VideoSmartResizeMatchesOfficialTotalPixelEnvelope with
positive cases whose rounded pixel totals trigger both smartResize correction
branches: one above max_pixels_ and one below min_pixels_. Use an odd num_frames
in at least one case so padded_frames differs from num_frames, and assert the
expected resized dimensions for each case.
mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp (1)

21-22: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add brief docstrings to the new public functions.

This header introduces eight public entry points. Only flattenNormalizedPatches has an explanatory comment at Lines 275-276. The others document neither their units nor their throw conditions, and several have non-obvious contracts:

  • convertQwen3_5I420ToRgb writes float RGB in the range [0,255], not [0,1].
  • resizeQwen3_5RgbLikeTorchvision expects [0,255] float input and returns quantized uint8.
  • sampleQwen3_5VideoFrames returns source frame indices, not timestamps.
  • smartResize takes num_frames first, then height, then width, and returns {height, width}.

Each of these functions throws std::invalid_argument or std::runtime_error on invalid input. State the units, the argument order, and the throw conditions.

As per coding guidelines: "Ensure public APIs, classes, and functions have clear docstrings or comments explaining purpose, parameters, returns, and errors."

Also applies to: 47-47, 80-81, 134-136, 165-166, 189-189, 218-218, 256-256

🤖 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/video_preprocessor_qwen3_5.hpp` around lines 21 - 22, Add
concise Doxygen-style comments for all eight new public functions, including
convertQwen3_5I420ToRgb, resizeQwen3_5RgbLikeTorchvision,
sampleQwen3_5VideoFrames, and smartResize. Document each function’s purpose,
parameter units and order, return value, relevant value ranges or types, and
that invalid input throws std::invalid_argument or std::runtime_error;
explicitly note RGB [0,255] output, [0,255] float input with uint8 output,
source frame indices, and smartResize’s num_frames/height/width order and
{height,width} result.

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.

Inline comments:
In `@examples/qwen3_5/README.md`:
- Line 28: Update the “Video decoder boundary” heading in the README from level
three to level two, unless an appropriate level-two parent heading is added
before it.

In `@examples/qwen3_5/video_decoder.cpp`:
- Around line 113-145: Update collectFrame and its caller to preserve
source-frame identity by tracking input timestamps, mapping each
SBufferInfo::uiOutYuvTimeStamp to its corresponding source index, and selecting
decoded RGB data by that mapped index rather than decoded_frames order.
Materialize selected_rgb in selected_indices order even when H.264 outputs
frames out of order, and add a fixture covering reordered decoder output.

In `@mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp`:
- Around line 241-252: Update the over-budget and under-budget beta calculations
in the surrounding video preprocessing logic to use padded_frames instead of
num_frames. Keep the existing geometry correction formulas unchanged so both
corrections remain consistent with rounded_pixels and enforce the pixel budget
after temporal padding.
- Around line 277-288: Update flattenNormalizedPatches to require
video_thwc.isContiguous() alongside its existing dtype, device, rank, and
channel validation before obtaining the raw pointer. Reject non-contiguous
tensors with the same invalid-argument path, while preserving the current dense
THWC indexing behavior for valid inputs.
- Around line 189-202: Update makeQwen3_5VideoMarkers to imbue its markers
std::ostringstream with std::locale::classic() before formatting timestamps, and
include the <locale> header so decimal output remains locale-independent.

---

Nitpick comments:
In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp`:
- Around line 253-259: Refactor makeQwen3_5ImagePositionIds to delegate to
makeQwen3_5MultimodalPositionIds with a nil video_grid_thw tensor, preserving
image-only validation, type-2 rejection, position calculations, and existing
error behavior. Keep the current image tests passing and avoid maintaining a
second span/offset walker.
- Around line 56-86: Refactor expandQwen3_5VideoPlaceholders and
expandQwen3_5ImagePlaceholders to use one shared modality-placeholder expansion
helper, parameterized by token type and modality-specific validation/error text.
Keep the existing image and video wrappers as thin adapters, matching the
shared-helper pattern used by injectQwen3_5ModalityEmbeddings and its modality
wrappers.

In `@mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp`:
- Around line 21-22: Add concise Doxygen-style comments for all eight new public
functions, including convertQwen3_5I420ToRgb, resizeQwen3_5RgbLikeTorchvision,
sampleQwen3_5VideoFrames, and smartResize. Document each function’s purpose,
parameter units and order, return value, relevant value ranges or types, and
that invalid input throws std::invalid_argument or std::runtime_error;
explicitly note RGB [0,255] output, [0,255] float input with uint8 output,
source frame indices, and smartResize’s num_frames/height/width order and
{height,width} result.

In `@tests/cpu/Qwen35MultimodalTest.cpp`:
- Around line 400-446: Add an asymmetric-grid video position test alongside
BuildsTimestampSeparatedVideoPositions or
MatchesPinnedTransformersVideoMropeOracle, using video_grid dimensions such as
{2, 2, 4}. Update the token layout and expected temporal, height, and width
position arrays to validate the video branch’s temporal-slice bookkeeping and
max-dimension spatial offset when grid_h differs from grid_w.
- Around line 135-142: Extend
Qwen35MultimodalTest.VideoSmartResizeMatchesOfficialTotalPixelEnvelope with
positive cases whose rounded pixel totals trigger both smartResize correction
branches: one above max_pixels_ and one below min_pixels_. Use an odd num_frames
in at least one case so padded_frames differs from num_frames, and assert the
expected resized dimensions for each case.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c391bfe5-e0ce-482b-a5db-6550dd0be11f

📥 Commits

Reviewing files that changed from the base of the PR and between d1bc93b and aa8b2eb.

⛔ Files ignored due to path filters (2)
  • bench_assets/qwen35_moving_square_192x128_4fps_4s.mp4 is excluded by !**/*.mp4
  • bench_assets/qwen35_moving_square_demo.gif is excluded by !**/*.gif
📒 Files selected for processing (13)
  • CMakeLists.txt
  • examples/qwen3_5/CMakeLists.txt
  • examples/qwen3_5/README.md
  • examples/qwen3_5/main.cpp
  • examples/qwen3_5/video_decoder.cpp
  • examples/qwen3_5/video_decoder.hpp
  • examples/qwen3_5/video_decoder_smoke.cpp
  • mllm/models/qwen3_5/modeling_qwen3_5.hpp
  • mllm/models/qwen3_5/multimodal_qwen3_5.hpp
  • mllm/models/qwen3_5/tokenization_qwen3_5.hpp
  • mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp
  • tests/cpu/CMakeLists.txt
  • tests/cpu/Qwen35MultimodalTest.cpp

videos, URLs, cameras, streaming, deep-stack visual features, MTP, and
multimodal benchmark mode are not supported.

### Video decoder boundary

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.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the heading level.

Line 28 changes from the level-one document heading to a level-three heading. Change this heading to level two, or add a level-two parent heading before it.

🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 28-28: Heading levels should only increment by one level at a time
Expected: h2; Actual: h3

(MD001, heading-increment)

🤖 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 `@examples/qwen3_5/README.md` at line 28, Update the “Video decoder boundary”
heading in the README from level three to level two, unless an appropriate
level-two parent heading is added before it.

Source: Linters/SAST tools

Comment on lines +113 to +145
void collectFrame(unsigned char* const planes[3], const SBufferInfo& info, const std::vector<int32_t>& selected_indices,
int32_t& decoded_frames, int32_t& width, int32_t& height, std::vector<float>& selected_rgb,
int64_t max_decoded_pixels, int64_t max_selected_pixels) {
if (info.iBufferStatus != 1) return;
const int32_t frame_width = info.UsrData.sSystemBuffer.iWidth;
const int32_t frame_height = info.UsrData.sSystemBuffer.iHeight;
if (frame_width <= 0 || frame_height <= 0 || frame_width % 2 != 0 || frame_height % 2 != 0 || planes[0] == nullptr
|| planes[1] == nullptr || planes[2] == nullptr) {
throw std::runtime_error("OpenH264 produced an invalid I420 frame");
}
if (width == 0) {
width = frame_width;
height = frame_height;
} else if (width != frame_width || height != frame_height) {
throw std::invalid_argument("mid-stream video dimension changes are outside the bounded contract");
}
const int64_t frame_pixels = static_cast<int64_t>(width) * height;
if (frame_pixels > max_decoded_pixels / (decoded_frames + 1)) {
throw std::invalid_argument("decoded video exceeds the configured pixel limit");
}

if (std::binary_search(selected_indices.begin(), selected_indices.end(), decoded_frames)) {
const int64_t selected_pixels = static_cast<int64_t>(selected_rgb.size() / 3);
if (selected_pixels > max_selected_pixels || frame_pixels > max_selected_pixels - selected_pixels) {
throw std::invalid_argument("selected source frames exceed the configured pixel limit");
}
const size_t old_size = selected_rgb.size();
selected_rgb.resize(old_size + static_cast<size_t>(width) * height * 3);
models::qwen3_5::convertQwen3_5I420ToRgb(
planes[0], info.UsrData.sSystemBuffer.iStride[0], planes[1], info.UsrData.sSystemBuffer.iStride[1], planes[2],
info.UsrData.sSystemBuffer.iStride[1], width, height, selected_rgb.data() + old_size);
}
++decoded_frames;

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect whether short-video sampling repeats source indices to satisfy min_sampled_frames.
ast-grep outline mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp \
  --match sampleQwen3_5VideoFrames --view expanded

rg -n -C 8 \
  'sampleQwen3_5VideoFrames|temporal.*pad|min_sampled_frames|source_frame_indices' \
  mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp \
  examples/qwen3_5/video_decoder.cpp \
  tests/cpu/Qwen35MultimodalTest.cpp

Repository: UbiquitousLearning/mllm

Length of output: 10501


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- sampler implementation ---'
sed -n '134,175p' mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp

printf '%s\n' '--- decoder setup, timestamp use, and collection ---'
sed -n '200,278p' examples/qwen3_5/video_decoder.cpp

printf '%s\n' '--- OpenH264 timestamp declarations/usages ---'
rg -n -C 5 'uiOutYuvTimeStamp|uiInTimestamp|SBufferInfo|DecodeFrame2|DecodeFrameNoDelay' \
  examples include mllm tests 2>/dev/null || true

printf '%s\n' '--- all sampler tests and decoder call sites ---'
rg -n -C 4 'sampleQwen3_5VideoFrames|decodeH264Mp4Portable|source_frame_indices' \
  mllm examples tests

Repository: UbiquitousLearning/mllm

Length of output: 23850


🌐 Web query:

OpenH264 SBufferInfo uiOutYuvTimeStamp uiInTimestamp DecodeFrame2 timestamp semantics reordered output

💡 Result:

In the OpenH264 decoder API, the relationship between SBufferInfo fields and timestamp semantics during frame reordering is defined as follows: SBufferInfo Semantics - uiInBsTimeStamp: This field is intended to carry the input bitstream (BS) timestamp [1][2]. It is the responsibility of the caller to pass the presentation timestamp (PTS) of the encoded packet into this field when calling DecodeFrame2 or DecodeFrameNoDelay [3][4][5]. - uiOutYuvTimeStamp: This field contains the output YUV timestamp [1][2]. Crucially, this value is only valid and relevant when iBufferStatus is 1 (indicating a frame is ready for output) [1][6][7]. The decoder populates this field with the timestamp corresponding to the decoded YUV frame [4][5]. Timestamp Semantics and Reordering When dealing with video streams containing B-frames, the decoder must perform buffering and reordering because the decoding order of frames does not necessarily match their display (presentation) order. - Reordered Output: Because of this reordering, a single call to the decoder with a specific input packet may not result in an immediate output frame, or it may result in an output frame that corresponds to a previously supplied input timestamp [4][5]. - Tracking: The OpenH264 decoder is designed to propagate timestamps correctly through its internal reordering buffers. When a frame is finally ready to be output (iBufferStatus == 1), the decoder sets uiOutYuvTimeStamp to the correct PTS for that specific frame [3][4]. Consequently, downstream applications must rely on the uiOutYuvTimeStamp returned in the SBufferInfo structure for each output frame, rather than assuming it corresponds to the uiInBsTimeStamp of the most recently provided input packet [3][4]. Usage Recommendation - The recommended practice, as seen in implementations like FFmpeg, is to capture the uiOutYuvTimeStamp returned by the decoder upon successful decoding (iBufferStatus == 1) and assign it to the decoded AVFrame's PTS [4][5]. - In scenarios where an older OpenH264 API version is used or where the library fails to properly propagate timestamps, applications might fall back to using the input timestamp, though this may lead to incorrect presentation timing (jank) in streams with B-frames [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

path = Path("mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp")
text = path.read_text()
start = text.index("inline auto sampleQwen3_5VideoFrames")
end = text.index("\n}", start) + 2
print(text[start:end])
PY

printf '%s\n' '--- decoder function outline ---'
ast-grep outline examples/qwen3_5/video_decoder.cpp --view expanded | sed -n '1,220p'

Repository: UbiquitousLearning/mllm

Length of output: 2626


🌐 Web query:

OpenH264 DecodeFrameNoDelay frame reordering uiOutYuvTimeStamp B frames documentation

💡 Result:

In the OpenH264 library, DecodeFrameNoDelay is the recommended entry point for decoding H.264 streams, specifically to handle B-frame reordering and buffering [1][2][3]. Despite the name "NoDelay," this function performs necessary buffering to manage the presentation order of frames [1][4]. The relationship between frame reordering and timestamps in OpenH264 is managed via the SBufferInfo structure [5][6]. 1. Frame Reordering: OpenH264 manages internal reordering to ensure frames are emitted in the correct presentation order, which is essential for streams containing B-frames [1][4]. Historically, relying on earlier API methods like DecodeFrame2 caused issues with B-frames, where buffers could be overwritten because the decoder did not manage the release of ready frames correctly [3]. DecodeFrameNoDelay addresses this by performing the internal logic required to release non-reference B-frames at the appropriate time [3]. 2. uiOutYuvTimeStamp: This field in SBufferInfo is intended to provide the presentation timestamp (PTS) for the decoded frame [5][6]. When a frame is ready (indicated by iBufferStatus == 1), the decoder fills this field with the corresponding output timestamp [5][6]. Implementation Note: Users of the OpenH264 library are generally expected to use the uiOutYuvTimeStamp provided by the decoder to associate the correct presentation time with the output YUV frame [1][6]. In some older or specific integrations (such as older versions of Firefox's OpenH264 plugin), this timestamp was not correctly propagated, causing video playback issues (jank) because the system defaulted to using the input timestamp instead [7]. Developers integrating OpenH264 should ensure they utilize this output timestamp to maintain proper A/V synchronization, as the decoder's output order may differ from the input (bitstream) order due to B-frame reordering [1][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- local OpenH264 API/header references ---'
rg -n -C 8 'DecodeFrameNoDelay|uiOutYuvTimeStamp|uiInBsTimeStamp|iBufferStatus' . \
  -g '!build/**' -g '!dist/**' -g '!node_modules/**' 2>/dev/null || true

printf '%s\n' '--- MP4 timestamp metadata and sample ordering assumptions ---'
sed -n '150,210p' examples/qwen3_5/video_decoder.cpp
rg -n -C 5 'MP4D_frame_offset|timestamp|duration_units|source_timescale' \
  examples/qwen3_5 tests mllm 2>/dev/null || true

Repository: UbiquitousLearning/mllm

Length of output: 25823


Preserve decoder output identity when collecting frames.

sampleQwen3_5VideoFrames returns unique indices for clips shorter than four frames, so the short-clip repetition claim does not apply. However, collectFrame selects by decoded output ordinal and ignores SBufferInfo::uiOutYuvTimeStamp. Reordered H.264 output can associate RGB data with the wrong source_frame_indices. Track input timestamps, map each output timestamp to its source index, and materialize selected_rgb in selected_indices order. Add a reordered-output fixture.

🤖 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 `@examples/qwen3_5/video_decoder.cpp` around lines 113 - 145, Update
collectFrame and its caller to preserve source-frame identity by tracking input
timestamps, mapping each SBufferInfo::uiOutYuvTimeStamp to its corresponding
source index, and selecting decoded RGB data by that mapped index rather than
decoded_frames order. Materialize selected_rgb in selected_indices order even
when H.264 outputs frames out of order, and add a fixture covering reordered
decoder output.

Comment on lines +189 to +202
inline auto makeQwen3_5VideoMarkers(const std::vector<double>& timestamps) -> std::string {
if (timestamps.empty()) { throw std::invalid_argument("Qwen3.5 video markers require timestamps"); }
std::ostringstream markers;
markers << "<|vision_start|>";
markers << std::fixed << std::setprecision(1);
for (const double timestamp : timestamps) {
if (!std::isfinite(timestamp) || timestamp < 0.0) {
throw std::invalid_argument("Qwen3.5 video timestamps must be finite and non-negative");
}
markers << '<' << timestamp << " seconds><|vision_start|><|video_pad|><|vision_end|>";
}
markers << "<|vision_end|>";
return markers.str();
}

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find initLocal and check whether it mutates the global C++ locale.
set -euo pipefail

rg -nP --type=cpp --type=cc -C 12 '\binitLocal\s*\(' || true
fd -i 'Local' -e hpp -e cpp -e h -e cc | head -50

Repository: UbiquitousLearning/mllm

Length of output: 192


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- target file ---'
sed -n '1,240p' mllm/models/qwen3_5/video_preprocessor_qwen3_5.hpp

printf '%s\n' '--- initLocal definitions and call sites ---'
rg -n -C 12 '\binitLocal\s*\(' . || true

printf '%s\n' '--- locale mutations ---'
rg -n -C 4 'global\(\)|locale::global|imbue\s*\(' mllm mllm-cli pymllm tests 2>/dev/null || true

printf '%s\n' '--- relevant test ---'
sed -n '150,210p' tests/cpu/Qwen35MultimodalTest.cpp

Repository: UbiquitousLearning/mllm

Length of output: 45735


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '%s\n' '--- Qwen3.5 prompt construction around marker generation ---'
sed -n '250,390p' mllm/models/qwen3_5/tokenization_qwen3_5.hpp
rg -n -C 8 'makeQwen3_5VideoMarkers|video_markers|VideoMarkers' mllm tests

printf '%s\n' '--- available locales ---'
locale -a 2>/dev/null | head -80 || true

printf '%s\n' '--- standalone C++ locale behavior probe ---'
cat <<'CPP' | c++ -std=c++17 -x c++ -o /tmp/qwen_locale_probe -
`#include` <iomanip>
`#include` <iostream>
`#include` <locale>
`#include` <sstream>
`#include` <string>

struct comma_num_put final : std::num_put<char> {
  iter_type do_put(iter_type out, std::ios_base& state, char_type fill, double value) const override {
    std::ostringstream classic;
    classic.imbue(std::locale::classic());
    classic << std::fixed << std::setprecision(1) << value;
    std::string text = classic.str();
    for (char& c : text) {
      if (c == '.') c = ',';
    }
    for (char c : text) *out++ = c;
    return out;
  }
};

std::string format_without_imbue(double value) {
  std::ostringstream stream;
  stream << std::fixed << std::setprecision(1) << value;
  return stream.str();
}

std::string format_with_classic(double value) {
  std::ostringstream stream;
  stream.imbue(std::locale::classic());
  stream << std::fixed << std::setprecision(1) << value;
  return stream.str();
}

int main() {
  std::locale comma(std::locale::classic(), new comma_num_put);
  std::locale::global(comma);
  std::cout << "custom global: " << format_without_imbue(0.25) << '\n';
  std::cout << "classic stream: " << format_with_classic(0.25) << '\n';

  std::locale::global(std::locale::classic());
  for (const char* name : {"en_US.UTF-8", "C.UTF-8"}) {
    try {
      std::locale selected(name);
      std::locale::global(selected);
      std::cout << name << ": " << format_without_imbue(0.25) << '\n';
    } catch (const std::exception& error) {
      std::cout << name << ": unavailable (" << error.what() << ")\n";
    }
  }
}
CPP
/tmp/qwen_locale_probe
rm -f /tmp/qwen_locale_probe

Repository: UbiquitousLearning/mllm

Length of output: 14467


Make timestamp formatting locale-independent. Imbue markers with std::locale::classic() and include <locale>. A caller with a comma-decimal global locale can otherwise produce <0,2 seconds> and change tokenization.

🤖 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/video_preprocessor_qwen3_5.hpp` around lines 189 - 202,
Update makeQwen3_5VideoMarkers to imbue its markers std::ostringstream with
std::locale::classic() before formatting timestamps, and include the <locale>
header so decimal output remains locale-independent.

Comment on lines +241 to +252
const int32_t padded_frames = python_round_to_factor(num_frames, temporal_patch_size_);
const int64_t rounded_pixels = static_cast<int64_t>(padded_frames) * resized_height * resized_width;

if (rounded_pixels > max_pixels_) {
const double beta = std::sqrt(static_cast<double>(num_frames) * height * width / max_pixels_);
resized_height = std::max(factor, static_cast<int32_t>(std::floor(height / beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::floor(width / beta / factor)) * factor);
} else if (rounded_pixels < min_pixels_) {
const double beta = std::sqrt(static_cast<double>(min_pixels_) / (static_cast<double>(num_frames) * height * width));
resized_height = std::max(factor, static_cast<int32_t>(std::ceil(height * beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::ceil(width * beta / factor)) * factor);
}

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.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use padded_frames in the pixel-budget corrections.

Line 242 computes rounded_pixels from padded_frames, but Line 245 and Line 249 compute beta from num_frames. The two frame counts differ whenever num_frames is not a multiple of temporal_patch_size_, and sampleQwen3_5VideoFrames can produce an odd frame count.

The effect on the over-budget path: rounded_pixels exceeds max_pixels_ while num_frames * height * width does not. Then beta < 1, and std::floor(height / beta / factor) * factor returns a value larger than the current resized_height. The function enlarges the geometry instead of shrinking it, and the returned geometry still exceeds max_pixels_ after temporal padding. resizeFrames allocates from this result without re-checking, so the bounded-pixel limit does not hold for odd frame counts.

Compute both beta values from padded_frames so the correction uses the same quantity as the budget check.

🛠️ Proposed fix to align the budget check and the correction
     if (rounded_pixels > max_pixels_) {
-      const double beta = std::sqrt(static_cast<double>(num_frames) * height * width / max_pixels_);
+      const double beta = std::sqrt(static_cast<double>(padded_frames) * height * width / max_pixels_);
       resized_height = std::max(factor, static_cast<int32_t>(std::floor(height / beta / factor)) * factor);
       resized_width = std::max(factor, static_cast<int32_t>(std::floor(width / beta / factor)) * factor);
     } else if (rounded_pixels < min_pixels_) {
-      const double beta = std::sqrt(static_cast<double>(min_pixels_) / (static_cast<double>(num_frames) * height * width));
+      const double beta = std::sqrt(static_cast<double>(min_pixels_) / (static_cast<double>(padded_frames) * height * width));
       resized_height = std::max(factor, static_cast<int32_t>(std::ceil(height * beta / factor)) * factor);
       resized_width = std::max(factor, static_cast<int32_t>(std::ceil(width * beta / factor)) * factor);
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const int32_t padded_frames = python_round_to_factor(num_frames, temporal_patch_size_);
const int64_t rounded_pixels = static_cast<int64_t>(padded_frames) * resized_height * resized_width;
if (rounded_pixels > max_pixels_) {
const double beta = std::sqrt(static_cast<double>(num_frames) * height * width / max_pixels_);
resized_height = std::max(factor, static_cast<int32_t>(std::floor(height / beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::floor(width / beta / factor)) * factor);
} else if (rounded_pixels < min_pixels_) {
const double beta = std::sqrt(static_cast<double>(min_pixels_) / (static_cast<double>(num_frames) * height * width));
resized_height = std::max(factor, static_cast<int32_t>(std::ceil(height * beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::ceil(width * beta / factor)) * factor);
}
const int32_t padded_frames = python_round_to_factor(num_frames, temporal_patch_size_);
const int64_t rounded_pixels = static_cast<int64_t>(padded_frames) * resized_height * resized_width;
if (rounded_pixels > max_pixels_) {
const double beta = std::sqrt(static_cast<double>(padded_frames) * height * width / max_pixels_);
resized_height = std::max(factor, static_cast<int32_t>(std::floor(height / beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::floor(width / beta / factor)) * factor);
} else if (rounded_pixels < min_pixels_) {
const double beta =
std::sqrt(static_cast<double>(min_pixels_) / (static_cast<double>(padded_frames) * height * width));
resized_height = std::max(factor, static_cast<int32_t>(std::ceil(height * beta / factor)) * factor);
resized_width = std::max(factor, static_cast<int32_t>(std::ceil(width * beta / factor)) * factor);
}
🤖 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/video_preprocessor_qwen3_5.hpp` around lines 241 - 252,
Update the over-budget and under-budget beta calculations in the surrounding
video preprocessing logic to use padded_frames instead of num_frames. Keep the
existing geometry correction formulas unchanged so both corrections remain
consistent with rounded_pixels and enforce the pixel budget after temporal
padding.

Comment on lines +277 to +288
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& video_thwc) const {
const auto& shape = video_thwc.shape();
if (video_thwc.dtype() != kFloat32 || video_thwc.device() != kCPU || shape.size() != 4 || shape[3] != 3) {
throw std::invalid_argument("Qwen3.5 video preprocessor expects a float32 CPU RGB tensor in THWC layout");
}
const int32_t frames = shape[0];
const int32_t height = shape[1];
const int32_t width = shape[2];
const int32_t factor = patch_size_ * merge_size_;
if (frames <= 0 || height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized video dimensions must be positive and divisible by patch_size * merge_size");
}

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.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Validate contiguity in flattenNormalizedPatches.

Line 279 checks dtype, device, rank, and channel count, but it does not check isContiguous(). resizeFrames at Line 258 does check it. Line 297 takes a raw pointer and Line 313 indexes it with a dense THWC offset. If a caller passes a non-contiguous view, this method reads the wrong elements or reads past the buffer.

The current in-repo caller in mllm/models/qwen3_5/tokenization_qwen3_5.hpp at Line 433 passes the freshly allocated output of resizeFrames, so the fault is not reachable today. The method is public, and the tests call it directly, so add the guard to match resizeFrames.

🛡️ Proposed fix to add the contiguity guard
-    if (video_thwc.dtype() != kFloat32 || video_thwc.device() != kCPU || shape.size() != 4 || shape[3] != 3) {
+    if (video_thwc.dtype() != kFloat32 || video_thwc.device() != kCPU || !video_thwc.isContiguous() || shape.size() != 4
+        || shape[3] != 3) {
       throw std::invalid_argument("Qwen3.5 video preprocessor expects a float32 CPU RGB tensor in THWC layout");
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& video_thwc) const {
const auto& shape = video_thwc.shape();
if (video_thwc.dtype() != kFloat32 || video_thwc.device() != kCPU || shape.size() != 4 || shape[3] != 3) {
throw std::invalid_argument("Qwen3.5 video preprocessor expects a float32 CPU RGB tensor in THWC layout");
}
const int32_t frames = shape[0];
const int32_t height = shape[1];
const int32_t width = shape[2];
const int32_t factor = patch_size_ * merge_size_;
if (frames <= 0 || height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized video dimensions must be positive and divisible by patch_size * merge_size");
}
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& video_thwc) const {
const auto& shape = video_thwc.shape();
if (video_thwc.dtype() != kFloat32 || video_thwc.device() != kCPU || !video_thwc.isContiguous() || shape.size() != 4
|| shape[3] != 3) {
throw std::invalid_argument("Qwen3.5 video preprocessor expects a float32 CPU RGB tensor in THWC layout");
}
const int32_t frames = shape[0];
const int32_t height = shape[1];
const int32_t width = shape[2];
const int32_t factor = patch_size_ * merge_size_;
if (frames <= 0 || height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized video dimensions must be positive and divisible by patch_size * 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/video_preprocessor_qwen3_5.hpp` around lines 277 - 288,
Update flattenNormalizedPatches to require video_thwc.isContiguous() alongside
its existing dtype, device, rank, and channel validation before obtaining the
raw pointer. Reject non-contiguous tensors with the same invalid-argument path,
while preserving the current dense THWC indexing behavior for valid inputs.

@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 b9b46ae into UbiquitousLearning:main Aug 12, 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