<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="https://rahulsharmavishwakarma.github.io/feed.xml" rel="self" type="application/atom+xml"/><link href="https://rahulsharmavishwakarma.github.io/" rel="alternate" type="text/html" hreflang="en"/><updated>2026-08-29T21:58:11+00:00</updated><id>https://rahulsharmavishwakarma.github.io/feed.xml</id><title type="html">Rahul Sharma</title><subtitle>Rahul Sharma — AI/ML Engineer (SDE-2 at ZysecAI). Building vision-language models, retrieval pipelines, and agentic systems. Portfolio, writing, and projects. </subtitle><author><name>Rahul Sharma</name></author><entry><title type="html">Serving LLMs: what actually decides your latency</title><link href="https://rahulsharmavishwakarma.github.io/blog/2026/serving-llms-in-production/" rel="alternate" type="text/html" title="Serving LLMs: what actually decides your latency"/><published>2026-08-29T20:00:00+00:00</published><updated>2026-08-29T20:00:00+00:00</updated><id>https://rahulsharmavishwakarma.github.io/blog/2026/serving-llms-in-production</id><content type="html" xml:base="https://rahulsharmavishwakarma.github.io/blog/2026/serving-llms-in-production/"><![CDATA[<p>Training a model is a throughput problem. Serving one is a latency problem under concurrency — and almost every interesting engineering decision in an inference stack falls out of that difference. This is a working note on the pieces that actually decide what your users feel: the phases of a request, the metrics worth measuring, the memory the KV cache quietly eats, what changes when you serve fine-tuned models instead of base ones, and how the popular runtimes — vLLM, SGLang, llama.cpp, MLX — approach the same problem.</p> <blockquote> <p><strong>TL;DR</strong> — Prefill is compute-bound; decode is bandwidth-bound. <strong>TTFT</strong> is a prefill problem, <strong>TPOT</strong> is a bandwidth problem, the <strong>KV cache</strong> is a memory bill that grows with context × concurrency, and <strong>multi-LoRA</strong> decides whether your GPU count scales with your number of fine-tuned variants. Everything below is those four sentences, unpacked.</p> </blockquote> <h2 id="1-a-request-has-two-phases-and-they-are-not-alike">1. A request has two phases, and they are not alike</h2> <p>Every LLM request runs in two distinct phases.</p> <p><strong>Prefill</strong> processes the entire prompt — system message, context, user question — in one pass. Because all prompt tokens are available at once, this is a large parallel matrix multiply: <strong>compute-bound</strong>. Its cost grows roughly linearly with prompt length for the dense work, plus the quadratic attention term over the sequence.</p> <p><strong>Decode</strong> generates the reply one token at a time. Each new token needs a full pass over the model, which means reading <em>every weight</em> from GPU memory to produce a single token. That makes decode <strong>memory-bandwidth-bound</strong> — the arithmetic units spend most of their time waiting on data.</p> <table> <thead> <tr> <th> </th> <th>Prefill</th> <th>Decode</th> </tr> </thead> <tbody> <tr> <td><strong>What runs</strong></td> <td>whole prompt, one pass</td> <td>one token per pass</td> </tr> <tr> <td><strong>Work shape</strong></td> <td>large parallel matmuls</td> <td>full weight read per token</td> </tr> <tr> <td><strong>Bottleneck</strong></td> <td>GPU <em>compute</em></td> <td>GPU <em>memory bandwidth</em></td> </tr> <tr> <td><strong>Cost scales with</strong></td> <td>prompt length (linear + quadratic attention)</td> <td>output length × weight size</td> </tr> </tbody> </table> <p>This split explains most of what follows: optimizations for serving are really optimizations for one phase or the other.</p> <figure class="paper-figure"> <svg viewBox="0 0 760 242" role="img" aria-label="Request timeline: prefill phase, TTFT marker, decode tokens with TPOT, end-to-end span" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"> <text x="115" y="88" text-anchor="middle" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">PREFILL</text> <text x="115" y="104" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">compute-bound</text> <text x="455" y="88" text-anchor="middle" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">DECODE</text> <text x="455" y="104" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">memory-bandwidth-bound</text> <rect x="42" y="118" width="146" height="30" rx="4" fill="var(--global-theme-color)"/> <text x="115" y="138" text-anchor="middle" font-size="11.5" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600">1,000 prompt tokens</text> <line x1="196" y1="60" x2="196" y2="176" stroke="var(--global-text-color-light)" stroke-width="1.2" stroke-dasharray="4 4"/> <text x="196" y="52" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">TTFT</text> <g fill="var(--global-theme-color)"> <rect x="212" y="125" width="11" height="16" rx="2.5"/><rect x="234" y="125" width="11" height="16" rx="2.5"/> <rect x="256" y="125" width="11" height="16" rx="2.5"/><rect x="278" y="125" width="11" height="16" rx="2.5"/> <rect x="300" y="125" width="11" height="16" rx="2.5"/><rect x="322" y="125" width="11" height="16" rx="2.5"/> <rect x="344" y="125" width="11" height="16" rx="2.5"/><rect x="366" y="125" width="11" height="16" rx="2.5"/> <rect x="388" y="125" width="11" height="16" rx="2.5"/><rect x="410" y="125" width="11" height="16" rx="2.5"/> <rect x="432" y="125" width="11" height="16" rx="2.5"/> </g> <text x="462" y="138" font-size="13" fill="var(--global-text-color-light)">···</text> <rect x="486" y="125" width="11" height="16" rx="2.5" fill="none" stroke="var(--global-theme-color)" stroke-width="1.5"/> <text x="508" y="138" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">last token</text> <path d="M234,164 L234,170 L267,170 L267,164" fill="none" stroke="var(--global-text-color-light)" stroke-width="1.2"/> <text x="250" y="186" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">TPOT ≈ 20 ms</text> <path d="M42,206 L42,212 L697,212 L697,206" fill="none" stroke="var(--global-text-color)" stroke-width="1.2"/> <text x="370" y="228" text-anchor="middle" font-size="11.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">end-to-end ≈ TTFT + (n_out − 1) × TPOT</text> </svg> <figcaption>Figure 1 — Anatomy of a request: prefill runs the whole prompt as one parallel, compute-bound pass; decode then emits tokens one at a time, bounded by memory bandwidth.</figcaption> </figure> <h2 id="2-the-metrics-that-matter">2. The metrics that matter</h2> <p>The four numbers worth putting on a dashboard:</p> <ul> <li><strong>TTFT — time to first token.</strong> Request sent → first streamed token. Dominated by prefill (plus queueing). This is <em>perceived</em> latency: chat UIs feel instant at 300ms TTFT even if the full answer takes twenty seconds.</li> <li><strong>TPOT — time per output token</strong> (sometimes ITL, inter-token latency). The steady-state rhythm of the stream after the first token. Its inverse is your per-request decode speed.</li> <li><strong>Throughput</strong> — total tokens/second or requests/second across all concurrent users. What the <em>operator</em> optimizes — in direct tension with per-request latency, because the trick that raises throughput (bigger batches) usually raises TPOT too.</li> <li><strong>Goodput</strong> — throughput that <em>counts</em>: only requests meeting your latency SLO. Serving at 95% GPU utilization with 30% of requests over TTFT budget can be worse than running cooler with better scheduling.</li> </ul> <p>E2E / TTOT — end-to-end time — is roughly:</p> \[t_{e2e} \approx t_{TTFT} + (n_{out} - 1) \cdot t_{TPOT}\] <p><strong>Worked example.</strong> Say your stack reports TTFT 250ms and TPOT 20ms for a request with a 1,000-token prompt and a 512-token completion:</p> <ul> <li>TTFT = 250ms</li> <li>decode of 511 remaining tokens ≈ 511 × 20ms ≈ 10.2s</li> <li>E2E ≈ 10.5s</li> </ul> <p>One number — “tokens per second” — hides all of this. A stack can halve TPOT and leave TTFT untouched; whether that helps depends on whether your users are reading a stream or waiting for a JSON blob.</p> <h2 id="3-model-size-the-memory-bill">3. Model size: the memory bill</h2> <p>The weights set the floor. A useful rule: <strong>gigabytes ≈ parameters × bytes-per-parameter</strong>.</p> <table> <thead> <tr> <th>Model</th> <th>FP16 (2B)</th> <th>INT8 (1B)</th> <th>INT4 (0.5B)</th> </tr> </thead> <tbody> <tr> <td>7B</td> <td>14 GB</td> <td>7 GB</td> <td>~3.5 GB</td> </tr> <tr> <td>13B</td> <td>26 GB</td> <td>13 GB</td> <td>~6.5 GB</td> </tr> <tr> <td>70B</td> <td>140 GB</td> <td>70 GB</td> <td>~35 GB</td> </tr> </tbody> </table> <p>Quantization trades a little quality for a lot of memory — and memory buys <em>speed</em> here, because decode is bandwidth-bound. A back-of-envelope decode ceiling for one stream:</p> \[\text{tokens/s} \;\lesssim\; \frac{\text{memory bandwidth}}{\text{weight bytes}}\] <p>A 7B model at FP16 (14 GB) on an A100 (2 TB/s) tops out near 140 tokens/s <em>per stream</em> — real-world numbers land lower. Quantize to INT4 (3.5 GB) and the same bandwidth now feeds ~4× more streams, or a faster single stream. This is why quantization is a serving decision, not just a “fit it on the GPU” decision.</p> <p><strong>The quantization formats you’ll actually meet:</strong></p> <ul> <li><strong>GPTQ / AWQ</strong> — 4-bit weight formats built for GPU serving; behind most INT4 checkpoints on the Hub.</li> <li><strong>GGUF k-quants</strong> — llama.cpp’s format family, tuned for CPU / Metal execution from one file.</li> <li><strong>FP8</strong> — hardware-accelerated on Hopper-class NVIDIA chips and newer; near-FP16 quality at half the memory and double the effective bandwidth.</li> <li><strong>MLX 4-bit</strong> — the Apple-side equivalent, computed on unified memory.</li> </ul> <p>The caveat that matters: quantization quality is <em>eval-dependent</em>. Always re-run your own evals on the quantized checkpoint — a 4-bit model that aces its benchmarks can still lose your production edge cases.</p> <h2 id="4-the-kv-cache-the-hidden-state-that-eats-your-gpu">4. The KV cache: the hidden state that eats your GPU</h2> <p>Transformers are autoregressive: each new token attends over <em>everything before it</em>. Recomputing keys and values for the whole history at every step would be quadratic and absurd, so runtimes cache them per sequence. That cache is the <strong>KV cache</strong>, and after the weights it is the largest consumer of GPU memory — often <em>the</em> largest at real context lengths.</p> <p>Its size per token:</p> \[\text{KV bytes/token} = 2 \times n_{layers} \times n_{kv\_heads} \times d_{head} \times \text{bytes}\] <p>(the 2 is K and V; modern models use GQA/MQA, so <code class="language-plaintext highlighter-rouge">n_kv_heads</code> is much smaller than query heads).</p> <p><strong>Worked numbers:</strong></p> <ul> <li><strong>Llama-2-7B</strong>: 32 layers × 32 heads × 128 head-dim, FP16 → 2 × 32 × 32 × 128 × 2 ≈ <strong>512 KiB/token</strong>. A full 4,096-token context: ~2 GB <em>per sequence</em>.</li> <li><strong>Llama-3-70B</strong> (GQA, 8 KV heads): 2 × 80 × 8 × 128 × 2 ≈ <strong>320 KiB/token</strong> → ~1.25 GB at 4k tokens — <em>per concurrent request</em>.</li> </ul> <p>The key mental model: <strong>weights are a fixed cost; the KV cache scales with context × concurrency.</strong> Serve ten streams of 8k context on that 7B and the cache alone is nearly triple the weights. This is why “what’s the max context I can serve?” is really “how much memory is left after weights and KV at my target batch size?”</p> <figure class="paper-figure"> <svg viewBox="0 0 760 318" role="img" aria-label="Stacked VRAM bars: fixed weights versus KV cache growing with context and concurrency" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"> <g stroke="var(--global-divider-color)" stroke-width="1"> <line x1="70" y1="270" x2="720" y2="270"/> <line x1="70" y1="211" x2="720" y2="211" stroke-dasharray="3 5"/> <line x1="70" y1="152" x2="720" y2="152" stroke-dasharray="3 5"/> <line x1="70" y1="93" x2="720" y2="93" stroke-dasharray="3 5"/> </g> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <text x="62" y="274" text-anchor="end">0</text> <text x="62" y="215" text-anchor="end">20</text> <text x="62" y="156" text-anchor="end">40</text> <text x="62" y="97" text-anchor="end">60</text> <text x="70" y="28" text-anchor="start">GB of VRAM</text> </g> <g font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <rect x="170" y="14" width="12" height="12" fill="#8a8a8f"/><text x="188" y="24">weights (7B FP16)</text> <rect x="368" y="14" width="12" height="12" fill="var(--global-theme-color)"/><text x="386" y="24">KV cache</text> </g> <rect x="150" y="229" width="90" height="41" fill="#8a8a8f"/> <rect x="150" y="223" width="90" height="6" fill="var(--global-theme-color)"/> <text x="195" y="216" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">16 GB</text> <text x="195" y="292" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">1 req · 4k ctx</text> <rect x="350" y="229" width="90" height="41" fill="#8a8a8f"/> <rect x="350" y="135" width="90" height="94" fill="var(--global-theme-color)"/> <text x="395" y="128" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">46 GB</text> <text x="395" y="180" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600">KV 32 GB</text> <text x="395" y="292" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">8 reqs · 8k ctx</text> <rect x="550" y="229" width="90" height="41" fill="#8a8a8f"/> <rect x="550" y="40" width="90" height="189" fill="var(--global-theme-color)"/> <text x="595" y="30" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">78 GB</text> <text x="595" y="122" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600">KV 64 GB</text> <text x="595" y="138" text-anchor="middle" font-size="10" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)">FP8</text> <text x="595" y="292" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">16 reqs · 16k ctx</text> <text x="370" y="312" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">one 80 GB GPU is full before you reach 16k × 16 — and this is a 7B model</text> </svg> <figcaption>Figure 2 — The VRAM bill for a 7B FP16 model: weights are a fixed cost, the KV cache scales with context × concurrency until it dwarfs everything else. FP8 KV cache halves the slope.</figcaption> </figure> <p>The runtime-side mitigations, briefly:</p> <ul> <li><strong>PagedAttention</strong> (vLLM) — store the cache in fixed-size pages instead of contiguous blocks, eliminating the fragmentation that used to waste 60–80% of KV memory; also the basis of its prefix caching.</li> <li><strong>GQA / MQA</strong> — fewer KV heads (an architecture choice) shrink the cache 4–8× for a small quality cost.</li> <li><strong>Quantized KV cache</strong> — 8-bit (or lower) K/V halves the bill again.</li> <li><strong>Sliding-window attention</strong> — cap how far back a token attends; bounded cache by construction.</li> </ul> <h2 id="5-context-length-at-inference-time">5. Context length at inference time</h2> <p>“Supports 128k context” is a model property; <em>serving</em> 128k is an economics problem:</p> <ul> <li><strong>Prefill compute</strong> grows quadratically with sequence length — the first token of a 100k-token prompt costs far more than a thousand 1k prompts.</li> <li><strong>KV memory</strong> grows linearly, and stays resident for the whole generation.</li> <li>Long prompts <em>amplify</em> every queueing effect, because a long prefill blocks the compute everyone else needs.</li> </ul> <p>Positional behavior matters too: models trained at 4k don’t magically attend well at 32k. Techniques like RoPE scaling (linear, NTK-aware, YaRN) stretch positional encodings so the model <em>extrapolates</em> — this is what most “long-context” releases actually ship.</p> <p>The practical lever is <strong>prefix caching</strong>: identical prompt prefixes (system prompts, few-shot blocks, retrieved RAG context) produce identical KV, so cache and reuse it instead of recomputing per request. For repeated-prefix workloads this is the single biggest TTFT win available — measured results on Apple Silicon showed cached-context TTFT dropping from 21.7s to 0.78s. SGLang generalizes the idea with <strong>RadixAttention</strong>: cached prefixes live in a radix tree, so any two requests sharing any prefix branch reuse it automatically.</p> <h2 id="6-continuous-batching">6. Continuous batching</h2> <p>Classic serving batches a fixed set of requests and waits for the longest generation before admitting new ones — every straggler holds the whole batch hostage. <strong>Continuous batching</strong> (iteration-level scheduling) admits and retires requests <em>at every decode step</em>: finished sequences free their KV pages immediately and queued prompts slot in. It’s the reason modern stacks report several-fold higher throughput than naive batching, and by now it’s table stakes — every runtime below does it.</p> <figure class="paper-figure"> <svg viewBox="0 0 780 372" role="img" aria-label="Gantt comparison: static batching holds the batch for stragglers; continuous batching admits requests as slots free" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"> <text x="130" y="28" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">STATIC BATCHING</text> <text x="730" y="28" text-anchor="end" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">batch ends →</text> <line x1="730" y1="38" x2="730" y2="158" stroke="var(--global-text-color-light)" stroke-width="1.2" stroke-dasharray="4 4"/> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <text x="120" y="68" text-anchor="end">R1</text><text x="120" y="93" text-anchor="end">R2</text> <text x="120" y="118" text-anchor="end">R3</text><text x="120" y="143" text-anchor="end">R4</text> </g> <g fill="var(--global-theme-color)"> <rect x="130" y="55" width="280" height="16" rx="3"/> <rect x="130" y="80" width="380" height="16" rx="3"/> <rect x="130" y="105" width="490" height="16" rx="3"/> <rect x="130" y="130" width="600" height="16" rx="3"/> </g> <g fill="none" stroke="var(--global-divider-color)" stroke-width="1.2"> <rect x="414" y="55" width="312" height="16" rx="3"/> <rect x="514" y="80" width="212" height="16" rx="3"/> <rect x="624" y="105" width="102" height="16" rx="3"/> </g> <text x="570" y="67" text-anchor="middle" font-size="10" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">idle · GPU waits</text> <text x="680" y="178" text-anchor="end" font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">R5 queued behind the straggler</text> <text x="130" y="212" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">CONTINUOUS BATCHING</text> <text x="730" y="212" text-anchor="end" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">same wall clock →</text> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <text x="120" y="252" text-anchor="end">R1</text><text x="120" y="277" text-anchor="end">R2</text> <text x="120" y="302" text-anchor="end">R3</text><text x="120" y="327" text-anchor="end">R4</text> </g> <g fill="var(--global-theme-color)"> <rect x="130" y="239" width="280" height="16" rx="3"/> <rect x="130" y="264" width="380" height="16" rx="3"/> <rect x="130" y="289" width="490" height="16" rx="3"/> <rect x="130" y="314" width="600" height="16" rx="3"/> </g> <g fill="var(--site-bg, #161617)" stroke="var(--global-theme-color)" stroke-width="1.5"> <rect x="418" y="239" width="172" height="16" rx="3"/> <rect x="518" y="264" width="182" height="16" rx="3"/> </g> <g font-size="10" font-family="JetBrains Mono, monospace" fill="var(--global-theme-color)"> <text x="424" y="251">R5 admitted</text> <text x="524" y="276">R6 admitted</text> </g> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <rect x="130" y="350" width="12" height="10" fill="var(--global-theme-color)"/><text x="148" y="359">generating</text> <rect x="238" y="350" width="12" height="10" fill="var(--site-bg, #161617)" stroke="var(--global-theme-color)" stroke-width="1.5"/><text x="256" y="359">admitted mid-cycle</text> <rect x="392" y="350" width="12" height="10" fill="none" stroke="var(--global-divider-color)" stroke-width="1.2"/><text x="410" y="359">idle slot</text> </g> </svg> <figcaption>Figure 3 — Static vs continuous batching: with iteration-level scheduling a finished sequence hands its slot to the next request immediately, instead of every row waiting on the longest one.</figcaption> </figure> <h2 id="7-serving-fine-tuned-models-lora-and-the-multi-adapter-problem">7. Serving fine-tuned models: LoRA and the multi-adapter problem</h2> <p>Fine-tuning produces variants; serving them is a separate problem. <strong>LoRA</strong> freezes the base weights and learns a low-rank delta: $\Delta W = BA$ with rank $r \ll d$, applied to attention (and sometimes MLP) projections. Practical adapters are 0.1–1% the size of the base — a rank-16 adapter on a 7B model is tens of megabytes against the base’s 14 GB.</p> <p>You can serve those variants two ways:</p> <table> <thead> <tr> <th>Approach</th> <th>How</th> <th>Memory (50 variants)</th> <th>Latency</th> <th>Use when</th> </tr> </thead> <tbody> <tr> <td><strong>Merge</strong></td> <td>bake ΔW into the weights</td> <td>50 × 14 GB ≈ <strong>700 GB</strong></td> <td>best — zero overhead</td> <td>one or two variants</td> </tr> <tr> <td><strong>Dynamic adapters</strong></td> <td>one base, attach per request</td> <td>14 GB + 50 × ~40 MB ≈ <strong>16 GB</strong></td> <td>tiny routing overhead</td> <td>many per-tenant variants</td> </tr> </tbody> </table> <p>That second shape is <strong>multi-LoRA serving</strong>, and doing it well is genuinely hard: requests on different adapters must batch <em>together</em> on the same base weights, adapter matrices must be resident and swapped with near-zero overhead, and the KV cache must be shared/paged across everything. The S-LoRA paper framed the memory problem — a <em>unified pool</em> that pages base weights and adapters together — and modern engines implement versions of it:</p> <ul> <li><strong>vLLM</strong> serves many adapters on one base with hot reload and per-request routing — the most mature option, and my default for this pattern.</li> <li><strong>SGLang</strong> matches it and, in several published benchmarks, edges ahead at scale, especially when tenants share long system prompts (its radix tree amortizes those across adapters).</li> </ul> <p>The rule of thumb: one or two fine-tunes → merge and serve as separate models. Many per-user or per-tenant variants → one base + multi-LoRA serving, or your GPU bill scales with your customer count.</p> <figure class="paper-figure"> <svg viewBox="0 0 760 348" role="img" aria-label="Merged variants versus shared base with per-request LoRA adapters" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"> <text x="185" y="26" text-anchor="middle" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">MERGED — one model per variant</text> <g> <rect x="75" y="80" width="70" height="150" rx="6" fill="#8a8a8f"/> <rect x="175" y="80" width="70" height="150" rx="6" fill="#8a8a8f"/> <rect x="275" y="80" width="70" height="150" rx="6" fill="#8a8a8f"/> </g> <g fill="var(--global-theme-color)"> <rect x="75" y="66" width="70" height="10" rx="3"/> <rect x="175" y="66" width="70" height="10" rx="3"/> <rect x="275" y="66" width="70" height="10" rx="3"/> </g> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600"> <text x="110" y="150" text-anchor="middle">7B</text> <text x="210" y="150" text-anchor="middle">7B</text> <text x="310" y="150" text-anchor="middle">7B</text> </g> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)" text-anchor="middle"> <text x="110" y="250">variant A</text><text x="210" y="250">variant B</text><text x="310" y="250">variant C</text> </g> <text x="185" y="286" text-anchor="middle" font-size="11.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">50 variants ≈ 700 GB</text> <text x="185" y="304" text-anchor="middle" font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">zero runtime overhead, linear memory</text> <line x1="385" y1="40" x2="385" y2="300" stroke="var(--global-divider-color)" stroke-width="1"/> <text x="572" y="26" text-anchor="middle" font-size="12" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">SHARED BASE + ADAPTERS</text> <g font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)"> <text x="402" y="70">req · A</text> <text x="402" y="103">req · B</text> <text x="402" y="136">req · C</text> </g> <g fill="var(--global-theme-color)"> <rect x="458" y="58" width="76" height="18" rx="4"/> <rect x="458" y="91" width="76" height="18" rx="4"/> <rect x="458" y="124" width="76" height="18" rx="4"/> </g> <g font-size="10" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600"> <text x="496" y="70" text-anchor="middle">LoRA A</text> <text x="496" y="103" text-anchor="middle">LoRA B</text> <text x="496" y="136" text-anchor="middle">LoRA C</text> </g> <g stroke="var(--global-theme-color)" stroke-width="1.4" stroke-dasharray="3 3" fill="none"> <path d="M534,67 L590,67 L590,148"/> <path d="M534,100 L578,100 L578,148"/> <path d="M534,133 L566,133 L566,148"/> </g> <g fill="var(--global-theme-color)"> <path d="M586,148 L590,156 L594,148 Z"/> <path d="M574,148 L578,156 L582,148 Z"/> <path d="M562,148 L566,156 L570,148 Z"/> </g> <rect x="500" y="158" width="150" height="110" rx="6" fill="#8a8a8f"/> <text x="575" y="208" text-anchor="middle" font-size="11" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)" font-weight="600">7B base — 14 GB</text> <text x="575" y="226" text-anchor="middle" font-size="9.5" font-family="JetBrains Mono, monospace" fill="var(--site-bg, #161617)">shared KV pages</text> <text x="572" y="286" text-anchor="middle" font-size="11.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color)">50 variants ≈ 16 GB</text> <text x="572" y="304" text-anchor="middle" font-size="10.5" font-family="JetBrains Mono, monospace" fill="var(--global-text-color-light)">tiny per-request routing overhead</text> </svg> <figcaption>Figure 4 — Serving fine-tuned variants: merging duplicates the base model per variant; shared-base multi-LoRA keeps one base in memory and pages small adapters per request.</figcaption> </figure> <h2 id="8-the-runtimes-at-a-high-level">8. The runtimes, at a high level</h2> <p>All four below are excellent; they optimize different points on the same curve.</p> <h3 id="vllm">vLLM</h3> <p>The production default on NVIDIA hardware. PagedAttention for the KV cache, continuous batching, prefix caching, mature multi-LoRA serving with hot-swappable adapters. If you’re serving a popular open model to many users on GPUs, start here.</p> <h3 id="sglang">SGLang</h3> <p>Built around RadixAttention: cached prefixes in a radix tree shared across requests. That makes it exceptional for agentic and structured workloads — multi-turn tool loops, shared system prompts, RAG pipelines where everything shares a prefix. Also very fast at constrained decoding (guaranteed-JSON outputs), which agent stacks love. Benchmarks in 2026 frequently show it matching or beating vLLM, particularly on prefix-heavy and multi-LoRA-at-scale loads.</p> <h3 id="llamacpp">llama.cpp</h3> <p>The everything-runs-everywhere runtime: GGUF quantized weights, CPU execution with GPU offload, Metal on macOS. Single-stream latency on modest hardware is remarkable, and it’s the right answer for local tools, edge boxes, and embedded deployments. High-concurrency throughput is not its game.</p> <h3 id="mlx">MLX</h3> <p>Apple’s array framework for Apple Silicon (serve via mlx-lm or vllm-mlx). Unified memory means the RAM/GPU split doesn’t exist: a 64 GB Mac can hold a 70B model that would need two data-center GPUs elsewhere. Recent benchmarks show it competitive natively, with prefix caching delivering dramatic TTFT wins on cached contexts. The right choice for Mac-local serving and Apple-first products.</p> <table> <thead> <tr> <th>Pick</th> <th>When</th> </tr> </thead> <tbody> <tr> <td>vLLM</td> <td>Multi-user GPU serving, multi-LoRA, the safe default</td> </tr> <tr> <td>SGLang</td> <td>Agentic loops, shared prefixes, structured/JSON output</td> </tr> <tr> <td>llama.cpp</td> <td>Local, edge, CPU/mixed hardware, single-stream latency</td> </tr> <tr> <td>MLX</td> <td>Apple Silicon, unified-memory serving on Macs</td> </tr> </tbody> </table> <h2 id="9-the-hardware-the-third-axis">9. The hardware: the third axis</h2> <p>Runtimes don’t run in the abstract. The same model, same version, same settings lands <em>different numbers</em> on different silicon, because kernel coverage and memory systems differ — so hardware is a first-class serving decision, not a procurement detail.</p> <h3 id="nvidia-cuda">NVIDIA (CUDA)</h3> <p>The default gravity of the ecosystem: every runtime above targets it first, kernel coverage is deepest, and the memory story (HBM bandwidth, NVLink for multi-GPU) is what most serving math assumes. Two NVIDIA-specific layers sit above the runtimes:</p> <ul> <li><strong>TensorRT-LLM</strong> — NVIDIA’s hand-tuned kernel and engine library; often the fastest raw CUDA path, paid for in a heavier build-and-version matrix.</li> <li><strong>Dynamo</strong> — NVIDIA’s datacenter-scale serving framework, and the clearest expression of the prefill/decode split from §1: <strong>disaggregated serving</strong> runs prefill and decode as <em>independently scalable GPU pools</em>, transfers the KV state between them, and routes with KV-cache awareness. It orchestrates TensorRT-LLM, vLLM, and SGLang as backends — prefill and decode simply stop fighting over the same GPUs. This only earns its complexity at serious scale, but it’s where big fleets are going.</li> </ul> <p>Newer chips (Hopper, Blackwell) add FP8 and FP4 tensor-core paths — the hardware half of the quantization story above.</p> <h3 id="amd-rocm">AMD (ROCm)</h3> <p>The credible second source. The MI300X puts <strong>192 GB of HBM3 on one card</strong>, which changes the sizing math — a 70B FP16 model fits with room for KV to spare. vLLM and SGLang ship ROCm builds, llama.cpp runs via ROCm/HIP or Vulkan, and PyTorch support is real. The honest caveat: kernel coverage and edge-case polish lag CUDA by quarters, so expect rougher edges at exotic configurations — and validate <em>your model on your card</em> before committing a fleet.</p> <h3 id="apple-silicon-mlx">Apple Silicon (MLX)</h3> <p>Covered in §8; the hardware note belongs here too — <strong>unified memory removes the RAM/VRAM split entirely</strong>, which is <em>why</em> a single Mac can serve models that need multi-GPU rigs elsewhere, at bandwidth numbers well below data-center GPUs.</p> <table> <thead> <tr> <th> </th> <th>NVIDIA</th> <th>AMD</th> <th>Apple</th> </tr> </thead> <tbody> <tr> <td>Ecosystem</td> <td>CUDA — deepest, default</td> <td>ROCm — maturing fast</td> <td>MLX — Apple-only</td> </tr> <tr> <td>Serve via</td> <td>TensorRT-LLM, vLLM, SGLang, Dynamo</td> <td>vLLM / SGLang ROCm builds</td> <td>mlx-lm, vllm-mlx</td> </tr> <tr> <td>Memory angle</td> <td>HBM + NVLink pooling</td> <td>huge HBM per card (MI300X: 192 GB)</td> <td>unified memory</td> </tr> <tr> <td>Quant paths</td> <td>FP8 / FP4 tensor cores</td> <td>growing</td> <td>4-bit MLX</td> </tr> </tbody> </table> <p>The practical rule: <strong>pick the runtime and the hardware together, not sequentially.</strong> “vLLM on the cheapest GPUs,” “SGLang on MI300X,” and “llama.cpp on a Mac mini” are three different products — and Dynamo-style disaggregation only earns its keep at datacenter scale.</p> <h2 id="10-the-pluggable-layer-cache-extensions-quant-backends-speculative-decoding">10. The pluggable layer: cache extensions, quant backends, speculative decoding</h2> <p>Modern engines are platforms — the kernel is only the base, and a set of loadable optimizations rides on top. Three matter most in practice.</p> <h3 id="kv-cache-extensions--lmcache">KV-cache extensions — LMCache</h3> <p><strong>LMCache</strong> adds a cache <em>layer</em> around the runtime’s KV store: it persists and shares KV state <strong>across requests and across instances</strong>, tiered from GPU → CPU DRAM → local NVMe. The payoff is TTFT on repeated content — chat history, the same RAG corpus, multi-turn agent state — because the prefix’s KV is <em>loaded</em>, not recomputed, and a second replica can reuse what the first one computed. vLLM integrates it through its KV-connector API, which is also the pattern disaggregated stores (Mooncake-style) build on. If your workload re-reads the same context all day — and most agent stacks do — this is the single highest-leverage extension to try.</p> <h3 id="pluggable-quantization-backends">Pluggable quantization backends</h3> <p>vLLM’s <code class="language-plaintext highlighter-rouge">--quantization</code> flag is a plugin point, not a single feature: <code class="language-plaintext highlighter-rouge">fp8</code>, <code class="language-plaintext highlighter-rouge">int8</code> (weight-and-activation), <code class="language-plaintext highlighter-rouge">awq</code>, <code class="language-plaintext highlighter-rouge">gptq</code>, <code class="language-plaintext highlighter-rouge">compressed-tensors</code>, even <code class="language-plaintext highlighter-rouge">gguf</code> checkpoints — each backed by different kernels (e.g. Marlin kernels for fast INT4 dequant). Two practical notes:</p> <ul> <li><strong>Match the format to the silicon</strong> — FP8 wants Hopper-or-newer tensor cores; INT4/AWQ is the portable choice; <code class="language-plaintext highlighter-rouge">compressed-tensors</code> is the output of the llm-compressor pipeline and the smoothest path if you quantize yourself.</li> <li><strong>The backend changes the kernels, not just the weights</strong> — the same INT4 checkpoint can serve at noticeably different speeds under different kernel paths, so benchmark the <em>flag</em>, not just the checkpoint.</li> </ul> <h3 id="speculative-decoding">Speculative decoding</h3> <p>Decode is bandwidth-bound — so amortize it. <strong>Speculative decoding</strong> drafts <em>k</em> candidate tokens cheaply, then verifies them against the big model in one pass: accept what matches, regenerate from the first mismatch. Same output distribution (verification is exact, so it’s lossless), but up to <em>k</em> tokens per forward pass.</p> <p>Draft sources, cheapest first:</p> <ul> <li><strong>Prompt lookup / n-gram</strong> — copy spans from the prompt itself; free, and shockingly good for summarization, editing, and code.</li> <li><strong>Medusa heads</strong> — extra decoding heads on the base model predicting several future tokens at once.</li> <li><strong>EAGLE-style drafters</strong> — a tiny model drafting in feature space; the current quality/acceptance frontier.</li> <li><strong>A small draft model</strong> — the classic setup; pick one from the same family.</li> </ul> <p>The catch: the win scales with the <strong>acceptance rate</strong>. Predictable output (code continuation, structured edits, summarization) accepts often and flies; high-entropy creative text rejects drafts and can even lose a little throughput. vLLM, SGLang, and llama.cpp (draft-model mode) all support it — measure on your traffic before betting on it.</p> <h2 id="11-a-practical-checklist">11. A practical checklist</h2> <ol> <li><strong>Measure TTFT and TPOT separately</strong> — one SLO each. A single “latency” number will mislead you.</li> <li><strong>Do the KV math at real concurrency</strong> — context × batch × KV-bytes-per-token, on top of weights. This decides your GPU before any tuning does.</li> <li><strong>Enable prefix caching</strong> — if your prompts share any structure, it’s the cheapest TTFT win available; reach for LMCache when the sharing crosses requests or instances.</li> <li><strong>Quantize deliberately</strong> — weights (GPTQ/AWQ INT4, FP8) for bandwidth, KV (FP8) for long context; measure quality on <em>your</em> evals, not vibes.</li> <li><strong>Try speculative decoding where output is predictable</strong> — code, edits, summaries; near-free tokens when the acceptance rate is high.</li> <li><strong>Decide merge-vs-adapter early</strong> — it’s an architecture decision, not a deployment detail.</li> <li><strong>Load-test with realistic prompt mixes</strong> — long prompts change the bottleneck from decode to prefill and queueing.</li> <li><strong>Choose hardware and runtime as one decision</strong> — model × quant × runtime × vendor all constrain each other; check the matrix before you rent.</li> </ol> <p>The unifying idea: serving is memory management under a latency budget. Once you see prefill, decode, and the KV cache as separate line items, every runtime feature — paging, radix trees, adapter pooling — reads as a solution to a specific line of that bill.</p> <h2 id="further-reading">Further reading</h2> <ul> <li><a href="https://blog.vllm.ai/2023/06/20/vllm.html">vLLM — Efficient Memory Management for LLM Serving with PagedAttention</a></li> <li><a href="https://github.com/ai-dynamo/dynamo">NVIDIA Dynamo — disaggregated inference serving framework</a></li> <li><a href="https://github.com/LMCache/LMCache">LMCache — KV cache layer for LLM serving</a></li> <li><a href="https://docs.vllm.ai/en/latest/features/spec_decode.html">vLLM — speculative decoding</a></li> <li><a href="https://arxiv.org/abs/2311.03285">S-LoRA: Serving Thousands of Concurrent LoRA Adapters</a></li> <li><a href="https://github.com/sgl-project/sglang">SGLang — RadixAttention</a></li> <li><a href="https://github.com/ggml-org/llama.cpp">llama.cpp</a></li> <li><a href="https://github.com/ml-explore/mlx">MLX — array framework for Apple Silicon</a></li> <li><a href="https://tensorfoundry.io/blog/llm-inference-servers-compared">LLM inference servers compared — TensorFoundry</a></li> <li><a href="https://leetllm.com/blog/llm-inference-engine-comparison-2026">vLLM vs SGLang vs TensorRT-LLM vs Ollama (2026) — LeetLLM</a></li> <li><a href="https://developers.redhat.com/articles/2025/09/30/vllm-or-llamacpp-choosing-right-llm-inference-engine-your-use-case">vLLM or llama.cpp? — Red Hat Developer</a></li> <li><a href="https://arxiv.org/html/2601.19139v1">Native LLM inference at scale on Apple Silicon (arXiv)</a></li> </ul> <h2 id="cite-this-article">Cite this article</h2> <p>If this note was useful in your own writing, cite it as:</p> <div class="language-bibtex highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nc">@misc</span><span class="p">{</span><span class="nl">sharma2026servingllms</span><span class="p">,</span>
  <span class="na">title</span>        <span class="p">=</span> <span class="s">{Serving LLMs: what actually decides your latency}</span><span class="p">,</span>
  <span class="na">author</span>       <span class="p">=</span> <span class="s">{Sharma, Rahul}</span><span class="p">,</span>
  <span class="na">year</span>         <span class="p">=</span> <span class="s">{2026}</span><span class="p">,</span>
  <span class="na">month</span>        <span class="p">=</span> <span class="s">{August}</span><span class="p">,</span>
  <span class="na">howpublished</span> <span class="p">=</span> <span class="s">{\url{https://rahulsharmavishwakarma.github.io/blog/2026/serving-llms-in-production/}}</span><span class="p">,</span>
  <span class="na">note</span>         <span class="p">=</span> <span class="s">{Online; accessed \today}</span>
<span class="p">}</span>
</code></pre></div></div> <p>Or in APA style:</p> <blockquote> <p>Sharma, R. (2026, August 30). <em>Serving LLMs: what actually decides your latency</em>. Rahul Sharma. https://rahulsharmavishwakarma.github.io/blog/2026/serving-llms-in-production/</p> </blockquote>]]></content><author><name>Rahul Sharma</name></author><category term="articles"/><category term="inference"/><category term="serving"/><category term="llm"/><summary type="html"><![CDATA[TTFT, prefill vs decode, the KV-cache memory bill, quantization, multi-LoRA serving, and how vLLM, SGLang, llama.cpp and MLX run them across NVIDIA, AMD and Apple silicon.]]></summary></entry><entry><title type="html">Beyond Imagery: AI-Enhanced Diagnostic Assistant for Cancer and Tumor Diagnosis using Radiology Imaging</title><link href="https://rahulsharmavishwakarma.github.io/blog/2024/beyond-imagery-ai-enhanced-diagnostic-assistant/" rel="alternate" type="text/html" title="Beyond Imagery: AI-Enhanced Diagnostic Assistant for Cancer and Tumor Diagnosis using Radiology Imaging"/><published>2024-03-15T03:30:00+00:00</published><updated>2024-03-15T03:30:00+00:00</updated><id>https://rahulsharmavishwakarma.github.io/blog/2024/beyond-imagery-ai-enhanced-diagnostic-assistant</id><content type="html" xml:base="https://rahulsharmavishwakarma.github.io/blog/2024/beyond-imagery-ai-enhanced-diagnostic-assistant/"><![CDATA[<blockquote> <p>This post is a technical walkthrough of our IJETS paper, <a href="https://ijets.in/Downloads/Published/E0202403007.pdf"><em>Beyond Imagery: AI-Enhanced Diagnostic Assistant for Cancer and Tumor Diagnosis using Radiology Imaging</em></a> (NandhaGopal S M, Rahul Sharma, Nithin M, Prajwal B R, Prashanth Kalgonda — International Journal On Engineering Technology and Sciences, Vol. XI, Issue III, March 2024). It was my final-year undergraduate research project at HKBK College of Engineering.</p> </blockquote> <p>A radiologist looking at a CT scan is not just pattern-matching pixels against a list of diseases. She is interrogating the image — <em>Is there a mass in the upper lobe? Is it enhancing? Has it grown since the last scan?</em> — and answering by fusing what she sees with years of accumulated medical knowledge.</p> <p>Most computer vision systems in medicine do none of that. They classify: this image is “malignant” or “benign”, with a confidence score. Useful, but rigid. The clinician cannot ask a follow-up question, and the system cannot explain itself by pointing at the literature it “learned” from.</p> <p>Our paper asks a simple question: <strong>what if the diagnostic assistant could hold a conversation about the image — and ground its answers in biomedical literature at the same time?</strong> The answer we landed on is a multimodal generator (LLaVA-Med) wired to a retrieval layer over biomedical literature. Below is the full architecture, how each part works, what it scores, and where it breaks.</p> <h2 id="1-the-system-at-a-glance">1. The system at a glance</h2> <figure class="paper-figure"> <svg viewBox="0 0 880 480" role="img" aria-label="Architecture of the RAG-augmented medical VQA system" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"> <defs> <marker id="arr" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> <path d="M 0 0 L 10 5 L 0 10 z" fill="#8a8a8f"/> </marker> <marker id="arrAccent" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"> <path d="M 0 0 L 10 5 L 0 10 z" fill="#8a8a8f"/> </marker> </defs> <rect x="20" y="42" width="160" height="58" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="100" y="66" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Radiology image</text> <text x="100" y="86" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">CT · MRI · X-ray</text> <rect x="250" y="42" width="180" height="58" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="340" y="66" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Vision encoder</text> <text x="340" y="86" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">CLIP ViT-L/14 (frozen)</text> <rect x="500" y="42" width="170" height="58" rx="6" fill="var(--global-theme-color)" fill-opacity="0.08" stroke="var(--global-theme-color)" stroke-width="1.6"/> <text x="585" y="66" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">MLP adapter g&#8345;</text> <text x="585" y="86" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">projector — the only trained part</text> <rect x="740" y="42" width="120" height="58" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="800" y="66" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">LLM decoder</text> <text x="800" y="86" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">Vicuna (frozen)</text> <rect x="20" y="160" width="160" height="58" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="100" y="184" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Clinical question</text> <text x="100" y="204" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">natural language</text> <rect x="250" y="160" width="180" height="58" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="340" y="184" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Question encoder</text> <text x="340" y="204" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">tokenizer + transformer</text> <rect x="740" y="160" width="120" height="58" rx="6" fill="var(--global-theme-color)" fill-opacity="0.08" stroke="var(--global-theme-color)" stroke-width="1.6"/> <text x="800" y="184" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Answer</text> <text x="800" y="204" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">generated text</text> <line x1="180" y1="71" x2="244" y2="71" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <line x1="430" y1="71" x2="494" y2="71" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <line x1="670" y1="71" x2="734" y2="71" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <line x1="180" y1="189" x2="244" y2="189" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <path d="M 430 189 L 585 189 L 585 130 L 734 130 L 734 100" fill="none" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <rect x="20" y="280" width="840" height="180" rx="8" fill="none" stroke="var(--global-text-color-light)" stroke-width="1.1" stroke-dasharray="6 4"/> <text x="40" y="306" font-size="12.5" fill="var(--global-text-color-light)" letter-spacing="1">RETRIEVAL AUGMENTED GENERATION</text> <rect x="45" y="330" width="230" height="70" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="160" y="357" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Biomedical corpus 𝒞</text> <text x="160" y="377" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">literature · case studies (PMC-15M)</text> <rect x="345" y="330" width="220" height="70" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="455" y="357" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Retriever</text> <text x="455" y="377" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">top-k by cosine similarity</text> <rect x="635" y="330" width="200" height="70" rx="6" fill="none" stroke="var(--global-text-color)" stroke-width="1.3"/> <text x="735" y="357" text-anchor="middle" font-size="13.5" fill="var(--global-text-color)">Prompt builder</text> <text x="735" y="377" text-anchor="middle" font-size="11.5" fill="var(--global-text-color-light)">context assembly</text> <line x1="275" y1="365" x2="339" y2="365" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <line x1="565" y1="365" x2="629" y2="365" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <path d="M 340 218 L 340 250 L 455 250 L 455 324" fill="none" stroke="#8a8a8f" stroke-width="1.1" stroke-dasharray="5 4" marker-end="url(#arr)"/> <text x="468" y="262" font-size="11" fill="var(--global-text-color-light)">query embedding e&#8339;</text> <path d="M 735 330 L 735 106" fill="none" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <text x="748" y="240" font-size="11" fill="var(--global-text-color-light)">top-k passages 𝒟&#8339;</text> <line x1="40" y1="478" x2="70" y2="478" stroke="#8a8a8f" stroke-width="1.3" marker-end="url(#arr)"/> <text x="78" y="482" font-size="11" fill="var(--global-text-color-light)">inference path</text> <line x1="200" y1="478" x2="230" y2="478" stroke="#8a8a8f" stroke-width="1.1" stroke-dasharray="5 4" marker-end="url(#arr)"/> <text x="238" y="482" font-size="11" fill="var(--global-text-color-light)">retrieval query path</text> </svg> <figcaption><strong>Fig. 1</strong> — Architecture of the proposed RAG-augmented medical VQA system. Frozen pretrained components (CLIP ViT-L/14 vision tower, Vicuna language model) are connected by a lightweight trainable MLP adapter; a retrieval layer over biomedical literature conditions generation on external evidence at answer time.</figcaption> </figure> <p>The design philosophy is the same one that made parameter-efficient fine-tuning take off: <strong>keep the expensive pretrained machinery frozen, and train only the glue.</strong> The vision tower and the LLM already know how to see and speak — the adapter’s whole job is to translate image features into tokens the LLM is willing to attend to.</p> <h2 id="2-the-multimodal-core-piece-by-piece">2. The multimodal core, piece by piece</h2> <h3 id="21-vision-encoder--turning-pixels-into-patch-tokens">2.1 Vision encoder — turning pixels into patch tokens</h3> <p>Medical images are not ImageNet photos: grayscale, high resolution, pathology in fine texture. The encoder is a pretrained vision transformer (CLIP ViT-L/14 in the LLaVA-Med lineage; ResNet-18-style CNNs are the classic baseline). It splits the scan into patches and emits one feature vector per patch:</p> \[\mathbf{Z}_v = f_v(I) \in \mathbb{R}^{N \times d_v}\] <p>where $N$ is the number of patches and $d_v$ the vision width. The encoder is trained/frozen against medical imaging sets such as VQA-RAD and PathVQA, optimizing cross-entropy — so the features it surfaces are the ones that separate pathologies, not natural-image categories.</p> <h3 id="22-question-encoder--parsing-the-interrogation">2.2 Question encoder — parsing the interrogation</h3> <p>The question “Is there pleural effusion, and on which side?” is tokenized and passed through a transformer encoder (RNN/LSTM in older designs). Self-attention resolves what the question is actually about — the organ (pleura), the finding (effusion), the demanded output (a side). This produces the query representation used twice downstream: fused with the image for answering, and as the retrieval query for RAG.</p> <h3 id="23-multimodal-fusion--the-mlp-adapter">2.3 Multimodal fusion — the MLP adapter</h3> <p>This is the heart of the parameter-efficient design. Rather than co-training a giant joint model, a small two-layer MLP projects vision tokens into the LLM’s embedding space:</p> \[\mathbf{H}_v = \big(\mathrm{GELU}(\mathbf{Z}_v W_1 + \mathbf{b}_1)\big) W_2 + \mathbf{b}_2\] <p>with the hidden layer deliberately <em>smaller</em> than the input embeddings — an information bottleneck that forces the projection to keep only what matters for answering. The first layer compresses each patch token from the vision dimension $d_v$ down to the bottleneck width; GELU is the nonlinearity that lets the mapping bend around the geometry of the language embedding space rather than staying a flat linear rotation; the second layer expands back out to the LLM’s input dimension $d_l$. After training, an LLM that has never seen an image attends to these projected tokens as if they were ordinary word embeddings — that is the whole trick.</p> <p>Why an adapter and not fine-tuning the encoders themselves? Three reasons, all practical:</p> <ul> <li><strong>Parameter budget.</strong> The adapter holds $O(d_v \cdot h + h \cdot d_l)$ weights — a few million — against tens of billions frozen across the towers. Training signal is concentrated where the modality gap actually lives.</li> <li><strong>Catastrophic forgetting.</strong> The frozen LLM retains everything it knows about medical language and reasoning; fine-tuning it end-to-end on a few thousand QA pairs would overwrite exactly the knowledge we are borrowing.</li> <li><strong>Feature selection on top.</strong> LLaVA-Med additionally ranks fused image/question features by cosine similarity to the query vector and keeps only the most relevant — a cheap attention-like filter that stops noisy patch tokens from diluting the question.</li> </ul> <h3 id="24-answer-decoder--generation-under-evidence">2.4 Answer decoder — generation under evidence</h3> <p>The decoder is a frozen transformer LLM (Vicuna in LLaVA-Med; the paper also discusses LLaMA-2 and GPT-3.5-class models). At each step it predicts:</p> \[P(y_t \mid y_{&lt;t},\; q,\; \mathbf{H}_v,\; \mathcal{D}_q)\] <p>Note the $\mathcal{D}_q$ — the retrieved passages. That conditioning term is the entire difference between a chatbot that <em>remembers</em> medicine and one that <em>looks it up</em>. Training minimizes cross-entropy against ground-truth answers; because only the adapter receives gradients, the LLM’s clinical language ability (and its failures) stay untouched.</p> <h2 id="3-the-rag-layer--retrieval-before-generation">3. The RAG layer — retrieval before generation</h2> <p>The retrieval pipeline runs in four steps:</p> <p><strong>1. Index.</strong> Biomedical literature and case studies (the PMC-15M corpus that LLaVA-Med itself was curriculum-trained on, plus curated medical databases) are chunked and embedded once, offline.</p> <p><strong>2. Query.</strong> For each incoming question, the question encoder’s representation $e_q$ is matched against document embeddings by cosine similarity:</p> \[\mathcal{D}_q = \operatorname{TopK}_{d \in \mathcal{C}} \; \frac{e_q \cdot e_d}{\lVert e_q \rVert \, \lVert e_d \rVert}\] <p><strong>3. Assemble.</strong> The prompt builder stitches together: image tokens from the adapter, the question, and the top-$k$ retrieved passages — in that order, so attention flows from evidence to question.</p> <p><strong>4. Generate.</strong> The LLM answers <em>conditioned on the passages</em>, which is what makes responses citable: claims in the answer trace back to retrievable sources rather than to compressed weights.</p> <p>This matters double in oncology. First, for <strong>credibility</strong> — a generated claim a clinician can verify against its source is a different artifact than one she has to take on faith. Second, for <strong>freshness</strong> — treatment guidelines move faster than model retraining cycles, and retrieval absorbs that drift without touching a single weight.</p> <h2 id="4-evaluation-setup">4. Evaluation setup</h2> <p>The paper evaluates on the three standard biomedical VQA benchmarks:</p> <table> <thead> <tr> <th>Dataset</th> <th style="text-align: right">Images</th> <th style="text-align: right">QA pairs</th> <th>Character</th> </tr> </thead> <tbody> <tr> <td><strong>VQA-RAD</strong></td> <td style="text-align: right">315</td> <td style="text-align: right">3,515</td> <td>Clinician-authored; head/chest/abdomen; 11 question categories; ~50% closed-ended</td> </tr> <tr> <td><strong>SLAKE</strong></td> <td style="text-align: right">642</td> <td style="text-align: right">7,000+</td> <td>Physician-annotated, knowledge-enhanced; segmentation masks + bounding boxes; bilingual (English subset used)</td> </tr> <tr> <td><strong>PathVQA</strong></td> <td style="text-align: right">4,998</td> <td style="text-align: right">32,799</td> <td>Pathology; location/shape/color/appearance; open- and closed-ended</td> </tr> </tbody> </table> <p><strong>Metrics.</strong> Accuracy for closed-set questions; recall (fraction of ground-truth tokens present in the generated answer) for open-set questions.</p> <p><strong>Base model provenance.</strong> LLaVA-Med is curriculum-trained over the <a href="https://doi.org/10.48550/arXiv.2306.00890">PMC-15M</a> figure–caption corpus and fine-tuned on studies from MIMIC-CXR, reaching BLEU-4 of 0.264 and precision of 0.311 on its report-generation objective — the numbers that establish the floor our VQA task builds on.</p> <h2 id="5-results--and-the-honest-part">5. Results — and the honest part</h2> <p>Three findings from the paper’s comparison against supervised and generative baselines:</p> <ol> <li><strong>LLaVA-Med variants beat vanilla LLaVA</strong> across the board, with margins shifting based on language-model and vision-encoder initialization. Biomedical curriculum training transfers.</li> <li><strong>Closed-set questions: state of the art on VQA-RAD and PathVQA.</strong> When the answer set is constrained, instruction-tuned multimodal models outperform fully supervised pipelines — they follow the task definition better.</li> <li><strong>Open-set questions are where it gets uncomfortable.</strong> The model achieves SoTA on SLAKE but lags elsewhere. Unconstrained biomedical questions are ambiguous; a generative decoder without answer options can produce fluent, <em>plausible-sounding</em> answers that are simply not in the image.</li> </ol> <p>Point 3 is the most useful result in the paper. Closed evaluation hides failure modes — with five options, a lucky guess and grounded reasoning score identically. Open generation exposes the gap, and it is precisely the gap retrieval is meant to close: give the decoder something concrete to condition on, and fluency stops being a liability.</p> <h2 id="6-where-this-goes-next">6. Where this goes next</h2> <p>The paper’s limitation is its roadmap. Open-set performance is the real test of clinical usefulness, and the fix is not a bigger language model — it is better grounding: stronger retrieval (hybrid dense + keyword, reranking), domain-specific knowledge integration, and evaluation that rewards <em>correct-and-supported</em> answers over merely plausible ones. The first step of that direction is architectural: train only a lightweight projector on a frozen backbone, and let retrieval carry the domain knowledge.</p> <h2 id="reference">Reference</h2> <blockquote> <p>NandhaGopal S M, <strong>Rahul Sharma</strong>, Nithin M, Prajwal B R, Prashanth Kalgonda, “Beyond Imagery: AI-Enhanced Diagnostic Assistant for Cancer and Tumor Diagnosis using Radiology Imaging,” <em>International Journal On Engineering Technology and Sciences (IJETS)</em>, Vol. XI, Issue III, March 2024, pp. 41–46. ISSN (P) 2349-3968, ISSN (O) 2349-3976. Available: <a href="https://ijets.in/Downloads/Published/E0202403007.pdf">PDF ↗</a></p> </blockquote>]]></content><author><name>Rahul Sharma</name></author><category term="whitepapers"/><category term="vision-language"/><category term="medical-imaging"/><category term="computer-vision"/><category term="rag"/><summary type="html"><![CDATA[Our IJETS 2024 paper — combining LLaVA-Med with Retrieval Augmented Generation to build a clinically grounded visual question answering assistant for CT and MRI imaging.]]></summary></entry></feed>