This tutorial walks through deploying Qwen3-VL-32B-Instruct with vLLM Neuron. It covers environment setup, model download, an optional MXFP8 quantization step, online serving, and offline inference.
The model runs in two precisions:
- BF16 — the default, on
trn2.48xlargeor Trn3. - MXFP8 — micro-scaled FP8 weights for the text transformer, Trn3-only. Reduces the weight footprint and improves both throughput and latency while keeping accuracy close to BF16.
| Precision | Instance | Steps |
|---|---|---|
| BF16 | Trn2 or Trn3 | 1 → 2 → 4 (skip the MXFP8-only Step 3) |
| MXFP8 | Trn3 only | 1 → 2 → 3 → 4 |
BF16 is the main path throughout; each step calls out the MXFP8 differences where they apply. Step 3 (quantization) is only for MXFP8 — skip it for BF16.
Prerequisites:
- A
trn2.48xlargeinstance with Neuron SDK2.31.0or later. See setup guide. MXFP8 requires a Trn3 instance (the native MXFP8 kernels are Trn3-only) with Neuron SDK2.32or later. - vLLM Neuron plugin
0.21.0or above installed. - Python 3.10+
- For MXFP8, LLM-Compressor to quantize the checkpoint:
pip install llmcompressor compressed-tensors
Verify Neuron devices are visible:
neuron-ls
# Lists the Neuron devices and cores available on your instance.Set environment variables before running any inference script:
# Extend timeouts for large model compilation
export VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS=1200
export NEURON_LIBTORCH_COMPILATION_TIMEOUT=1200
# Required if your home directory is on NFS
export NEURON_CC_FLAGS="--temp-dir=/tmp/neuroncc_tmp"
mkdir -p /tmp/neuroncc_tmphuggingface-cli download \
Qwen/Qwen3-VL-32B-Instruct \
--local-dir /path/to/Qwen3-VL-32B-InstructNote: This step is optional. You can pass the Hugging Face model ID (e.g.
Qwen/Qwen3-VL-32B-Instruct) directly tovllm serve, and the weights will be downloaded automatically on first run.
Skip this step for BF16. Qwen3-VL is not published with MXFP8 weights, so you must quantize the BF16 checkpoint offline. This uses LLM-Compressor and compressed-tensors to apply weight-only, data-free post-training quantization (PTQ) and save a checkpoint in the compressed-tensors format. For background on the quantization workflow, see Quantize using external libraries in the quantization guide.
This is weight-only, data-free PTQ. Run this step on CPU, not on Trainium. Use a machine with enough memory to load the ~64 GB BF16 model, plus disk for both the base and quantized checkpoints. You can quantize once and reuse the checkpoint across Trn3 instances.
Only the text-model linear layers are quantized. The script builds an ignore list that keeps lm_head and every vision-encoder (visual.*) linear layer in full precision — the Neuron MXFP8 path applies to the text transformer only. This text-only scheme mirrors the published Qwen/Qwen3-VL-32B-Instruct-FP8 checkpoint, which likewise quantizes the text model and leaves the vision tower and lm_head in higher precision.
import torch
from compressed_tensors.offload import dispatch_model
from transformers import AutoTokenizer, Qwen3VLForConditionalGeneration
from llmcompressor import oneshot
from llmcompressor.modifiers.quantization import QuantizationModifier
# Path (or Hugging Face ID) of the BF16 base checkpoint.
MODEL_ID = "Qwen/Qwen3-VL-32B-Instruct"
# Where to write the quantized checkpoint.
SAVE_DIR = "/path/to/Qwen3-VL-32B-Instruct-MXFP8-text-only"
# Load the model and tokenizer.
model = Qwen3VLForConditionalGeneration.from_pretrained(MODEL_ID, dtype="auto")
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
# Build the ignore list: skip lm_head and every vision-encoder linear layer so
# only the text-model linear layers are quantized to MXFP8.
ignored_list = ["lm_head"]
for name, module in model.named_modules():
if "visual" in name and isinstance(module, torch.nn.Linear):
ignored_list.append(name)
print(f"Ignoring {len(ignored_list)} layers from quantization")
# Quantize all remaining Linear layers to MXFP8 via weight-only PTQ.
recipe = QuantizationModifier(targets="Linear", scheme="MXFP8", ignore=ignored_list)
oneshot(model=model, recipe=recipe)
# Confirm the quantized model still generates sane text.
print("========== SAMPLE GENERATION ==============")
dispatch_model(model)
input_ids = tokenizer("Hello my name is", return_tensors="pt").input_ids.to(model.device)
output = model.generate(input_ids, max_new_tokens=20)
print(tokenizer.decode(output[0]))
print("===========================================")
# Save to disk in compressed-tensors format.
model.save_pretrained(SAVE_DIR)
tokenizer.save_pretrained(SAVE_DIR)
print(f"Saved quantized model to {SAVE_DIR}")The script prints a short sample generation as a sanity check, then writes the quantized checkpoint and tokenizer to SAVE_DIR. Point the serving commands below at that directory.
Run the model through the online serving endpoint or the offline LLM API — choose whichever fits your deployment.
Start a vLLM OpenAI-compatible server.
:::{note}
On a Trn3 instance without EFA (Elastic Fabric Adapter) installed, the server fails to start during EFA-affinity setup — this affects both BF16 and MXFP8. To work around it, prepend NEURON_SKIP_EFA_AFFINITY=1 to the vllm serve command (and set it in the environment before the offline LLM script below).
:::
BF16 (Trn2 or Trn3):
vllm serve /path/to/Qwen3-VL-32B-Instruct \
--served-model-name Qwen3-VL-32B-Instruct \
--max-model-len 8192 \
--max-num-batched-tokens 8192 \
--max-num-seqs 4 \
--tensor-parallel-size 4 \
--additional-config '{
"neuron_config": {
"quantization": "bf16",
"num_batched_tokens_buckets": [8192],
"num_seqs_buckets": [4],
"on_device_sampling_config": {"all_greedy": true}
},
"vision_neuron_config": {
"num_vision_tokens_buckets": [2048],
"vision_attention_block_size": 2048
}
}'MXFP8 (Trn3 only) — point at the quantized checkpoint from Step 3:
vllm serve /path/to/Qwen3-VL-32B-Instruct-MXFP8-text-only \
--served-model-name Qwen3-VL-32B-Instruct-MXFP8 \
--max-model-len 8192 \
--max-num-batched-tokens 8192 \
--max-num-seqs 4 \
--tensor-parallel-size 4 \
--block-size 16 \
--tokenizer-mode slow \
--no-enable-chunked-prefill \
--no-enable-prefix-caching \
--hf-overrides '{"quantization_config": {}}' \
--additional-config '{
"neuron_config": {
"quantization": "mxfp8",
"modules_to_not_convert": [],
"num_batched_tokens_buckets": [8192],
"num_seqs_buckets": [4],
"on_device_sampling_config": {"all_greedy": true}
},
"vision_neuron_config": {
"num_vision_tokens_buckets": [2048, 4096],
"vision_attention_block_size": 2048
}
}'The MXFP8-specific settings, compared to the BF16 command:
neuron_config.quantization: "mxfp8"— selects the on-device MXFP8 path. This is a Neuron-specific setting; do not pass vLLM's--quantizationflag.modules_to_not_convert: []— an empty list runs the whole text transformer (attention and MLP) in MXFP8. The vision encoder stays BF16 regardless, because its weights were left unquantized in Step 3.--hf-overrides '{"quantization_config": {}}'— clears the checkpoint's compressed-tensorsquantization_configso the Neuron loader owns the MXFP8 path.--tokenizer-mode slow,--no-enable-chunked-prefill,--no-enable-prefix-caching,--block-size 16— pin the deployment to the validated MXFP8 configuration.
Current limitation: at tensor-parallel-size >= 32, change "modules_to_not_convert" to ["mlp"] (keeps the MLP layers in BF16); full MXFP8 ([]) is supported at tensor-parallel-size <= 16.
Once the server is up, send requests using the OpenAI Python SDK (use the --served-model-name you launched with):
import base64
from openai import OpenAI
client = OpenAI(api_key="EMPTY", base_url="http://localhost:8000/v1")
# Text-only
response = client.chat.completions.create(
model="Qwen3-VL-32B-Instruct",
messages=[{"role": "user", "content": "What is the capital of France?"}],
max_tokens=50,
)
print(response.choices[0].message.content)
# Image + text
with open("image.jpg", "rb") as f:
image_b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model="Qwen3-VL-32B-Instruct",
messages=[{"role": "user", "content": [
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{image_b64}"}},
{"type": "text", "text": "Describe this image."},
]}],
max_tokens=200,
)
print(response.choices[0].message.content)vLLM-Neuron compiles the model on the first run and caches the artifacts to ~/.cache/vllm/neuron/compile_cache. Subsequent runs skip recompilation and load from cache.
The model has two components, each with its own config object passed via additional_config (see the configuration options reference):
neuron_config: text decoder settings (token/sequence bucket sizes, sampling, quantization).vision_neuron_config: vision encoder settings (vision token buckets, attention block size, and optional vision TP/DP split).
Bucket sizes control the discrete padded shapes compiled into each NEFF. Each bucket adds compile time; start with one and add more as needed. The two components bucket along different dimensions, so their buckets are configured separately:
num_batched_tokens_buckets(inneuron_config): text-decoder buckets over the number of batched text tokens per forward pass. See compilation options.num_vision_tokens_buckets(invision_neuron_config): vision-encoder buckets over the number of vision patches per encoder forward pass (rawT*H*Wpatches fromimage_grid_thw, before the 2x2 spatial merge; this is the countselect_vision_bucketmatches against). The scheduler may batch images from multiple requests into one forward pass, so size the buckets for the total images processed together, not a single request. These scale with image count and resolution:
num_vision_tokens_buckets |
Approximate capacity |
|---|---|
[2048] |
1–2 images at 448×448 px |
[2048, 8192] |
Up to ~8 images |
[2048, 8192, 20480] |
Up to ~20 images |
The following script runs text-only, single-image, and multi-image inference. It shows the BF16 LLM(...) construction; for MXFP8, swap in the LLM(...) block that follows (the rest of the script is identical).
import os
os.environ["VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS"] = "1200"
os.environ["NEURON_LIBTORCH_COMPILATION_TIMEOUT"] = "1200"
from transformers import AutoProcessor
from vllm import LLM, SamplingParams
from vllm.assets.image import ImageAsset
MODEL_PATH = "/path/to/Qwen3-VL-32B-Instruct"
# BF16 (Trn2 or Trn3)
llm = LLM(
model=MODEL_PATH,
max_model_len=8192,
max_num_batched_tokens=8192,
max_num_seqs=4,
tensor_parallel_size=4,
additional_config={
"neuron_config": {
"quantization": "bf16",
"num_batched_tokens_buckets": [8192],
"num_seqs_buckets": [4],
"on_device_sampling_config": {"all_greedy": True},
},
"vision_neuron_config": {
"num_vision_tokens_buckets": [2048],
"vision_attention_block_size": 2048,
},
},
)
processor = AutoProcessor.from_pretrained(MODEL_PATH)
sampling_params = SamplingParams(max_tokens=200, temperature=0.0)
# --- Text-only ---
outputs = llm.generate(["What is the capital of France?"], sampling_params)
print(outputs[0].outputs[0].text)
# --- Single image ---
image = ImageAsset("cherry_blossom").pil_image.resize((640, 320))
messages = [{"role": "user", "content": [
{"type": "image"},
{"type": "text", "text": "Describe this image."},
]}]
prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
outputs = llm.generate([{"prompt": prompt, "multi_modal_data": {"image": [image]}}], sampling_params)
print(outputs[0].outputs[0].text)
# --- Multi-image ---
images = [
ImageAsset("stop_sign").pil_image.resize((448, 448)),
ImageAsset("cherry_blossom").pil_image.resize((448, 448)),
]
messages = [{"role": "user", "content": [
{"type": "image"},
{"type": "image"},
{"type": "text", "text": "Compare these two images."},
]}]
prompt = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
outputs = llm.generate([{"prompt": prompt, "multi_modal_data": {"image": images}}], sampling_params)
print(outputs[0].outputs[0].text)For MXFP8 (Trn3 only), point MODEL_PATH at the quantized checkpoint from Step 3 and build the engine with the MXFP8 config instead — the mirror of the MXFP8 vllm serve command in Step 4:
MODEL_PATH = "/path/to/Qwen3-VL-32B-Instruct-MXFP8-text-only"
llm = LLM(
model=MODEL_PATH,
max_model_len=8192,
max_num_batched_tokens=8192,
max_num_seqs=4,
block_size=16,
tensor_parallel_size=4,
tokenizer_mode="slow",
enable_chunked_prefill=False,
enable_prefix_caching=False,
hf_overrides={"quantization_config": {}},
additional_config={
"neuron_config": {
"quantization": "mxfp8",
"modules_to_not_convert": [],
"num_batched_tokens_buckets": [8192],
"num_seqs_buckets": [4],
"on_device_sampling_config": {"all_greedy": True},
},
"vision_neuron_config": {
"num_vision_tokens_buckets": [2048, 4096],
"vision_attention_block_size": 2048,
},
},
)By default the vision encoder runs as one DP replica per NeuronCore (TP1), the recommended layout for high-throughput multi-image workloads. To shard the encoder weights across cores for a single large image instead, increase tp_size.
Set tp_size inside vision_neuron_config to change the split. DP is derived automatically as world_size / tp_size, where world_size is your tensor_parallel_size. The commands above use tensor_parallel_size=4, so:
tp_size |
Vision TP | Vision DP | Best for |
|---|---|---|---|
1 (default) |
1 | 4 | Multi-image, high throughput |
4 |
4 | 1 | Single-image, low latency |
The same applies at a larger tensor_parallel_size (e.g. 16), which raises the default vision DP accordingly. Example — TP1, full DP:
"vision_neuron_config": {
"num_vision_tokens_buckets": [2048],
"vision_attention_block_size": 2048,
"tp_size": 1, # DP = world_size / tp_size
}You have deployed Qwen3-VL-32B-Instruct on Trainium — in BF16 on trn2.48xlarge (or Trn3), or in MXFP8 on Trn3 after self-quantizing the text model. The model supports text-only, single-image, multi-image, and video inputs via both the offline LLM API and the OpenAI-compatible online serving endpoint. To validate accuracy after enabling MXFP8, see the accuracy debugging guide. For feature support and accuracy validation results, see the model card.
To tune this deployment for your workload — choosing vision and text sharding with roofline analysis, profiling to find the bottleneck, and enabling the multimodal features and fused kernels — see Optimizing a Vision-Language Model.