@tduncan / Nvidia-Optimization-Stack-VLLM Mirror · read-only / commits / 7198940b39
Fork 0

A high-throughput and memory-efficient inference and serving engine for LLMs

Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git

Synced Nov 4, 10:00 UTC

[Model] Add Moondream3 model support(only query and caption skills) (#32325)

Signed-off-by: Dong Wang <dongw2019@gmail.com>

Dong W committed Apr 30, 2026 at 19:06 UTC 7198940b395e74337101e7b62437c55a7e1d4284
19 files changed +3238 -9
docs/features/multimodal_inputs.md
+61
@@ -215,6 +215,67 @@ When loading RGBA images (images with transparency), vLLM converts them to RGB f
215 - This setting only affects RGBA images with transparency; RGB images are unchanged
216 - If not specified, the default white background `(255, 255, 255)` is used for backward compatibility
217
218 +#### Moondream3 Prompt Recipes { #moondream3-prompt-recipes }
219 +
220 +`Moondream3ForCausalLM` supports two task-specific prompt formats:
221 +
222 +- `query`: ask a question about the image.
223 +- `caption`: generate a caption for the image.
224 +
225 +```python
226 +from vllm import LLM, SamplingParams
227 +from vllm.assets.image import ImageAsset
228 +
229 +llm = LLM(
230 + model="moondream/moondream3-preview",
231 + tokenizer="moondream/starmie-v1",
232 + trust_remote_code=True,
233 + max_model_len=2048,
234 + limit_mm_per_prompt={"image": 1},
235 +)
236 +
237 +image = ImageAsset("stop_sign").pil_image
238 +
239 +
240 +def make_query_prompt(question: str) -> str:
241 + return (
242 + "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
243 + f"{question}<|md_reserved_2|>"
244 + )
245 +
246 +
247 +def make_caption_prompt(length: str = "normal") -> str:
248 + return (
249 + "<|endoftext|><image><|md_reserved_0|>"
250 + f"describe<|md_reserved_1|>{length}<|md_reserved_2|>"
251 + )
252 +
253 +
254 +query_out = llm.generate(
255 + {
256 + "prompt": make_query_prompt("What is shown in this image?"),
257 + "multi_modal_data": {"image": image},
258 + },
259 + SamplingParams(max_tokens=64, temperature=0),
260 +)[0].outputs[0].text
261 +
262 +caption_out = llm.generate(
263 + {
264 + "prompt": make_caption_prompt(),
265 + "multi_modal_data": {"image": image},
266 + },
267 + SamplingParams(max_tokens=100, temperature=0),
268 +)[0].outputs[0].text
269 +
270 +print("query:", query_out)
271 +print("caption:", caption_out)
272 +```
273 +
274 +!!! note
275 + The native Moondream3 model also has `detect` and `point` skills. Those
276 + require custom coordinate decoding and are not exposed by this vLLM
277 + implementation.
278 +
279 ### Video Inputs
280
281 You can pass a list of NumPy arrays directly to the `'video'` field of the multi-modal dictionary
docs/models/supported_models.md
+7
@@ -599,6 +599,7 @@ These models primarily accept the [`LLM.generate`](./generative_models.md#llmgen
599 | `Mistral3ForConditionalGeneration` | Mistral3 (HF Transformers) | T + I<sup>+</sup> | `mistralai/Mistral-Small-3.1-24B-Instruct-2503`, etc. | ✅︎ | ✅︎ |
600 | `MolmoForCausalLM` | Molmo | T + I<sup>+</sup> | `allenai/Molmo-7B-D-0924`, `allenai/Molmo-7B-O-0924`, etc. | ✅︎ | ✅︎ |
601 | `Molmo2ForConditionalGeneration` | Molmo2 | T + I<sup>+</sup> / V | `allenai/Molmo2-4B`, `allenai/Molmo2-8B`, `allenai/Molmo2-O-7B` | ✅︎ | ✅︎ |
602 +| `Moondream3ForCausalLM` | Moondream3 | T + I | `moondream/moondream3-preview` | | ✅︎ |
603 | `MusicFlamingoForConditionalGeneration` | MusicFlamingo | T + A | `nvidia/music-flamingo-2601-hf`, `nvidia/music-flamingo-think-2601-hf` | ✅︎ | ✅︎ |
604 | `NVLM_D_Model` | NVLM-D 1.0 | T + I<sup>+</sup> | `nvidia/NVLM-D-72B`, etc. | | ✅︎ |
605 | `OpenCUAForConditionalGeneration` | OpenCUA-7B | T + I<sup>E+</sup> | `xlangai/OpenCUA-7B` | ✅︎ | ✅︎ |
@@ -661,6 +662,12 @@ Some models are supported only via the [Transformers modeling backend](#transfor
662 !!! note
663 For `InternVLChatModel`, only InternVL2.5 with Qwen2.5 text backbone (`OpenGVLab/InternVL2.5-1B` etc.), InternVL3 and InternVL3.5 have video inputs support currently.
664
665 +!!! note
666 + `Moondream3ForCausalLM` uses task-specific prompt templates for `query`
667 + and `caption`. The native `detect` and `point` skills require custom
668 + coordinate decoding and are not exposed by this vLLM implementation.
669 + See [Moondream3 prompt recipes](../features/multimodal_inputs.md#moondream3-prompt-recipes).
670 +
671 !!! note
672 To use `TIGER-Lab/Mantis-8B-siglip-llama3`, you have to pass `--hf_overrides '{"architectures": ["MantisForConditionalGeneration"]}'` when running vLLM.
673
tests/conftest.py
+31 -8
@@ -371,6 +371,8 @@ class HfRunner:
371 is_cross_encoder: bool = False,
372 skip_tokenizer_init: bool = False,
373 auto_cls: type[_BaseAutoModelClass] = AutoModelForCausalLM,
374 + tokenizer_name: str | None = None,
375 + processor: Any | None = None,
376 # Set this to avoid hanging issue
377 default_torch_num_threads: int | None = None,
378 ) -> None:
@@ -391,6 +393,8 @@ class HfRunner:
393 is_cross_encoder=is_cross_encoder,
394 skip_tokenizer_init=skip_tokenizer_init,
395 auto_cls=auto_cls,
396 + tokenizer_name=tokenizer_name,
397 + processor=processor,
398 )
399
400 def _init(
@@ -405,6 +409,8 @@ class HfRunner:
409 is_cross_encoder: bool = False,
410 skip_tokenizer_init: bool = False,
411 auto_cls: type[_BaseAutoModelClass] = AutoModelForCausalLM,
412 + tokenizer_name: str | None = None,
413 + processor: Any | None = None,
414 ) -> None:
415 model_name = maybe_model_redirect(model_name)
416 self.model_name = model_name
@@ -484,20 +490,27 @@ class HfRunner:
490 if not skip_tokenizer_init:
491 self.tokenizer: "PreTrainedTokenizer | PreTrainedTokenizerFast" = (
492 AutoTokenizer.from_pretrained(
487 - model_name,
493 + tokenizer_name or model_name,
494 trust_remote_code=trust_remote_code,
495 )
496 )
497
492 - # don't put this import at the top level
493 - # it will call torch.accelerator.device_count()
494 - from transformers import AutoProcessor
498 + if processor is not None:
499 + self.processor = processor
500 + else:
501 + # don't put this import at the top level
502 + # it will call torch.accelerator.device_count()
503 + from transformers import AutoProcessor
504
496 - self.processor = AutoProcessor.from_pretrained(
497 - model_name,
498 - trust_remote_code=trust_remote_code,
499 - )
505 + self.processor = AutoProcessor.from_pretrained(
506 + model_name,
507 + trust_remote_code=trust_remote_code,
508 + )
509 if skip_tokenizer_init:
510 + if self.processor is None:
511 + raise ValueError(
512 + "skip_tokenizer_init=True requires processor initialization."
513 + )
514 self.tokenizer = self.processor.tokenizer
515
516 def get_inputs(
@@ -520,6 +533,12 @@ class HfRunner:
533 all_inputs: list[BatchFeature | BatchEncoding | dict[str, torch.Tensor]] = []
534 for i, prompt in enumerate(prompts):
535 if isinstance(prompt, str):
536 + if self.processor is None:
537 + raise RuntimeError(
538 + "HfRunner.processor is not initialized. "
539 + "Pass processor=... to HfRunner or set "
540 + "hf_model.processor before generation."
541 + )
542 # Create a copy to avoid modifying the original dict
543 processor_kwargs = (
544 tokenization_kwargs.copy()
@@ -617,6 +636,10 @@ class HfRunner:
636 use_cache=True,
637 **kwargs,
638 )
639 + if self.processor is None:
640 + raise RuntimeError(
641 + "HfRunner.processor is not initialized; cannot decode output."
642 + )
643 output_str = self.processor.batch_decode(
644 output_ids,
645 skip_special_tokens=True,
tests/models/multimodal/generation/test_common.py
+26
@@ -805,6 +805,32 @@ VLM_TEST_SETTINGS = {
805 max_num_seqs=2,
806 patch_hf_runner=model_utils.molmo_patch_hf_runner,
807 ),
808 + "moondream3": VLMTestInfo(
809 + models=["moondream/moondream3-preview"],
810 + test_type=VLMTestType.IMAGE,
811 + prompt_formatter=identity,
812 + img_idx_to_prompt=lambda idx: "<|endoftext|><image>",
813 + # Common-image coverage here targets query/caption. The native
814 + # detect/point skills are not exposed by vLLM.
815 + single_image_prompts=IMAGE_ASSETS.prompts(
816 + {
817 + "stop_sign": "<vlm_image><|md_reserved_0|>query<|md_reserved_1|>What is this sign?<|md_reserved_2|>", # noqa: E501
818 + "cherry_blossom": (
819 + "<vlm_image><|md_reserved_0|>query<|md_reserved_1|>What season is shown?<|md_reserved_2|>" # noqa: E501
820 + ),
821 + }
822 + ),
823 + max_model_len=4096,
824 + max_num_seqs=2,
825 + dtype="bfloat16",
826 + hf_processor=model_utils.moondream3_processor,
827 + patch_hf_runner=model_utils.moondream3_patch_hf_runner,
828 + # Single size factor to avoid GPU OOM when running multiple test
829 + # cases sequentially (9B MoE model uses ~18 GiB per instance).
830 + image_size_factors=[(1.0,)],
831 + # Moondream3 is 9B params with MoE, needs significant GPU memory
832 + marks=[large_gpu_mark(min_gb=48)],
833 + ),
834 "ovis1_6-gemma2": VLMTestInfo(
835 models=["AIDC-AI/Ovis1.6-Gemma2-9B"],
836 test_type=(VLMTestType.IMAGE, VLMTestType.MULTI_IMAGE),
tests/models/multimodal/generation/test_moondream3.py new
+176
@@ -0,0 +1,176 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Generation tests for Moondream3 query and caption support."""
4 +
5 +import pytest
6 +
7 +from tests.models.registry import HF_EXAMPLE_MODELS
8 +from vllm.platforms import current_platform
9 +
10 +from ....conftest import IMAGE_ASSETS, ImageTestAssets
11 +from ....utils import large_gpu_mark, multi_gpu_test
12 +
13 +MOONDREAM3_MODEL_ID = "moondream/moondream3-preview"
14 +MOONDREAM3_TOKENIZER = "moondream/starmie-v1"
15 +
16 +HF_IMAGE_PROMPTS = IMAGE_ASSETS.prompts(
17 + {
18 + "stop_sign": "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What color is the stop sign?<|md_reserved_2|>", # noqa: E501
19 + "cherry_blossom": "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What color are the flowers?<|md_reserved_2|>", # noqa: E501
20 + }
21 +)
22 +
23 +
24 +def make_query_prompt(question: str) -> str:
25 + """Create a direct-answer query prompt for Moondream3."""
26 + return (
27 + "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
28 + f"{question}<|md_reserved_2|>"
29 + )
30 +
31 +
32 +def make_caption_prompt(length: str = "normal") -> str:
33 + """Create a caption prompt for Moondream3."""
34 + return (
35 + "<|endoftext|><image><|md_reserved_0|>"
36 + f"describe<|md_reserved_1|>{length}<|md_reserved_2|>"
37 + )
38 +
39 +
40 +@multi_gpu_test(num_gpus=2)
41 +@large_gpu_mark(min_gb=80)
42 +def test_tensor_parallel(image_assets: ImageTestAssets):
43 + import gc
44 +
45 + from vllm import LLM, SamplingParams
46 + from vllm.distributed.parallel_state import destroy_model_parallel
47 +
48 + destroy_model_parallel()
49 + gc.collect()
50 + current_platform.empty_cache()
51 +
52 + llm = LLM(
53 + model=MOONDREAM3_MODEL_ID,
54 + tokenizer=MOONDREAM3_TOKENIZER,
55 + trust_remote_code=True,
56 + dtype="bfloat16",
57 + tensor_parallel_size=2,
58 + max_model_len=1024,
59 + enforce_eager=True,
60 + limit_mm_per_prompt={"image": 1},
61 + gpu_memory_utilization=0.45,
62 + )
63 +
64 + image = image_assets[0].pil_image
65 + prompt = make_query_prompt("What color is the stop sign?")
66 +
67 + try:
68 + outputs = llm.generate(
69 + {"prompt": prompt, "multi_modal_data": {"image": image}},
70 + SamplingParams(max_tokens=20, temperature=0),
71 + )
72 +
73 + assert len(outputs) > 0
74 + assert outputs[0].outputs[0].text is not None
75 + finally:
76 + del llm
77 + gc.collect()
78 + current_platform.empty_cache()
79 +
80 +
81 +@pytest.fixture(scope="module")
82 +def llm():
83 + model_info = HF_EXAMPLE_MODELS.get_hf_info("Moondream3ForCausalLM")
84 + model_info.check_transformers_version(on_fail="skip")
85 +
86 + from vllm import LLM
87 +
88 + try:
89 + return LLM(
90 + model=MOONDREAM3_MODEL_ID,
91 + tokenizer=MOONDREAM3_TOKENIZER,
92 + trust_remote_code=True,
93 + dtype="bfloat16",
94 + max_model_len=2048,
95 + enforce_eager=True,
96 + limit_mm_per_prompt={"image": 1},
97 + gpu_memory_utilization=0.45,
98 + )
99 + except Exception as exc:
100 + pytest.skip(f"Failed to load {MOONDREAM3_MODEL_ID}: {exc}")
101 +
102 +
103 +@large_gpu_mark(min_gb=48)
104 +def test_model_loading(llm):
105 + assert llm is not None
106 +
107 +
108 +@large_gpu_mark(min_gb=48)
109 +def test_query_skill(llm, image_assets: ImageTestAssets):
110 + from vllm import SamplingParams
111 +
112 + image = image_assets[0].pil_image
113 + prompt = make_query_prompt("What color is the stop sign?")
114 +
115 + outputs = llm.generate(
116 + {"prompt": prompt, "multi_modal_data": {"image": image}},
117 + SamplingParams(max_tokens=50, temperature=0),
118 + )
119 +
120 + output_text = outputs[0].outputs[0].text
121 + assert output_text is not None
122 + assert len(output_text) > 0
123 +
124 +
125 +@large_gpu_mark(min_gb=48)
126 +def test_caption_skill(llm, image_assets: ImageTestAssets):
127 + from vllm import SamplingParams
128 +
129 + image = image_assets[1].pil_image
130 + prompt = make_caption_prompt()
131 +
132 + outputs = llm.generate(
133 + {"prompt": prompt, "multi_modal_data": {"image": image}},
134 + SamplingParams(max_tokens=100, temperature=0),
135 + )
136 +
137 + output_text = outputs[0].outputs[0].text
138 + assert output_text is not None
139 + assert len(output_text) > 0
140 +
141 +
142 +@large_gpu_mark(min_gb=48)
143 +def test_batched_inference(llm, image_assets: ImageTestAssets):
144 + from vllm import SamplingParams
145 +
146 + images = [asset.pil_image for asset in image_assets]
147 + prompts = [
148 + {"prompt": prompt, "multi_modal_data": {"image": img}}
149 + for img, prompt in zip(images, HF_IMAGE_PROMPTS)
150 + ]
151 +
152 + outputs = llm.generate(prompts, SamplingParams(max_tokens=50, temperature=0))
153 +
154 + assert len(outputs) == len(images)
155 + for output in outputs:
156 + assert output.outputs[0].text is not None
157 + assert len(output.outputs[0].text) > 0
158 +
159 +
160 +@pytest.mark.parametrize("asset_name", ["stop_sign", "cherry_blossom"])
161 +@large_gpu_mark(min_gb=48)
162 +def test_image_assets(llm, image_assets: ImageTestAssets, asset_name: str):
163 + from vllm import SamplingParams
164 +
165 + asset_idx = 0 if asset_name == "stop_sign" else 1
166 + image = image_assets[asset_idx].pil_image
167 + prompt = HF_IMAGE_PROMPTS[asset_idx]
168 +
169 + outputs = llm.generate(
170 + {"prompt": prompt, "multi_modal_data": {"image": image}},
171 + SamplingParams(max_tokens=50, temperature=0),
172 + )
173 +
174 + output_text = outputs[0].outputs[0].text
175 + assert output_text is not None
176 + assert len(output_text) > 0
tests/models/multimodal/generation/vlm_utils/core.py
+12 -1
@@ -38,6 +38,7 @@ def run_test(
38 limit_mm_per_prompt: dict[str, int],
39 vllm_runner_kwargs: dict[str, Any] | None,
40 hf_model_kwargs: dict[str, Any] | None,
41 + hf_processor: Callable[[str], Any] | None,
42 patch_hf_runner: Callable[[HfRunner], HfRunner] | None,
43 runner: RunnerOption = "auto",
44 distributed_executor_backend: str | None = None,
@@ -116,8 +117,18 @@ def run_test(
117 )
118 vllm_outputs_per_mm.append(vllm_output)
119
120 + hf_runner_kwargs: dict[str, Any] = {}
121 + if model_info.tokenizer:
122 + hf_runner_kwargs["tokenizer_name"] = model_info.tokenizer
123 + if hf_processor is not None:
124 + hf_runner_kwargs["processor"] = hf_processor(model)
125 +
126 hf_model = hf_runner(
120 - model, dtype=dtype, auto_cls=auto_cls, model_kwargs=hf_model_kwargs
127 + model,
128 + dtype=dtype,
129 + auto_cls=auto_cls,
130 + model_kwargs=hf_model_kwargs,
131 + **hf_runner_kwargs,
132 )
133
134 # Some models need to patch things like the model processor, e.g., internvl
tests/models/multimodal/generation/vlm_utils/model_utils.py
+218
@@ -1336,3 +1336,221 @@ def voxtral_patch_hf_runner(hf_model: "HfRunner") -> "HfRunner":
1336 hf_model.get_inputs = patched_get_inputs # type: ignore[method-assign, assignment]
1337 hf_model.model.generate = patched_generate # type: ignore[method-assign]
1338 return hf_model
1339 +
1340 +
1341 +def moondream3_processor(model: str):
1342 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
1343 +
1344 + return Moondream3Processor.from_pretrained(model, trust_remote_code=True)
1345 +
1346 +
1347 +def moondream3_patch_hf_runner(hf_model: HfRunner) -> HfRunner:
1348 + """Patch HfRunner for Moondream3."""
1349 + moondream_processor = hf_model.processor
1350 +
1351 + def processor(*args, text="", images=None, **kwargs):
1352 + if images is None:
1353 + return moondream_processor(text=text, **kwargs)
1354 +
1355 + images_list = [images] if isinstance(images, Image) else images
1356 + return moondream_processor(images=images_list, text=text, **kwargs)
1357 +
1358 + hf_model.processor = processor
1359 +
1360 + # Expose the LM head for logprob extraction.
1361 + hf_model.model.get_output_embeddings = lambda: hf_model.model.model.text.lm_head
1362 +
1363 + native_model = hf_model.model.model # MoondreamModel instance
1364 +
1365 + from torch.nn import functional as F
1366 +
1367 + from vllm.model_executor.models.moondream3 import reconstruct_from_crops
1368 +
1369 + # Resolve the placeholder tokens from the tokenizer instead of hard-coding.
1370 + image_placeholder_ids = moondream_processor.tokenizer.encode(
1371 + "<image>", add_special_tokens=False
1372 + )
1373 +
1374 + def _normalize_tiling(tilings):
1375 + """Extract (h, w) tuple from various tiling container formats."""
1376 + tiling = tilings
1377 + if isinstance(tiling, torch.Tensor):
1378 + tiling = tuple(tiling.squeeze().tolist())
1379 + elif isinstance(tiling, (list, tuple)):
1380 + t0 = tiling[0]
1381 + if isinstance(t0, torch.Tensor):
1382 + tiling = tuple(t0.tolist())
1383 + elif isinstance(t0, (list, tuple)):
1384 + tiling = tuple(t0)
1385 + return tiling
1386 +
1387 + def _encode_vision(pixel_values, tilings):
1388 + """Run preprocessed crops through vision encoder + projection."""
1389 + device = native_model.device
1390 + dtype = native_model.vision.pos_emb.dtype
1391 + config = native_model.config
1392 +
1393 + pv = pixel_values
1394 + while pv.dim() > 4:
1395 + pv = pv.squeeze(0)
1396 + pv = pv.to(device=device, dtype=dtype)
1397 +
1398 + features = native_model._vis_enc(pv)
1399 + grid_size = config.vision.crop_size // config.vision.enc_patch_size
1400 + global_feat = features[0]
1401 +
1402 + if features.shape[0] > 1 and tilings is not None:
1403 + tiling = _normalize_tiling(tilings)
1404 + local = features[1:].view(-1, grid_size, grid_size, config.vision.enc_dim)
1405 + reconstructed = reconstruct_from_crops(
1406 + local,
1407 + tiling,
1408 + config.vision.overlap_margin,
1409 + patch_size=1,
1410 + )
1411 + else:
1412 + reconstructed = global_feat.view(
1413 + grid_size, grid_size, config.vision.enc_dim
1414 + )
1415 +
1416 + return native_model._vis_proj(global_feat, reconstructed)
1417 +
1418 + def _find_subsequence(seq, subseq):
1419 + """Find start index of subseq in seq, or None."""
1420 + n = len(subseq)
1421 + for i in range(len(seq) - n + 1):
1422 + if seq[i : i + n] == subseq:
1423 + return i
1424 + return None
1425 +
1426 + def _generate(
1427 + self,
1428 + input_ids=None,
1429 + pixel_values=None,
1430 + tilings=None,
1431 + attention_mask=None,
1432 + **kwargs,
1433 + ):
1434 + max_new_tokens = kwargs.get("max_new_tokens", 128)
1435 + return_dict = kwargs.get("return_dict_in_generate", False)
1436 + output_hs = kwargs.get("output_hidden_states", False)
1437 +
1438 + if pixel_values is None:
1439 + sequences = input_ids
1440 + if return_dict:
1441 + return types.SimpleNamespace(
1442 + sequences=sequences,
1443 + hidden_states=() if output_hs else None,
1444 + )
1445 + return sequences
1446 +
1447 + # Processor may return lists; extract the single element.
1448 + if isinstance(pixel_values, (list, tuple)):
1449 + pixel_values = pixel_values[0]
1450 + if (
1451 + isinstance(tilings, (list, tuple))
1452 + and tilings
1453 + and not isinstance(tilings[0], int)
1454 + ):
1455 + tilings = tilings[0]
1456 +
1457 + hf_model.model._setup_caches()
1458 + native_model.use_flex_decoding = False
1459 +
1460 + device = native_model.device
1461 + config = native_model.config
1462 +
1463 + with torch.inference_mode():
1464 + for block in native_model.text.blocks:
1465 + block.kv_cache.k_cache.zero_()
1466 + block.kv_cache.v_cache.zero_()
1467 +
1468 + img_emb = _encode_vision(pixel_values, tilings)
1469 +
1470 + bos_emb = F.embedding(
1471 + torch.tensor([[config.tokenizer.bos_id]], device=device),
1472 + native_model.text.wte,
1473 + )
1474 + img_input = torch.cat([bos_emb, img_emb.unsqueeze(0)], dim=1)
1475 + prefix_len = img_input.size(1)
1476 +
1477 + mask = native_model.attn_mask[:, :, :prefix_len, :]
1478 + pos_ids = torch.arange(prefix_len, dtype=torch.long, device=device)
1479 + native_model._prefill(img_input, mask, pos_ids, None)
1480 +
1481 + ids = input_ids.squeeze(0).tolist()
1482 + img_start = _find_subsequence(ids, image_placeholder_ids)
1483 +
1484 + if img_start is None:
1485 + sequences = input_ids
1486 + if return_dict:
1487 + return types.SimpleNamespace(
1488 + sequences=sequences,
1489 + hidden_states=() if output_hs else None,
1490 + )
1491 + return sequences
1492 +
1493 + prompt_tokens = ids[img_start + len(image_placeholder_ids) :]
1494 +
1495 + if not prompt_tokens:
1496 + sequences = input_ids
1497 + if return_dict:
1498 + return types.SimpleNamespace(
1499 + sequences=sequences,
1500 + hidden_states=() if output_hs else None,
1501 + )
1502 + return sequences
1503 +
1504 + prompt_tensor = torch.tensor([prompt_tokens], device=device)
1505 + prompt_emb = F.embedding(prompt_tensor, native_model.text.wte)
1506 + prompt_len = prompt_emb.size(1)
1507 +
1508 + mask = native_model.attn_mask[:, :, prefix_len : prefix_len + prompt_len, :]
1509 + pos_ids = torch.arange(
1510 + prefix_len,
1511 + prefix_len + prompt_len,
1512 + dtype=torch.long,
1513 + device=device,
1514 + )
1515 + hidden = native_model._prefill(prompt_emb, mask, pos_ids, None)
1516 + pos = prefix_len + prompt_len
1517 +
1518 + hidden_last = native_model.text.post_ln(hidden[:, -1:, :])
1519 + logits = native_model.text.lm_head(hidden_last.squeeze(1))
1520 +
1521 + generated = []
1522 + all_hidden_states = []
1523 + # Record the hidden state that predicted each generated token.
1524 + prev_hs = hidden_last
1525 + for _ in range(max_new_tokens):
1526 + next_token = logits.argmax(dim=-1).item()
1527 + if next_token == 0:
1528 + break
1529 + generated.append(next_token)
1530 + if output_hs:
1531 + all_hidden_states.append((prev_hs,))
1532 +
1533 + next_emb = F.embedding(
1534 + torch.tensor([[next_token]], device=device),
1535 + native_model.text.wte,
1536 + )
1537 + mask = native_model.attn_mask[:, :, pos : pos + 1, :]
1538 + pos_ids_step = torch.tensor([pos], dtype=torch.long, device=device)
1539 + hidden = native_model._prefill(next_emb, mask, pos_ids_step, None)
1540 + hidden_last = native_model.text.post_ln(hidden[:, -1:, :])
1541 + prev_hs = hidden_last
1542 + logits = native_model.text.lm_head(hidden_last.squeeze(1))
1543 + pos += 1
1544 +
1545 + result_ids = ids + generated
1546 + sequences = torch.tensor([result_ids], device=device)
1547 +
1548 + if return_dict:
1549 + return types.SimpleNamespace(
1550 + sequences=sequences,
1551 + hidden_states=tuple(all_hidden_states) if output_hs else None,
1552 + )
1553 + return sequences
1554 +
1555 + hf_model.model.generate = types.MethodType(_generate, hf_model.model)
1556 + return hf_model
tests/models/multimodal/generation/vlm_utils/types.py
+2
@@ -133,6 +133,7 @@ class VLMTestInfo(NamedTuple):
133
134 # Exposed options for HF runner
135 hf_model_kwargs: dict[str, Any] | None = None
136 + hf_processor: Callable[[str], Any] | None = None
137 # Indicates we should explicitly pass the EOS from the tokenizer
138 use_tokenizer_eos: bool = False
139 auto_cls: type[_BaseAutoModelClass] = AutoModelForCausalLM
@@ -196,6 +197,7 @@ class VLMTestInfo(NamedTuple):
197 "comparator": self.comparator,
198 "get_stop_token_ids": self.get_stop_token_ids,
199 "hf_model_kwargs": self.hf_model_kwargs,
200 + "hf_processor": self.hf_processor,
201 "stop_str": self.stop_str,
202 "patch_hf_runner": self.patch_hf_runner,
203 }
tests/models/multimodal/processing/test_moondream3.py new
+553
@@ -0,0 +1,553 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Tests for Moondream3 multimodal processing.
4 +
5 +Includes:
6 +- Processor creation and application tests
7 +- Image tokenization and placeholder expansion tests
8 +- Tiling and cropping logic tests (CPU-based)
9 +- Pixel normalization tests
10 +"""
11 +
12 +import numpy as np
13 +import pytest
14 +import torch
15 +
16 +from vllm.multimodal import MULTIMODAL_REGISTRY
17 +
18 +from ....conftest import ImageTestAssets
19 +from ...utils import build_model_context
20 +
21 +MOONDREAM3_MODEL_ID = "moondream/moondream3-preview"
22 +# Expected multimodal prefix: BOS + 729 image tokens.
23 +EXPECTED_IMAGE_TOKENS = 730
24 +# Vision encoder constants
25 +CROP_SIZE = 378
26 +PATCH_SIZE = 14
27 +MAX_CROPS = 12
28 +
29 +
30 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
31 +def test_processor_creation(model_id: str):
32 + """Test that Moondream3 processor can be created."""
33 + ctx = build_model_context(
34 + model_id,
35 + limit_mm_per_prompt={"image": 1},
36 + )
37 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
38 + assert processor is not None
39 +
40 +
41 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
42 +def test_processor_apply(
43 + image_assets: ImageTestAssets,
44 + model_id: str,
45 +):
46 + """Test that Moondream3 processor can process inputs.
47 +
48 + NOTE: The prompt includes the leading BOS token because Moondream3
49 + pre-fills BOS and image embeddings together.
50 + """
51 + ctx = build_model_context(
52 + model_id,
53 + limit_mm_per_prompt={"image": 1},
54 + )
55 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
56 +
57 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>" # noqa: E501
58 + mm_data = {"image": [image_assets[0].pil_image]}
59 +
60 + processed_inputs = processor(
61 + prompt,
62 + mm_items=processor.info.parse_mm_data(mm_data),
63 + hf_processor_mm_kwargs={},
64 + )
65 +
66 + assert "prompt_token_ids" in processed_inputs
67 + image_placeholders = processed_inputs["mm_placeholders"]["image"]
68 + assert len(image_placeholders) == 1
69 + assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS
70 +
71 +
72 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
73 +def test_processor_pixel_values(
74 + image_assets: ImageTestAssets,
75 + model_id: str,
76 +):
77 + """Test that pixel values are correctly produced."""
78 + ctx = build_model_context(
79 + model_id,
80 + limit_mm_per_prompt={"image": 1},
81 + )
82 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
83 +
84 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>" # noqa: E501
85 + mm_data = {"image": [image_assets[0].pil_image]}
86 +
87 + processed_inputs = processor(
88 + prompt,
89 + mm_items=processor.info.parse_mm_data(mm_data),
90 + hf_processor_mm_kwargs={},
91 + )
92 +
93 + # Check mm_kwargs contains pixel_values
94 + mm_kwargs = processed_inputs.get("mm_kwargs")
95 + assert mm_kwargs is not None
96 + mm_data_result = mm_kwargs.get_data()
97 + assert "pixel_values" in mm_data_result
98 +
99 + # Verify pixel_values shape
100 + pixel_values = mm_data_result["pixel_values"]
101 + assert pixel_values.dim() == 5 # [batch, num_crops, C, H, W]
102 + assert pixel_values.shape[2] == 3 # RGB channels
103 + assert pixel_values.shape[3] == 378 # crop height
104 + assert pixel_values.shape[4] == 378 # crop width
105 +
106 +
107 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
108 +def test_processor_image_token_expansion(
109 + image_assets: ImageTestAssets,
110 + model_id: str,
111 +):
112 + """Test that <image> placeholder is expanded to correct number of tokens."""
113 + ctx = build_model_context(
114 + model_id,
115 + limit_mm_per_prompt={"image": 1},
116 + )
117 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
118 +
119 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>Describe.<|md_reserved_2|>" # noqa: E501
120 + mm_data = {"image": [image_assets[0].pil_image]}
121 +
122 + processed_inputs = processor(
123 + prompt,
124 + mm_items=processor.info.parse_mm_data(mm_data),
125 + hf_processor_mm_kwargs={},
126 + )
127 + image_placeholders = processed_inputs["mm_placeholders"]["image"]
128 + assert len(image_placeholders) == 1
129 + assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS
130 +
131 +
132 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
133 +def test_multi_crop_tiling(
134 + model_id: str,
135 +):
136 + """Test that large images produce correct multi-crop tiling."""
137 + from PIL import Image
138 +
139 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
140 +
141 + processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
142 +
143 + # Create a large image that requires multiple crops
144 + large_image = Image.new("RGB", (1000, 1000), color="blue")
145 + pixel_values, tiling = processor.preprocess_image(large_image)
146 +
147 + # Large images should produce more than 1x1 tiling
148 + assert tiling[0] >= 1 and tiling[1] >= 1
149 + # Check that we have global crop + local crops
150 + expected_crops = tiling[0] * tiling[1] + 1
151 + assert pixel_values.shape[0] == expected_crops
152 +
153 +
154 +@pytest.mark.parametrize(
155 + "image_size",
156 + [
157 + (500, 500),
158 + (800, 600),
159 + (1920, 1080),
160 + ],
161 +)
162 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
163 +def test_tiling_various_sizes(
164 + image_size: tuple[int, int],
165 + model_id: str,
166 +):
167 + """Test tiling with various image sizes."""
168 + from PIL import Image
169 +
170 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
171 +
172 + processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
173 +
174 + width, height = image_size
175 + image = Image.new("RGB", (width, height), color="red")
176 + pixel_values, tiling = processor.preprocess_image(image)
177 +
178 + # Basic shape checks
179 + assert pixel_values.dim() == 4 # [num_crops, C, H, W]
180 + assert pixel_values.shape[1] == 3 # RGB
181 + assert pixel_values.shape[2] == 378 # crop height
182 + assert pixel_values.shape[3] == 378 # crop width
183 +
184 + # Tiling should respect max_crops (12)
185 + assert tiling[0] * tiling[1] <= 12
186 +
187 +
188 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
189 +def test_pixel_normalization(
190 + model_id: str,
191 +):
192 + """Test that pixel values are normalized to [-1, 1] range."""
193 + from PIL import Image
194 +
195 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
196 +
197 + processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
198 +
199 + # Create test image
200 + image = Image.new("RGB", (378, 378), color="green")
201 + pixel_values, _ = processor.preprocess_image(image)
202 +
203 + # Normalization: (x - 0.5) / 0.5 = 2*x - 1
204 + # For input [0, 1], output should be [-1, 1]
205 + assert pixel_values.min() >= -1.0
206 + assert pixel_values.max() <= 1.0
207 +
208 +
209 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
210 +def test_chat_template_with_image(
211 + image_assets: ImageTestAssets,
212 + model_id: str,
213 +):
214 + """Test that chat template correctly formats BOS + image + prompt."""
215 + ctx = build_model_context(
216 + model_id,
217 + limit_mm_per_prompt={"image": 1},
218 + )
219 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
220 + tokenizer = ctx.tokenizer
221 +
222 + # Use the chat template format
223 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>" # noqa: E501
224 + mm_data = {"image": [image_assets[0].pil_image]}
225 +
226 + processed_inputs = processor(
227 + prompt,
228 + mm_items=processor.info.parse_mm_data(mm_data),
229 + hf_processor_mm_kwargs={},
230 + )
231 + token_ids = processed_inputs["prompt_token_ids"]
232 +
233 + # BOS token (<|endoftext|>) should be token ID 0
234 + bos_token_id = tokenizer.encode("<|endoftext|>", add_special_tokens=False)[0]
235 + assert bos_token_id == 0
236 +
237 + # First token should be BOS
238 + assert token_ids[0] == bos_token_id
239 +
240 +
241 +@pytest.mark.parametrize(
242 + "content",
243 + [
244 + pytest.param(
245 + [
246 + {
247 + "type": "image_url",
248 + "image_url": {"url": "https://example.invalid/image.png"},
249 + },
250 + {"type": "text", "text": "What is in this image?"},
251 + ],
252 + id="image-first",
253 + ),
254 + pytest.param(
255 + [
256 + {"type": "text", "text": "What is in this image?"},
257 + {
258 + "type": "image_url",
259 + "image_url": {"url": "https://example.invalid/image.png"},
260 + },
261 + ],
262 + id="text-first",
263 + ),
264 + ],
265 +)
266 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
267 +def test_chat_template_content_list_uses_moondream_image_prefix(
268 + image_assets: ImageTestAssets,
269 + content: list[dict[str, object]],
270 + model_id: str,
271 +):
272 + ctx = build_model_context(
273 + model_id,
274 + limit_mm_per_prompt={"image": 1},
275 + )
276 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
277 + hf_processor = processor.info.get_hf_processor()
278 +
279 + prompt = hf_processor.tokenizer.apply_chat_template(
280 + [{"role": "user", "content": content}],
281 + chat_template=hf_processor.chat_template,
282 + tokenize=False,
283 + )
284 +
285 + expected_prompt = (
286 + "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
287 + "What is in this image?<|md_reserved_2|>"
288 + )
289 + assert prompt == expected_prompt
290 +
291 + processed_inputs = processor(
292 + prompt,
293 + mm_items=processor.info.parse_mm_data({"image": [image_assets[0].pil_image]}),
294 + hf_processor_mm_kwargs={},
295 + )
296 + image_placeholders = processed_inputs["mm_placeholders"]["image"]
297 + assert len(image_placeholders) == 1
298 + assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS
299 +
300 +
301 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
302 +def test_bos_token_always_first(
303 + image_assets: ImageTestAssets,
304 + model_id: str,
305 +):
306 + """Test that BOS token (ID 0) is always at position 0."""
307 + ctx = build_model_context(
308 + model_id,
309 + limit_mm_per_prompt={"image": 1},
310 + )
311 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
312 +
313 + # Start with BOS token explicitly
314 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>Describe this image.<|md_reserved_2|>" # noqa: E501
315 + mm_data = {"image": [image_assets[0].pil_image]}
316 +
317 + processed_inputs = processor(
318 + prompt,
319 + mm_items=processor.info.parse_mm_data(mm_data),
320 + hf_processor_mm_kwargs={},
321 + )
322 + token_ids = processed_inputs["prompt_token_ids"]
323 +
324 + # Token ID 0 (<|endoftext|>) should be the first token
325 + assert token_ids[0] == 0, (
326 + f"Expected BOS token (0) at position 0, got {token_ids[0]}"
327 + )
328 +
329 +
330 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
331 +def test_processor_with_small_image(
332 + model_id: str,
333 +):
334 + """Test processor with image smaller than crop size."""
335 + from PIL import Image
336 +
337 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
338 +
339 + processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
340 +
341 + # Small image (smaller than crop size)
342 + small_image = Image.new("RGB", (100, 100), color="yellow")
343 + pixel_values, tiling = processor.preprocess_image(small_image)
344 +
345 + # Small images should use 1x1 tiling
346 + assert tiling == (1, 1)
347 + # Should have 2 crops (global + 1 local)
348 + assert pixel_values.shape[0] == 2
349 +
350 +
351 +@pytest.mark.parametrize(
352 + "image_kind",
353 + [
354 + pytest.param("numpy_hwc", id="numpy-hwc"),
355 + pytest.param("numpy_chw", id="numpy-chw"),
356 + pytest.param("torch_chw", id="torch-chw"),
357 + ],
358 +)
359 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
360 +def test_preprocess_image_accepts_non_pil_inputs(
361 + image_assets: ImageTestAssets,
362 + image_kind: str,
363 + model_id: str,
364 +):
365 + from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
366 +
367 + processor = Moondream3Processor.from_pretrained(model_id, trust_remote_code=True)
368 + pil_image = image_assets[0].pil_image.convert("RGB")
369 + hwc_array = np.asarray(pil_image)
370 + expected_pixel_values, expected_tiling = processor.preprocess_image(pil_image)
371 +
372 + if image_kind == "numpy_hwc":
373 + image = hwc_array
374 + elif image_kind == "numpy_chw":
375 + image = np.transpose(hwc_array, (2, 0, 1))
376 + else:
377 + image = torch.from_numpy(np.transpose(hwc_array, (2, 0, 1)).copy())
378 +
379 + pixel_values, tiling = processor.preprocess_image(image)
380 +
381 + assert tiling == expected_tiling
382 + assert pixel_values.shape == expected_pixel_values.shape
383 + assert pixel_values.dtype == torch.bfloat16
384 + assert torch.equal(pixel_values, expected_pixel_values)
385 +
386 +
387 +@pytest.mark.parametrize("image_kind", ["numpy_chw", "torch_chw"])
388 +@pytest.mark.parametrize("model_id", [MOONDREAM3_MODEL_ID])
389 +def test_processor_apply_accepts_non_pil_image_inputs(
390 + image_assets: ImageTestAssets,
391 + image_kind: str,
392 + model_id: str,
393 +):
394 + ctx = build_model_context(
395 + model_id,
396 + limit_mm_per_prompt={"image": 1},
397 + )
398 + processor = MULTIMODAL_REGISTRY.create_processor(ctx.model_config)
399 +
400 + prompt = "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>What is this?<|md_reserved_2|>" # noqa: E501
401 + hwc_array = np.asarray(image_assets[0].pil_image.convert("RGB"))
402 + chw_array = np.transpose(hwc_array, (2, 0, 1)).copy()
403 + image = chw_array if image_kind == "numpy_chw" else torch.from_numpy(chw_array)
404 +
405 + processed_inputs = processor(
406 + prompt,
407 + mm_items=processor.info.parse_mm_data({"image": [image]}),
408 + hf_processor_mm_kwargs={},
409 + )
410 +
411 + image_placeholders = processed_inputs["mm_placeholders"]["image"]
412 + assert len(image_placeholders) == 1
413 + assert image_placeholders[0].length == EXPECTED_IMAGE_TOKENS
414 +
415 + mm_kwargs = processed_inputs["mm_kwargs"].get_data()
416 + assert mm_kwargs["pixel_values"].shape[2:] == (3, 378, 378)
417 +
418 +
419 +class TestMoondream3TilingLogic:
420 + """CPU-based tests for Moondream3 tiling selection logic.
421 +
422 + These tests validate the select_tiling() function which determines
423 + how images are divided into crops for the vision encoder.
424 + """
425 +
426 + def test_small_image_no_tiling(self):
427 + """Small images should use 1x1 tiling."""
428 + from vllm.transformers_utils.processors.moondream3 import select_tiling
429 +
430 + tiling = select_tiling(
431 + height=300, width=300, crop_size=CROP_SIZE, max_crops=MAX_CROPS
432 + )
433 + assert tiling == (1, 1)
434 +
435 + def test_exact_crop_size(self):
436 + """Image exactly at crop size should use 1x1."""
437 + from vllm.transformers_utils.processors.moondream3 import select_tiling
438 +
439 + tiling = select_tiling(
440 + height=CROP_SIZE, width=CROP_SIZE, crop_size=CROP_SIZE, max_crops=MAX_CROPS
441 + )
442 + assert tiling == (1, 1)
443 +
444 + def test_large_square_image(self):
445 + """Large square image should use multiple tiles."""
446 + from vllm.transformers_utils.processors.moondream3 import select_tiling
447 +
448 + tiling = select_tiling(
449 + height=800, width=800, crop_size=CROP_SIZE, max_crops=MAX_CROPS
450 + )
451 + h_tiles, w_tiles = tiling
452 + assert h_tiles >= 2
453 + assert w_tiles >= 2
454 + assert h_tiles * w_tiles <= MAX_CROPS
455 +
456 + def test_wide_image(self):
457 + """Wide image should have more width tiles."""
458 + from vllm.transformers_utils.processors.moondream3 import select_tiling
459 +
460 + tiling = select_tiling(
461 + height=400, width=1200, crop_size=CROP_SIZE, max_crops=MAX_CROPS
462 + )
463 + h_tiles, w_tiles = tiling
464 + assert w_tiles >= h_tiles
465 +
466 + def test_tall_image(self):
467 + """Tall image should have more height tiles."""
468 + from vllm.transformers_utils.processors.moondream3 import select_tiling
469 +
470 + tiling = select_tiling(
471 + height=1200, width=400, crop_size=CROP_SIZE, max_crops=MAX_CROPS
472 + )
473 + h_tiles, w_tiles = tiling
474 + assert h_tiles >= w_tiles
475 +
476 + def test_respects_max_crops(self):
477 + """Tiling should not exceed max_crops."""
478 + from vllm.transformers_utils.processors.moondream3 import select_tiling
479 +
480 + tiling = select_tiling(
481 + height=2000, width=2000, crop_size=CROP_SIZE, max_crops=4
482 + )
483 + h_tiles, w_tiles = tiling
484 + assert h_tiles * w_tiles <= 4
485 +
486 +
487 +class TestMoondream3VisionShapes:
488 + """CPU-based tests for vision encoder expected shapes.
489 +
490 + These tests verify the mathematical relationships between
491 + crop size, patch size, and token counts.
492 + """
493 +
494 + def test_expected_patch_count(self):
495 + """Test 378/14 = 27 patches per side, 729 total."""
496 + patches_per_side = CROP_SIZE // PATCH_SIZE
497 + total_patches = patches_per_side**2
498 +
499 + assert patches_per_side == 27
500 + assert total_patches == EXPECTED_IMAGE_TOKENS - 1
501 +
502 + def test_patch_embedding_input_dim(self):
503 + """Test patch embedding input dimension."""
504 + channels = 3
505 + input_dim = PATCH_SIZE * PATCH_SIZE * channels
506 +
507 + assert input_dim == 14 * 14 * 3
508 + assert input_dim == 588
509 +
510 +
511 +class TestMoondream3TauAttention:
512 + """CPU-based tests for tau attention scaling components.
513 +
514 + These tests validate the tau attention formula used in Moondream3:
515 + - Token-based: tok_q = tanh(gelu(qkv) @ tau_wq.T)
516 + - Position-based: tau_pos = 1 + (sigmoid(alpha * log(pos+1)) - 0.5)
517 + """
518 +
519 + def test_tau_position_range(self):
520 + """Test tau position scaling produces values in valid range."""
521 + num_heads = 32
522 + seq_len = 100
523 +
524 + tau_alpha = torch.randn(num_heads)
525 + positions = torch.arange(seq_len)
526 +
527 + pos_float = (positions.float() + 1.0).clamp(min=1e-6)
528 + pos_log = pos_float.log()
529 + tau_pos = 1.0 + (torch.sigmoid(tau_alpha[:, None] * pos_log[None, :]) - 0.5)
530 +
531 + assert tau_pos.shape == (num_heads, seq_len)
532 + # tau_pos should be between 0.5 and 1.5
533 + assert tau_pos.min() >= 0.5
534 + assert tau_pos.max() <= 1.5
535 +
536 + def test_tau_token_output_range(self):
537 + """Test tau token scaling output is bounded by tanh."""
538 + import torch.nn.functional as F
539 +
540 + seq_len = 100
541 + qkv_dim = 6144 # 2048 * 3
542 + num_heads = 32
543 +
544 + qkv = torch.randn(seq_len, qkv_dim)
545 + tau_wq = torch.randn(num_heads, qkv_dim)
546 +
547 + tok_feat = F.gelu(qkv)
548 + tok_q = torch.tanh(tok_feat @ tau_wq.t())
549 +
550 + assert tok_q.shape == (seq_len, num_heads)
551 + # tanh output is bounded by [-1, 1]
552 + assert tok_q.min() >= -1.0
553 + assert tok_q.max() <= 1.0
tests/models/registry.py
+10
@@ -1122,6 +1122,16 @@ _MULTIMODAL_EXAMPLE_MODELS = {
1122 extras={"olmo": "allenai/Molmo-7B-O-0924"},
1123 trust_remote_code=True,
1124 ),
1125 + "Moondream3ForCausalLM": _HfExamplesInfo(
1126 + "moondream/moondream3-preview",
1127 + tokenizer="moondream/starmie-v1",
1128 + trust_remote_code=True,
1129 + ),
1130 + "HfMoondream": _HfExamplesInfo(
1131 + "moondream/moondream3-preview",
1132 + tokenizer="moondream/starmie-v1",
1133 + trust_remote_code=True,
1134 + ),
1135 "Molmo2ForConditionalGeneration": _HfExamplesInfo(
1136 "allenai/Molmo2-8B",
1137 extras={"olmo": "allenai/Molmo2-O-7B"},
tests/models/utils.py
+14
@@ -523,6 +523,17 @@ def dummy_hf_overrides(
523
524 text_config.update(update_dict)
525
526 + # Update n_layers and moe configs for Moondream3 model
527 + if model_arch in ("Moondream3ForCausalLM", "HfMoondream"):
528 + text_config.update(
529 + {
530 + "n_layers": num_hidden_layers,
531 + "moe_num_experts": num_experts,
532 + "moe_experts_per_token": 2,
533 + "moe_start_layer": num_hidden_layers,
534 + }
535 + )
536 +
537 if hasattr(hf_config, "vision_config"):
538 hf_config.vision_config.update(
539 {
@@ -531,6 +542,9 @@ def dummy_hf_overrides(
542 }
543 )
544
545 + if model_arch in ("Moondream3ForCausalLM", "HfMoondream"):
546 + hf_config.vision_config.update({"enc_n_layers": 1})
547 +
548 # e.g.: ibm-granite/granite-speech-3.3-2b
549 if hasattr(hf_config, "encoder_config"):
550 hf_config.encoder_config.update(
vllm/model_executor/models/moondream3.py new
+1423
@@ -0,0 +1,1423 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Inference-only Moondream3 model implementation."""
4 +
5 +from collections.abc import Iterable, Mapping
6 +from dataclasses import dataclass
7 +from functools import cached_property
8 +from itertools import islice
9 +
10 +import torch
11 +import torch.nn as nn
12 +import torch.nn.functional as F
13 +from transformers import BatchFeature
14 +
15 +from vllm.config import VllmConfig
16 +from vllm.config.multimodal import BaseDummyOptions
17 +from vllm.distributed import (
18 + get_pp_group,
19 + get_tensor_model_parallel_rank,
20 + get_tensor_model_parallel_world_size,
21 + tensor_model_parallel_all_gather,
22 + tensor_model_parallel_all_reduce,
23 +)
24 +from vllm.inputs import MultiModalDataDict
25 +from vllm.logger import init_logger
26 +from vllm.model_executor.layers.activation import get_act_fn
27 +from vllm.model_executor.layers.attention import Attention
28 +from vllm.model_executor.layers.attention.mm_encoder_attention import (
29 + MMEncoderAttention,
30 +)
31 +from vllm.model_executor.layers.fused_moe import MoEActivation, fused_experts
32 +from vllm.model_executor.layers.fused_moe.config import biased_moe_quant_config
33 +from vllm.model_executor.layers.linear import (
34 + ColumnParallelLinear,
35 + QKVParallelLinear,
36 + ReplicatedLinear,
37 + RowParallelLinear,
38 +)
39 +from vllm.model_executor.layers.logits_processor import LogitsProcessor
40 +from vllm.model_executor.layers.quantization import QuantizationConfig
41 +from vllm.model_executor.layers.rotary_embedding import get_rope
42 +from vllm.model_executor.layers.vocab_parallel_embedding import (
43 + ParallelLMHead,
44 + VocabParallelEmbedding,
45 +)
46 +from vllm.model_executor.model_loader.weight_utils import default_weight_loader
47 +from vllm.multimodal import MULTIMODAL_REGISTRY
48 +from vllm.multimodal.inputs import (
49 + MultiModalFieldConfig,
50 + MultiModalKwargsItems,
51 +)
52 +from vllm.multimodal.parse import ImageSize, MultiModalDataItems
53 +from vllm.multimodal.processing import (
54 + BaseDummyInputsBuilder,
55 + BaseMultiModalProcessor,
56 + BaseProcessingInfo,
57 + PromptReplacement,
58 + PromptUpdate,
59 + PromptUpdateDetails,
60 +)
61 +from vllm.sequence import IntermediateTensors
62 +from vllm.transformers_utils.configs.moondream3 import (
63 + Moondream3Config,
64 + Moondream3TextConfig,
65 + Moondream3VisionConfig,
66 +)
67 +from vllm.transformers_utils.processors.moondream3 import Moondream3Processor
68 +
69 +from .interfaces import (
70 + MultiModalEmbeddings,
71 + SupportsMultiModal,
72 + SupportsPP,
73 +)
74 +from .utils import (
75 + extract_layer_index,
76 + make_empty_intermediate_tensors_factory,
77 + make_layers,
78 + maybe_prefix,
79 +)
80 +
81 +logger = init_logger(__name__)
82 +
83 +
84 +# ============================================================================
85 +# Image Processing Utilities
86 +# ============================================================================
87 +
88 +
89 +def reconstruct_from_crops(
90 + crops: torch.Tensor,
91 + tiling: tuple[int, int],
92 + overlap_margin: int,
93 + patch_size: int = 14,
94 +) -> torch.Tensor:
95 + """Reconstruct features from overlapping crops."""
96 + tiling_h, tiling_w = tiling
97 + crop_height, crop_width = crops[0].shape[:2]
98 + margin_pixels = overlap_margin * patch_size
99 +
100 + output_h = (crop_height - 2 * margin_pixels) * tiling_h + 2 * margin_pixels
101 + output_w = (crop_width - 2 * margin_pixels) * tiling_w + 2 * margin_pixels
102 +
103 + reconstructed = torch.zeros(
104 + (output_h, output_w, crops[0].shape[2]),
105 + device=crops[0].device,
106 + dtype=crops[0].dtype,
107 + )
108 +
109 + for i, crop in enumerate(crops):
110 + tile_y = i // tiling_w
111 + tile_x = i % tiling_w
112 +
113 + x_start = 0 if tile_x == 0 else margin_pixels
114 + x_end = crop_width if tile_x == tiling_w - 1 else crop_width - margin_pixels
115 + y_start = 0 if tile_y == 0 else margin_pixels
116 + y_end = crop_height if tile_y == tiling_h - 1 else crop_height - margin_pixels
117 +
118 + out_x = tile_x * (crop_width - 2 * margin_pixels)
119 + out_y = tile_y * (crop_height - 2 * margin_pixels)
120 +
121 + reconstructed[
122 + out_y + y_start : out_y + y_end, out_x + x_start : out_x + x_end
123 + ] = crop[y_start:y_end, x_start:x_end]
124 +
125 + return reconstructed
126 +
127 +
128 +# ============================================================================
129 +# Vision Encoder Components
130 +# ============================================================================
131 +
132 +
133 +class Moondream3VisionMLP(nn.Module):
134 + """MLP for vision encoder blocks."""
135 +
136 + def __init__(
137 + self,
138 + hidden_size: int,
139 + intermediate_size: int,
140 + quant_config: QuantizationConfig | None = None,
141 + prefix: str = "",
142 + ):
143 + super().__init__()
144 + self.fc1 = ColumnParallelLinear(
145 + hidden_size,
146 + intermediate_size,
147 + bias=True,
148 + quant_config=quant_config,
149 + prefix=f"{prefix}.fc1",
150 + )
151 + self.act = get_act_fn("gelu_pytorch_tanh")
152 + self.fc2 = RowParallelLinear(
153 + intermediate_size,
154 + hidden_size,
155 + bias=True,
156 + quant_config=quant_config,
157 + prefix=f"{prefix}.fc2",
158 + )
159 +
160 + def forward(self, x: torch.Tensor) -> torch.Tensor:
161 + x, _ = self.fc1(x)
162 + x = self.act(x)
163 + x, _ = self.fc2(x)
164 + return x
165 +
166 +
167 +class Moondream3VisionAttention(nn.Module):
168 + """Self-attention for vision encoder (bidirectional)."""
169 +
170 + def __init__(
171 + self,
172 + hidden_size: int,
173 + num_heads: int,
174 + quant_config: QuantizationConfig | None = None,
175 + prefix: str = "",
176 + ):
177 + super().__init__()
178 + self.hidden_size = hidden_size
179 + self.num_heads = num_heads
180 + self.head_dim = hidden_size // num_heads
181 +
182 + self.qkv_proj = QKVParallelLinear(
183 + hidden_size=hidden_size,
184 + head_size=self.head_dim,
185 + total_num_heads=num_heads,
186 + bias=True,
187 + quant_config=quant_config,
188 + prefix=f"{prefix}.qkv_proj",
189 + )
190 + self.out_proj = RowParallelLinear(
191 + input_size=hidden_size,
192 + output_size=hidden_size,
193 + bias=True,
194 + quant_config=quant_config,
195 + prefix=f"{prefix}.out_proj",
196 + )
197 +
198 + tp_size = get_tensor_model_parallel_world_size()
199 + self.num_heads_per_partition = num_heads // tp_size
200 +
201 + self.attn = MMEncoderAttention(
202 + num_heads=self.num_heads_per_partition,
203 + head_size=self.head_dim,
204 + scale=self.head_dim**-0.5,
205 + prefix=f"{prefix}.attn",
206 + )
207 +
208 + def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
209 + qkv, _ = self.qkv_proj(hidden_states)
210 + q, k, v = qkv.chunk(3, dim=-1)
211 + out = self.attn(q, k, v)
212 + out, _ = self.out_proj(out)
213 + return out
214 +
215 +
216 +class Moondream3VisionBlock(nn.Module):
217 + """Transformer block for vision encoder."""
218 +
219 + def __init__(
220 + self,
221 + hidden_size: int,
222 + intermediate_size: int,
223 + num_heads: int,
224 + quant_config: QuantizationConfig | None = None,
225 + prefix: str = "",
226 + ):
227 + super().__init__()
228 + self.ln1 = nn.LayerNorm(hidden_size, eps=1e-5)
229 + self.attn = Moondream3VisionAttention(
230 + hidden_size=hidden_size,
231 + num_heads=num_heads,
232 + quant_config=quant_config,
233 + prefix=f"{prefix}.attn",
234 + )
235 + self.ln2 = nn.LayerNorm(hidden_size, eps=1e-5)
236 + self.mlp = Moondream3VisionMLP(
237 + hidden_size=hidden_size,
238 + intermediate_size=intermediate_size,
239 + quant_config=quant_config,
240 + prefix=f"{prefix}.mlp",
241 + )
242 +
243 + def forward(self, x: torch.Tensor) -> torch.Tensor:
244 + x = x + self.attn(self.ln1(x))
245 + x = x + self.mlp(self.ln2(x))
246 + return x
247 +
248 +
249 +class Moondream3VisionEncoder(nn.Module):
250 + """Vision encoder (SigLIP-style ViT)."""
251 +
252 + def __init__(
253 + self,
254 + config: Moondream3VisionConfig,
255 + quant_config: QuantizationConfig | None = None,
256 + prefix: str = "",
257 + ):
258 + super().__init__()
259 + self.config = config
260 +
261 + # Patch embedding
262 + self.patch_emb = nn.Linear(
263 + config.enc_patch_size * config.enc_patch_size * 3,
264 + config.enc_dim,
265 + bias=True,
266 + )
267 +
268 + # Position embeddings (27x27 = 729 patches for 378x378 / 14)
269 + num_patches = (config.crop_size // config.enc_patch_size) ** 2
270 + self.pos_emb = nn.Parameter(torch.zeros(1, num_patches, config.enc_dim))
271 +
272 + # Transformer blocks
273 + self.blocks = nn.ModuleList(
274 + [
275 + Moondream3VisionBlock(
276 + hidden_size=config.enc_dim,
277 + intermediate_size=config.enc_ff_dim,
278 + num_heads=config.enc_n_heads,
279 + quant_config=quant_config,
280 + prefix=f"{prefix}.blocks.{i}",
281 + )
282 + for i in range(config.enc_n_layers)
283 + ]
284 + )
285 +
286 + self.post_ln = nn.LayerNorm(config.enc_dim, eps=1e-5)
287 +
288 + def create_patches(self, images: torch.Tensor) -> torch.Tensor:
289 + """Convert images to patch embeddings.
290 +
291 + Args:
292 + images: (batch, channels, height, width)
293 +
294 + Returns:
295 + patches: (batch, num_patches, patch_dim)
296 + """
297 + patch_size = self.config.enc_patch_size
298 + batch, channels, height, width = images.shape
299 + patches_h = height // patch_size
300 + patches_w = width // patch_size
301 +
302 + # Unfold into patches
303 + patches = images.unfold(2, patch_size, patch_size).unfold(
304 + 3, patch_size, patch_size
305 + )
306 + # (batch, channels, patches_h, patches_w, patch_size, patch_size)
307 + patches = patches.permute(0, 2, 3, 1, 4, 5).contiguous()
308 + # (batch, patches_h, patches_w, channels, patch_size, patch_size)
309 + patches = patches.view(batch, patches_h * patches_w, -1)
310 + # (batch, num_patches, channels * patch_size * patch_size)
311 +
312 + return patches
313 +
314 + def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
315 + """Encode images.
316 +
317 + Args:
318 + pixel_values: (batch, channels, height, width)
319 +
320 + Returns:
321 + features: (batch, num_patches, hidden_size)
322 + """
323 + # Create patches and embed
324 + patches = self.create_patches(pixel_values)
325 + x = self.patch_emb(patches)
326 +
327 + # Add position embeddings
328 + x = x + self.pos_emb
329 +
330 + # Apply transformer blocks
331 + for block in self.blocks:
332 + x = block(x)
333 +
334 + # Final layer norm
335 + x = self.post_ln(x)
336 +
337 + return x
338 +
339 +
340 +class Moondream3VisionProjection(nn.Module):
341 + """Projects vision features to text embedding dimension."""
342 +
343 + def __init__(
344 + self,
345 + input_dim: int,
346 + inner_dim: int,
347 + output_dim: int,
348 + quant_config: QuantizationConfig | None = None,
349 + prefix: str = "",
350 + ):
351 + super().__init__()
352 + # Input is concatenated global and local features (2 * input_dim)
353 + self.fc1 = ColumnParallelLinear(
354 + input_dim * 2,
355 + inner_dim,
356 + bias=True,
357 + quant_config=quant_config,
358 + prefix=f"{prefix}.fc1",
359 + )
360 + self.act = get_act_fn("gelu_pytorch_tanh")
361 + self.fc2 = RowParallelLinear(
362 + inner_dim,
363 + output_dim,
364 + bias=True,
365 + quant_config=quant_config,
366 + prefix=f"{prefix}.fc2",
367 + )
368 +
369 + def forward(self, x: torch.Tensor) -> torch.Tensor:
370 + x, _ = self.fc1(x)
371 + x = self.act(x)
372 + x, _ = self.fc2(x)
373 + return x
374 +
375 +
376 +# ============================================================================
377 +# Text Decoder Components
378 +# ============================================================================
379 +
380 +
381 +class Moondream3TextMLP(nn.Module):
382 + """Standard MLP for non-MoE layers (layers 0-3)."""
383 +
384 + def __init__(
385 + self,
386 + hidden_size: int,
387 + intermediate_size: int,
388 + quant_config: QuantizationConfig | None = None,
389 + prefix: str = "",
390 + ):
391 + super().__init__()
392 + self.fc1 = ColumnParallelLinear(
393 + hidden_size,
394 + intermediate_size,
395 + bias=True,
396 + quant_config=quant_config,
397 + prefix=f"{prefix}.fc1",
398 + )
399 + self.act = get_act_fn("gelu_pytorch_tanh")
400 + self.fc2 = RowParallelLinear(
401 + intermediate_size,
402 + hidden_size,
403 + bias=True,
404 + quant_config=quant_config,
405 + prefix=f"{prefix}.fc2",
406 + )
407 +
408 + def forward(self, x: torch.Tensor) -> torch.Tensor:
409 + x, _ = self.fc1(x)
410 + x = self.act(x)
411 + x, _ = self.fc2(x)
412 + return x
413 +
414 +
415 +class Moondream3TextMoE(nn.Module):
416 + """Mixture of Experts layer for layers 4+ with expert parallelism.
417 +
418 + Moondream3 uses a custom GeGLU activation: gelu(h) * (g + 1)
419 + where fc1 outputs [gate, up] and the activation is gelu(gate) * (up + 1).
420 +
421 + Uses expert parallelism where each GPU stores num_experts/tp_size experts.
422 + Routing and communication handled via all-to-all or replicated computation.
423 +
424 + Checkpoint format:
425 + - fc1.weight: [num_experts, expert_inner_dim * 2, hidden_size] (gate+up)
426 + - fc2.weight: [num_experts, hidden_size, expert_inner_dim] (down)
427 + - router.weight: [num_experts, hidden_size]
428 + - router.bias: [num_experts]
429 + """
430 +
431 + def __init__(
432 + self,
433 + hidden_size: int,
434 + expert_inner_dim: int,
435 + num_experts: int,
436 + experts_per_token: int,
437 + quant_config: QuantizationConfig | None = None,
438 + prefix: str = "",
439 + ):
440 + super().__init__()
441 + self.hidden_size = hidden_size
442 + self.expert_inner_dim = expert_inner_dim
443 + self.num_experts = num_experts
444 + self.experts_per_token = experts_per_token
445 +
446 + # Expert parallelism: each GPU stores a subset of experts
447 + self.tp_size = get_tensor_model_parallel_world_size()
448 + self.experts_per_rank = num_experts // self.tp_size
449 + self.num_local_experts = self.experts_per_rank
450 +
451 + # Router (gate) - use ReplicatedLinear for compatibility
452 + self.gate = ReplicatedLinear(
453 + hidden_size,
454 + num_experts,
455 + bias=True,
456 + quant_config=None,
457 + prefix=f"{prefix}.gate",
458 + )
459 +
460 + # Local expert weights (only store experts_per_rank experts)
461 + # fc1: [experts_per_rank, expert_inner_dim * 2, hidden_size]
462 + # fc2: [experts_per_rank, hidden_size, expert_inner_dim]
463 + self.fc1_weight = nn.Parameter(
464 + torch.empty(self.num_local_experts, expert_inner_dim * 2, hidden_size)
465 + )
466 + self.fc2_weight = nn.Parameter(
467 + torch.empty(self.num_local_experts, hidden_size, expert_inner_dim)
468 + )
469 + self._use_fused_moe = True
470 +
471 + local_expert_start = get_tensor_model_parallel_rank() * self.experts_per_rank
472 + expert_map = torch.full((num_experts,), -1, dtype=torch.int32)
473 + expert_map[local_expert_start : local_expert_start + self.num_local_experts] = (
474 + torch.arange(self.num_local_experts, dtype=torch.int32)
475 + )
476 + self.register_buffer("_expert_map", expert_map, persistent=False)
477 +
478 + # Preserve Moondream3's exact GeGLU variant (gelu(h) * (g + 1)) by
479 + # adding +1 bias to the second half of the fused fc1 activations.
480 + fused_w1_bias = torch.zeros(
481 + self.num_local_experts,
482 + expert_inner_dim * 2,
483 + dtype=torch.float32,
484 + )
485 + fused_w1_bias[:, expert_inner_dim:] = 1.0
486 + self.register_buffer("_fused_w1_bias", fused_w1_bias, persistent=False)
487 +
488 + def forward(self, x: torch.Tensor) -> torch.Tensor:
489 + """Forward pass with expert parallelism and custom GeGLU activation."""
490 +
491 + # Get router logits and compute top-k
492 + router_logits, _ = self.gate(x) # [num_tokens, num_experts]
493 + topk_logits, topk_ids = torch.topk(
494 + router_logits, self.experts_per_token, dim=-1
495 + )
496 + # Softmax over selected experts
497 + topk_weights = F.softmax(topk_logits, dim=-1, dtype=torch.float32).to(x.dtype)
498 +
499 + if self._use_fused_moe and x.is_cuda:
500 + try:
501 + out = fused_experts(
502 + hidden_states=x.contiguous(),
503 + w1=self.fc1_weight,
504 + w2=self.fc2_weight,
505 + topk_weights=topk_weights.contiguous(),
506 + topk_ids=topk_ids.contiguous(),
507 + activation=MoEActivation.GELU,
508 + global_num_experts=self.num_experts,
509 + expert_map=self._expert_map,
510 + quant_config=biased_moe_quant_config(self._fused_w1_bias, None),
511 + )
512 + out = tensor_model_parallel_all_reduce(out)
513 + return out
514 + except (NotImplementedError, RuntimeError) as exc:
515 + self._use_fused_moe = False
516 + logger.warning_once(
517 + "Disabling fused Moondream3 MoE path and falling back to "
518 + "the Python expert loop: %s",
519 + str(exc),
520 + )
521 +
522 + tp_rank = get_tensor_model_parallel_rank()
523 + # Compute local expert range
524 + local_expert_start = tp_rank * self.experts_per_rank
525 +
526 + # Fallback path for environments where fused kernels are unavailable.
527 + out = x.new_zeros(x.shape)
528 +
529 + for local_expert_idx in range(self.num_local_experts):
530 + global_expert_id = local_expert_start + local_expert_idx
531 +
532 + # Find tokens assigned to this expert
533 + token_pos, which_k = (topk_ids == global_expert_id).nonzero(as_tuple=True)
534 + if token_pos.numel() == 0:
535 + continue
536 +
537 + # Get tokens and their routing weights
538 + x_tok = x.index_select(0, token_pos) # [n_tokens, hidden_size]
539 + gate_tok = topk_weights[token_pos, which_k] # [n_tokens]
540 +
541 + # fc1: [expert_inner_dim * 2, hidden_size]
542 + # h_full: [n_tokens, expert_inner_dim * 2]
543 + h_full = F.linear(x_tok, self.fc1_weight[local_expert_idx])
544 +
545 + # GeGLU with (g + 1): h, g = split; output = gelu(h) * (g + 1)
546 + # HF MoE uses exact GELU (not tanh approximation).
547 + h, g = h_full.chunk(2, dim=-1) # Each [n_tokens, expert_inner_dim]
548 + h = F.gelu(h) * (g + 1.0)
549 +
550 + # fc2: [hidden_size, expert_inner_dim]
551 + # y: [n_tokens, hidden_size]
552 + y = F.linear(h, self.fc2_weight[local_expert_idx])
553 +
554 + # Apply routing weight
555 + y = y * gate_tok.unsqueeze(-1)
556 +
557 + # Accumulate output
558 + out.index_add_(0, token_pos, y)
559 +
560 + # All-reduce to combine results from all experts across GPUs
561 + out = tensor_model_parallel_all_reduce(out)
562 +
563 + return out
564 +
565 +
566 +class Moondream3Attention(nn.Module):
567 + """Decoder attention with RoPE and tau scaling.
568 +
569 + Moondream3 uses a tau attention mechanism that scales Q and V
570 + based on both token content and position.
571 + """
572 +
573 + def __init__(
574 + self,
575 + config: Moondream3TextConfig,
576 + layer_idx: int,
577 + cache_config=None,
578 + quant_config: QuantizationConfig | None = None,
579 + prefix: str = "",
580 + ):
581 + super().__init__()
582 + self.hidden_size = config.dim
583 + self.num_heads = config.n_heads
584 + self.num_kv_heads = config.n_kv_heads
585 + self.head_dim = config.dim // config.n_heads
586 +
587 + tp_size = get_tensor_model_parallel_world_size()
588 + self.num_heads_per_partition = self.num_heads // tp_size
589 + self.num_kv_heads_per_partition = max(1, self.num_kv_heads // tp_size)
590 +
591 + self.qkv_proj = QKVParallelLinear(
592 + hidden_size=self.hidden_size,
593 + head_size=self.head_dim,
594 + total_num_heads=self.num_heads,
595 + total_num_kv_heads=self.num_kv_heads,
596 + bias=True,
597 + quant_config=quant_config,
598 + prefix=f"{prefix}.qkv_proj",
599 + )
600 +
601 + self.out_proj = RowParallelLinear(
602 + input_size=self.hidden_size,
603 + output_size=self.hidden_size,
604 + bias=True,
605 + quant_config=quant_config,
606 + prefix=f"{prefix}.out_proj",
607 + )
608 +
609 + # Moondream uses 32-dim rotation out of 64-dim head (partial_rotary_factor=0.5)
610 + # HF Moondream uses non-interleaved RoPE (split by half)
611 + # In vLLM, is_neox_style=True means split by half (GPT-NeoX style)
612 + rope_parameters = {
613 + "rope_theta": config.rope_theta,
614 + "partial_rotary_factor": 32 / self.head_dim, # 32/64 = 0.5
615 + }
616 + self.rotary_emb = get_rope(
617 + head_size=self.head_dim,
618 + max_position=config.max_context,
619 + rope_parameters=rope_parameters,
620 + is_neox_style=True, # Moondream uses split-by-half (GPT-NeoX) style
621 + )
622 +
623 + self.scaling = self.head_dim**-0.5
624 + self.attn = Attention(
625 + num_heads=self.num_heads_per_partition,
626 + head_size=self.head_dim,
627 + scale=self.scaling,
628 + num_kv_heads=self.num_kv_heads_per_partition,
629 + cache_config=cache_config,
630 + quant_config=quant_config,
631 + prefix=f"{prefix}.attn",
632 + )
633 +
634 + # Tau scaling parameters for position-dependent attention
635 + # These are learned during training to modulate attention based on position
636 + # tau_wq and tau_wv need full qkv_dim for correct computation
637 + # Only heads are partitioned, qkv dimension is kept full for all-gather
638 + qkv_dim = self.hidden_size * 3 # Q + K + V dimension (full)
639 + self.tau_alpha = nn.Parameter(torch.zeros(self.num_heads_per_partition))
640 + self.tau_wq = nn.Parameter(torch.zeros(self.num_heads_per_partition, qkv_dim))
641 + self.tau_wv = nn.Parameter(torch.zeros(self.num_heads_per_partition, qkv_dim))
642 + self.tp_size = tp_size
643 +
644 + def forward(
645 + self,
646 + positions: torch.Tensor,
647 + hidden_states: torch.Tensor,
648 + ) -> torch.Tensor:
649 + qkv, _ = self.qkv_proj(hidden_states)
650 +
651 + q, k, v = qkv.split(
652 + [
653 + self.num_heads_per_partition * self.head_dim,
654 + self.num_kv_heads_per_partition * self.head_dim,
655 + self.num_kv_heads_per_partition * self.head_dim,
656 + ],
657 + dim=-1,
658 + )
659 +
660 + # Apply tau scaling to Q and V
661 + # Tau scaling has two components:
662 + # 1. Token-based: tok_q = tanh(gelu(qkv) @ tau_wq.T)
663 + # 2. Position-based: tau_pos = 1 + (sigmoid(alpha * log(pos+1)) - 0.5)
664 + # Final: tau = tok + tau_pos
665 + #
666 + # For TP, tau weights are sharded by head, but qkv_dim is kept full
667 +
668 + # Get full qkv for tau computation
669 + # With TP, reconstruct qkv in correct layout [q_full, k_full, v_full]
670 + # (all-gather would produce [q_0, k_0, v_0, q_1, k_1, v_1] - wrong)
671 + if self.tp_size > 1:
672 + # All-gather once, then reconstruct [q_full, k_full, v_full].
673 + qkv_full_sharded = tensor_model_parallel_all_gather(qkv.contiguous())
674 + q_local_dim = q.shape[-1]
675 + kv_local_dim = k.shape[-1]
676 + qkv_full_sharded = qkv_full_sharded.view(
677 + qkv.shape[0],
678 + self.tp_size,
679 + q_local_dim + 2 * kv_local_dim,
680 + )
681 + q_full = qkv_full_sharded[:, :, :q_local_dim].reshape(qkv.shape[0], -1)
682 + k_full = qkv_full_sharded[
683 + :, :, q_local_dim : q_local_dim + kv_local_dim
684 + ].reshape(qkv.shape[0], -1)
685 + v_full = qkv_full_sharded[:, :, q_local_dim + kv_local_dim :].reshape(
686 + qkv.shape[0], -1
687 + )
688 + qkv_full = torch.cat([q_full, k_full, v_full], dim=-1).contiguous()
689 + else:
690 + qkv_full = qkv
691 +
692 + # Compute tau scaling factors matching HF implementation exactly:
693 + # tok_feat = gelu(qkv)
694 + # tok_q = tanh(tok_feat @ tau_wq.T) # [num_tokens, num_heads]
695 + # tau_pos = 1 + (sigmoid(alpha * log(pos+1)) - 0.5) # [num_heads, num_tokens]
696 + # tau = (tok_q.T + tau_pos).T # [num_tokens, num_heads]
697 + num_tokens = qkv_full.shape[0]
698 + orig_dtype = q.dtype
699 +
700 + # Token-based component
701 + tok_feat = F.gelu(qkv_full) # Apply GELU activation
702 + tok_q = torch.tanh(tok_feat @ self.tau_wq.t()) # [N, H_per_partition]
703 + tok_v = torch.tanh(tok_feat @ self.tau_wv.t()) # [N, H_per_partition]
704 +
705 + # Position-based component
706 + # tau_pos = 1 + (sigmoid(alpha * log(pos+1)) - 0.5)
707 + # positions is [num_tokens], need to compute for each head
708 + # tau_alpha: [num_heads_per_partition]
709 + pos_float = (positions.to(orig_dtype) + 1.0).clamp(min=1e-6)
710 + pos_log = pos_float.log() # [num_tokens]
711 + # alpha[:, None] * pos_log[None, :] -> [num_heads, num_tokens]
712 + tau_pos = 1.0 + (
713 + torch.sigmoid(self.tau_alpha[:, None] * pos_log[None, :]) - 0.5
714 + ) # [H_per_partition, N]
715 +
716 + # Combine token and position components
717 + tau_q = (tok_q + tau_pos.t()).to(orig_dtype) # [N, H_per_partition]
718 + tau_v = (tok_v + tau_pos.t()).to(orig_dtype) # [N, H_per_partition]
719 +
720 + # Reshape q and v to apply per-head tau scaling
721 + q = q.view(num_tokens, self.num_heads_per_partition, self.head_dim)
722 + v = v.view(num_tokens, self.num_kv_heads_per_partition, self.head_dim)
723 +
724 + # Apply tau scaling
725 + q = q * tau_q.unsqueeze(-1)
726 + v = v * tau_v[:, : self.num_kv_heads_per_partition].unsqueeze(-1)
727 +
728 + # Reshape back
729 + q = q.view(num_tokens, -1)
730 + v = v.view(num_tokens, -1)
731 +
732 + q, k = self.rotary_emb(positions, q, k)
733 +
734 + attn_output = self.attn(q, k, v)
735 +
736 + output, _ = self.out_proj(attn_output)
737 + return output
738 +
739 +
740 +class Moondream3DecoderLayer(nn.Module):
741 + """Decoder layer with attention + MLP/MoE."""
742 +
743 + def __init__(
744 + self,
745 + config: Moondream3TextConfig,
746 + cache_config=None,
747 + quant_config: QuantizationConfig | None = None,
748 + prefix: str = "",
749 + ):
750 + super().__init__()
751 + layer_idx = extract_layer_index(prefix)
752 + self.layer_idx = layer_idx
753 +
754 + self.ln = nn.LayerNorm(config.dim, eps=1e-5, bias=True)
755 +
756 + self.attn = Moondream3Attention(
757 + config=config,
758 + layer_idx=layer_idx,
759 + cache_config=cache_config,
760 + quant_config=quant_config,
761 + prefix=f"{prefix}.attn",
762 + )
763 +
764 + # Use MoE for layers >= moe_start_layer, standard MLP otherwise
765 + if layer_idx >= config.moe_start_layer:
766 + self.mlp = Moondream3TextMoE(
767 + hidden_size=config.dim,
768 + expert_inner_dim=config.moe_expert_inner_dim,
769 + num_experts=config.moe_num_experts,
770 + experts_per_token=config.moe_experts_per_token,
771 + quant_config=quant_config,
772 + prefix=f"{prefix}.mlp",
773 + )
774 + else:
775 + self.mlp = Moondream3TextMLP(
776 + hidden_size=config.dim,
777 + intermediate_size=config.ff_dim,
778 + quant_config=quant_config,
779 + prefix=f"{prefix}.mlp",
780 + )
781 +
782 + def forward(
783 + self,
784 + positions: torch.Tensor,
785 + hidden_states: torch.Tensor,
786 + ) -> torch.Tensor:
787 + # Pre-norm architecture
788 + normed = self.ln(hidden_states)
789 + attn_out = self.attn(positions, normed)
790 + mlp_out = self.mlp(normed)
791 + hidden_states = hidden_states + attn_out + mlp_out
792 + return hidden_states
793 +
794 +
795 +class Moondream3TextModel(nn.Module):
796 + """Text decoder model."""
797 +
798 + def __init__(
799 + self,
800 + config: Moondream3TextConfig,
801 + cache_config=None,
802 + quant_config: QuantizationConfig | None = None,
803 + prefix: str = "",
804 + ):
805 + super().__init__()
806 + self.config = config
807 +
808 + self.wte = VocabParallelEmbedding(
809 + config.vocab_size,
810 + config.dim,
811 + prefix=f"{prefix}.wte",
812 + )
813 +
814 + blocks_prefix = maybe_prefix(prefix, "blocks")
815 + self.start_layer, self.end_layer, self.blocks = make_layers(
816 + config.n_layers,
817 + lambda prefix: Moondream3DecoderLayer(
818 + config=config,
819 + cache_config=cache_config,
820 + quant_config=quant_config,
821 + prefix=prefix,
822 + ),
823 + prefix=blocks_prefix,
824 + )
825 +
826 + self.post_ln = nn.LayerNorm(config.dim, eps=1e-5, bias=True)
827 + self.make_empty_intermediate_tensors = make_empty_intermediate_tensors_factory(
828 + ["hidden_states"], config.dim
829 + )
830 +
831 + def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
832 + return self.wte(input_ids)
833 +
834 + def forward(
835 + self,
836 + input_ids: torch.Tensor | None,
837 + positions: torch.Tensor,
838 + intermediate_tensors: IntermediateTensors | None = None,
839 + inputs_embeds: torch.Tensor | None = None,
840 + ) -> torch.Tensor | IntermediateTensors:
841 + pp_group = get_pp_group()
842 + if pp_group.is_first_rank:
843 + if inputs_embeds is not None:
844 + hidden_states = inputs_embeds
845 + else:
846 + assert input_ids is not None
847 + hidden_states = self.embed_input_ids(input_ids)
848 + else:
849 + assert intermediate_tensors is not None
850 + hidden_states = intermediate_tensors["hidden_states"]
851 +
852 + for i, layer in enumerate(
853 + islice(self.blocks, self.start_layer, self.end_layer)
854 + ):
855 + hidden_states = layer(positions, hidden_states)
856 +
857 + if not pp_group.is_last_rank:
858 + return IntermediateTensors({"hidden_states": hidden_states})
859 +
860 + hidden_states = self.post_ln(hidden_states)
861 + return hidden_states
862 +
863 +
864 +@dataclass(frozen=True)
865 +class Moondream3ImageInput:
866 + """Container holding per-image inputs for embedding."""
867 +
868 + pixel_values: torch.Tensor
869 + tiling: tuple[int, int] | None
870 +
871 +
872 +# ============================================================================
873 +# Multimodal Processing
874 +# ============================================================================
875 +
876 +
877 +class Moondream3ProcessingInfo(BaseProcessingInfo):
878 + """Processing info for Moondream3."""
879 +
880 + def get_hf_config(self):
881 + return self.ctx.get_hf_config()
882 +
883 + def get_hf_processor(self, **kwargs: object):
884 + return self.ctx.get_hf_processor(Moondream3Processor, **kwargs)
885 +
886 + def get_supported_mm_limits(self) -> Mapping[str, int | None]:
887 + return {"image": 1}
888 +
889 + def get_num_image_tokens(
890 + self,
891 + *,
892 + image_width: int,
893 + image_height: int,
894 + ) -> int:
895 + # HF pre-fills BOS together with the fixed 27x27 vision grid under
896 + # the same bidirectional prefix mask: 1 BOS + 729 image embeddings.
897 + return 730
898 +
899 + def get_image_size_with_most_features(self) -> ImageSize:
900 + return ImageSize(width=378, height=378)
901 +
902 + def get_max_image_tokens(self) -> int:
903 + return 730
904 +
905 + def get_mm_max_tokens_per_item(
906 + self,
907 + seq_len: int,
908 + mm_counts: Mapping[str, int],
909 + ) -> Mapping[str, int]:
910 + return {"image": self.get_max_image_tokens()}
911 +
912 +
913 +class Moondream3DummyInputsBuilder(BaseDummyInputsBuilder[Moondream3ProcessingInfo]):
914 + """Dummy inputs builder for profiling."""
915 +
916 + def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
917 + return (
918 + "<|endoftext|><image><|md_reserved_0|>query<|md_reserved_1|>"
919 + "What is this image?<|md_reserved_2|>"
920 + )
921 +
922 + def get_dummy_mm_data(
923 + self,
924 + seq_len: int,
925 + mm_counts: Mapping[str, int],
926 + mm_options: Mapping[str, BaseDummyOptions] | None = None,
927 + mm_processor_kwargs: Mapping[str, object] | None = None,
928 + ) -> MultiModalDataDict:
929 + num_images = mm_counts.get("image", 0)
930 + return {
931 + "image": self._get_dummy_images(
932 + width=378,
933 + height=378,
934 + num_images=num_images,
935 + )
936 + }
937 +
938 +
939 +class Moondream3MultiModalProcessor(BaseMultiModalProcessor[Moondream3ProcessingInfo]):
940 + """Multimodal processor for Moondream3."""
941 +
942 + image_placeholder: str = "<image>"
943 + bos_image_placeholder: str = "<|endoftext|><image>"
944 +
945 + def _call_hf_processor(
946 + self,
947 + prompt: str,
948 + mm_data: Mapping[str, object],
949 + mm_kwargs: Mapping[str, object],
950 + tok_kwargs: Mapping[str, object],
951 + ) -> BatchFeature:
952 + # Moondream3's processor handles images directly rather than exposing a
953 + # separate `image_processor`, so keep the cache path on text+MM calls.
954 + return super()._call_hf_processor(prompt, mm_data, mm_kwargs, tok_kwargs)
955 +
956 + @cached_property
957 + def bos_image_placeholder_tokens(self) -> list[int]:
958 + tokenizer = self.info.get_tokenizer()
959 + token_ids = tokenizer.encode(
960 + self.bos_image_placeholder,
961 + add_special_tokens=False,
962 + )
963 + if len(token_ids) < 2:
964 + raise ValueError(
965 + "Tokenizer could not encode Moondream3 BOS/image placeholder "
966 + f"{self.bos_image_placeholder!r}."
967 + )
968 + return token_ids
969 +
970 + def _get_mm_fields_config(
971 + self,
972 + hf_inputs: BatchFeature,
973 + hf_processor_mm_kwargs: Mapping[str, object],
974 + ) -> Mapping[str, MultiModalFieldConfig]:
975 + return {
976 + "pixel_values": MultiModalFieldConfig.batched("image"),
977 + "tilings": MultiModalFieldConfig.batched("image", keep_on_cpu=True),
978 + }
979 +
980 + def _hf_processor_applies_updates(
981 + self,
982 + prompt_text: str,
983 + mm_items: MultiModalDataItems,
984 + hf_processor_mm_kwargs: Mapping[str, object],
985 + tokenization_kwargs: Mapping[str, object],
986 + ) -> bool:
987 + # Moondream3 HF processor does NOT expand placeholder tokens.
988 + # vLLM expands BOS + <image> so the whole HF image prefix is marked
989 + # bidirectional by the multimodal prefix-LM mask.
990 + return False
991 +
992 + def _get_prompt_updates(
993 + self,
994 + mm_items: MultiModalDataItems,
995 + hf_processor_mm_kwargs: Mapping[str, object],
996 + out_mm_kwargs: MultiModalKwargsItems,
997 + ) -> list[PromptUpdate]:
998 + image_size = self.info.get_image_size_with_most_features()
999 + num_image_tokens = self.info.get_num_image_tokens(
1000 + image_width=image_size.width,
1001 + image_height=image_size.height,
1002 + )
1003 + placeholder_tokens = self.bos_image_placeholder_tokens
1004 + bos_token = placeholder_tokens[0]
1005 + image_token = placeholder_tokens[-1]
1006 + return [
1007 + PromptReplacement(
1008 + modality="image",
1009 + target=placeholder_tokens,
1010 + replacement=PromptUpdateDetails(
1011 + full=[bos_token] + [image_token] * (num_image_tokens - 1),
1012 + ),
1013 + ),
1014 + ]
1015 +
1016 +
1017 +# ============================================================================
1018 +# Main Model
1019 +# ============================================================================
1020 +
1021 +
1022 +@MULTIMODAL_REGISTRY.register_processor(
1023 + Moondream3MultiModalProcessor,
1024 + info=Moondream3ProcessingInfo,
1025 + dummy_inputs=Moondream3DummyInputsBuilder,
1026 +)
1027 +class Moondream3ForCausalLM(nn.Module, SupportsMultiModal, SupportsPP):
1028 + """Moondream3 multimodal model for causal language modeling.
1029 +
1030 + vLLM supports the standard autoregressive Moondream3 query and caption
1031 + prompt formats. The region-module point/detect skills require custom
1032 + coordinate decoding and are intentionally not exposed here.
1033 + """
1034 +
1035 + supports_multimodal = True
1036 + packed_modules_mapping = {
1037 + "qkv_proj": ["q_proj", "k_proj", "v_proj"],
1038 + }
1039 +
1040 + def __init__(
1041 + self,
1042 + *,
1043 + vllm_config: VllmConfig,
1044 + prefix: str = "",
1045 + ):
1046 + super().__init__()
1047 +
1048 + hf_config = vllm_config.model_config.hf_config
1049 + quant_config = vllm_config.quant_config
1050 + cache_config = vllm_config.cache_config
1051 +
1052 + # Reuse the transformers_utils config implementation.
1053 + if isinstance(hf_config, Moondream3Config):
1054 + self.config = hf_config
1055 + else:
1056 + config_dict = hf_config.config if hasattr(hf_config, "config") else {}
1057 + self.config = Moondream3Config(config=config_dict)
1058 +
1059 + with self._mark_tower_model(vllm_config, "image"):
1060 + # Vision encoder
1061 + self.vision = Moondream3VisionEncoder(
1062 + config=self.config.vision_config,
1063 + quant_config=quant_config,
1064 + prefix=maybe_prefix(prefix, "vision"),
1065 + )
1066 +
1067 + # Vision projection
1068 + self.vision_proj = Moondream3VisionProjection(
1069 + input_dim=self.config.vision_config.enc_dim,
1070 + inner_dim=self.config.vision_config.proj_inner_dim,
1071 + output_dim=self.config.text_config.dim,
1072 + quant_config=quant_config,
1073 + prefix=maybe_prefix(prefix, "vision_proj"),
1074 + )
1075 +
1076 + with self._mark_language_model(vllm_config):
1077 + # Text decoder
1078 + self.text = Moondream3TextModel(
1079 + config=self.config.text_config,
1080 + cache_config=cache_config,
1081 + quant_config=quant_config,
1082 + prefix=maybe_prefix(prefix, "text"),
1083 + )
1084 +
1085 + # LM head (with bias - Moondream3 has lm_head bias)
1086 + self.lm_head = ParallelLMHead(
1087 + self.config.text_config.vocab_size,
1088 + self.config.text_config.dim,
1089 + bias=True,
1090 + quant_config=quant_config,
1091 + prefix=maybe_prefix(prefix, "lm_head"),
1092 + )
1093 +
1094 + self.logits_processor = LogitsProcessor(self.config.text_config.vocab_size)
1095 + self.make_empty_intermediate_tensors = self.text.make_empty_intermediate_tensors
1096 + self._answer_id = getattr(
1097 + self.config,
1098 + "answer_token_id",
1099 + getattr(hf_config, "answer_token_id", 3),
1100 + )
1101 +
1102 + @classmethod
1103 + def get_placeholder_str(cls, modality: str, i: int) -> str | None:
1104 + if modality == "image":
1105 + return "<image>"
1106 + return None
1107 +
1108 + def get_language_model(self) -> nn.Module:
1109 + return self.text
1110 +
1111 + def get_num_mm_encoder_tokens(self, num_image_tokens: int) -> int:
1112 + return num_image_tokens
1113 +
1114 + def get_num_mm_connector_tokens(self, num_vision_tokens: int) -> int:
1115 + return num_vision_tokens
1116 +
1117 + def _split_pixel_values(
1118 + self,
1119 + pixel_values: object,
1120 + ) -> list[torch.Tensor]:
1121 + # The processor should standardize image inputs into:
1122 + # - torch.Tensor [num_images, num_crops, C, H, W], or
1123 + # - list[torch.Tensor[num_crops, C, H, W]] for ragged crops.
1124 + if isinstance(pixel_values, torch.Tensor):
1125 + if pixel_values.dim() != 5:
1126 + raise ValueError(
1127 + "Expected `pixel_values` tensor with shape "
1128 + "[num_images, num_crops, C, H, W], got "
1129 + f"{tuple(pixel_values.shape)}."
1130 + )
1131 + return [pv.contiguous() for pv in pixel_values]
1132 +
1133 + if isinstance(pixel_values, (list, tuple)):
1134 + tensors: list[torch.Tensor] = []
1135 + for value in pixel_values:
1136 + if not isinstance(value, torch.Tensor):
1137 + raise TypeError(
1138 + "Expected each `pixel_values` element to be a tensor, "
1139 + f"got {type(value)!r}."
1140 + )
1141 + if value.dim() != 4:
1142 + raise ValueError(
1143 + f"Unsupported pixel_values element shape {tuple(value.shape)}."
1144 + )
1145 + tensors.append(value.contiguous())
1146 + return tensors
1147 +
1148 + raise TypeError(
1149 + "pixel_values must be a tensor or a sequence of tensors, "
1150 + f"got {type(pixel_values)!r}."
1151 + )
1152 +
1153 + def _split_tilings(
1154 + self,
1155 + tilings: object,
1156 + expected: int,
1157 + ) -> list[tuple[int, int] | None]:
1158 + if tilings is None:
1159 + return [None] * expected
1160 +
1161 + if isinstance(tilings, torch.Tensor):
1162 + if tilings.dim() != 2 or tilings.shape[1] != 2:
1163 + raise ValueError(
1164 + "Expected `tilings` tensor with shape [num_images, 2], got "
1165 + f"{tuple(tilings.shape)}."
1166 + )
1167 + tiling_items = tilings.tolist()
1168 + elif isinstance(tilings, (list, tuple)):
1169 + tiling_items = list(tilings)
1170 + else:
1171 + raise TypeError(
1172 + "tilings must be None, a tensor or a sequence of tuples, "
1173 + f"got {type(tilings)!r}."
1174 + )
1175 +
1176 + if len(tiling_items) != expected:
1177 + raise ValueError(
1178 + "Mismatch between the number of pixel_values entries "
1179 + f"({expected}) and tilings ({len(tiling_items)})."
1180 + )
1181 +
1182 + normalized: list[tuple[int, int] | None] = []
1183 + for tiling in tiling_items:
1184 + if tiling is None:
1185 + normalized.append(None)
1186 + continue
1187 + if isinstance(tiling, torch.Tensor):
1188 + tiling = tiling.tolist()
1189 + if isinstance(tiling, (list, tuple)) and len(tiling) == 2:
1190 + normalized.append((int(tiling[0]), int(tiling[1])))
1191 + else:
1192 + raise ValueError(
1193 + f"Each tiling entry must be a pair of integers, got {tiling!r}."
1194 + )
1195 + return normalized
1196 +
1197 + def _parse_image_inputs(self, **kwargs: object) -> list[Moondream3ImageInput]:
1198 + pixel_values = kwargs.get("pixel_values")
1199 + if pixel_values is None:
1200 + return []
1201 +
1202 + pixel_values_list = self._split_pixel_values(pixel_values)
1203 + tilings_list = self._split_tilings(
1204 + kwargs.get("tilings"), len(pixel_values_list)
1205 + )
1206 +
1207 + image_inputs: list[Moondream3ImageInput] = []
1208 + for value, tiling in zip(pixel_values_list, tilings_list):
1209 + if value.dim() != 4:
1210 + raise ValueError(
1211 + f"Expected 4D tensor for crops, got {tuple(value.shape)}."
1212 + )
1213 + image_inputs.append(Moondream3ImageInput(pixel_values=value, tiling=tiling))
1214 + return image_inputs
1215 +
1216 + def _encode_image_input(self, image_input: Moondream3ImageInput) -> torch.Tensor:
1217 + pixel_values = image_input.pixel_values
1218 + if pixel_values.dim() != 4:
1219 + raise ValueError(
1220 + f"Expected 4D tensor for crops, got {tuple(pixel_values.shape)}."
1221 + )
1222 +
1223 + device = self.vision.patch_emb.weight.device
1224 + dtype = self.vision.patch_emb.weight.dtype
1225 + pixel_values = pixel_values.to(device=device, dtype=dtype)
1226 +
1227 + features = self.vision(pixel_values)
1228 +
1229 + # Grid size = crop_size / patch_size (e.g., 378 / 14 = 27)
1230 + grid_size = (
1231 + self.config.vision_config.crop_size
1232 + // self.config.vision_config.enc_patch_size
1233 + )
1234 + enc_dim = self.config.vision_config.enc_dim
1235 + global_features = features[0]
1236 +
1237 + if features.shape[0] > 1:
1238 + if image_input.tiling is None:
1239 + raise ValueError(
1240 + "Missing tiling metadata for multi-crop Moondream image."
1241 + )
1242 + local = features[1:].contiguous().view(-1, grid_size, grid_size, enc_dim)
1243 + reconstructed = reconstruct_from_crops(
1244 + local,
1245 + image_input.tiling,
1246 + overlap_margin=self.config.vision_config.overlap_margin,
1247 + patch_size=1,
1248 + )
1249 + else:
1250 + reconstructed = global_features.view(grid_size, grid_size, enc_dim)
1251 +
1252 + recon = reconstructed.permute(2, 0, 1).contiguous()
1253 + # Mirror HF reference behavior: reconstructed local features are pooled
1254 + # to enc_n_layers x enc_n_layers. For moondream3-preview this is 27x27.
1255 + pooled_size = self.config.vision_config.enc_n_layers
1256 + if pooled_size != grid_size:
1257 + logger.warning_once(
1258 + "Moondream3 pooled_size (%d) differs from crop grid (%d). "
1259 + "Using enc_n_layers to match HF reference behavior.",
1260 + pooled_size,
1261 + grid_size,
1262 + )
1263 + recon = F.adaptive_avg_pool2d(recon, output_size=(pooled_size, pooled_size))
1264 + recon = recon.permute(1, 2, 0).contiguous().view(-1, enc_dim)
1265 +
1266 + combined = torch.cat([global_features, recon], dim=-1).unsqueeze(0)
1267 + projected = self.vision_proj(combined).squeeze(0)
1268 +
1269 + # Note: Vision embeddings are already synchronized across TP ranks
1270 + # because the vision projection uses RowParallelLinear which performs
1271 + # all-reduce internally, ensuring identical outputs on all ranks.
1272 +
1273 + return projected
1274 +
1275 + def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
1276 + """Generate the HF image prefix: BOS embedding + 729 image embeddings."""
1277 + image_inputs = self._parse_image_inputs(**kwargs)
1278 + if not image_inputs:
1279 + return []
1280 +
1281 + device = self.vision.patch_emb.weight.device
1282 + bos_ids = torch.tensor([self.config.bos_token_id], device=device)
1283 + bos_embedding = self.text.embed_input_ids(bos_ids)
1284 +
1285 + embeddings: list[torch.Tensor] = []
1286 + for image_input in image_inputs:
1287 + image_embeddings = self._encode_image_input(image_input)
1288 + embeddings.append(
1289 + torch.cat([bos_embedding.to(image_embeddings.dtype), image_embeddings])
1290 + )
1291 + return embeddings
1292 +
1293 + def forward(
1294 + self,
1295 + input_ids: torch.Tensor | None,
1296 + positions: torch.Tensor,
1297 + intermediate_tensors: IntermediateTensors | None = None,
1298 + inputs_embeds: torch.Tensor | None = None,
1299 + **kwargs,
1300 + ) -> torch.Tensor | IntermediateTensors:
1301 + hidden_states = self.text(
1302 + input_ids=input_ids,
1303 + positions=positions,
1304 + intermediate_tensors=intermediate_tensors,
1305 + inputs_embeds=inputs_embeds,
1306 + )
1307 + return hidden_states
1308 +
1309 + def compute_logits(
1310 + self,
1311 + hidden_states: torch.Tensor,
1312 + ) -> torch.Tensor | None:
1313 + logits = self.logits_processor(self.lm_head, hidden_states)
1314 + if logits is not None:
1315 + logits[:, self._answer_id] = float("-inf")
1316 + return logits
1317 +
1318 + def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
1319 + """Load weights with remapping from HuggingFace format."""
1320 +
1321 + params_dict = dict(self.named_parameters())
1322 + loaded_params: set[str] = set()
1323 +
1324 + # Get expert intermediate size for fc1 splitting
1325 +
1326 + for name, loaded_weight in weights:
1327 + # Map from HF naming to vLLM naming
1328 + # model.vision.* -> vision.*
1329 + # model.text.* -> text.*
1330 + if name.startswith("model."):
1331 + name = name[6:] # Remove "model." prefix
1332 +
1333 + # Specific name mappings
1334 + # Vision projection: vision.proj_mlp.fc1 -> vision_proj.fc1
1335 + name = name.replace("vision.proj_mlp.", "vision_proj.")
1336 +
1337 + # Text embedding: text.wte (no suffix) -> text.wte.weight
1338 + if name == "text.wte":
1339 + name = "text.wte.weight"
1340 +
1341 + # LM head: text.lm_head -> lm_head
1342 + name = name.replace("text.lm_head.", "lm_head.")
1343 +
1344 + # Attention mapping
1345 + name = name.replace(".attn.qkv.", ".attn.qkv_proj.")
1346 + name = name.replace(".attn.proj.", ".attn.out_proj.")
1347 +
1348 + # Tau attention scaling weights
1349 + # HF format: .attn.tau.alpha -> .attn.tau_alpha
1350 + name = name.replace(".attn.tau.alpha", ".attn.tau_alpha")
1351 + name = name.replace(".attn.tau.wq", ".attn.tau_wq")
1352 + name = name.replace(".attn.tau.wv", ".attn.tau_wv")
1353 +
1354 + # MoE router mapping: mlp.router -> mlp.gate
1355 + name = name.replace(".mlp.router.", ".mlp.gate.")
1356 +
1357 + # Handle MoE expert weights for layers 4+ with expert parallelism
1358 + # fc1.weight: [n_experts, expert_inner_dim * 2, hidden_size] (gate+up)
1359 + # fc2.weight: [n_experts, hidden_size, expert_inner_dim] (down)
1360 + # Each GPU stores n_experts/tp_size experts
1361 + # Note: Only 3D weights are MoE, 2D weights are standard MLP
1362 + if ".mlp.fc1.weight" in name and loaded_weight.dim() == 3:
1363 + from vllm.distributed import get_tensor_model_parallel_rank
1364 +
1365 + tp_size = get_tensor_model_parallel_world_size()
1366 + tp_rank = get_tensor_model_parallel_rank()
1367 + num_experts = loaded_weight.shape[0]
1368 + experts_per_rank = num_experts // tp_size
1369 + expert_start = tp_rank * experts_per_rank
1370 + expert_end = expert_start + experts_per_rank
1371 + # Shard by expert dimension
1372 + loaded_weight = loaded_weight[expert_start:expert_end].contiguous()
1373 + # Map to our custom MoE format: mlp.fc1_weight
1374 + name = name.replace(".mlp.fc1.weight", ".mlp.fc1_weight")
1375 +
1376 + if ".mlp.fc2.weight" in name and loaded_weight.dim() == 3:
1377 + from vllm.distributed import get_tensor_model_parallel_rank
1378 +
1379 + tp_size = get_tensor_model_parallel_world_size()
1380 + tp_rank = get_tensor_model_parallel_rank()
1381 + num_experts = loaded_weight.shape[0]
1382 + experts_per_rank = num_experts // tp_size
1383 + expert_start = tp_rank * experts_per_rank
1384 + expert_end = expert_start + experts_per_rank
1385 + # Shard by expert dimension
1386 + loaded_weight = loaded_weight[expert_start:expert_end].contiguous()
1387 + # Map to our custom MoE format: mlp.fc2_weight
1388 + name = name.replace(".mlp.fc2.weight", ".mlp.fc2_weight")
1389 +
1390 + # Handle tau weights with tensor parallelism
1391 + # tau_alpha: [num_heads] -> [num_heads/tp]
1392 + # tau_wq: [num_heads, qkv_dim] -> [num_heads/tp, qkv_dim/tp]
1393 + # tau_wv: [num_heads, qkv_dim] -> [num_heads/tp, qkv_dim/tp]
1394 + if ".tau_alpha" in name:
1395 + from vllm.distributed import get_tensor_model_parallel_rank
1396 +
1397 + tp_size = get_tensor_model_parallel_world_size()
1398 + tp_rank = get_tensor_model_parallel_rank()
1399 + num_heads = loaded_weight.shape[0]
1400 + heads_per_partition = num_heads // tp_size
1401 + start = tp_rank * heads_per_partition
1402 + end = start + heads_per_partition
1403 + loaded_weight = loaded_weight[start:end].contiguous()
1404 +
1405 + if ".tau_wq" in name or ".tau_wv" in name:
1406 + from vllm.distributed import get_tensor_model_parallel_rank
1407 +
1408 + tp_size = get_tensor_model_parallel_world_size()
1409 + tp_rank = get_tensor_model_parallel_rank()
1410 + num_heads, qkv_dim = loaded_weight.shape
1411 + heads_per_partition = num_heads // tp_size
1412 + # Only shard by head dimension, keep full qkv_dim for all-gather
1413 + head_start = tp_rank * heads_per_partition
1414 + head_end = head_start + heads_per_partition
1415 + loaded_weight = loaded_weight[head_start:head_end, :].contiguous()
1416 +
1417 + if name in params_dict:
1418 + param = params_dict[name]
1419 + weight_loader = getattr(param, "weight_loader", default_weight_loader)
1420 + weight_loader(param, loaded_weight)
1421 + loaded_params.add(name)
1422 +
1423 + return loaded_params
vllm/model_executor/models/registry.py
+2
@@ -484,6 +484,8 @@ _MULTIMODAL_MODELS = {
484 ),
485 "MolmoForCausalLM": ("molmo", "MolmoForCausalLM"),
486 "Molmo2ForConditionalGeneration": ("molmo2", "Molmo2ForConditionalGeneration"),
487 + "Moondream3ForCausalLM": ("moondream3", "Moondream3ForCausalLM"),
488 + "HfMoondream": ("moondream3", "Moondream3ForCausalLM"),
489 "NemotronH_Nano_VL_V2": ("nano_nemotron_vl", "NemotronH_Nano_VL_V2"),
490 "NemotronH_Nano_Omni_Reasoning_V3": ("nano_nemotron_vl", "NemotronH_Nano_VL_V2"),
491 "NemotronH_Super_Omni_Reasoning_V3": ("nano_nemotron_vl", "NemotronH_Nano_VL_V2"),
vllm/transformers_utils/config.py
+1
@@ -114,6 +114,7 @@ _CONFIG_REGISTRY: dict[str, type[PretrainedConfig]] = LazyConfigDict(
114 mlp_speculator="MLPSpeculatorConfig",
115 medusa="MedusaConfig",
116 midashenglm="MiDashengLMConfig",
117 + moondream3="Moondream3Config",
118 eagle="EAGLEConfig",
119 speculators="SpeculatorsConfig",
120 nemotron="NemotronConfig",
vllm/transformers_utils/configs/__init__.py
+6
@@ -50,6 +50,9 @@ _CLASS_TO_MODULE: dict[str, str] = {
50 "MedusaConfig": "vllm.transformers_utils.configs.medusa",
51 "MiDashengLMConfig": "vllm.transformers_utils.configs.midashenglm",
52 "MLPSpeculatorConfig": "vllm.transformers_utils.configs.mlp_speculator",
53 + "Moondream3Config": "vllm.transformers_utils.configs.moondream3",
54 + "Moondream3TextConfig": "vllm.transformers_utils.configs.moondream3",
55 + "Moondream3VisionConfig": "vllm.transformers_utils.configs.moondream3",
56 "MoonViTConfig": "vllm.transformers_utils.configs.moonvit",
57 "KimiLinearConfig": "vllm.transformers_utils.configs.kimi_linear",
58 "KimiVLConfig": "vllm.transformers_utils.configs.kimi_vl",
@@ -111,6 +114,9 @@ __all__ = [
114 "MedusaConfig",
115 "MiDashengLMConfig",
116 "MLPSpeculatorConfig",
117 + "Moondream3Config",
118 + "Moondream3TextConfig",
119 + "Moondream3VisionConfig",
120 "MoonViTConfig",
121 "KimiLinearConfig",
122 "KimiVLConfig",
vllm/transformers_utils/configs/moondream3.py new
+152
@@ -0,0 +1,152 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Configuration for Moondream3 model."""
4 +
5 +from transformers import PretrainedConfig
6 +
7 +
8 +class Moondream3VisionConfig(PretrainedConfig):
9 + """Vision encoder configuration for Moondream3."""
10 +
11 + model_type = "moondream3_vision"
12 +
13 + def __init__(
14 + self,
15 + enc_dim: int = 1152,
16 + enc_patch_size: int = 14,
17 + enc_n_layers: int = 27,
18 + enc_ff_dim: int = 4304,
19 + enc_n_heads: int = 16,
20 + proj_inner_dim: int = 8192,
21 + crop_size: int = 378,
22 + max_crops: int = 12,
23 + overlap_margin: int = 4,
24 + **kwargs,
25 + ):
26 + super().__init__(**kwargs)
27 + self.enc_dim = enc_dim
28 + self.enc_patch_size = enc_patch_size
29 + self.enc_n_layers = enc_n_layers
30 + self.enc_ff_dim = enc_ff_dim
31 + self.enc_n_heads = enc_n_heads
32 + self.proj_inner_dim = proj_inner_dim
33 + self.crop_size = crop_size
34 + self.max_crops = max_crops
35 + self.overlap_margin = overlap_margin
36 +
37 + # Standard HuggingFace attributes for vision config
38 + self.hidden_size = enc_dim
39 + self.num_attention_heads = enc_n_heads
40 + self.num_hidden_layers = enc_n_layers
41 + self.intermediate_size = enc_ff_dim
42 + self.patch_size = enc_patch_size
43 + self.image_size = crop_size
44 +
45 +
46 +class Moondream3TextConfig(PretrainedConfig):
47 + """Text decoder configuration for Moondream3."""
48 +
49 + model_type = "moondream3_text"
50 +
51 + def __init__(
52 + self,
53 + dim: int = 2048,
54 + ff_dim: int = 8192,
55 + n_layers: int = 24,
56 + vocab_size: int = 51200,
57 + max_context: int = 4096,
58 + n_heads: int = 32,
59 + n_kv_heads: int = 32,
60 + prefix_attn: int = 730,
61 + rope_theta: float = 1500000.0,
62 + moe: dict | None = None,
63 + **kwargs,
64 + ):
65 + super().__init__(**kwargs)
66 +
67 + # Store original moondream3 config names
68 + self.dim = dim
69 + self.ff_dim = ff_dim
70 + self.n_layers = n_layers
71 + self.n_heads = n_heads
72 + self.n_kv_heads = n_kv_heads
73 + self.prefix_attn = prefix_attn
74 + self.max_context = max_context
75 + self.rope_theta = rope_theta
76 +
77 + # MoE config
78 + moe = moe or {}
79 + self.moe_start_layer = moe.get("start_layer", 4)
80 + self.moe_num_experts = moe.get("n_experts", 64)
81 + self.moe_experts_per_token = moe.get("n_experts_per_tok", 8)
82 + self.moe_expert_inner_dim = moe.get("expert_inner_dim", 1024)
83 +
84 + # Standard HuggingFace attributes (required by vLLM)
85 + self.hidden_size = dim
86 + self.num_attention_heads = n_heads
87 + self.num_key_value_heads = n_kv_heads
88 + self.num_hidden_layers = n_layers
89 + self.intermediate_size = ff_dim
90 + self.vocab_size = vocab_size
91 + self.max_position_embeddings = max_context
92 +
93 + # Moondream3 uses token 0 (<|endoftext|>) as both BOS and EOS.
94 + # Token 3 (<|md_reserved_2|>) is an answer delimiter that the model
95 + # implementation suppresses during generation.
96 + self.bos_token_id = 0
97 + self.eos_token_id = 0
98 +
99 + # MoE standard attributes
100 + self.num_local_experts = self.moe_num_experts
101 + self.num_experts_per_tok = self.moe_experts_per_token
102 +
103 +
104 +class Moondream3Config(PretrainedConfig):
105 + """Combined configuration for Moondream3 multimodal model."""
106 +
107 + model_type = "moondream3"
108 + is_composition = True
109 +
110 + def __init__(
111 + self,
112 + config: dict | None = None,
113 + **kwargs,
114 + ):
115 + config = config or {}
116 +
117 + # Parse text config
118 + text_config = config.get("text", {})
119 + self.text_config: Moondream3TextConfig = Moondream3TextConfig(**text_config)
120 +
121 + # Parse vision config
122 + vision_config = config.get("vision", {})
123 + self.vision_config = Moondream3VisionConfig(**vision_config)
124 +
125 + # Store the original config dict for model access
126 + self.config = config
127 + tokenizer_config = config.get("tokenizer", {})
128 + self.answer_token_id = tokenizer_config.get("answer_id", 3)
129 +
130 + super().__init__(**kwargs)
131 +
132 + # Expose key attributes at top level for vLLM compatibility
133 + self.hidden_size = self.text_config.hidden_size
134 + self.num_attention_heads = self.text_config.num_attention_heads
135 + self.num_key_value_heads = self.text_config.num_key_value_heads
136 + self.num_hidden_layers = self.text_config.num_hidden_layers
137 + self.vocab_size = self.text_config.vocab_size
138 + self.intermediate_size = self.text_config.intermediate_size
139 +
140 + # Moondream3 uses token 0 (<|endoftext|>) as both BOS and EOS.
141 + # Token 3 (<|md_reserved_2|>) is an answer delimiter that the model
142 + # implementation suppresses during generation.
143 + self.bos_token_id = 0
144 + self.eos_token_id = 0
145 +
146 + def get_text_config(self, decoder: bool = False) -> "Moondream3TextConfig":
147 + """Return the text config for vLLM's text_config detection.
148 +
149 + Args:
150 + decoder: Ignored. Only used for encoder-decoder models.
151 + """
152 + return self.text_config
vllm/transformers_utils/model_arch_config_convertor.py
+1
@@ -267,6 +267,7 @@ class ModelArchConfigConvertorBase:
267 "bagel",
268 "gemma3",
269 "molmo2",
270 + "moondream3",
271 "paligemma",
272 "umm",
273 )
vllm/transformers_utils/processors/__init__.py
+2
@@ -23,6 +23,7 @@ __all__ = [
23 "H2OVLProcessor",
24 "HunYuanVLProcessor",
25 "HunYuanVLImageProcessor",
26 + "Moondream3Processor",
27 "InternVLProcessor",
28 "IsaacProcessor",
29 "KimiAudioProcessor",
@@ -61,6 +62,7 @@ _CLASS_TO_MODULE: dict[str, str] = {
62 "MiMoOmniProcessor": "vllm.transformers_utils.processors.mimo_v2_omni",
63 "MistralCommonPixtralProcessor": "vllm.transformers_utils.processors.pixtral",
64 "MistralCommonVoxtralProcessor": "vllm.transformers_utils.processors.voxtral",
65 + "Moondream3Processor": "vllm.transformers_utils.processors.moondream3",
66 "NanoNemotronVLProcessor": "vllm.transformers_utils.processors.nano_nemotron_vl",
67 "NemotronVLProcessor": "vllm.transformers_utils.processors.nemotron_vl",
68 "LlamaNemotronVLEmbedProcessor": "vllm.transformers_utils.processors.nemotron_vl",
vllm/transformers_utils/processors/moondream3.py new
+541
@@ -0,0 +1,541 @@
1 +# SPDX-License-Identifier: Apache-2.0
2 +# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 +"""Custom processor for Moondream3 model."""
4 +
5 +import math
6 +
7 +import numpy as np
8 +import torch
9 +from PIL import Image
10 +from transformers import AutoProcessor, BatchFeature
11 +from transformers.image_utils import ImageInput
12 +from transformers.processing_utils import ProcessingKwargs, ProcessorMixin, Unpack
13 +from transformers.tokenization_utils_base import (
14 + PreTokenizedInput,
15 + PreTrainedTokenizerBase,
16 + TextInput,
17 +)
18 +
19 +from vllm.multimodal.image import convert_image_mode
20 +
21 +__all__ = ["Moondream3Processor"]
22 +
23 +
24 +class Moondream3ProcessorKwargs(ProcessingKwargs, total=False): # type: ignore[call-arg]
25 + _defaults = {
26 + "text_kwargs": {
27 + "padding": False,
28 + },
29 + "images_kwargs": {
30 + "max_crops": 12,
31 + "overlap_margin": 4,
32 + "crop_size": 378,
33 + "patch_size": 14,
34 + "convert_to_rgb": True,
35 + "return_tensors": "pt",
36 + },
37 + }
38 +
39 +
40 +def select_tiling(
41 + height: int, width: int, crop_size: int, max_crops: int
42 +) -> tuple[int, int]:
43 + """Determine the optimal number of tiles to cover an image."""
44 + if height <= crop_size or width <= crop_size:
45 + return (1, 1)
46 +
47 + min_h = math.ceil(height / crop_size)
48 + min_w = math.ceil(width / crop_size)
49 +
50 + if min_h * min_w > max_crops:
51 + ratio = math.sqrt(max_crops / (min_h * min_w))
52 + return (max(1, math.floor(min_h * ratio)), max(1, math.floor(min_w * ratio)))
53 +
54 + h_tiles = math.floor(math.sqrt(max_crops * height / width))
55 + w_tiles = math.floor(math.sqrt(max_crops * width / height))
56 +
57 + h_tiles = max(h_tiles, min_h)
58 + w_tiles = max(w_tiles, min_w)
59 +
60 + if h_tiles * w_tiles > max_crops:
61 + if w_tiles > h_tiles:
62 + w_tiles = math.floor(max_crops / h_tiles)
63 + else:
64 + h_tiles = math.floor(max_crops / w_tiles)
65 +
66 + return (max(1, h_tiles), max(1, w_tiles))
67 +
68 +
69 +class Moondream3Processor(ProcessorMixin):
70 + """
71 + Constructs a Moondream3 processor which handles image preprocessing
72 + and tokenization for the Moondream3 multimodal model.
73 +
74 + Args:
75 + tokenizer: The tokenizer to use for text processing.
76 + chat_template: Optional chat template string.
77 + crop_size: Size of each image crop.
78 + max_crops: Maximum number of crops per image.
79 + overlap_margin: Margin for overlapping crops in patches.
80 + patch_size: Size of each patch.
81 + """
82 +
83 + attributes = ["tokenizer"]
84 + valid_kwargs = [
85 + "chat_template",
86 + "crop_size",
87 + "max_crops",
88 + "overlap_margin",
89 + "patch_size",
90 + ]
91 +
92 + tokenizer_class = "AutoTokenizer"
93 + # Use separate tokenizer repo
94 + _tokenizer_repo = "moondream/starmie-v1"
95 +
96 + # Default chat template for Moondream3
97 + # Moondream uses special tokens for prompting:
98 + # - Token 0 (<|endoftext|>): BOS token (ALWAYS present at position 0)
99 + # - Token 1 (<|md_reserved_0|>): Start of instruction
100 + # - Token 2 (<|md_reserved_1|>): Separator before question
101 + # - Token 3 (<|md_reserved_2|>): End of question / start of answer
102 + #
103 + # Task routing based on text prefix:
104 + # "caption [short|normal|long]" → describe<|md_reserved_1|>{length}
105 + # "describe [short|normal|long]" → describe<|md_reserved_1|>{length}
106 + # otherwise → query<|md_reserved_1|><text>
107 + #
108 + # Format with image:
109 + # <|endoftext|><image><|md_reserved_0|>{task}<|md_reserved_1|>{q}<|md_reserved_2|>
110 + # Format without image:
111 + # <|endoftext|><|md_reserved_0|>{task}<|md_reserved_1|>{q}<|md_reserved_2|>
112 + _default_chat_template = (
113 + "{% for message in messages %}"
114 + "{% if message['role'] == 'user' %}"
115 + "{% if message['content'] is string %}"
116 + # Simple string content (with image assumed) - route by prefix
117 + "<|endoftext|><image><|md_reserved_0|>"
118 + "{% if message['content'] == 'caption' %}"
119 + "describe<|md_reserved_1|>normal<|md_reserved_2|>"
120 + "{% elif message['content'].startswith('caption ') %}"
121 + "describe<|md_reserved_1|>{{ message['content'][8:] }}<|md_reserved_2|>"
122 + "{% elif message['content'] == 'describe' %}"
123 + "describe<|md_reserved_1|>normal<|md_reserved_2|>"
124 + "{% elif message['content'].startswith('describe ') %}"
125 + "describe<|md_reserved_1|>{{ message['content'][9:] }}<|md_reserved_2|>"
126 + "{% else %}"
127 + "query<|md_reserved_1|>{{ message['content'] }}<|md_reserved_2|>"
128 + "{% endif %}"
129 + "{% else %}"
130 + # List content - build Moondream's image prefix independently of
131 + # OpenAI-style content part order, then render the text task.
132 + "<|endoftext|>"
133 + "{% for content in message['content'] %}"
134 + "{% if content['type'] in ['image', 'image_url', 'input_image', 'image_pil'] %}" # noqa: E501
135 + "<image>"
136 + "{% endif %}"
137 + "{% endfor %}"
138 + "{% for content in message['content'] %}"
139 + "{% if content['type'] == 'text' %}"
140 + "<|md_reserved_0|>"
141 + "{% if content['text'] == 'caption' %}"
142 + "describe<|md_reserved_1|>normal<|md_reserved_2|>"
143 + "{% elif content['text'].startswith('caption ') %}"
144 + "describe<|md_reserved_1|>{{ content['text'][8:] }}<|md_reserved_2|>"
145 + "{% elif content['text'] == 'describe' %}"
146 + "describe<|md_reserved_1|>normal<|md_reserved_2|>"
147 + "{% elif content['text'].startswith('describe ') %}"
148 + "describe<|md_reserved_1|>{{ content['text'][9:] }}<|md_reserved_2|>"
149 + "{% else %}"
150 + "query<|md_reserved_1|>{{ content['text'] }}<|md_reserved_2|>"
151 + "{% endif %}"
152 + "{% endif %}"
153 + "{% endfor %}"
154 + "{% endif %}"
155 + "{% elif message['role'] == 'assistant' %}"
156 + "{{ message['content'] }}"
157 + "{% endif %}"
158 + "{% endfor %}"
159 + )
160 +
161 + def __init__(
162 + self,
163 + tokenizer: PreTrainedTokenizerBase | None = None,
164 + chat_template: str | None = None,
165 + crop_size: int = 378,
166 + max_crops: int = 12,
167 + overlap_margin: int = 4,
168 + patch_size: int = 14,
169 + **kwargs,
170 + ):
171 + self.image_token = "<image>"
172 + self.crop_size = crop_size
173 + self.max_crops = max_crops
174 + self.overlap_margin = overlap_margin
175 + self.patch_size = patch_size
176 +
177 + # Number of patches per crop (27x27 = 729 for 378/14)
178 + self.patches_per_crop = (crop_size // patch_size) ** 2
179 +
180 + # Use default chat template if none provided
181 + if chat_template is None:
182 + chat_template = self._default_chat_template
183 +
184 + super().__init__(tokenizer, chat_template=chat_template)
185 +
186 + @classmethod
187 + def from_pretrained(
188 + cls,
189 + pretrained_model_name_or_path,
190 + **kwargs,
191 + ):
192 + """
193 + Load the processor, using a separate tokenizer repo.
194 +
195 + The moondream3 model uses a custom tokenizer from 'moondream/starmie-v1'
196 + instead of having tokenizer files in the model repo.
197 + """
198 + from transformers import AutoTokenizer, PreTrainedTokenizerFast
199 + from transformers.utils import cached_file
200 +
201 + tokenizer = kwargs.pop("tokenizer", None)
202 +
203 + tokenizer_kwargs = {
204 + "trust_remote_code": kwargs.get("trust_remote_code", False),
205 + }
206 + for key in (
207 + "cache_dir",
208 + "force_download",
209 + "local_files_only",
210 + "revision",
211 + "subfolder",
212 + "token",
213 + "use_fast",
214 + ):
215 + if key in kwargs:
216 + tokenizer_kwargs[key] = kwargs[key]
217 +
218 + cached_file_kwargs = {
219 + key: tokenizer_kwargs[key]
220 + for key in (
221 + "cache_dir",
222 + "force_download",
223 + "local_files_only",
224 + "revision",
225 + "subfolder",
226 + "token",
227 + )
228 + if key in tokenizer_kwargs
229 + }
230 +
231 + def load_tokenizer(repo_or_path):
232 + try:
233 + return AutoTokenizer.from_pretrained(repo_or_path, **tokenizer_kwargs)
234 + except Exception:
235 + tokenizer_file = cached_file(
236 + repo_or_path,
237 + "tokenizer.json",
238 + **cached_file_kwargs,
239 + )
240 + return PreTrainedTokenizerFast(
241 + tokenizer_file=tokenizer_file,
242 + clean_up_tokenization_spaces=False,
243 + )
244 +
245 + if isinstance(tokenizer, str):
246 + tokenizer = load_tokenizer(tokenizer)
247 +
248 + if tokenizer is None:
249 + # Prefer model-local tokenizer files first. If unavailable, fall
250 + # back to moondream's dedicated tokenizer repository.
251 + try:
252 + tokenizer = load_tokenizer(pretrained_model_name_or_path)
253 + except Exception:
254 + tokenizer = load_tokenizer(cls._tokenizer_repo)
255 +
256 + # Configure special tokens for Moondream3
257 + # BOS and EOS are both token 0 (<|endoftext|>), matching the native
258 + # config (TokenizerConfig.bos_id=0, eos_id=0). This is standard for
259 + # GPT-2 style models where <|endoftext|> signals both start and end.
260 + # Token 1 (<|md_reserved_0|>) is a template delimiter, NOT the EOS.
261 + tokenizer.bos_token = "<|endoftext|>"
262 + tokenizer.bos_token_id = 0
263 + tokenizer.eos_token = "<|endoftext|>"
264 + tokenizer.eos_token_id = 0
265 +
266 + # Extract processor-specific kwargs
267 + crop_size = kwargs.pop("crop_size", 378)
268 + max_crops = kwargs.pop("max_crops", 12)
269 + overlap_margin = kwargs.pop("overlap_margin", 4)
270 + patch_size = kwargs.pop("patch_size", 14)
271 + chat_template = kwargs.pop("chat_template", None)
272 +
273 + # Set default chat template on tokenizer if not already set
274 + if chat_template is None:
275 + chat_template = cls._default_chat_template
276 + if tokenizer.chat_template is None:
277 + tokenizer.chat_template = chat_template
278 +
279 + return cls(
280 + tokenizer=tokenizer,
281 + chat_template=chat_template,
282 + crop_size=crop_size,
283 + max_crops=max_crops,
284 + overlap_margin=overlap_margin,
285 + patch_size=patch_size,
286 + )
287 +
288 + def __call__(
289 + self,
290 + images: ImageInput = None,
291 + text: TextInput
292 + | PreTokenizedInput
293 + | list[TextInput]
294 + | list[PreTokenizedInput] = None,
295 + **kwargs: Unpack[Moondream3ProcessorKwargs],
296 + ) -> BatchFeature:
297 + """
298 + Process images and text for Moondream3 model.
299 +
300 + Args:
301 + images: Input images (PIL Image, numpy array, or list thereof).
302 + text: Input text or list of texts.
303 + **kwargs: Additional processing arguments.
304 +
305 + Returns:
306 + BatchFeature with processed inputs.
307 + """
308 + output_kwargs = self._merge_kwargs(
309 + Moondream3ProcessorKwargs,
310 + tokenizer_init_kwargs=self.tokenizer.init_kwargs,
311 + **kwargs,
312 + )
313 +
314 + # Process images
315 + image_features = {}
316 + if images is not None:
317 + processed_images = []
318 + tilings = []
319 +
320 + images_list = images if isinstance(images, list) else [images]
321 + for image in images_list:
322 + pixel_values, tiling = self.preprocess_image(
323 + image, **output_kwargs["images_kwargs"]
324 + )
325 + processed_images.append(pixel_values)
326 + tilings.append(tiling)
327 +
328 + if processed_images:
329 + image_features["pixel_values"] = processed_images
330 + image_features["tilings"] = tilings
331 +
332 + # Process text
333 + if text is not None:
334 + if not isinstance(text, list):
335 + text = [text]
336 +
337 + # Get text kwargs, remove keys we set ourselves
338 + text_kwargs = output_kwargs.get("text_kwargs", {}).copy()
339 + text_kwargs.pop("return_tensors", None)
340 + text_kwargs.pop("add_special_tokens", None)
341 +
342 + # Tokenize text
343 + tokenized = self.tokenizer(
344 + text,
345 + add_special_tokens=True,
346 + return_tensors="pt",
347 + **text_kwargs,
348 + )
349 +
350 + output = BatchFeature(data=dict(tokenized))
351 +
352 + # Add image features
353 + if image_features:
354 + output["pixel_values"] = image_features["pixel_values"]
355 + output["tilings"] = image_features["tilings"]
356 +
357 + return output
358 +
359 + # If only images were provided
360 + return BatchFeature(data=image_features)
361 +
362 + @staticmethod
363 + def _image_array_to_uint8(array: np.ndarray) -> np.ndarray:
364 + if array.dtype == np.uint8:
365 + return np.ascontiguousarray(array)
366 +
367 + if array.dtype == np.bool_:
368 + return np.ascontiguousarray(array.astype(np.uint8) * 255)
369 +
370 + if np.issubdtype(array.dtype, np.floating):
371 + array = np.nan_to_num(array, nan=0.0, posinf=255.0, neginf=0.0)
372 + if array.size > 0 and array.max() <= 1.0:
373 + array = array * 255.0
374 + array = np.rint(array)
375 +
376 + return np.ascontiguousarray(np.clip(array, 0, 255).astype(np.uint8))
377 +
378 + @staticmethod
379 + def _to_pil_image(image: ImageInput) -> Image.Image:
380 + if isinstance(image, Image.Image):
381 + return image
382 +
383 + if isinstance(image, torch.Tensor):
384 + tensor = image.detach().cpu()
385 + if tensor.dtype == torch.bfloat16:
386 + tensor = tensor.to(torch.float32)
387 + image_array = tensor.numpy()
388 + elif isinstance(image, np.ndarray):
389 + image_array = image
390 + else:
391 + raise TypeError(
392 + "Moondream3 images must be PIL images, numpy arrays, "
393 + f"or torch tensors, got {type(image)!r}."
394 + )
395 +
396 + if image_array.ndim == 2:
397 + image_array = Moondream3Processor._image_array_to_uint8(image_array)
398 + return Image.fromarray(image_array)
399 +
400 + if image_array.ndim != 3:
401 + raise ValueError(
402 + "Moondream3 image arrays must have 2 or 3 dimensions, "
403 + f"got shape {image_array.shape}."
404 + )
405 +
406 + channel_dims = (1, 3, 4)
407 + if image_array.shape[-1] not in channel_dims:
408 + if image_array.shape[0] not in channel_dims:
409 + raise ValueError(
410 + "Moondream3 image arrays must be HWC or CHW with 1, 3, "
411 + f"or 4 channels, got shape {image_array.shape}."
412 + )
413 + image_array = np.transpose(image_array, (1, 2, 0))
414 +
415 + image_array = Moondream3Processor._image_array_to_uint8(image_array)
416 + if image_array.shape[-1] == 1:
417 + image_array = image_array[..., 0]
418 +
419 + return Image.fromarray(image_array)
420 +
421 + def preprocess_image(
422 + self,
423 + image: ImageInput,
424 + max_crops: int = 12,
425 + overlap_margin: int = 4,
426 + crop_size: int = 378,
427 + patch_size: int = 14,
428 + convert_to_rgb: bool = True,
429 + return_tensors: str = "pt",
430 + ) -> tuple[torch.Tensor, tuple[int, int]]:
431 + """
432 + Preprocess an image using overlap-and-resize cropping strategy.
433 +
434 + Args:
435 + image: Input PIL image, numpy array, or torch tensor.
436 + max_crops: Maximum number of crops.
437 + overlap_margin: Margin for overlapping in patches.
438 + crop_size: Size of each crop.
439 + patch_size: Size of each patch.
440 + convert_to_rgb: Whether to convert to RGB.
441 + return_tensors: Return type ("pt" for PyTorch).
442 +
443 + Returns:
444 + Tuple of (pixel_values tensor, tiling tuple).
445 + """
446 + image = self._to_pil_image(image)
447 + if convert_to_rgb:
448 + image = convert_image_mode(image, "RGB")
449 +
450 + # Convert to numpy array
451 + image_array = np.array(image)
452 + original_h, original_w = image_array.shape[:2]
453 +
454 + margin_pixels = patch_size * overlap_margin
455 + total_margin_pixels = margin_pixels * 2
456 +
457 + crop_patches = crop_size // patch_size
458 + crop_window_patches = crop_patches - (2 * overlap_margin)
459 + crop_window_size = crop_window_patches * patch_size
460 +
461 + tiling = select_tiling(
462 + original_h - total_margin_pixels,
463 + original_w - total_margin_pixels,
464 + crop_window_size,
465 + max_crops,
466 + )
467 +
468 + n_crops = tiling[0] * tiling[1] + 1
469 + crops = np.zeros((n_crops, crop_size, crop_size, 3), dtype=np.uint8)
470 +
471 + target_size = (
472 + tiling[0] * crop_window_size + total_margin_pixels,
473 + tiling[1] * crop_window_size + total_margin_pixels,
474 + )
475 +
476 + # Resize image
477 + pil_img = Image.fromarray(image_array)
478 + resized = pil_img.resize(
479 + (int(target_size[1]), int(target_size[0])),
480 + resample=Image.Resampling.LANCZOS,
481 + )
482 + resized_array = np.asarray(resized)
483 +
484 + # Create global crop
485 + global_pil = pil_img.resize(
486 + (crop_size, crop_size), resample=Image.Resampling.LANCZOS
487 + )
488 + crops[0] = np.asarray(global_pil)
489 +
490 + # Create local crops
491 + for i in range(tiling[0]):
492 + for j in range(tiling[1]):
493 + y0 = i * crop_window_size
494 + x0 = j * crop_window_size
495 + y_end = min(y0 + crop_size, resized_array.shape[0])
496 + x_end = min(x0 + crop_size, resized_array.shape[1])
497 +
498 + crop_region = resized_array[y0:y_end, x0:x_end]
499 + crop_idx = 1 + i * tiling[1] + j
500 + h_slice = slice(None, crop_region.shape[0])
501 + w_slice = slice(None, crop_region.shape[1])
502 + crops[crop_idx, h_slice, w_slice] = crop_region
503 +
504 + # Convert to tensor: (n_crops, H, W, C) -> (n_crops, C, H, W)
505 + pixel_values = np.transpose(crops, (0, 3, 1, 2))
506 +
507 + if return_tensors == "pt":
508 + # Match HF reference preprocessing exactly: convert uint8 crops to
509 + # bfloat16 before in-place normalization.
510 + pixel_values = (
511 + torch.from_numpy(pixel_values)
512 + .to(dtype=torch.bfloat16)
513 + .div_(255.0)
514 + .sub_(0.5)
515 + .div_(0.5)
516 + )
517 + else:
518 + pixel_values = pixel_values.astype(np.float32) / 255.0
519 + pixel_values = (pixel_values - 0.5) / 0.5
520 +
521 + return pixel_values, tiling
522 +
523 + def get_num_image_tokens(self) -> int:
524 + """Return the number of image tokens (729 = 27x27 patches)."""
525 + return self.patches_per_crop
526 +
527 + def batch_decode(self, *args, **kwargs):
528 + """Forward to tokenizer's batch_decode."""
529 + return self.tokenizer.batch_decode(*args, **kwargs)
530 +
531 + def decode(self, *args, **kwargs):
532 + """Forward to tokenizer's decode."""
533 + return self.tokenizer.decode(*args, **kwargs)
534 +
535 + @property
536 + def model_input_names(self):
537 + tokenizer_input_names = self.tokenizer.model_input_names
538 + return tokenizer_input_names + ["pixel_values", "tilings"]
539 +
540 +
541 +AutoProcessor.register("Moondream3Processor", Moondream3Processor)