Visual-Token Pruning (DART)#
Overview#
Visual-token pruning shortens VLM prefill by removing a configurable fraction of the visual tokens before the LLM engine runs. VLM inference on edge platforms is prefill-bound — a single image can contribute hundreds to thousands of tokens, and every one of them is re-computed on each request. Pruning the redundant ones cuts prefill latency roughly in proportion to the removed fraction, and slightly speeds up decode as well (shorter KV cache).
The default selection algorithm is DART (Duplication-Aware Reduction of Tokens; paper: Stop Looking for Important Tokens in Multimodal Language Models: Duplication Matters More): instead of ranking tokens by importance, it removes duplicated visual tokens, keeping a diverse subset. It is training-free, requires no attention scores, and needs no engine or export changes — pruning operates purely on runtime buffers, so existing engines work as-is.
Key Points:
Prefill-only, one-shot: assembled input embeddings (and DeepStack planes / mRoPE rows) are compacted before the engine executes; the decode path and CUDA graphs are untouched
Text tokens are never pruned; kept tokens retain their original absolute RoPE positions and generation continues as if nothing was removed
Disabled by default; enable per run with
--visualPruneMeasured on Qwen3-VL-4B (FP16, single image, 1995-token prefill):
Reduction ratio |
Accuracy impact |
Prefill speedup (Jetson Thor) |
Prefill speedup (x86 Blackwell) |
|---|---|---|---|
0.25 (default) |
~lossless (MME/MMBench/POPE/GQA/VQAv2) |
1.32× |
1.18× |
0.5 |
−1…2 pts on sensitive benchmarks (OCR/POPE) |
1.95× |
1.69× |
Usage#
./build/examples/llm/llm_inference \
--engineDir engines/qwen3-vl-4b/llm \
--multimodalEngineDir engines/qwen3-vl-4b/visual \
--inputFile tests/test_cases/vlm_basic.json \
--outputFile output.json \
--visualPrune --dartReductionRatio 0.25
Options#
Flag |
Default |
Description |
|---|---|---|
|
off |
Enable visual-token pruning |
|
|
Selection algorithm (custom algorithms register via |
|
0.25 |
Fraction of visual tokens to remove, in (0, 1) |
|
4 |
DART: number of image pivot tokens |
|
4 |
DART: number of text pivot tokens |
C++ API#
rt::VisualPrunerConfig config;
config.enabled = true;
config.algorithm = "dart"; // resolved via the pruner registry
config.reductionRatio = 0.25F;
runtime.setVisualPrunerConfig(config); // before the first request
Profiling#
With --dumpProfile, pruned tokens are reported separately from KV-cache reuse:
=== LLM Prefill ===
Reused Tokens: 0
Computed Tokens: 1005
Pruned Tokens (visual-token pruning): 990
(pruned_tokens in the profile JSON.)
Verifying accuracy#
examples/accuracy/scripts/run_dart_accuracy.py compares accuracy with pruning off vs. one run
per reduction ratio on a multiple-choice VLM dataset (MMStar / MMMU). It reports accuracy only;
for latency numbers use llm_inference --dumpProfile --warmup N (warmup matters — cold-start
and GPU-clock effects bias unwarmed single-pass timings):
python3 examples/accuracy/scripts/prepare_dataset.py --dataset MMStar --output_dir tmp/mmstar_output
python3 examples/accuracy/scripts/run_dart_accuracy.py \
--engine_dir engines/qwen3-vl-4b/llm \
--multimodal_engine_dir engines/qwen3-vl-4b/visual \
--dataset_file tmp/mmstar_output/mmstar_dataset.json \
--ratios 0.25 0.5
How DART selects tokens#
Selection runs independently per image: each contiguous visual span gets its own retention
quota ceil(spanTokens × (1 − reductionRatio)) (at least 1), so in multi-image or video
requests no image can be starved by the others. Within each span:
Pivots: pick the
pivotImageTokensspan tokens with the highest L1 norm of their input embeddings, pluspivotTextTokenstext tokens (shared across spans).Anti-duplication growth: per pivot, retain the candidate span tokens with the most negative cosine similarity to the pivot (i.e. the least duplicated information), removing them from the pool, until the span’s quota is retained.
Two small CUDA kernels compute the norms and pivot similarities; an exact greedy loop runs on the host. Total selector overhead is ~1–2 ms per prefill and is included in all numbers above.
Note: the cited paper runs DART after decoder layer 2, scoring pivots with that layer’s projected key states. Inside a monolithic TensorRT engine those activations are not observable, so this implementation is the embedding-level variant (validated to be near-lossless at ratio 0.25 against layer-2 DART on Qwen3-VL-4B). See the note in
cpp/runtime/preprocess/dartPruner.h.
Limitations#
Pruning is applied only when all of the following hold (it silently no-ops otherwise):
mRoPE VLM engine (Qwen2.5-VL / Qwen3-VL family) with visual input present
Batch size 1, fresh KV cache (not combined with system-prompt cache reuse)
Not combined with speculative decoding, the Omni Talker, or the VLA action runner
At least
minVisualTokens(default 16) visual tokens in the request
Quality is dataset-dependent: ratio 0.25 is a safe default; re-validate on your own workload before raising it (OCR-heavy tasks degrade first).
Adding a custom pruning algorithm#
Subclass rt::VisualTokenPruner (cpp/runtime/preprocess/visualTokenPruner.h) and register a
factory before constructing the runtime:
class MyPruner final : public rt::VisualTokenPruner
{
public:
using VisualTokenPruner::VisualTokenPruner;
char const* name() const noexcept override { return "my-algo"; }
protected:
int32_t prune(rt::PruneRequest const& req, rt::PipelineIO& io, cudaStream_t stream) override
{
std::vector<int32_t> retained = /* choose a subset of *req.imagePositions */;
return compactToKeepList(io, retained, req, stream); // shared, invariant-safe compaction
}
};
rt::registerVisualPruner("my-algo",
[](rt::VisualPrunerConfig const& cfg, rt::LLMEngineConfig const& engineCfg)
{ return std::make_unique<MyPruner>(cfg, engineCfg); });
The base class owns the guards and the buffer compaction; a selection-style algorithm only
chooses which visual tokens survive. Select the algorithm at runtime with
--visualPruneAlgo my-algo or VisualPrunerConfig::algorithm.