# Model-local KV caches Some models keep their attention KV as HuggingFace `transformers` cache objects instead of using the engine's paged KV manager. Each such model declares what it holds by implementing `model_local_kv_specs()`, and `OmniGPUModelRunner.load_model` sums and logs the result after load. ## What is and is not already accounted for This distinction decides what the protocol is for, and an earlier revision of this page got it wrong by claiming none of this memory was accounted for. CUDA-graph capture happens *during* `load_model` -- MiMo in its constructor, Qwen3-TTS in `load_weights`. `determine_available_memory()` then samples per-process GPU memory through NVML and subtracts it from the requested budget (`vllm_omni/worker/base.py`). So the **graph-resident** portion is already charged. Subtracting the declared total from the KV budget would double-count it. What is *not* charged is the **per-request** portion, which is allocated after profiling. That half is not small: Qwen3-TTS's codec decoder is 4.44 MiB per request, so a server at `max_num_seqs=256` reaches roughly 1.1 GiB that no per-stage number predicted. The consumer therefore reports and does not subtract. ## What the four models declare | Model | Cache | Bounded by | Per row | Rows | |---|---|---|---|---| | Qwen3-TTS codec decoder (async-chunk only) | sliding `DynamicCache` | `sliding_window - 1 = 71` | 4.44 MiB | `max_num_seqs`, x2 for the working copy | | Qwen3-TTS graph pool | retained captures | same | 4.44 MiB | fixed, from captured shapes | | MiMo-Audio local transformer | `DynamicCache` | `group_size + max(delay_pattern) = 11` | 704 KiB | `max_num_seqs` | | MiMo-Audio graph pool | captured | same | 704 KiB | fixed 261 (bucket sum) | | MiniCPM-o Whisper encoder (streaming only) | `EncoderDecoderCache` | `embed_positions` rows = 1500 | 140.62 MiB | fixed 1, per session | | ming_flash_omni talker | `StaticCache` | hardcoded `max_cache_len = 2048` | 24.00 MiB | fixed 1 | **No cache is bounded by `max_model_len`.** Each has its own mechanism: a sliding window, a decode-loop trip count, an encoder frame limit, a constant. **ming's geometry does not exist in this repo.** `self._llm_config` is a `Qwen2Config` from the checkpoint, so layers, kv-heads, head-dim and dtype are only knowable after load. That is what makes this a runtime query rather than a table. ## Lifetime is not multiplicity `scope` records when memory appears and goes. It does **not** decide how wide the allocation is, and an earlier revision that derived scaling from it was wrong in two different directions at once: - ming's talker is per-call but serialized -- `forward` takes `runtime_additional_information[0]` and runs the whole AR loop inline -- so scaling it by `max_num_seqs` over-reported it by that factor. - MiniCPM-o's encoder is per *session*, not per sequence, so `max_num_seqs` is simply the wrong number for it. So width is declared explicitly, by naming the driver. `RowDriver` has two values, because two are all the known caches need: - `FIXED` -- a count the model controls, with `rows_fixed` and a required `rows_reason`. `rows_fixed=1` on a cache that looks per-request is a claim a reviewer must be able to check. - `MAX_NUM_SEQS` -- one row per in-flight sequence; only the engine knows the value. A third value belongs here when a model actually has a cache the engine widens by some other number. An earlier revision added one speculatively, for a single declarer, and paid for it with a class hierarchy, an engine-capacity object and a conditional-annotation field. `rows` counts row-equivalents at peak and deliberately does not distinguish "one allocation N rows wide" from "N allocations of one row". Those cost the same, and a revision that tried to carry both got the object topology wrong in three of four declarers: MiniCPM-o keeps one cache object per session while asserting batch size 1, and both graph pools keep one distinct object per captured bucket. The layout belongs in `allocation_note`, where it cannot be mistaken for arithmetic. ## Declaring nothing is a valid declaration A declaration must be inert when its path is. Qwen3-TTS holds KV only in async-chunk mode -- stateless decoding runs `_forward_exact`, which calls the transformer without `use_cache` -- so the stateless path declares nothing. Getting this wrong is easy in both directions, and both have happened here: one revision hung the declaration off the CUDA-graph wrapper and reported zero under `enforce_eager`, where the eager async-chunk path does allocate; the next declared unconditionally and invented roughly a gigabyte for stateless deployments. Where the model cannot see the deciding setting, it declares the honest unit and says so in `allocation_note`. MiniCPM-o's encoder cache exists only on the streaming path and is one object per session; the session cap is not visible from the model, so it declares one session rather than scaling by a number it would have to guess at. ## Finding the declarers `collect_model_local_kv_specs()` walks `named_modules()`. Two wrinkles: - The runner's `self.model` may be a `CUDAGraphWrapper`, which is not an `nn.Module` but forwards attribute access to its runnable. The walk therefore works either way, but entering through both the wrapper and its runnable would count a root-level declarer twice. The collector calls the supported `unwrap()` accessor first. - An owner that is not a submodule at all (Qwen3-TTS's graph wrapper, ming's `MingAudioGenerator`) is reached by one explicit hop from the module that holds it. ## Adding a model Return one entry per distinct allocation. Prefer `spec_from_hf_config()` and pass the same config object the cache is built from; it raises rather than guessing when an attribute is missing. `capacity_source` is diagnostic text -- never branch on it. ## Known limits Row counts taken from live state after warmup miss caches `transformers` has not yet materialized, which makes Qwen3-TTS's graph-pool figure a floor for the xvec path. MiMo's graph-pool cache has no reachable Python object -- it is a local inside the captured frame -- so its width comes from the captured bucket list rather than from tensors. Its two entries are also mutually exclusive per call: a call either replays a bucket or runs eagerly. The sum is the right ceiling for the process and the wrong number for a single request. MiMo captures every bucket gated only on `torch.cuda.is_available()` (`mimo_audio_llm.py:670`), so those 179.44 MiB stay resident even when the deploy config sets `enforce_eager: true`. Gating it is a one-line change that needs a model boot to verify, so it is noted rather than bundled here.