npx skills add ...
npx skills add nvidia/tensorrt-llm --skill trtllm-model-onboard-multimodal
Onboard a HuggingFace multimodal model (vision/audio/video + text) to the TensorRT-LLM PyTorch backend. Use when writing a new `tensorrt_llm/_torch/models/modeling_<vlm>.py` plus its input processor and weight mapper, or extending an existing VLM. Not for AutoDeploy — use `ad-model-onboard` for that path.
npx skills add nvidia/tensorrt-llm --skill trtllm-model-onboard-multimodal
Scope. PyTorch backend only (
tensorrt_llm/_torch/) — the default forLLM(..., backend="pytorch"),trtllm-serve,trtllm-bench. Not for AutoDeploy (tensorrt_llm/_torch/auto_deploy/); usead-model-onboardfor that.
Output:
tensorrt_llm/_torch/models/modeling_{name}.py — wrapper class (multimodal encoder + LLM) decorated with @register_auto_model, @register_vision_encoder, @register_input_processor (and @support_multimodal_disaggregated if EPD is supported), plus a BaseMultimodalInputProcessor (+ BaseMultimodalDummyInputsBuilder) subclass._torch/models/checkpoints/hf/{name}_weight_mapper.py if HF prefixes need surgery.tests/unittest/_torch/modeling/test_modeling_<name>.py (subclass of TestModelingMultimodal); supplemental utility tests under tests/unittest/_torch/multimodal/ if needed; an accuracy test under tests/integration/defs/accuracy/test_llm_api_pytorch_multimodal.py; support-matrix entry; verified trtllm-serve flow.Key invariants:
asyncio.gather; [2] is single-threaded per request because the HF processor is request-scoped.non_blocking=True from pinned host memory.When @support_multimodal_disaggregated is set and the deployment uses TLLM_MULTIMODAL_DISAGGREGATED=1:
MultimodalEncoder (mm_encoder_only=True). It executes only the multimodal encoder and ships mm_embeddings (+ mRoPE position ids/deltas) to prefill+decode workers as shared-tensor handles.__init__ skips constructing self.mm_encoder when _is_mm_disagg() is true; the input processor's attach_multimodal_embeddings() override binds the encoder handles into the request. For context-only requests, the engine re-clones mrope tensors so IPC handles outlive the encoder worker's freed memory — replicate that pattern for any new GPU-resident mm tensors.modeling_qwen3vl.py, modeling_llava_next.py, and modeling_gemma3vl.py are the canonical references — fully-ported encoder, single-class wrapper, text_config-based LLM resolution. Other examples by modality: modeling_pixtral.py, modeling_phi4mm.py (audio), modeling_mllama.py, modeling_hyperclovax.py, modeling_mistral_large3.py. Pick the closest one (modality + LLM family + RoPE variant). modeling_qwen2vl.py retains an HF-passthrough vision tower for the outdated Qwen2-VL family — read it for context but don't copy that pattern.
Every common block has a TRT-LLM implementation; compose, don't reimplement. Hand-rolled nn.Linear / nn.LayerNorm / nn.MultiheadAttention silently work in fp16/bf16 single-GPU eager and silently break under quantization, TP, attention-backend selection, KV cache, and CUDA graphs. Browse tensorrt_llm/_torch/modules/ before writing a layer; the reference VLMs (modeling_qwen3vl.py, modeling_llava_next.py, modeling_gemma3vl.py) show canonical wiring. Reuse is also where every future perf improvement lands automatically.
Mappings most often missed by adapters:
| Concern | Module | Non-obvious wiring |
|---|---|---|
| Linear | _torch.modules.linear.Linear | Pass mapping=model_config.mapping, tensor_parallel_mode=TensorParallelMode.{COLUMN,ROW,NONE}, allreduce_strategy=model_config.allreduce_strategy. Every quant scheme (FP8 / NVFP4 / W4A8 / AWQ / weight-only) is automatic — never substitute nn.Linear. |
| Attention (text and vision) | _torch.attention.attention.Attention (variants: qk_norm_attention.QKNormRoPEAttention for QK-norm + YARN; mla.MLA for DeepSeek-style) | Same module runs the LLM and the multimodal encoder. For the encoder side, build an ad-hoc attn_metadata per forward and pass predefined_attention_mask=PredefinedAttentionMask.FULL (or windowed). Reference: Qwen2_5_VisionModel.prepare_attn_metadata. |
| MLP / Gated MLP | _torch.modules.mlp.MLP, _torch.modules.gated_mlp.GatedMLP, _torch.modules.swiglu.swiglu | GatedMLP covers the SwiGLU pattern (gate + up + silu + down) with fused gate/up weights — don't roll it from two Linears and F.silu. Plain MLP for non-gated cases. Both inherit the same TP / quant story as Linear. Reference: Qwen2_5_VLMLP. |
| RoPE | _torch.attention.rotary_embedding.{RotaryEmbedding, MRotaryEmbedding} | MRotaryEmbedding (mRoPE) is for the LLM side of mRoPE-using VLMs (Qwen-VL family), with mrope_section-aware cos/sin slicing and 3D position_ids. The encoder's internal 2D RoPE uses plain RotaryEmbedding. |
The inner LLM is loaded via TRT-LLM's own AutoModelForCausalLM (tensorrt_llm._torch.models.modeling_auto), not transformers.AutoModelForCausalLM. It dispatches on pretrained_config.architectures[0] to whichever class is registered via @register_auto_model. The canonical wiring (using text_config to surface the inner LLM) lives in the Phase 2 template; if the inner LLM doesn't yet have a TRT-LLM modeling file, finish that text-only onboarding first.
This is required, not a preference. Re-implement encoder blocks from _torch.modules.*; the encoder builds its own attn_metadata via prepare_attn_metadata. Reference: Qwen2_5_VisionModel, Qwen2_5_VLVisionAttention, Qwen2_5_VLPatchMerger. Two reasons:
nn.LayerNorm, plain nn.Linear — losing TRT-LLM-attention / FlashInfer, fused RMSNorm, FP8/NVFP4/AWQ Linear, TP, and CUDA-graph capture for static-shape paths. For a 0.5–7 B encoder running every prefill, the regression compounds each iteration.from transformers.models.<family> import <X> ties the modeling file to a specific transformers release. Upstream HF refactors (renamed classes, signature changes, internal helper migrations) silently break TRT-LLM imports months later, often surfacing only when users upgrade their environment. Porting cuts the dependency. The same applies to importing HF computations / helper functions, not just modules — keep both out of new modeling files.The lone existing exception is Qwen2VLModel, which keeps Qwen2VisionTransformerPretrainedModel from transformers because Qwen2-VL is an outdated family on life support — not because passthrough is acceptable for new onboarding. Don't copy that pattern. If you genuinely cannot port (e.g. patching an existing legacy model), the HF import must carry a code comment explicitly justifying it; otherwise PR review will bounce.
If HF prefixes don't match (model.vision_tower.* → mm_encoder.*, fused/un-fused QKV, etc.), inherit from a related mapper rather than ad-hoc translation. Reference: _torch/models/checkpoints/hf/qwen2vl_weight_mapper.py, qwen3vl_weight_mapper.py.
Host memory during init / weight loading. Large VLMs can blow past host RAM if every rank materializes the full state_dict before sharding. Two patterns from modeling_nemotron_h_multimodal.py:NemotronHMultimodalModel (PR #13283):
__init__ and into load_weights() when the encoder contains HF submodules whose deterministic init ops (ones_, zeros_, fill_, .detach(), .to(dtype=...)) clash with the LLM's MetaInitMode fast path. Snapshot the multimodal ModelConfig in __init__ (since post_config() overwrites self.model_config.pretrained_config to the LLM-only config), construct the encoder + .to("cuda") inside load_weights(). Otherwise MetaInitMode raises and the entire model falls back to slow CPU init.weights.mark_consumed(<prefix>) after each sub-module's load_weights(...) so the mmap-backed shards behind those weights can be released. Without it, peak host memory holds the entire checkpoint; with it, peak holds only the shard you're currently loading. Tag every prefix you've finished — encoder, sound, projector, LLM.PyExecutor + the C++ core own AttentionMetadata, KV cache, scheduler, sampler, decoder. Your model receives attn_metadata and multimodal_params as inputs and returns logits — never builds request-level metadata. The only attn_metadata you build yourself is the multimodal encoder's own, on the synthetic per-item batch (concatenated patches with per-image seqlens, mel frames, etc.).
Three rules. Multimodal prefill is long (image/audio tokens balloon sequence length) and media tensors are big (MBs–GBs); the overlap scheduler hides host work behind GPU work only if all three hold.
forwardA single sync inside forward collapses overlap, and per-iteration GPU work is long for VLMs, so stalls compound.
Banned in forward and anything it calls:
.item(), .tolist(), int(t), bool(t), float(t) on GPU tensorst.cpu(), t.to("cpu"), any device-crossing readif/while on tensor values (shape is fine; values are not)torch.nonzero, single-arg torch.where(condition) (index form; documented sync hazard in filter_mm_token_from_input_ids when run on GPU input_ids), torch.unique, masked_selecttorch.tensor([...], device="cuda") from a Python list (hidden H2D)if pixel_values is None: ...) that change tensor shapesThree-arg torch.where(cond, x, y) is fine when cond is built only on-device (no scalar readback). fuse_input_embeds: kwargs text_token_indices + mm_token_indices together ⇒ skip internal filter_*. trtllm-serve usually supplies both via model_engine.py (CPU-side index build → inputs → fuse_input_embeds(..., **kwargs)). Pure-text batches have no MM inputs; bare unit tests / direct calls may omit indices ⇒ in-model filter_* runs.
Patterns:
if has_mm: branches. find_input_mm_embeds returns input unchanged when runtime is None; fuse_input_embeds returns (input_ids, None) when mm_embeds == [] — preserve that contract.prepare_mrope_config) is laid out in the system map; the constraint here is that per-layer attention must read pre-sliced (cos, sin) — never recompute mrope inside the decoder loop.Audit: grep for the banned constructs; run one prefill iteration with torch.cuda.set_sync_debug_mode("warn") and confirm zero warnings from your model.
CPU-bound work (decode / resize / normalize / mel-spectrogram / frame extraction) must not compete with GPU work, block the request loop, or serialize across requests.
call_with_text_prompt (dispatched from __call__) — not in the model worker.async_load_image / async_load_video / async_load_audio (all wrap blocking decode in asyncio.to_thread). Never call PIL.Image.open(...).load() / cv2.VideoCapture / soundfile.read synchronously on the request hot path.prefer_pinned() (False under Confidential Compute (CC), True otherwise). The engine pins multimodal_data automatically via to_device(..., pin_memory=prefer_pinned()).multimodal_data_device_paths on the model — list of dotted paths (e.g. ["image.pixel_values", "image.image_grid_thw", "video.pixel_values_videos", "video.video_grid_thw", "multimodal_embedding"]) telling the engine which fields go to CUDA. Anything not listed stays on CPU.supports_token_id_mm_expansion = True (a ClassVar, default False) and implement get_text_with_mm_placeholders + expand_prompt_token_ids_for_mm. The base-class __call__ then routes prompt_token_ids + multi_modal_data (no prompt) requests through call_with_token_ids, skipping redundant detokenization. When the flag is False (most VLMs), the base class detokenizes prompt_token_ids → prompt and re-runs call_with_text_prompt, so token-ID inputs still work — just less efficiently. Only LlavaNext + Nemotron H multimodal opt in today.mm_processor_kwargs from inputs.get("mm_processor_kwargs", {}) to the HF processor (callers tune things like video sample rate via this).A 1024×1024 fp32 patch tensor is ~12 MB; a video clip can be hundreds of MB. Naive pickle through MPI broadcast turns the leader into the IPC bottleneck.
MultimodalParams.to_handle/to_tensor. to_handle swaps each tensor inside multimodal_data for a small dict — {method_key, tensor_size, storage_handle, ...} — that points at the same memory: a CUDA-IPC handle for GPU tensors (REBUILD_CUDA) or a POSIX-shm handle for CPU tensors (REBUILD_CPU). The dict is a few hundred bytes regardless of the original tensor size. Consumers call to_tensor to rebuild local tensor views from the handle. See _torch/shared_tensor/.py_multimodal_data via dist.broadcast / tp_cp_broadcast / PP send-recv. Payload size = the literal byte size of whatever's in py_multimodal_data — confirm every tensor inside has been swapped for its handle dict (i.e. to_handle ran) before this point._strip_py_multimodal_data_post_prefill clears everything except mrope_config.mrope_position_deltas. If your model needs to retain something across decode, update strip_mm_data_for_generation explicitly.MultimodalInput.multimodal_hashes (blake3) drives KV-cache reuse — never substitute raw pixels for them.Audit: payload size in NVTX broadcast_requests / tp_broadcast_requests ranges should be < 1 MB per rank per request. More means a broadcast leaked raw tensors.
get_multimodal_embeddings hands the encoder a list of MultimodalParams covering every uncached request in the current batch. The encoder must consume that list as a single batched forward pass — concatenate every request's pixel_values / image_grid_thw / mel frames into one tensor, build one ad-hoc attn_metadata whose seq_lens carries per-image boundaries, and run the encoder blocks once. Looping for p in mm_params: encoder.forward(p) loses kernel-launch coalescing and serializes N requests' worth of encoder work.
Pattern (Qwen2.5-VL). Qwen2_5_VisionModel concatenates every request's pixel_values into one [total_patches, ...] tensor and builds attn_metadata with batch_size=1 and seq_lens=[img1_patches, img2_patches, ...]. The TRT-LLM Attention module respects seq_lens so cross-image attention doesn't bleed. The patch merger / projector at the end then splits the result back per-request via torch.split over the same lengths (this is what _cache_multimodal_embeddings expects too).
Audit. Under load with several multimodal requests in one batch, the encoder kernels in nsys should appear as one wide block per iteration, not N narrow blocks. A fan of N narrow blocks means the encoder is being looped per request instead of batched — one of the easiest VLM perf regressions to introduce while refactoring.
Confirm preprocessor_config.json and chat_template.json are pulled. Verify AutoProcessor.from_pretrained(model_path) loads. Estimate LLM + multimodal encoder params for VRAM sanity (multimodal encoders are often 0.5–7 B on top of the LLM).
Read config.json's architectures and model_type. If a _torch/models/modeling_*.py already claims that architecture via @register_auto_model, extend rather than create new. Identify the closest existing multimodal model and note which TRT-LLM modules it reuses.
Create tensorrt_llm/_torch/models/modeling_{name}.py. The default pattern below mirrors modeling_llava_next.py and modeling_gemma3vl.py — a single wrapper class that composes a multimodal encoder + an LLM resolved through AutoModelForCausalLM.from_config(text_config). The *ModelBase + *Model Base/non-Base split in modeling_qwen2vl.py and modeling_qwen3vl.py is an implementation detail for sharing one wrapper between two variants of the same family (Qwen2-VL ↔ Qwen2.5-VL; Qwen3-VL ↔ Qwen3-VL-MoE) — keep the wrapper a single class unless you have the same multi-variant need.
Required (every multimodal model):
forward takes multimodal_params via **kwargs. Never add pixel_values / image_grid_thw / attention_mask as direct args — they live in multimodal_params.multimodal_data.mm_encoder.forward must return a single tensor whose first dimension equals the total number of MM tokens (excluding special tokens) the input processor placed in prompt_token_ids. If lengths don't agree — or if the encoder returns a list with more than one element — get_multimodal_embeddings silently skips caching the embedding back into multimodal_data, and chunked prefill re-runs the encoder from scratch on every chunk.Family-specific extras (apply only when relevant):
init_mrope_embedding(model_config) in __init__ to preallocate self.mrope_position_ids_padding_cuda, plus prepare_mrope_config(multimodal_params, num_context_requests) returning mrope_rotary_cos_sin. Pass through to self.llm.forward(..., mrope_config=...). Reference: Qwen3VLModelBase.prepare_mrope_config.mm_embed + deepstack_embeds, call fuse_input_embeds(..., extra_embeds=deepstack_embeds), forward deepstack_embeds= into the LLM.text_config: Qwen2-VL's Qwen2VLModelBase rewrites architectures to surface the inner LLM. Fall back to that pattern only when the multimodal HF config does not expose a text_config sub-config.text_config schema (Qwen3.5-MoE-VL → Qwen3Next). When the VLM's HF text_config schema differs from the TRT-LLM runtime model you want to reuse, write a config normalizer (e.g. _normalize_qwen35_moe_vl_config) that maps HF aliases to the runtime's expected names (mRoPE keys, intermediate_size aliases, quantization-exclude module paths). Wire it via lazy import from pyexecutor.config_utils.load_pretrained_config — the Mistral and Qwen3_5 branches are templates. Two gotchas: transformers 5.x's rope_scaling is a property aliasing rope_parameters — setting either silently overwrites the other, so the normalizer should mutate rope_parameters directly if the HF code still reads from it. And for VLMs, the normalizer must run on the composite config (with text_config / vision_config), not flattened away.@register_auto_model("YourArch")-decorated thin subclass — that's how weight-mapper dispatch picks the family-specific mapper. You can't stack two @register_auto_model decorators on a single shared class.Subclass both BaseMultimodalInputProcessor (drives every real request) and BaseMultimodalDummyInputsBuilder (drives engine warmup / KV-cache profiling). Colocate in the modeling file. References: Qwen3VLInputProcessorBase (image+video), Mistral3InputProcessor (Pixtral).
Encoder KV-cache memory profiling (deterministic dummy sizing). The KV-cache profiler sizes the encoder's memory contribution by running the encoder once on a worst-case dummy (held resident through the peak measurement), decoupled from the text-only LLM dummy. To opt in, the model exposes encode_multimodal_inputs (via MultimodalModelMixin) and the input processor implements the existing BaseMultimodalDummyInputsBuilder contract:
get_mm_max_tokens_per_item(max_num_encoder_tokens=None) -> {modality: tokens} — report bounded startup maxima when the argument is None, or the largest legal item per modality under a concrete runtime token budget.get_dummy_mm_data(*, max_num_encoder_tokens, mm_counts, dtype) -> multimodal_data — build the profiler-selected per-modality item counts directly as processed encoder tensors (no PIL image + HF-processor round-trip).Following vLLM's separation of common budgeting from model processing geometry, the profiler selects the largest legal runtime workload and computes its repetition count under encoder_max_num_tokens and encoder_max_batch_size. It passes the resulting plan (for example, {"image": 8}) to the concrete processor's get_dummy_mm_data; the processor owns only geometry and tensor layout. Qwen builds pixel_values/*_grid_thw; Mistral builds pixel_values/image_sizes and uses its private _vit_tokens helper so the LLM-side Pixtral hashing count is unchanged. A model without the contract falls back to a text-only dummy (encoder memory unaccounted). Don't hardcode the encoder attention workspace (max_num_*=8192): inherit MultimodalEncoderMixin and let the engine size it via setup_attn_metadata at load.
The workspace dimension comes from encoder_max_num_tokens, falling back to the LLM-side max_num_tokens when unset. encoder_max_batch_size independently bounds the number of atomic items in a multimodal encoder iteration and falls back to max_batch_size. The profiler resolves both limits into mm_counts before calling get_dummy_mm_data. Model-internal attention sequence counts are not the item batch size; they are derived separately from the token budget and model geometry.
Mixed image+video+audio models profile the supported modality with the largest legal per-item workload under the configured limits. Runtime mixed-modality requests still share the same aggregate token limit.
Implement call_with_text_prompt(inputs, sampling_params) — the per-model text-prompt path. Don't override __call__: the base class's concrete __call__ dispatches here for text prompts, and also detokenizes prompt_token_ids → prompt and falls through to here for non-fast-path VLMs. call_with_text_prompt does:
text_prompt, mm_data, mm_processor_kwargs from inputs._preprocess(...) — HF processor produces pixel_values / pixel_values_videos / *_grid_thw / input_ids.multimodal_data keyed by modality: {"image": {"pixel_values": ..., "image_grid_thw": ...}, "video": {...}}.mrope_config on CPU (.to("cpu").clone()) into multimodal_data["mrope_config"]. Required even on text-only Qwen-VL prompts — no branch._postprocess(input_ids) rewrites HF's image_token_id / video_token_id to tllm_multimodal_token_id = vocab_size + 1 (the OOV sentinel). Skip when mm_data is empty.(prompt_token_ids_list, {"multimodal_data": multimodal_data}).Optional tokenized+MM fast path (skip unless needed): set supports_token_id_mm_expansion = True (ClassVar) and implement get_text_with_mm_placeholders(mm_counts) + expand_prompt_token_ids_for_mm(prompt_token_ids, num_mm_tokens, ...). The base-class call_with_token_ids then builds dummy placeholder text, runs call_with_text_prompt on it, expands the real token IDs, and merges any returned mm_data_updates (e.g. video evs_ids) into multimodal_data. Leave the flag False and the base class just detokenizes token-ID inputs and re-runs call_with_text_prompt. Only LlavaNext + Nemotron H multimodal opt in today.
EPD override (if @support_multimodal_disaggregated): override _attach_multimodal_embeddings_impl(inputs, multimodal_embedding, sampling_params) — not the attach_multimodal_embeddings wrapper — to consume encoder outputs in the prefill+decode worker. The base wrapper detokenizes tokenized inputs for non-fast-path VLMs before delegating to your impl.
Decorator stack — bottom-up application; register_vision_encoder requires register_auto_model to have run first:
Inherit from a related mapper for prefix surgery — don't write a one-off translator.
Per-model unit test (the main one) at tests/unittest/_torch/modeling/test_modeling_<name>.py. Subclass TestModelingMultimodal from tests/unittest/_torch/modeling/test_modeling_multimodal.py (an abstract unittest.TestCase) and implement six abstract methods: get_model_config, get_trtllm_model_class, get_hf_model_class, get_weight_mapper_class, get_model_type, get_model_config_class. The base class drives a MultimodalScenario-parameterized run (modality ∈ image / multiple_image / video / text / mixture_text_image / audio, with optional use_cuda_graph / chunked_prefill / kv_cache_reuse) — comparing TRT-LLM logits to HF reference, exercising the KV cache manager, attn metadata, mrope, fusion path, and CUDA graph capture in one harness. Override get_scenarios to declare which combinations apply to your model. Reference: test_modeling_qwen3vl.py, test_modeling_qwen2_5vl.py, test_modeling_nemotron_h_multimodal.py. Test data lives under ${LLM_MODELS_ROOT}/multimodals/test_data/.
Hybrid linear-attention models. Override _dummy_request_kwargs to return {"use_mrope": True} if the model uses mRoPE (allocates the 3-D position-id buffer at dummy-request time). The base class's init_kv_cache_manager already dispatches on is_qwen3_hybrid / is_nemotron_hybrid to build CppMambaHybridCacheManager — don't override unless you need a different concrete manager. Use PyKvCacheConfig from llmapi.llm_args (Pydantic), not the C++ bindings KvCacheConfig — CppMambaHybridCacheManager.__init__ reads mamba_state_cache_interval which only exists on the Pydantic side. CUDA-graph capture in the harness doesn't currently address the Mamba SSM state buffer — keep use_cuda_graph=False in get_scenarios for hybrid models until that's wired through; production CUDA-graph support is independent and unaffected.
Synthetic-config shape couplings. head_dim × partial_rotary_factor / 2 == sum(mrope_section) — head_dim can't be shrunk independently. If the test loads the real tokenizer via _name_or_path, vocab_size must equal the real tokenizer's vocab — otherwise chat-template specials at ids >= your synthetic vocab_size get misclassified as mm tokens by fuse_input_embeds's OOV filter (manifests as "found N image tokens but received M image embeddings", off by exactly the number of chat-template specials). Vision deepstack indices [i, j, k] require depth > k — the HF processor reserves placeholder tokens for deepstack outputs regardless of whether the encoder is configured to emit them.
Two-config Approach B for tests. If you've added a config normalizer (Phase 2), keep self.hf_config raw and route a deepcopy + normalize only through create_trtllm_model. Reusing one normalized config for both HF and TRT-LLM construction trips the transformers 5.x property aliasing and silently corrupts HF-side schema (rope_scaling ↔ rope_parameters).
Tolerance band. Default get_tolerance returns 0.4 / 0.4, calibrated to pass for the existing VLM tests but wide enough to mask argmax-changing bugs. After your test passes, dial it tighter — keep atol = 0.4 to absorb single-logit tail outliers seen on multiple_image / video scenarios; tighten rtol toward 0.1 to gate bulk-of-logits relative agreement. Don't drop rtol below 0.05 without cross-SKU validation.
Supplemental utility tests (only if your model exercises new logic in shared utilities) under tests/unittest/_torch/multimodal/: test_fuse_input_embeds.py, test_multimodal_runtime.py, test_find_num_image_tokens.py, test_external_embedding.py, test_share_multiparams.py, test_mm_encoder_standalone.py. Extend the existing tests rather than creating new files when the coverage is generic.
Accuracy test at tests/integration/defs/accuracy/test_llm_api_pytorch_multimodal.py — subclass LlmapiAccuracyTestHarness, set MODEL_NAME / MODEL_PATH / MAX_NUM_TOKENS=16384, run MMMU (or ChartQA/ScienceQA). Reference: TestQwen2_5_VL_7B. Wire into tests/integration/test_lists/test-db/l0_<gpu>.yml.
TRTLLM_ACCURACY_NO_REFERENCE=1 for the first local run; the harness synthesizes a baseline reference (0 for higher-is-better metrics like MMMU), runs end-to-end, and prints the achieved accuracy. Paste the printed value verbatim into tests/integration/defs/accuracy/references/mmmu.yaml — that's the measured reference; the threshold derives from it via sigma / alpha / beta.quant_algo assertion in test_fp8_prequantized must match what the checkpoint actually advertises. Flat per-tensor FP8 is QuantAlgo.FP8; block-scaled FP8 (DeepSeek-V3 / Qwen3.5 style) is QuantAlgo.FP8_BLOCK_SCALES. Same applies to NVFP4 variants. Easy to copy from a peer model and assert the wrong one.Be parsimonious. The cartesian product modality × use_cuda_graph × chunked_prefill × kv_cache_reuse explodes fast. In get_scenarios(), pick the smallest set covering this model's distinctive paths — e.g. one image, one mixture_text_image, plus one chunked-prefill / one cuda-graph entry only if the model claims those features. One accuracy benchmark per model (MMMU for image VLMs); add another only for capabilities the first doesn't exercise (audio, video, very long context).
docs/source/models/supported-models.md:
Yes only what you've verified.First line of defense — quickstart smoke test. Before bringing up a server, run the bundled quickstart against your model:
It exercises setup_llm + default_multimodal_input_loader + the chat template + LLM.generate end-to-end with a couple of bundled prompts. Cheaper than spinning up trtllm-serve and fails fast on input-processor / encoder / fusion bugs. Run for every modality your model supports (--modality image|video|audio|image_audio|...).
Then aggregated serving:
Send a chat completion with a real image; confirm coherent output. (TODO: 2ez4bz to provide ready-to-use curl examples.)
Chunked-prefill cache verification (mandatory). Re-run with a deliberately small --max_num_tokens to force the prefill of one image to span multiple chunks, then grep the server log for these two lines:
Multimodal hashing failed: → the input processor's hash path fell back; KV-cache reuse across requests with the same image is broken (Contract 3 hash invariant).Multimodal runtime data missing or incomplete, will not cache embeddings. → the encoder-output cache is being skipped, so the encoder is being re-run on every chunked-prefill iteration of the same request (Phase 2 Required: encoder output length must match MM placeholder count).A clean serving log shows neither line. If either appears, fix it before declaring the model done — these are silent perf cliffs, not crashes.
For EPD: run MultimodalEncoder and LLM as separate process groups; verify embeddings cross via disaggregated_params.multimodal_embedding_handles.
Follow CONTRIBUTING.md. Title [JIRA/NVBUG/None][type] description, git commit -s. Body: one full multimodal prompt → output verbatim, reproduction commands, pytest output verbatim. Trigger CI via /bot run.
Architecture & registration
@support_multimodal_disaggregated (outermost, optional) → @register_vision_encoder → @register_auto_model → @register_input_processor (innermost).forward takes multimodal_params via **kwargs; no pixel_values / image_grid_thw / attention_mask direct args.multimodal_data_device_paths lists every GPU-resident mm field.@register_auto_model wrapper class present; config normalizer lazy-imported from pyexecutor.config_utils.load_pretrained_config.Module reuse
nn.Linear / nn.LayerNorm / nn.MultiheadAttention / hand-rolled attention — use _torch/modules/*.text_config + AutoModelForCausalLM.from_config (TRT-LLM's, not transformers'); architectures rewrite only as the documented Family-specific fallback.transformers.models.<family> — they couple the file to a specific transformers release and break silently on upstream refactors. The only existing exception is the outdated Qwen2-VL family (Qwen2VLModel); new models must not follow that pattern.BaseWeightMapper subclass under _torch/models/checkpoints/hf/.weights.mark_consumed(<prefix>) called after each sub-module load (mm encoder / projector / LLM) so mmap shards release incrementally; multimodal-encoder construction deferred to load_weights() if its HF submodules clash with MetaInitMode.Input processor
BaseMultimodalInputProcessor and BaseMultimodalDummyInputsBuilder.get_mm_max_tokens_per_item + get_dummy_mm_data) and the model exposes encode_multimodal_inputs; encoder inherits MultimodalEncoderMixin (no hardcoded max_num_*=8192 — sized by setup_attn_metadata). Skipping these = text-only dummy, encoder memory unaccounted.call_with_text_prompt (not __call__ — that's the base-class dispatcher) runs HF AutoProcessor + tokenizer, builds multimodal_data by modality, computes mrope_config on CPU, _postprocess-rewrites mm token ids to the OOV sentinel.mm_processor_kwargs flow-through preserved. (Tokenized fast path is optional: set supports_token_id_mm_expansion = True + implement get_text_with_mm_placeholders / expand_prompt_token_ids_for_mm; otherwise the base class detokenizes token-ID inputs automatically.)_attach_multimodal_embeddings_impl implemented (not the attach_multimodal_embeddings wrapper) if @support_multimodal_disaggregated.Performance contracts
.item() / .cpu() / .tolist() / torch.nonzero / single-arg torch.where / value-dependent if, etc.) in modeling forward paths — elementwise torch.where(cond, x, y) with GPU-only cond is fine.set_sync_debug_mode("warn") audit on prefill: zero warnings from your model.broadcast_requests / tp_broadcast_requests); media crosses ranks only via to_handle / to_tensor.mm_data is empty (post-prefill strip exercised in e2e test).--max_num_tokens) and confirming the encoder runs once per request, not once per chunk.Tests & docs
tests/unittest/_torch/modeling/test_modeling_<name>.py subclassing TestModelingMultimodal; six abstract methods implemented; get_scenarios() declares the minimum modality × cuda_graph × chunked_prefill × kv_cache_reuse combinations that cover this model's distinctive paths (no full cartesian product) and they all pass.mixture_text_image) included and passes against HF reference logits._dummy_request_kwargs overridden to set use_mrope=True; CUDA-graph scenarios skipped (harness limitation, not a model limitation).test_llm_api_pytorch_multimodal.py; entry in test-db/l0_<gpu>.yml.TRTLLM_ACCURACY_NO_REFERENCE=1 and entered in references/mmmu.yaml; threshold derived value sanity-checked.QuantAlgo variant for the checkpoint (flat FP8 vs FP8_BLOCK_SCALES; FP4 vs NVFP4).examples/llm-api/quickstart_multimodal.py round-trip passes for every modality the model supports (--modality image|video|audio|...); then trtllm-serve round-trip verified with a real image prompt; EPD round-trip verified if applicable.trtllm-serve with low --max_num_tokens and confirmed the log contains neither Multimodal hashing failed: nor Multimodal runtime data missing or incomplete, will not cache embeddings./bot run triggered and multimodal stages pass.**