<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: jidonglab</title>
    <description>The latest articles on DEV Community by jidonglab (@ji_ai).</description>
    <link>https://dev.to/ji_ai</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3791767%2F6eb19afc-a99c-4736-9d12-459108893a16.png</url>
      <title>DEV Community: jidonglab</title>
      <link>https://dev.to/ji_ai</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ji_ai"/>
    <language>en</language>
    <item>
      <title>Why Temperature 0 Doesn't Make Your LLM Deterministic</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 16 Jul 2026 19:54:21 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-temperature-0-doesnt-make-your-llm-deterministic-50fa</link>
      <guid>https://dev.to/ji_ai/why-temperature-0-doesnt-make-your-llm-deterministic-50fa</guid>
      <description>&lt;p&gt;Set &lt;code&gt;temperature=0&lt;/code&gt;, send the exact same prompt to Claude Opus 4.x or a self-hosted Qwen twice, and you can still get two different completions. Not subtly different — sometimes a whole different answer. Most engineers blame the sampler, add a &lt;code&gt;seed&lt;/code&gt;, and move on when it doesn't help. The seed was never the problem. Temperature 0 does not make your LLM deterministic because the &lt;em&gt;numerics&lt;/em&gt; of your request depend on what else is in the batch next to it on the server.&lt;/p&gt;

&lt;p&gt;This is the batch-invariance problem, and it's one of the most misunderstood sources of nondeterminism in LLM inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Temperature 0 = greedy decoding&lt;/strong&gt; (pick the argmax logit). There is no RNG left to seed, so a seed can't fix the variation you see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The real cause is floating-point non-associativity plus non-batch-invariant kernels.&lt;/strong&gt; &lt;code&gt;(a + b) + c ≠ a + (b + c)&lt;/code&gt; in float32, and the &lt;em&gt;order&lt;/em&gt; of those additions changes with server batch size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your request gets batched with other users' requests.&lt;/strong&gt; Batch size shifts with load, which shifts the reduction order in matmul and attention, which occasionally flips one token's argmax. One flip cascades into a different completion.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix is batch-invariant kernels:&lt;/strong&gt; force matmul, attention, and RMSNorm to use the same reduction order regardless of batch size. This buys bitwise-reproducible output.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It costs throughput,&lt;/strong&gt; because you give up the adaptive tiling and split reductions that make batched inference fast.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Isn't temperature 0 supposed to be deterministic?
&lt;/h2&gt;

&lt;p&gt;Logically, yes — and that's exactly why the bug is confusing. At temperature 0, decoding is &lt;code&gt;argmax(logits)&lt;/code&gt; at every step. There is no sampling distribution to draw from, no random seed that matters. Given identical logits, you get identical tokens forever.&lt;/p&gt;

&lt;p&gt;So if two runs diverge, the logits must have differed. And they did — by a few units in the last place (ULPs) of float32. Usually those tiny differences don't matter, because the top token wins by a comfortable margin. But when two candidate tokens are nearly tied, a ULP-level wobble flips the argmax. That flipped token changes the context for every subsequent step, and greedy decoding faithfully amplifies the divergence from there. One coin-flip near a tie, and the rest of the paragraph is different.&lt;/p&gt;

&lt;p&gt;The question becomes: why do the logits wobble at all if the input is identical?&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does floating-point math break determinism?
&lt;/h2&gt;

&lt;p&gt;Floating-point addition is not associative. Reorder the terms of a sum and the rounding changes. You can watch it happen in a few lines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="n"&gt;rng&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;default_rng&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;standard_normal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1_000_000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;astype&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;fwd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fwd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;

&lt;span class="n"&gt;bwd&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;float32&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[::&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;bwd&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bwd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fwd&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;bwd&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# 251.0908 251.09048 3.0517578e-04
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same numbers, same operation, different order — different result. This isn't a bug in NumPy; it's IEEE 754. Every accumulation into a fixed-width float rounds, and rounding errors depend on the order you accumulate.&lt;/p&gt;

&lt;p&gt;Now scale that up. A transformer forward pass is billions of these reductions: every matmul sums over the contraction dimension, every attention op sums over the KV sequence, every RMSNorm sums over the hidden dimension. If any of those reductions happens in a different order between two runs, the output logits differ by a few ULPs. That's your wobble.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is batch invariance, and why does it matter?
&lt;/h2&gt;

&lt;p&gt;A kernel is &lt;strong&gt;batch-invariant&lt;/strong&gt; if the result computed for a given sequence is bitwise identical no matter what else is in the batch or how big the batch is. Row 3 of the output should be the same whether you ran a batch of 1 or a batch of 512.&lt;/p&gt;

&lt;p&gt;Production inference kernels are &lt;em&gt;not&lt;/em&gt; batch-invariant by default, and that's the crux. GPU kernels pick their execution strategy — tile sizes, how many thread blocks split a reduction, whether to use a split-K GEMM — based on the shape of the problem. Change the batch size and the shape changes, so the kernel picks a different strategy, so the reduction order changes, so the rounding changes.&lt;/p&gt;

&lt;p&gt;Here's the part everyone misses: &lt;strong&gt;you don't control your batch size.&lt;/strong&gt; On a shared endpoint, the server groups concurrent requests into a batch to keep the GPU busy. How many requests land in the same batch depends on traffic at that instant. Send your prompt when the server is idle, it runs in a batch of 1. Send it during a spike, it rides in a batch of 200. Different batch → different kernel path → different reduction order → different ULPs → occasionally a flipped token.&lt;/p&gt;

&lt;p&gt;Your output depends on other people's traffic. That is the actual mechanism, and no &lt;code&gt;seed&lt;/code&gt; parameter touches it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the reduction order actually change?
&lt;/h2&gt;

&lt;p&gt;Three places dominate, and each needs its own fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Matmul.&lt;/strong&gt; For efficiency, GEMM kernels sometimes split the contraction dimension across thread blocks and combine partial sums (split-K), often with atomic adds whose completion order is nondeterministic. Whether split-K activates, and how many splits, depends on the M/N/K shape — which moves with batch size. A batch-invariant matmul must commit to a single reduction layout regardless of shape, even when a different one would be faster.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Attention.&lt;/strong&gt; FlashAttention-style kernels tile over the KV sequence and combine per-tile running softmax statistics. The number of KV splits (and thus the combine order) can depend on sequence length and batch. Long-context and paged/chunked prefill make this worse, because the KV dimension gets split differently depending on how the cache is laid out. Batch-invariant attention pins the split strategy so the online-softmax combination happens in a fixed order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RMSNorm.&lt;/strong&gt; The reduction over the hidden dimension is the easy one, but a naive data-parallel implementation that changes how it distributes rows across blocks under different batch sizes can still leak nondeterminism. Fix it the same way: one reduction layout, always.&lt;/p&gt;

&lt;p&gt;Note what is &lt;em&gt;not&lt;/em&gt; on this list: GPU concurrency and atomic race conditions per se. The forward pass is run-to-run deterministic on a fixed batch size on most stacks. The variance comes specifically from the batch size changing the kernel's plan. That reframing — from "GPUs are just nondeterministic" to "our kernels aren't batch-invariant" — is the whole insight, and it's the argument the Thinking Machines team laid out in their 2025 write-up on defeating nondeterminism in LLM inference.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you make an LLM deterministic?
&lt;/h2&gt;

&lt;p&gt;You swap in batch-invariant implementations of the three kernels above so the reduction order never depends on batch size. In practice you don't hand-write these; you enable a mode.&lt;/p&gt;

&lt;p&gt;At the framework level, the pattern looks like this — pin every source of nondeterminism, then use batch-invariant kernels:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="c1"&gt;# 1) Deterministic algorithms where cuBLAS/cuDNN offer them.
&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CUBLAS_WORKSPACE_CONFIG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:4096:8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use_deterministic_algorithms&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;manual_seed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 2) Enable batch-invariant kernels (matmul / attention / RMSNorm).
#    e.g. the batch-invariant-ops approach patches the relevant torch ops
#    so the reduction layout is fixed regardless of batch shape.
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;batch_invariant_ops&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;set_batch_invariant_mode&lt;/span&gt;
&lt;span class="nf"&gt;set_batch_invariant_mode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# 3) Greedy decoding: no sampling entropy on top.
&lt;/span&gt;&lt;span class="n"&gt;gen_kwargs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;do_sample&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For serving stacks like vLLM, the equivalent is a deterministic/batch-invariant execution mode plus a fixed attention backend — check the current docs for the exact flags, since these are actively moving. The important properties to verify are: (a) the same prompt returns byte-identical output across repeated calls, and (b) it stays identical when you vary the concurrent load. If (a) holds but (b) fails, your kernels still aren't batch-invariant.&lt;/p&gt;

&lt;p&gt;If you're on a hosted API like Claude or GPT-5.x, you can't install kernels. You get a &lt;code&gt;seed&lt;/code&gt; parameter that constrains sampling but not the server-side batch numerics, which is why hosted endpoints document their output as "mostly" deterministic rather than guaranteed. For those, the pragmatic answer is different: don't depend on exact-string reproducibility. Assert on parsed structure and semantics, not on the literal token stream.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does batch invariance cost?
&lt;/h2&gt;

&lt;p&gt;Throughput, mostly. The kernels that make batched inference fast are fast precisely because they adapt their reduction strategy to the shape in front of them — split-K when K is large, more KV splits when the sequence is long. Freezing that strategy leaves performance on the table for the shapes where the adaptive choice would have won. In published experiments the batch-invariant path runs slower than the tuned default, though the gap narrows as the kernels mature. Whether it's worth it depends on why you need reproducibility.&lt;/p&gt;

&lt;p&gt;The cases where it clearly pays off: on-policy RL where you need the sampler's numerics to match the trainer's, otherwise your "on-policy" updates are quietly off-policy; scientific or regulatory workloads that must reproduce a result exactly; and debugging, where a nondeterministic forward pass makes every bug a heisenbug. For a typical chat product, eat the variance and test on semantics instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Temperature 0 doesn't make your LLM deterministic because temperature 0 only removes sampling randomness — it doesn't remove floating-point non-associativity, and it doesn't stop your request from being batched with others. Your logits are computed by kernels whose reduction order shifts with batch size, and batch size shifts with server load, so a near-tied token occasionally flips and the completion diverges. The fix is batch-invariant kernels for matmul, attention, and RMSNorm, which give you bitwise reproducibility at some throughput cost. If you're on a hosted API, you can't force that, so stop asserting on exact strings and assert on meaning instead.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Speculative Decoding: Why a Great Draft Model Still Caps Speedup</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 16 Jul 2026 07:52:28 +0000</pubDate>
      <link>https://dev.to/ji_ai/speculative-decoding-why-a-great-draft-model-still-caps-speedup-12i1</link>
      <guid>https://dev.to/ji_ai/speculative-decoding-why-a-great-draft-model-still-caps-speedup-12i1</guid>
      <description>&lt;p&gt;A 7B draft model that agrees with your 70B target 90% of the time sounds like it should make decoding roughly 10x faster. It won't. In practice you'll see something closer to 2-3x, and if your server runs large batches you may see &lt;em&gt;nothing&lt;/em&gt; — or a regression. The gap between "the draft is usually right" and "the system is actually faster" is where most speculative decoding deployments quietly disappoint. This post is the math that explains why, and the two numbers you should actually measure before turning it on.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speculative decoding&lt;/strong&gt; runs a cheap draft model to propose &lt;code&gt;k&lt;/code&gt; tokens, then verifies all of them in one forward pass of the expensive target model. Output is provably identical to sampling from the target alone.&lt;/li&gt;
&lt;li&gt;The speedup is governed by the &lt;strong&gt;acceptance rate α&lt;/strong&gt;, not draft accuracy. Expected accepted tokens per target step = &lt;code&gt;(1 - α^(k+1)) / (1 - α)&lt;/code&gt; — a saturating curve, not linear.&lt;/li&gt;
&lt;li&gt;At α = 0.7 and k = 4 you accept ~2.6 tokens per target pass. That's your ceiling before you subtract draft and verification overhead.&lt;/li&gt;
&lt;li&gt;It works because decoding is &lt;strong&gt;memory-bound at low batch size&lt;/strong&gt;: verifying 5 tokens costs almost the same wall-clock as generating 1. That free lunch vanishes as batch size grows and you become compute-bound.&lt;/li&gt;
&lt;li&gt;Speculative decoding is a &lt;strong&gt;latency&lt;/strong&gt; optimization, not a &lt;strong&gt;throughput&lt;/strong&gt; one. On a saturated server it can slow you down.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is speculative decoding actually doing?
&lt;/h2&gt;

&lt;p&gt;Speculative decoding trades cheap FLOPs for expensive memory bandwidth. A small draft model autoregressively generates &lt;code&gt;k&lt;/code&gt; candidate tokens. The large target model then processes all &lt;code&gt;k&lt;/code&gt; in a &lt;em&gt;single&lt;/em&gt; forward pass — because with the candidate sequence in hand, you can compute the target's probability distribution at every one of those positions in parallel, exactly like prefill. You then verify the draft tokens left to right and keep the longest correct prefix.&lt;/p&gt;

&lt;p&gt;The non-obvious part is that this is lossless. Naively you might "accept the draft token if the target agrees," but that changes the output distribution. The correct algorithm is &lt;strong&gt;modified rejection sampling&lt;/strong&gt;: for a draft token &lt;code&gt;x&lt;/code&gt; with draft probability &lt;code&gt;q(x)&lt;/code&gt; and target probability &lt;code&gt;p(x)&lt;/code&gt;, accept with probability &lt;code&gt;min(1, p(x)/q(x))&lt;/code&gt;. On rejection, resample from the normalized residual distribution &lt;code&gt;(p(x) - q(x))₊&lt;/code&gt;. This provably yields samples distributed &lt;em&gt;exactly&lt;/em&gt; as if you'd sampled from the target model directly. No accuracy trade-off, no eval regression — the outputs are statistically the target's.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# One verification step. draft_probs/target_probs are [k, vocab] tensors
# for the k drafted positions; draft_tokens is [k].
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft_tokens&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;draft_probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target_probs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;draft_tokens&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;draft_probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;target_probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# keep and continue
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="c1"&gt;# reject: resample from (p - q)_+, renormalized
&lt;/span&gt;            &lt;span class="n"&gt;resid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;draft_probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resid&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;resid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;                   &lt;span class="c1"&gt;# stop at first rejection
&lt;/span&gt;    &lt;span class="c1"&gt;# all k accepted -&amp;gt; sample one bonus token from target's (k+1)th head
&lt;/span&gt;    &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_probs&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;rng&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the bonus token: if all &lt;code&gt;k&lt;/code&gt; drafts are accepted, the target already computed the distribution for position &lt;code&gt;k+1&lt;/code&gt; in the same pass, so you get one extra token for free. That's why the formula below runs to &lt;code&gt;k+1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why won't a 90%-accurate draft give 10x?
&lt;/h2&gt;

&lt;p&gt;Because rejection is a geometric process, and one wrong token throws away everything after it. Let α be the per-token acceptance probability (the expected &lt;code&gt;min(1, p/q)&lt;/code&gt;, averaged over the distribution — not the same as top-1 agreement). The number of accepted tokens in a block of &lt;code&gt;k&lt;/code&gt; follows a truncated geometric distribution. The expected number of tokens produced per target forward pass is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;E[tokens] = (1 - α^(k+1)) / (1 - α)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Plug in real numbers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;α&lt;/th&gt;
&lt;th&gt;k=3&lt;/th&gt;
&lt;th&gt;k=5&lt;/th&gt;
&lt;th&gt;k=7&lt;/th&gt;
&lt;th&gt;k→∞ ceiling&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0.6&lt;/td&gt;
&lt;td&gt;2.18&lt;/td&gt;
&lt;td&gt;2.72&lt;/td&gt;
&lt;td&gt;3.10&lt;/td&gt;
&lt;td&gt;2.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.7&lt;/td&gt;
&lt;td&gt;2.53&lt;/td&gt;
&lt;td&gt;3.19&lt;/td&gt;
&lt;td&gt;3.61&lt;/td&gt;
&lt;td&gt;3.33&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.8&lt;/td&gt;
&lt;td&gt;2.95&lt;/td&gt;
&lt;td&gt;3.69&lt;/td&gt;
&lt;td&gt;4.16&lt;/td&gt;
&lt;td&gt;5.00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.9&lt;/td&gt;
&lt;td&gt;3.44&lt;/td&gt;
&lt;td&gt;4.10&lt;/td&gt;
&lt;td&gt;4.70&lt;/td&gt;
&lt;td&gt;10.00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things jump out. First, α=0.9 &lt;em&gt;does&lt;/em&gt; have a 10x ceiling — but only at k→∞, and you never run infinite k because draft cost grows linearly with k. At a practical k=5 you get 4.1x, not 10x. Second, the curve saturates hard: going from k=3 to k=7 at α=0.7 moves you from 2.53 to 3.61, more than doubling draft work for a 43% gain. Every extra drafted token has lower marginal acceptance (&lt;code&gt;α^i&lt;/code&gt; shrinks), so there's an optimal k, usually 3-5.&lt;/p&gt;

&lt;p&gt;And "90% accurate" almost never means α=0.9. Top-1 agreement on easy tokens (whitespace, common words) is high, but α is the &lt;em&gt;distribution-weighted&lt;/em&gt; acceptance under rejection sampling, dragged down by exactly the hard, high-entropy tokens where the draft and target disagree — and those are the tokens that matter. Real α for a well-matched draft/target pair tends to land in 0.6-0.8.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the speedup actually come from?
&lt;/h2&gt;

&lt;p&gt;From the roofline. During decode, generating one token requires reading the entire model's weights (and the KV cache) from HBM to compute a single column of activations. Arithmetic intensity is terrible — you're memory-bandwidth-bound, and the GPU's compute units sit mostly idle. Verifying &lt;code&gt;k&lt;/code&gt; drafted tokens in one pass reads those same weights &lt;em&gt;once&lt;/em&gt; and does &lt;code&gt;k&lt;/code&gt;× the FLOPs. Since you had spare compute, those extra FLOPs are nearly free in wall-clock terms.&lt;/p&gt;

&lt;p&gt;So the real speedup is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;speedup ≈ E[tokens] / (1 + c)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;where &lt;code&gt;c&lt;/code&gt; is the overhead ratio: draft generation time plus the marginally larger verification pass, expressed as a fraction of one target decode step. If your draft is 1/10 the size of the target and you run k=4, the k sequential draft steps plus overhead might cost &lt;code&gt;c ≈ 0.3-0.5&lt;/code&gt; of a target step. At α=0.7, k=4, E[tokens] ≈ 2.87, so speedup ≈ 2.87 / 1.4 ≈ &lt;strong&gt;2.05x&lt;/strong&gt;. That matches what people report. The draft isn't free, and it's &lt;em&gt;sequential&lt;/em&gt; — those k draft tokens are themselves autoregressive, which is why models like Medusa (parallel heads) and EAGLE (feature-level drafting) exist: they cut &lt;code&gt;c&lt;/code&gt; by drafting cheaply, not by raising α.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does speculative decoding fail at high batch size?
&lt;/h2&gt;

&lt;p&gt;Because batching already fixed the problem speculative decoding solves. The free-FLOPs argument holds only while you're memory-bound. As you increase batch size, you amortize each weight read across many sequences, arithmetic intensity climbs, and you cross into the compute-bound regime. Now those extra verification FLOPs are &lt;em&gt;not&lt;/em&gt; free — you're paying full price for every speculated token, most of which you'll reject and throw away.&lt;/p&gt;

&lt;p&gt;At large batch, speculative decoding does strictly more compute for the same accepted output, so it becomes a throughput &lt;em&gt;tax&lt;/em&gt;. This is the single most common reason teams turn it on, benchmark it under load, and see a regression. The mental model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low batch / latency-critical&lt;/strong&gt; (interactive chat, single-user agents, &lt;code&gt;batch=1&lt;/code&gt;): memory-bound, speculative decoding shines, expect 1.5-3x.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High batch / throughput-critical&lt;/strong&gt; (offline batch jobs, saturated serving): compute-bound, speculative decoding hurts. Turn it off or use dynamic speculation that backs off as batch grows.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;vLLM and TensorRT-LLM both expose this. A minimal vLLM config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vllm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LLM&lt;/span&gt;

&lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-3.1-70B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;speculative_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-3.2-1B-Instruct&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# the draft
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;num_speculative_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                   &lt;span class="c1"&gt;# this is k
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# Serve interactive traffic through this; keep a non-speculative
# engine for bulk/offline batches. Measure acceptance rate in the logs
# before trusting the speedup.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set &lt;code&gt;num_speculative_tokens&lt;/code&gt; (k) to 3-5 and read the acceptance-rate metric vLLM emits. If α is under ~0.5, your draft is mismatched — a smaller, better-distilled, or same-family draft usually helps more than raising k.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does temperature change the acceptance rate?
&lt;/h2&gt;

&lt;p&gt;Yes, and it cuts both ways. At &lt;strong&gt;temperature 0&lt;/strong&gt; (greedy), acceptance collapses to "does the draft's argmax equal the target's argmax," which is often high on easy spans but brittle. As temperature rises, both &lt;code&gt;p&lt;/code&gt; and &lt;code&gt;q&lt;/code&gt; flatten, and the &lt;code&gt;min(1, p/q)&lt;/code&gt; acceptance can actually &lt;em&gt;increase&lt;/em&gt; because the two distributions overlap more — a flat target forgives a wrong draft. But high temperature also means more genuinely random high-entropy tokens where no draft can predict the sample. Net effect is workload-dependent, which is exactly why you must measure α on your real traffic and not trust a paper's number from a different sampling setup.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;Speculative decoding is a lossless latency optimization that exploits the fact that LLM decode is memory-bound at low batch size: a cheap draft proposes &lt;code&gt;k&lt;/code&gt; tokens and the target verifies them in one bandwidth-limited pass, with modified rejection sampling guaranteeing the exact target distribution. But the speedup is capped by &lt;code&gt;(1 - α^(k+1))/(1 - α)&lt;/code&gt; divided by draft overhead, so a "90% accurate" draft realistically buys 2-3x, not 10x — because α is distribution-weighted, k has a sweet spot around 3-5, and the sequential draft isn't free. Most importantly, the entire benefit rests on being memory-bound: crank up your batch size into the compute-bound regime and speculative decoding turns from a speedup into a tax. Measure your acceptance rate and your batch size before you turn it on, and keep a non-speculative path for bulk work.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Constrained Decoding: Force Valid JSON Without Wrecking Accuracy</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 15 Jul 2026 07:50:41 +0000</pubDate>
      <link>https://dev.to/ji_ai/constrained-decoding-force-valid-json-without-wrecking-accuracy-57mg</link>
      <guid>https://dev.to/ji_ai/constrained-decoding-force-valid-json-without-wrecking-accuracy-57mg</guid>
      <description>&lt;p&gt;You flip on strict JSON schema mode. Parse errors go to zero. Then your eval score drops two or three points and nobody can explain why. The model got &lt;em&gt;more&lt;/em&gt; reliable and &lt;em&gt;less&lt;/em&gt; correct at the same time. That is not a paradox — it is exactly what &lt;strong&gt;constrained decoding&lt;/strong&gt; does when you point it at the wrong task.&lt;/p&gt;

&lt;p&gt;Constrained decoding is the machinery behind OpenAI's Structured Outputs, &lt;code&gt;outlines&lt;/code&gt;, &lt;code&gt;xgrammar&lt;/code&gt;, vLLM's guided decoding, and llama.cpp's GBNF grammars. It guarantees the output matches a schema by editing the model's logits at every step. That guarantee is real. The cost is subtle, and most teams pay it without noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Constrained decoding masks the logits&lt;/strong&gt; at each decode step so only tokens that keep the output grammar-valid can be sampled. Invalid JSON becomes structurally impossible, not just unlikely.&lt;/li&gt;
&lt;li&gt;The mask is powered by a &lt;strong&gt;finite-state machine (FSM)&lt;/strong&gt; compiled from your schema/regex, with the allowed-token set &lt;strong&gt;precomputed per state&lt;/strong&gt; — so per-step cost is a gather-and-add, not a regex match.&lt;/li&gt;
&lt;li&gt;It can &lt;strong&gt;lower accuracy&lt;/strong&gt; because masking renormalizes probability over a tiny token set, forcing the model onto tokens it assigned low probability and suppressing reasoning tokens entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token misalignment&lt;/strong&gt; (grammar works on characters, the model emits BPE tokens) is a real failure mode; good engines fix it with token healing and jump-forward decoding.&lt;/li&gt;
&lt;li&gt;Use constrained decoding for &lt;strong&gt;extraction and API payloads&lt;/strong&gt;; for reasoning-heavy tasks, let the model think in free text first, then constrain only the final field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is constrained decoding, actually?
&lt;/h2&gt;

&lt;p&gt;Constrained decoding restricts a language model's next-token choice to the set of tokens that keep the output valid under a formal grammar. It does not re-ask the model, retry on parse failure, or fine-tune anything. It intervenes inside the sampling loop.&lt;/p&gt;

&lt;p&gt;At each step the model produces a logit vector over the whole vocabulary — 100K+ entries for a modern tokenizer. Normally you softmax and sample. Constrained decoding inserts one operation before that: set the logits of every &lt;em&gt;disallowed&lt;/em&gt; token to negative infinity. After softmax, those tokens have probability zero. The model literally cannot emit a character that breaks the schema.&lt;/p&gt;

&lt;p&gt;That is the entire trick. Everything else is making it fast and making it not lie about the model's intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does constrained decoding never emit invalid JSON?
&lt;/h2&gt;

&lt;p&gt;Because validity is enforced as a hard mask, not a soft preference. The schema is compiled into an FSM whose states represent "where we are in the grammar" and whose transitions are labeled with allowed tokens.&lt;/p&gt;

&lt;p&gt;The naive version re-checks a regex against the whole partial string on every token. That is too slow for production. The insight that made libraries like &lt;code&gt;outlines&lt;/code&gt; practical: for each FSM state, the set of vocabulary tokens that keep the string valid is fixed and can be &lt;strong&gt;computed once, offline&lt;/strong&gt;, and stored as an index. At decode time you look up the current state, gather its allowed-token mask, and add it to the logits.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;make_logit_processor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fsm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vocab_size&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# fsm.allowed[state] is a precomputed LongTensor of token ids
&lt;/span&gt;    &lt;span class="c1"&gt;# that keep the output grammar-valid from this state.
&lt;/span&gt;    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;process&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;advance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;input_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# O(1) amortized FSM walk
&lt;/span&gt;        &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;full&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;vocab_size&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;]]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;          &lt;span class="c1"&gt;# gather the legal tokens
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;                    &lt;span class="c1"&gt;# illegal tokens -&amp;gt; -inf
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;process&lt;/span&gt;

&lt;span class="c1"&gt;# After masking, softmax puts 0 probability on everything illegal.
# Sampling can only pick a token that keeps the JSON valid.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Per step this is a memory read plus an add — negligible next to the transformer forward pass. The expensive part (compiling the FSM and building the per-state index) happens once when you register the schema.&lt;/p&gt;

&lt;p&gt;There is a second optimization worth knowing: &lt;strong&gt;jump-forward decoding&lt;/strong&gt; (SGLang, &lt;code&gt;xgrammar&lt;/code&gt;). When the FSM state has exactly one legal continuation — after &lt;code&gt;{"temperature":&lt;/code&gt; the grammar forces a number, and after the last field it forces &lt;code&gt;}&lt;/code&gt; — the engine emits those deterministic tokens &lt;em&gt;without a forward pass&lt;/em&gt;. Structural boilerplate becomes free. On heavily-templated schemas this is a real latency win, not a rounding error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can constrained decoding lower accuracy?
&lt;/h2&gt;

&lt;p&gt;Because masking changes the distribution the model samples from, and the model was never trained on that clipped distribution.&lt;/p&gt;

&lt;p&gt;Three concrete mechanisms:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It renormalizes over a tiny set.&lt;/strong&gt; Say the model's honest next-token distribution puts 60% on a reasoning word, 30% spread across other prose, and 4% on &lt;code&gt;{&lt;/code&gt;. If the grammar demands JSON &lt;em&gt;right now&lt;/em&gt;, you mask everything except &lt;code&gt;{&lt;/code&gt; and its friends. That 4% gets renormalized to near-100%. You are sampling from the tail of the model's belief and treating it as the head.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It kills the scratchpad.&lt;/strong&gt; Chain-of-thought works because the model spends tokens computing before it commits. A schema that starts with &lt;code&gt;{"answer":&lt;/code&gt; forbids those tokens. The model has to emit the answer field with zero prior reasoning tokens in context. For extraction that is fine. For math, multi-hop QA, or classification-with-justification, you have amputated the exact mechanism that makes the model accurate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Key order becomes a silent variable.&lt;/strong&gt; In &lt;code&gt;{"answer": ..., "reasoning": ...}&lt;/code&gt; the answer is decoded first, so the reasoning field is post-hoc rationalization the model already can't use. Flip to &lt;code&gt;{"reasoning": ..., "answer": ...}&lt;/code&gt; and the reasoning tokens are in context when the answer is generated. Same schema, same model, materially different accuracy. JSON object key order is part of your prompt now, whether you meant it to be or not.&lt;/p&gt;

&lt;p&gt;The fix is not to abandon constraints. It is to constrain the &lt;em&gt;shape&lt;/em&gt; while leaving the &lt;em&gt;thinking&lt;/em&gt; free — put a free-text reasoning field first, or let the model produce unconstrained chain-of-thought and constrain only a final extraction call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is token misalignment, and why does it corrupt output?
&lt;/h2&gt;

&lt;p&gt;Token misalignment is the mismatch between grammars, which operate on characters or bytes, and models, which emit BPE tokens that can straddle grammar boundaries. It is the most under-discussed failure mode in constrained decoding.&lt;/p&gt;

&lt;p&gt;Concrete case: your grammar has just forced a closing quote &lt;code&gt;"&lt;/code&gt;. The model, unconstrained, would have emitted the single token &lt;code&gt;",&lt;/code&gt; (quote-comma is one common BPE merge). But the FSM only allows the &lt;code&gt;"&lt;/code&gt; transition here, so &lt;code&gt;",&lt;/code&gt; is masked out. The model is forced onto a different, rarer tokenization of the same string — pushing it off the distribution it learned. You still get valid JSON, but the token path is one the model never saw in training, and quality degrades.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token healing&lt;/strong&gt; is the mitigation: instead of appending a forced prefix and masking, the engine backs up over the last token, and re-decodes constrained to continuations consistent with both the model's natural tokenization and the grammar. Good libraries do this transparently. If you hand-roll a logit processor, you will hit this and your outputs will look subtly wrong in ways no schema validator catches.&lt;/p&gt;

&lt;p&gt;This is also why "just add &lt;code&gt;-inf&lt;/code&gt; to bad tokens" is a demo, not a product. The demo produces valid JSON. The product produces valid JSON that reads like the model wrote it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Constrained decoding vs prompting vs prefill: which should you use?
&lt;/h2&gt;

&lt;p&gt;Different providers expose different levels of this, and the guarantee is not the same.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;OpenAI Structured Outputs&lt;/strong&gt; (&lt;code&gt;response_format&lt;/code&gt; with a JSON schema, GPT-5.x) is true constrained decoding — a grammar enforces the schema at the token level. Validity is guaranteed by construction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anthropic Claude&lt;/strong&gt; (Opus 4.x / Sonnet 4.x) does not expose raw grammar masks. You get structure by forcing a tool call — set &lt;code&gt;tool_choice&lt;/code&gt; to a specific tool and Claude fills its &lt;code&gt;input_schema&lt;/code&gt;. The reliability is very high in practice, but the guarantee comes from tool-use training plus the API validating the call, not from a decoder-level FSM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefilling&lt;/strong&gt; the assistant turn (start Claude's response with &lt;code&gt;{&lt;/code&gt;) is not constrained decoding at all — it is a soft nudge. Cheap, no schema guarantee, and it composes with everything.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Claude: structure via forced tool use, not logit masking.
&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;extract&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return the extracted fields.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input_schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;object&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;string&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enum&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pos&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;neutral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;required&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sentiment&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;confidence&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="c1"&gt;# tool_choice = {"type": "tool", "name": "extract"} forces the call.
# Claude decodes into input_schema; the API rejects a malformed call.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decision rule I actually use: if the task is &lt;strong&gt;extraction, routing, or an API payload&lt;/strong&gt; where the answer is mostly copied out of context, turn on hard constraints and don't think about it. If the task needs the model to &lt;strong&gt;compute or reason&lt;/strong&gt;, keep the reasoning field unconstrained and first, and constrain only the leaf value — or run reasoning and extraction as two separate calls. Never wrap a reasoning task in a schema whose first key is the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct answer: does constrained decoding hurt accuracy, and how do you get valid JSON safely?
&lt;/h2&gt;

&lt;p&gt;Constrained decoding forces valid JSON by compiling your schema into a finite-state machine and masking every grammar-illegal token's logit to negative infinity at each decode step, with the allowed-token set precomputed per FSM state so the runtime cost is a gather-and-add. It never emits invalid output. It &lt;em&gt;can&lt;/em&gt; lower accuracy because masking renormalizes probability onto tokens the model rated unlikely, suppresses chain-of-thought scratchpad tokens, and makes JSON key order a hidden variable — and token misalignment can force off-distribution tokenizations unless the engine does token healing. Get both reliability and accuracy by constraining structure, not thought: let the model reason in a free-text field (or a separate unconstrained call) placed &lt;em&gt;before&lt;/em&gt; any constrained answer field, and reserve hard schema enforcement for extraction and payload tasks where there is nothing to reason about. On Claude, achieve the same with forced tool use and a well-ordered &lt;code&gt;input_schema&lt;/code&gt;; on GPT-5.x, use Structured Outputs but keep a reasoning field first.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>GRPO Explained: Why DeepSeek Dropped the Critic in RLHF</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 13 Jul 2026 07:48:23 +0000</pubDate>
      <link>https://dev.to/ji_ai/grpo-explained-why-deepseek-dropped-the-critic-in-rlhf-3pa8</link>
      <guid>https://dev.to/ji_ai/grpo-explained-why-deepseek-dropped-the-critic-in-rlhf-3pa8</guid>
      <description>&lt;p&gt;Train a reasoning model with PPO and half your GPU memory goes to a network that never ships: the critic. It's the same size as the policy, it's notoriously hard to fit, and its only job is to guess how good a half-finished answer will turn out. &lt;strong&gt;GRPO — Group Relative Policy Optimization&lt;/strong&gt; — deletes it. Instead of learning a value function, it samples several answers to the same prompt and uses their average reward as the baseline. That single swap is most of why DeepSeek could scale RL on math and code the way it did.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GRPO&lt;/strong&gt; replaces PPO's learned value network with a baseline computed from a &lt;em&gt;group&lt;/em&gt; of sampled completions per prompt — no critic, roughly half the training memory.&lt;/li&gt;
&lt;li&gt;The advantage for a completion is its reward minus the group mean, divided by the group std. Every token in that completion gets the same advantage (outcome supervision).&lt;/li&gt;
&lt;li&gt;The KL penalty to the reference model is added directly to the loss as an unbiased positive estimator, not folded into the reward as in classic RLHF.&lt;/li&gt;
&lt;li&gt;It shines with &lt;strong&gt;verifiable rewards&lt;/strong&gt; (RLVR) — math answers, unit tests — where a rule can score a completion 0/1 without a reward model.&lt;/li&gt;
&lt;li&gt;Known failure modes: groups where all rewards are equal produce zero gradient, and the length/std normalization introduce measurable biases that later work (Dr. GRPO) strips out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does PPO need a critic in the first place?
&lt;/h2&gt;

&lt;p&gt;Policy-gradient methods multiply the log-prob of each action by an &lt;em&gt;advantage&lt;/em&gt;: how much better this action was than average. You need a baseline for "average," or the gradient has brutal variance. PPO estimates that baseline with a value network V(s) trained via GAE (generalized advantage estimation), so it can assign credit token-by-token to a partially generated sequence.&lt;/p&gt;

&lt;p&gt;That works, but the cost is real. The critic is typically initialized from the same backbone as the policy, so you're holding two large models in memory, running two forward/backward passes, and tuning a second optimizer. In LLM RL the value target is also weak — you get one scalar reward at the &lt;em&gt;end&lt;/em&gt; of a long generation, and the critic has to back-propagate that through hundreds of tokens of intermediate reasoning it can't really evaluate. In practice the critic is the flakiest part of the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does GRPO do instead?
&lt;/h2&gt;

&lt;p&gt;GRPO throws out the value network and builds the baseline empirically. For each prompt &lt;code&gt;q&lt;/code&gt;, sample a group of &lt;code&gt;G&lt;/code&gt; completions from the current policy. Score each one to get rewards &lt;code&gt;r_1 … r_G&lt;/code&gt;. The baseline is just the group mean, and the advantage is the standardized reward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;A_i = (r_i - mean(r_1..r_G)) / std(r_1..r_G)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every token in completion &lt;code&gt;i&lt;/code&gt; receives the same advantage &lt;code&gt;A_i&lt;/code&gt;. This is &lt;em&gt;outcome&lt;/em&gt; supervision: the model isn't told which token was good, only that the whole answer beat or missed the group average. With verifiable tasks (was the final answer correct? did the tests pass?) that's often all the signal you have anyway, and it turns out to be enough.&lt;/p&gt;

&lt;p&gt;The surrogate objective keeps PPO's clipped importance ratio so you can do multiple gradient steps per batch of samples:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ρ_{i,t} = π_θ(o_{i,t} | q, o_{i,&amp;lt;t}) / π_θ_old(o_{i,t} | q, o_{i,&amp;lt;t})

J = (1/G) Σ_i (1/|o_i|) Σ_t [ min( ρ_{i,t} A_i,
                                   clip(ρ_{i,t}, 1-ε, 1+ε) A_i ) ]
      - β · D_KL(π_θ ‖ π_ref)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things differ from vanilla PPO. There's no &lt;code&gt;V(s)&lt;/code&gt; anywhere. And the KL term sits in the loss, not the reward.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does GRPO handle the KL divergence?
&lt;/h2&gt;

&lt;p&gt;Classic RLHF shapes the reward: &lt;code&gt;r = r_model − β·log(π_θ/π_ref)&lt;/code&gt; per token, then runs PPO on that. GRPO pulls the KL out of the reward and adds it to the loss as an explicit penalty against the frozen reference (usually the SFT model). It uses the low-variance, always-positive &lt;strong&gt;k3 estimator&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;D_KL ≈ π_ref(o|q) / π_θ(o|q) − log( π_ref(o|q) / π_θ(o|q) ) − 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That expression is &lt;code&gt;≥ 0&lt;/code&gt; for every sample and unbiased in expectation, which keeps the penalty well-behaved token to token. Keeping KL out of the reward also means your reward stays interpretable — if you're using a 0/1 correctness reward, it stays 0/1, and the reference anchoring is a separate, tunable force.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the core computation actually look like?
&lt;/h2&gt;

&lt;p&gt;Here's the part that replaces the entire critic — group-normalized advantages and the clipped loss, in PyTorch-ish pseudocode:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch.nn.functional&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;grpo_loss&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logp_old&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logp_ref&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rewards&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_size&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;clip_eps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.04&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# logp*, shape [B*G, T]: per-token log-probs of the sampled tokens
&lt;/span&gt;    &lt;span class="c1"&gt;# rewards, shape [B*G]:  one scalar per completion (e.g. 1.0 if correct)
&lt;/span&gt;    &lt;span class="n"&gt;B&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rewards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;group_size&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rewards&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;B&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_size&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# --- the baseline that used to be a whole value network ---
&lt;/span&gt;    &lt;span class="n"&gt;adv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;std&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1e-6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;adv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adv&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# broadcast to every token
&lt;/span&gt;
    &lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;logp_old&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# importance ratio ρ
&lt;/span&gt;    &lt;span class="n"&gt;unclipped&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;adv&lt;/span&gt;
    &lt;span class="n"&gt;clipped&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;clip_eps&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;clip_eps&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;adv&lt;/span&gt;
    &lt;span class="n"&gt;policy_loss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unclipped&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clipped&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# k3 unbiased, non-negative KL to the reference policy
&lt;/span&gt;    &lt;span class="n"&gt;log_r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logp_ref&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;logp&lt;/span&gt;
    &lt;span class="n"&gt;kl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;log_r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;log_r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;

    &lt;span class="n"&gt;per_token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;policy_loss&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;beta&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kl&lt;/span&gt;
    &lt;span class="c1"&gt;# length-normalize within each completion, then average
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;per_token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note what's missing: no &lt;code&gt;value_head&lt;/code&gt;, no GAE, no value-loss term, no second optimizer. The baseline is three lines of tensor arithmetic. If a group's rewards are all identical, &lt;code&gt;r.std&lt;/code&gt; is ~0 and &lt;code&gt;adv&lt;/code&gt; collapses to zero — that group contributes no gradient. This is the first failure mode to watch.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is GRPO the right choice — and when not?
&lt;/h2&gt;

&lt;p&gt;GRPO earns its keep when you can &lt;strong&gt;sample many completions cheaply and score them with a rule&lt;/strong&gt;. Math with a checkable final answer, code with a test suite, format-constrained tasks — this is the RLVR (RL with verifiable rewards) sweet spot. No reward model to train, no critic to babysit, and the group baseline is exactly as good as your reward is honest.&lt;/p&gt;

&lt;p&gt;It's a worse fit when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Rewards are dense or need per-step credit.&lt;/strong&gt; Outcome-only advantages give every token the same push. For long-horizon agentic tasks where an early tool call mattered and a late one didn't, a value function (or process reward model) carries information GRPO discards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can't afford &lt;code&gt;G&lt;/code&gt; samples per prompt.&lt;/strong&gt; GRPO's variance drops as the group grows; tiny groups (G=2–4) are noisy. You're trading critic compute for extra rollouts. On long generations, those rollouts dominate wall-clock.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reward saturates.&lt;/strong&gt; As the policy improves, more groups come back all-correct or all-wrong. Those produce zero advantage, so effective batch size silently shrinks and training stalls. Curriculum or difficulty filtering helps keep groups mixed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What biases does GRPO's normalization introduce?
&lt;/h2&gt;

&lt;p&gt;Two, and both come from the innocuous-looking normalizers. The &lt;code&gt;(1/|o_i|)&lt;/code&gt; length term means a wrong short answer is penalized harder per token than a wrong long one, which nudges the model toward longer completions when it's failing — a documented driver of response-length inflation during RL. And dividing by &lt;code&gt;std(r)&lt;/code&gt; makes easy prompts (low variance) contribute disproportionately large advantages, biasing optimization toward questions the model already mostly solves.&lt;/p&gt;

&lt;p&gt;The "Understanding R1-Zero-like Training" work (a.k.a. &lt;strong&gt;Dr. GRPO&lt;/strong&gt;) argues both terms are unnecessary artifacts: drop the length normalization and the std division, keep only &lt;code&gt;A_i = r_i − mean(r)&lt;/code&gt;, and you remove the length and difficulty biases while matching or beating quality. If you're rolling your own GRPO and seeing answers balloon in length, that's the first knob to touch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Dr. GRPO variant: mean-centered only, no length/std normalization
&lt;/span&gt;&lt;span class="n"&gt;adv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# ... and sum per-token losses instead of length-averaging
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  GRPO vs DPO vs PPO — how do they line up?
&lt;/h2&gt;

&lt;p&gt;DPO skips RL entirely: it optimizes a closed-form preference loss on &lt;em&gt;pairs&lt;/em&gt; of (chosen, rejected) responses, no sampling, no reward model at inference-time collection. It's cheap and stable but offline — it only ever sees the pairs you hand it, and it has its own pathologies like &lt;a href="https://dev.to"&gt;likelihood displacement&lt;/a&gt;. PPO is online, uses a reward model and a critic, and gives the finest-grained credit assignment at the highest engineering cost. GRPO sits between: online like PPO (it samples fresh completions and reacts to the current policy), but critic-free like nothing else, and it needs a scorer rather than a full reward model when your task is verifiable. For reasoning workloads where correctness is checkable, that's the combination that scaled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why did DeepSeek drop the critic in RLHF?&lt;/strong&gt; Because in verifiable-reward RL the critic is the most expensive and least reliable component, and GRPO shows you don't need it: sampling a group of completions per prompt and standardizing their rewards gives a baseline that's good enough to compute advantages directly. That halves training memory, removes the flakiest part of the PPO loop, and — with a rule-based reward for math or code — turns RL fine-tuning into something you can actually scale. The costs are real too: outcome-only credit, zero-gradient groups when rewards saturate, and length/std biases you may want to normalize away. Reach for GRPO when completions are cheap to sample and rewards are cheap to verify; reach for PPO when you genuinely need per-token value estimates, and DPO when you only have offline preference pairs.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Online Softmax: How FlashAttention Skips the N N Matrix</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 12 Jul 2026 19:46:39 +0000</pubDate>
      <link>https://dev.to/ji_ai/online-softmax-how-flashattention-skips-the-nxn-matrix-442</link>
      <guid>https://dev.to/ji_ai/online-softmax-how-flashattention-skips-the-nxn-matrix-442</guid>
      <description>&lt;p&gt;Standard attention allocates an N×N score matrix in GPU memory, softmaxes it, then throws it away. For a 32K-token sequence that intermediate is a billion floats per head, and it never needs to exist. &lt;strong&gt;Online softmax&lt;/strong&gt; is the recurrence that lets FlashAttention produce the exact same output while only ever holding a few blocks in fast memory at a time. Not an approximation. Bit-for-bit the same result, minus the floating-point reordering.&lt;/p&gt;

&lt;p&gt;If you have ever wondered how FlashAttention gets a "no approximation" asterisk while sparse and low-rank attention variants do not, the answer is this one numerical trick.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Online softmax&lt;/strong&gt; computes softmax incrementally over blocks of the input, keeping a running max and running normalizer instead of scanning the whole row twice.&lt;/li&gt;
&lt;li&gt;It lets FlashAttention fuse the QKᵀ, softmax, and ·V steps into a single kernel that &lt;strong&gt;never writes the N×N attention matrix to HBM&lt;/strong&gt; — memory drops from O(N²) to O(N).&lt;/li&gt;
&lt;li&gt;The result is &lt;strong&gt;numerically exact&lt;/strong&gt; (up to float reassociation), unlike sparse or linear attention, because the recurrence rescales earlier partial sums when a new maximum appears.&lt;/li&gt;
&lt;li&gt;The win is memory bandwidth, not FLOPs: attention is memory-bound, so removing the N×N read/write is what produces the wall-clock speedup.&lt;/li&gt;
&lt;li&gt;The same running-normalizer state is what makes streaming and KV-cache decoding work — you can extend attention one token at a time without recomputing the row.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What problem does online softmax actually solve?
&lt;/h2&gt;

&lt;p&gt;Softmax over a row of scores &lt;code&gt;s&lt;/code&gt; is usually taught as three passes: find the max &lt;code&gt;m&lt;/code&gt;, compute &lt;code&gt;exp(s_i - m)&lt;/code&gt; and sum them into &lt;code&gt;l&lt;/code&gt;, then divide each term by &lt;code&gt;l&lt;/code&gt;. The max-subtraction is mandatory for numerical stability — &lt;code&gt;exp(30)&lt;/code&gt; overflows fp16 instantly, so you shift by the max to keep exponents in range.&lt;/p&gt;

&lt;p&gt;The catch in attention: you need the whole row of scores before you can start, because the max depends on every element. So the naive kernel materializes the full row (and, batched across queries, the full N×N matrix), reads it back, and only then normalizes. That matrix is the single largest tensor in a transformer forward pass, and it lives in HBM (high-bandwidth memory), the slow pool. Every byte written there and read back costs bandwidth you cannot spare.&lt;/p&gt;

&lt;p&gt;Online softmax removes the "need the whole row first" constraint. It computes the correct normalized result while streaming the row in blocks, carrying just enough state to retroactively fix its earlier work when a larger score shows up later.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does the online softmax recurrence work?
&lt;/h2&gt;

&lt;p&gt;You maintain three running values as you consume blocks of keys/values: the running max &lt;code&gt;m&lt;/code&gt;, the running sum of exponentials &lt;code&gt;l&lt;/code&gt;, and the running weighted output &lt;code&gt;O&lt;/code&gt;. When a new block arrives with its own local max, you rescale the accumulated state by &lt;code&gt;exp(m_old - m_new)&lt;/code&gt; so everything is expressed relative to the new, larger maximum, then fold in the new block.&lt;/p&gt;

&lt;p&gt;Here is the core recurrence in NumPy, deliberately unfused so you can see the state:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;online_attention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Q: (d,)   single query for clarity
&lt;/span&gt;    &lt;span class="c1"&gt;# K, V: (N, d)
&lt;/span&gt;    &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;
    &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inf&lt;/span&gt;            &lt;span class="c1"&gt;# running max of scores
&lt;/span&gt;    &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;                &lt;span class="c1"&gt;# running sum of exp(scores - m)
&lt;/span&gt;    &lt;span class="n"&gt;O&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;        &lt;span class="c1"&gt;# running unnormalized output
&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;N&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;Kb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;Vb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;block&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;Kb&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;                      &lt;span class="c1"&gt;# block scores, shape (block,)
&lt;/span&gt;        &lt;span class="n"&gt;m_new&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;         &lt;span class="c1"&gt;# updated running max
&lt;/span&gt;        &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;m_new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# rescale factor for old state
&lt;/span&gt;        &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;m_new&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;           &lt;span class="c1"&gt;# new block weights
&lt;/span&gt;
        &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;         &lt;span class="c1"&gt;# correct the normalizer
&lt;/span&gt;        &lt;span class="n"&gt;O&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;O&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;Vb&lt;/span&gt;          &lt;span class="c1"&gt;# correct the output, add new block
&lt;/span&gt;        &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m_new&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;O&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt;                        &lt;span class="c1"&gt;# normalize once, at the end
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two lines carry the whole idea. &lt;code&gt;alpha = exp(m - m_new)&lt;/code&gt; is the correction factor: if a block introduces a bigger score, every previously accumulated exponential was computed against a smaller max and is now too large by exactly &lt;code&gt;exp(m_old - m_new)&lt;/code&gt;. Multiplying &lt;code&gt;l&lt;/code&gt; and &lt;code&gt;O&lt;/code&gt; by &lt;code&gt;alpha&lt;/code&gt; retroactively re-bases them. The final division by &lt;code&gt;l&lt;/code&gt; happens exactly once.&lt;/p&gt;

&lt;p&gt;Compare it to the textbook version and the outputs match to floating-point tolerance:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;naive_attention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;Q&lt;/span&gt;
    &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
    &lt;span class="nf"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;Q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;K&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;V&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;randn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;online_attention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nf"&gt;naive_attention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;K&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;V&lt;/span&gt;&lt;span class="p"&gt;))))&lt;/span&gt;
&lt;span class="c1"&gt;# ~1e-15
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;1e-15&lt;/code&gt; is the entire claim. Online softmax is not "close enough" — it is the same computation with the summation reassociated, so it differs only by the rounding you would get from adding the same numbers in a different order.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why is online softmax numerically stable?
&lt;/h2&gt;

&lt;p&gt;Because every exponential is always evaluated against the current running maximum, never against a stale one. The instant a block raises the max, &lt;code&gt;alpha&lt;/code&gt; shrinks the old accumulators before any new large term is added, so no partial sum is ever computed with a positive exponent. &lt;code&gt;exp(s - m_new)&lt;/code&gt; has &lt;code&gt;s ≤ m_new&lt;/code&gt; by construction, meaning every exponent is &lt;code&gt;≤ 0&lt;/code&gt; and every term is in &lt;code&gt;(0, 1]&lt;/code&gt;. You never overflow, and you never divide by an underflowed normalizer, because &lt;code&gt;l&lt;/code&gt; accumulates the same bounded terms.&lt;/p&gt;

&lt;p&gt;This is the property sparse and linear-attention methods sacrifice for speed. They change the math — dropping score entries or replacing softmax with a kernel feature map — so their outputs diverge from full attention and need retraining or accuracy caveats. Online softmax changes only the &lt;em&gt;evaluation order&lt;/em&gt;, so it slots under any existing trained model with no quality loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does online softmax approximate attention?
&lt;/h2&gt;

&lt;p&gt;No. This is the most common misconception. FlashAttention is exact attention; online softmax is why. The kernel produces the identical tensor a naive softmax-then-matmul kernel would, differing only by floating-point non-associativity (the same reason &lt;code&gt;(a + b) + c&lt;/code&gt; can differ from &lt;code&gt;a + (b + c)&lt;/code&gt; in the last bit).&lt;/p&gt;

&lt;p&gt;The confusion comes from lumping FlashAttention in with the "efficient attention" literature — Performer, Linformer, Longformer, BigBird — which &lt;em&gt;do&lt;/em&gt; approximate. Those trade exactness for sub-quadratic FLOPs. FlashAttention keeps the quadratic FLOP count and attacks a different bottleneck: memory traffic. Different axis, no accuracy trade.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does removing the N×N matrix matter if the FLOPs are the same?
&lt;/h2&gt;

&lt;p&gt;Because attention is memory-bound on modern GPUs, not compute-bound. The QKᵀ and ·V matmuls are relatively cheap; the expensive part is shoving the N×N intermediate out to HBM and reading it back for the softmax. HBM bandwidth is roughly an order of magnitude slower than on-chip SRAM, and the arithmetic intensity of attention is low enough that the hardware sits idle waiting on those transfers.&lt;/p&gt;

&lt;p&gt;Online softmax lets you keep the running &lt;code&gt;m&lt;/code&gt;, &lt;code&gt;l&lt;/code&gt;, and &lt;code&gt;O&lt;/code&gt; in registers/SRAM and process each block of K and V without ever writing scores to HBM. FlashAttention tiles Q, K, and V into blocks sized to fit SRAM, runs this recurrence in the inner loop, and writes only the final O back to global memory. Memory for the intermediate goes from O(N²) to O(N) — you hold a few blocks and the running state, not the full matrix. That is what turns a bandwidth-bound kernel into one that actually uses the GPU's FLOPs, and it is the source of the wall-clock speedups the FlashAttention papers report, plus the ability to train on much longer sequences without running out of memory.&lt;/p&gt;

&lt;p&gt;The follow-up work (FlashAttention-2 and 3) is mostly about better work partitioning across warps and overlapping the matmul with the softmax rescaling — but the online-softmax core is unchanged. The recurrence above is still the beating heart of the kernel.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does this connect to KV-cache decoding?
&lt;/h2&gt;

&lt;p&gt;The same running &lt;code&gt;m&lt;/code&gt; and &lt;code&gt;l&lt;/code&gt; are exactly the state you need to extend attention by one token during autoregressive generation. When you append a new key/value to the cache, you are running one more iteration of the online-softmax loop: compute the new score, update the max, rescale, fold in the new value. This is why streaming attention and incremental decoding fall out naturally — the recurrence is inherently online in the sequence dimension. If you have read about "attention sinks" or long-context streaming, the mechanism that lets you drop or add tokens without recomputing the whole row is this normalizer bookkeeping.&lt;/p&gt;

&lt;p&gt;It also explains a subtle serving detail: because the normalizer &lt;code&gt;l&lt;/code&gt; is computed on the fly, you cannot cache softmax &lt;em&gt;probabilities&lt;/em&gt; across requests — only the raw K and V. The probabilities depend on the full set of keys attended to, which changes every step. The KV cache stores pre-softmax state precisely because online softmax reconstructs the normalization cheaply each time.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does online softmax not help you?
&lt;/h2&gt;

&lt;p&gt;When N is small. For short sequences the N×N matrix fits comfortably in cache and the memory-traffic savings vanish, so a fused naive kernel can match or beat FlashAttention because it skips the rescaling overhead. The &lt;code&gt;alpha&lt;/code&gt; multiply on every block is real work; it only pays off once the sequence is long enough that HBM traffic dominates. It also does nothing for the MLP blocks, which are often the larger share of total FLOPs — online softmax is an attention-kernel optimization, not a whole-model one. And if you are running a genuinely sub-quadratic model (state-space, linear attention), you are in a different regime entirely; there is no N×N matrix to avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bottom line
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Online softmax&lt;/strong&gt; is a two-pass-into-one-pass reformulation of softmax that carries a running max and running normalizer, rescaling earlier partial results by &lt;code&gt;exp(m_old - m_new)&lt;/code&gt; whenever a larger score appears. It is what lets FlashAttention compute exact attention in a single fused kernel without ever materializing the N×N score matrix in HBM, cutting attention memory from O(N²) to O(N) and turning a bandwidth-bound operation into one the GPU can actually saturate. The output is numerically identical to naive attention up to floating-point reassociation — so unlike sparse or linear attention, it is a pure systems win with zero accuracy cost, and the same recurrence is what makes KV-cache decoding and long-context streaming work one token at a time.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Late Interaction Retrieval: Why ColBERT Beats Single-Vector RAG</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 12 Jul 2026 07:44:46 +0000</pubDate>
      <link>https://dev.to/ji_ai/late-interaction-retrieval-why-colbert-beats-single-vector-rag-1713</link>
      <guid>https://dev.to/ji_ai/late-interaction-retrieval-why-colbert-beats-single-vector-rag-1713</guid>
      <description>&lt;p&gt;Your RAG pipeline embeds a 400-token chunk into one 1024-dim vector, and that vector has to answer every possible question about the chunk. Ask it "what was the 2019 revenue figure?" and cosine similarity against a dense blob that also encoded the CEO bio, the footnotes, and three product names. The one exact number you needed got averaged into oblivion. That averaging is the core weakness of single-vector retrieval, and &lt;strong&gt;late interaction retrieval&lt;/strong&gt; — the mechanism behind ColBERT — is the fix that most teams skip because they assume it's too expensive.&lt;/p&gt;

&lt;p&gt;It isn't, if you know how MaxSim and the two-stage index actually work.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Single-vector (bi-encoder) retrieval pools an entire chunk into one vector&lt;/strong&gt;, so precise term-level signals (a number, a rare entity, a negation) get diluted by everything else in the chunk.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late interaction retrieval keeps one small vector per token&lt;/strong&gt; for both query and document, then scores with &lt;strong&gt;MaxSim&lt;/strong&gt;: for each query token, take the max cosine over all document tokens, and sum those maxes.&lt;/li&gt;
&lt;li&gt;This recovers most of a cross-encoder's accuracy while keeping document embeddings &lt;strong&gt;precomputable and offline&lt;/strong&gt; — you don't re-encode the corpus per query.&lt;/li&gt;
&lt;li&gt;The cost is index size: naively, hundreds of vectors per chunk. &lt;strong&gt;ColBERTv2/PLAID crush this&lt;/strong&gt; with centroid-based residual compression (roughly 1–2 bits per dimension) and a candidate-generation stage so you never run MaxSim over the full corpus.&lt;/li&gt;
&lt;li&gt;Use ColBERT when out-of-domain generalization and exact-match precision matter (BEIR-style zero-shot); stick with bi-encoder + reranker when your index budget is tight and your domain is stable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is late interaction retrieval?
&lt;/h2&gt;

&lt;p&gt;Late interaction retrieval is a design where the query and document are encoded &lt;strong&gt;independently&lt;/strong&gt; into sequences of token-level vectors, and their similarity is computed &lt;strong&gt;after&lt;/strong&gt; encoding via a cheap operator over those token vectors. "Late" contrasts with a cross-encoder, where query and document are concatenated and interact &lt;em&gt;early&lt;/em&gt;, inside the transformer's attention layers.&lt;/p&gt;

&lt;p&gt;Three architectures sit on a spectrum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bi-encoder (single vector):&lt;/strong&gt; encode query and doc separately, pool each to one vector, dot-product. Fast, precomputable, lossy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder:&lt;/strong&gt; feed &lt;code&gt;[query [SEP] doc]&lt;/code&gt; through the model together, read one relevance score. Most accurate, but you must run the model once per (query, doc) pair at query time — impossible over millions of docs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late interaction:&lt;/strong&gt; encode separately like a bi-encoder (so docs are precomputed), but keep every token's vector and interact them at scoring time like a lightweight cross-encoder.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Late interaction is the middle path that keeps the bi-encoder's offline-indexing property while restoring token granularity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do single-vector embeddings lose token detail?
&lt;/h2&gt;

&lt;p&gt;Because pooling is destructive. A bi-encoder runs your chunk through a transformer, producing one contextual vector per token, then collapses that whole matrix into a single vector — usually mean pooling or the CLS token. A 400-token chunk becomes one point in 1024-dim space.&lt;/p&gt;

&lt;p&gt;That point is a compromise. It has to be simultaneously close to "revenue," "CEO," "supply chain risk," and every other concept in the chunk. When a query is about one specific slice, the pooled vector's similarity is dragged down by all the unrelated dimensions it also had to represent. This is why bi-encoders degrade on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Exact-entity and numeric queries&lt;/strong&gt; — the token that matters is one of 400.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Long chunks&lt;/strong&gt; — more content per vector means more dilution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Out-of-domain queries&lt;/strong&gt; — the pooled representation overfits to the training distribution's notion of "what a chunk is about."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Late interaction never pools. Each token keeps its own vector, so a query token can find its exact match in the document regardless of what else is in the chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does MaxSim score a query against a document?
&lt;/h2&gt;

&lt;p&gt;MaxSim is the scoring function that makes late interaction work. Given a query encoded as token vectors &lt;code&gt;Q = [q_1, ..., q_n]&lt;/code&gt; and a document as &lt;code&gt;D = [d_1, ..., d_m]&lt;/code&gt;, the relevance score is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(Q, D) = Σ_i  max_j  (q_i · d_j)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each query token &lt;code&gt;q_i&lt;/code&gt;, find the single document token it matches best (max dot product), then sum those best-match scores across all query tokens. Vectors are L2-normalized, so the dot product is cosine similarity.&lt;/p&gt;

&lt;p&gt;The intuition: every query token gets to "vote" for its strongest evidence anywhere in the document, and no token is forced to compromise with the others. A numeric query token lands hard on the matching number; a topical token lands on the topical phrase. Nothing is averaged.&lt;/p&gt;

&lt;p&gt;Here it is in PyTorch — the whole operator is a batched matmul plus a max:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch.nn.functional&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maxsim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q_emb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_emb&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    q_emb: (Nq, dim) query token embeddings
    d_emb: (Nd, dim) document token embeddings
    returns: scalar late-interaction score
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# (Nq, Nd) cosine sim between every query token and every doc token
&lt;/span&gt;    &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;

    &lt;span class="c1"&gt;# for each query token, take its best-matching doc token, then sum
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="c1"&gt;# batched over many candidate docs, padded to Nd_max with a mask
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;maxsim_batched&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d_mask&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# (Nq, dim)
&lt;/span&gt;    &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d_emb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# (B, Nd, dim)
&lt;/span&gt;    &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;einsum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;qd,bnd-&amp;gt;bqn&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# (B, Nq, Nd)
&lt;/span&gt;    &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;masked_fill&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;d_mask&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;:],&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;1e4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# (B,)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the asymmetry: you &lt;code&gt;max&lt;/code&gt; over document tokens and &lt;code&gt;sum&lt;/code&gt; over query tokens. Sum over the query rewards documents that cover &lt;em&gt;more&lt;/em&gt; of the query's intent; max over the document means a long document isn't penalized for containing irrelevant tokens — they simply never get selected. This is deliberate and is why late interaction is robust to chunk length in a way bi-encoders are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doesn't storing a vector per token blow up your index?
&lt;/h2&gt;

&lt;p&gt;Yes — and this is the real engineering problem, not the scoring. A bi-encoder stores one vector per chunk. Late interaction stores one vector per &lt;em&gt;token&lt;/em&gt;. A corpus of 10M chunks at ~150 tokens each is 1.5B vectors. At full fp16 128-dim that's hundreds of gigabytes. That number is why people wrongly conclude ColBERT "doesn't scale."&lt;/p&gt;

&lt;p&gt;ColBERTv2 and its PLAID engine solve it with two ideas:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Aggressive residual compression.&lt;/strong&gt; Cluster all token vectors into a set of centroids (k-means). Store each token as its nearest centroid ID plus a heavily quantized residual — on the order of 1–2 bits per dimension. Token vectors are far more clusterable than chunk vectors because token-level semantics repeat across the corpus (the word "revenue" produces similar vectors everywhere). This drops per-token storage by roughly an order of magnitude versus fp16 with small quality loss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Two-stage retrieval so you never MaxSim the whole corpus.&lt;/strong&gt; You do not compute MaxSim against 10M documents. Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Candidate generation:&lt;/strong&gt; for each query token, find the nearest centroids; the documents whose tokens map to those centroids become candidates. This is an approximate-NN step over the centroid space, not the full residuals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scoring/ranking:&lt;/strong&gt; decompress residuals only for the candidate set (typically a few thousand docs) and run exact MaxSim to produce the final ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So query-time cost scales with candidates, not corpus size. The expensive-looking MaxSim runs on a small shortlist.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# conceptual two-stage flow
&lt;/span&gt;&lt;span class="n"&gt;candidate_doc_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;centroid_ann_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_token_embs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_centroids&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# cheap, approximate
&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;maxsim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query_token_embs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;decompress&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                     &lt;span class="c1"&gt;# exact, on shortlist
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;doc_id&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;candidate_doc_ids&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;ranked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;get&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reverse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When should you use ColBERT instead of a bi-encoder plus reranker?
&lt;/h2&gt;

&lt;p&gt;Both give you "cheap first stage, precise final ranking," but they fail differently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bi-encoder + cross-encoder reranker&lt;/strong&gt; is bounded by the first stage. If your bi-encoder's top-100 doesn't contain the right document, no reranker can save you — the reranker only reorders what it's given. On hard, out-of-domain, or exact-match queries, that first-stage recall is exactly where single vectors are weakest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Late interaction&lt;/strong&gt; pushes token-level matching into the retrieval stage itself, so recall is better &lt;em&gt;before&lt;/em&gt; any reranking. In zero-shot BEIR-style evaluations, ColBERT-family models are consistently strong out-of-domain precisely because token matching generalizes better than a pooled representation tuned to one domain.&lt;/p&gt;

&lt;p&gt;Reach for late interaction when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Queries hinge on &lt;strong&gt;specific terms, numbers, or entities&lt;/strong&gt; that get lost in pooling.&lt;/li&gt;
&lt;li&gt;You need &lt;strong&gt;zero-shot / out-of-domain&lt;/strong&gt; robustness and can't fine-tune per domain.&lt;/li&gt;
&lt;li&gt;First-stage &lt;strong&gt;recall&lt;/strong&gt; is your bottleneck, not just ranking.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stick with &lt;strong&gt;bi-encoder + reranker&lt;/strong&gt; when your index budget is tight, your domain is stable and you can fine-tune the bi-encoder, or you're already running a strong cross-encoder reranker and recall is fine. And note these compose: some teams use ColBERT as a superior first stage, then still rerank the shortlist with a cross-encoder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes and gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizer coupling.&lt;/strong&gt; Late interaction scores tokens, so query and document must share the model's tokenizer and the same encoder checkpoint. You can't mix a query encoder from one model with document embeddings from another.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Query augmentation matters.&lt;/strong&gt; ColBERT pads queries with &lt;code&gt;[MASK]&lt;/code&gt; tokens that the model fills with query expansion terms. Drop this and short queries lose recall — those extra query tokens are doing real retrieval work through the &lt;code&gt;sum&lt;/code&gt; in MaxSim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index size is still your constraint.&lt;/strong&gt; Even compressed, a token-level index is bigger than a single-vector one. Budget for it, and lean on chunking discipline — shorter, cleaner chunks reduce token count directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compression has a floor.&lt;/strong&gt; Push residual quantization too low and near-duplicate documents become indistinguishable. Validate recall at your chosen bit-width; don't assume the default is right for your corpus.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is not a language model.&lt;/strong&gt; Late interaction ranks; it doesn't reason. It won't synthesize across two documents. It gets the right chunks in front of your generator — that's the job.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Late interaction retrieval beats single-vector RAG because it never pools.&lt;/strong&gt; A bi-encoder crushes a whole chunk into one vector, so precise signals — a number, a rare entity, a negated clause — get averaged away, which is fatal on exact-match and out-of-domain queries. ColBERT instead keeps one small vector per token and scores with MaxSim: each query token takes its best match anywhere in the document, and those maxes are summed. That recovers most of a cross-encoder's precision while keeping document embeddings precomputable offline. The historical objection — a vector per token is too big — is solved by centroid-based residual compression (about 1–2 bits per dimension) plus a two-stage index that runs exact MaxSim only over a small candidate shortlist. Use it when recall and exact matching are your bottleneck; keep bi-encoder + reranker when your index budget is tight and your domain is stable.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why repetition_penalty Quietly Corrupts Your Code Generation</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 11 Jul 2026 19:42:47 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-repetitionpenalty-quietly-corrupts-your-code-generation-585b</link>
      <guid>https://dev.to/ji_ai/why-repetitionpenalty-quietly-corrupts-your-code-generation-585b</guid>
      <description>&lt;p&gt;A model that writes clean prose starts emitting broken Python the moment you set &lt;code&gt;repetition_penalty=1.3&lt;/code&gt;. Indentation collapses, brackets go unmatched, and it refuses to write &lt;code&gt;return&lt;/code&gt; twice in the same function. Nothing in your prompt changed. What changed is that you turned on a sampling penalty that was designed to stop prose from looping — and code is nothing but legal repetition.&lt;/p&gt;

&lt;p&gt;This is the trap with &lt;strong&gt;repetition penalty&lt;/strong&gt; and its cousins &lt;code&gt;frequency_penalty&lt;/code&gt; and &lt;code&gt;presence_penalty&lt;/code&gt;. They look like interchangeable "stop the model repeating itself" knobs. They are three different math operations on the logits, one of them has a sign quirk that surprises people, and all three fight directly against structured output.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;repetition_penalty&lt;/strong&gt; (Hugging Face, llama.cpp) is &lt;em&gt;multiplicative&lt;/em&gt; on the logit and scale-free: it divides positive logits and multiplies negative ones by the penalty. Above ~1.2 it visibly degrades code, JSON, and any format with mandatory repeated tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;frequency_penalty&lt;/strong&gt; and &lt;strong&gt;presence_penalty&lt;/strong&gt; (OpenAI Chat Completions) are &lt;em&gt;additive&lt;/em&gt; subtractions from the logit — frequency scales with how many times a token appeared, presence is a flat one-time hit.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;Anthropic Messages API for Claude exposes none of these&lt;/strong&gt; — no repetition, frequency, or presence penalty. That is a deliberate design choice, and it's usually the right one.&lt;/li&gt;
&lt;li&gt;Penalties operate on &lt;em&gt;tokens&lt;/em&gt;, not concepts, so they punish &lt;code&gt;&lt;/code&gt; (indentation), &lt;code&gt;,&lt;/code&gt;, &lt;code&gt;"&lt;/code&gt;, and &lt;code&gt;return&lt;/code&gt; exactly as hard as a genuinely looping phrase.&lt;/li&gt;
&lt;li&gt;For repetition problems, prefer &lt;code&gt;no_repeat_ngram_size&lt;/code&gt; or a DRY sampler over a blunt global penalty, or just lower the temperature and use min-p.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does repetition_penalty actually do to the logits?
&lt;/h2&gt;

&lt;p&gt;It rescales every logit for tokens that already appeared in the context, before softmax. The Hugging Face / CTRL formulation (Keskar et al., 2019) is not a subtraction — it's a conditional multiply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Hugging Face-style repetition_penalty, per generated token
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;apply_repetition_penalty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generated_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;penalty&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;generated_ids&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;/=&lt;/span&gt; &lt;span class="n"&gt;penalty&lt;/span&gt;      &lt;span class="c1"&gt;# shrink toward 0
&lt;/span&gt;        &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="n"&gt;penalty&lt;/span&gt;      &lt;span class="c1"&gt;# push more negative
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note the branch. For a positive logit, dividing by &lt;code&gt;penalty &amp;gt; 1&lt;/code&gt; moves it toward zero (less likely). For a &lt;em&gt;negative&lt;/em&gt; logit, you multiply, which moves it further from zero — more negative, also less likely. The sign flip is there precisely so the penalty always reduces probability regardless of the logit's sign.&lt;/p&gt;

&lt;p&gt;That branch is also the source of endless confusion. The penalty is &lt;strong&gt;scale-dependent&lt;/strong&gt;: a logit of 8.0 with &lt;code&gt;penalty=1.3&lt;/code&gt; becomes 6.15 (a 1.85 drop); a logit of 0.5 becomes 0.38 (a 0.12 drop). The same penalty value hits confident tokens harder in absolute terms but leaves the &lt;em&gt;relative&lt;/em&gt; ordering warped in ways that depend on the raw logit magnitudes of that specific model. A "safe" &lt;code&gt;repetition_penalty&lt;/code&gt; on Llama is not a safe one on Qwen, because their logit scales differ.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does repetition_penalty break code and JSON?
&lt;/h2&gt;

&lt;p&gt;Because the penalty acts on token IDs with no idea what they mean, and code is built from a small set of tokens that &lt;em&gt;must&lt;/em&gt; recur. Python indentation is the same whitespace token on every line. JSON is a stream of &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt;, &lt;code&gt;{&lt;/code&gt;, &lt;code&gt;}&lt;/code&gt;. A function that returns in three branches emits the &lt;code&gt;return&lt;/code&gt; token three times, correctly.&lt;/p&gt;

&lt;p&gt;Once those tokens land in the context window, &lt;code&gt;repetition_penalty&lt;/code&gt; discounts them on every subsequent step. By the tenth line of a function, the model has been told — repeatedly — that the indentation token, the comma, and the closing brace are all "overused." So it starts reaching for alternatives that don't exist in valid syntax: a different quote character, a dropped comma, three spaces instead of four. You get output that is locally plausible and globally uncompilable.&lt;/p&gt;

&lt;p&gt;The effect scales with how much you've already generated. Short completions look fine; the corruption shows up in longer files, which is exactly when you can't eyeball it. This is why teams ship a &lt;code&gt;repetition_penalty=1.15&lt;/code&gt; default that "worked in testing" and then get bug reports about malformed JSON in production — the test prompts were short.&lt;/p&gt;

&lt;h2&gt;
  
  
  frequency_penalty vs presence_penalty: what's the difference?
&lt;/h2&gt;

&lt;p&gt;They're both &lt;em&gt;additive&lt;/em&gt; penalties on the logit, and they differ only in how they count. Given &lt;code&gt;count[tok]&lt;/code&gt; = how many times a token has appeared:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# OpenAI-style additive penalties
&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;frequency_penalty&lt;/span&gt;     &lt;span class="c1"&gt;# grows with repetition
&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;presence_penalty&lt;/span&gt;  &lt;span class="c1"&gt;# flat, one-time hit
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;frequency_penalty&lt;/code&gt; scales linearly with occurrences — the fourth use of a word is penalized four times as hard as the first. &lt;code&gt;presence_penalty&lt;/code&gt; is binary: appear once and you take a fixed hit, appear ten more times and it doesn't grow. Both range from -2.0 to 2.0 in the OpenAI API; negative values &lt;em&gt;encourage&lt;/em&gt; repetition, which is occasionally useful for forcing a fixed refrain or schema.&lt;/p&gt;

&lt;p&gt;Because they subtract a fixed amount rather than rescaling, additive penalties are more predictable across models than multiplicative &lt;code&gt;repetition_penalty&lt;/code&gt; — the penalty is in logit units you can reason about, independent of the base logit's magnitude. They still punish structural tokens, so a high &lt;code&gt;frequency_penalty&lt;/code&gt; will damage JSON and code too, just more gently and more legibly. As a rule, keep &lt;code&gt;frequency_penalty&lt;/code&gt; under ~0.3 for any structured output, and reach for &lt;code&gt;presence_penalty&lt;/code&gt; when you want "cover new topics" behavior without compounding.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does Claude have no repetition penalty at all?
&lt;/h2&gt;

&lt;p&gt;The Anthropic Messages API exposes &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, &lt;code&gt;top_k&lt;/code&gt;, and &lt;code&gt;stop_sequences&lt;/code&gt; — and no repetition, frequency, or presence penalty. This is not an oversight. Penalty knobs are a patch for models whose base sampling distribution degenerates into loops (the "neural text degeneration" problem Holtzman et al. described in 2019). A well-tuned modern model with good sampling defaults rarely needs them, and exposing them invites exactly the code-corruption footgun above.&lt;/p&gt;

&lt;p&gt;If Claude Opus 4.x starts looping — which is uncommon — the fix is upstream: tighten the prompt, add a &lt;code&gt;stop_sequences&lt;/code&gt; entry, drop the temperature, or give it an explicit instruction not to repeat a section. You do not get a blunt logit hammer, and in practice you don't miss it. GPT-5 via the Chat Completions API still exposes &lt;code&gt;frequency_penalty&lt;/code&gt; and &lt;code&gt;presence_penalty&lt;/code&gt;; if you're building a provider-agnostic layer, treat those as OpenAI-only params and don't try to emulate them for Claude.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I use instead of a global penalty?
&lt;/h2&gt;

&lt;p&gt;Match the tool to the failure. A global penalty is the wrong instrument for most repetition problems because it's context-length-blind and token-blind.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;no_repeat_ngram_size&lt;/code&gt;&lt;/strong&gt; hard-bans any n-gram from occurring twice. Set it to 3 and the model can never emit the same 3-token sequence again. This is a scalpel compared to &lt;code&gt;repetition_penalty&lt;/code&gt;'s hammer: single tokens like indentation and commas are untouched, only actual repeated &lt;em&gt;phrases&lt;/em&gt; get blocked. The risk is over-blocking legitimate n-grams (&lt;code&gt;for i in&lt;/code&gt;, &lt;code&gt;} else {&lt;/code&gt;), so keep the n large enough that real code patterns survive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;DRY (Don't Repeat Yourself) sampler&lt;/strong&gt;, available in llama.cpp and text-generation-webui, penalizes tokens that would &lt;em&gt;extend&lt;/em&gt; an existing repeated sequence, with a length-scaled multiplier and an allowlist for sequences you expect to recur. It targets runaway loops specifically while leaving normal structure alone — the best general-purpose option for open models prone to looping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just lower the temperature and add min-p.&lt;/strong&gt; A surprising share of "the model keeps repeating" reports are actually high-temperature sampling wandering into a low-probability loop it can't escape. Dropping temperature to 0.7 with &lt;code&gt;min_p=0.05&lt;/code&gt; fixes the degeneration without penalizing any structural token. Try this before you touch any penalty.&lt;/p&gt;

&lt;p&gt;Here's a defensible config split by task:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Prose / brainstorming — light penalty is fine
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.9&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Code / JSON / structured output — no global penalty
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;top_p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.95&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;frequency_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;presence_penalty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no_repeat_ngram_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;# rely on low temp
&lt;/span&gt;
&lt;span class="c1"&gt;# Open model looping badly — DRY beats repetition_penalty
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;temperature&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;min_p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dry_multiplier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
 &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dry_base&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;1.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dry_allowed_length&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The one-paragraph answer
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;repetition_penalty&lt;/code&gt; corrupts code generation because it is a &lt;em&gt;multiplicative, token-level&lt;/em&gt; penalty applied to every token that already appeared in the context — and code is built from a tiny alphabet of tokens (indentation, commas, quotes, keywords, brackets) that must legally repeat. Above roughly 1.2, the model gets told its own indentation and punctuation are "overused" and starts substituting invalid alternatives, producing longer completions that silently fail to parse. It differs from OpenAI's additive &lt;code&gt;frequency_penalty&lt;/code&gt; (scales with count) and &lt;code&gt;presence_penalty&lt;/code&gt; (flat one-time hit), which are gentler but share the same structural-token problem, and from Claude, which exposes no penalty at all by design. For structured output, disable global penalties entirely and control repetition with low temperature plus min-p, &lt;code&gt;no_repeat_ngram_size&lt;/code&gt;, or a DRY sampler — instruments that target repeated &lt;em&gt;phrases&lt;/em&gt; instead of punishing the syntax your output depends on.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>DPO Likelihood Displacement: When Preferred Answers Get Rarer</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 11 Jul 2026 07:40:58 +0000</pubDate>
      <link>https://dev.to/ji_ai/dpo-likelihood-displacement-when-preferred-answers-get-rarer-2587</link>
      <guid>https://dev.to/ji_ai/dpo-likelihood-displacement-when-preferred-answers-get-rarer-2587</guid>
      <description>&lt;p&gt;You run Direct Preference Optimization on a clean set of preference pairs. The reward margin climbs, the loss falls, the eval curve looks textbook. Then you sample from the tuned model and the &lt;em&gt;chosen&lt;/em&gt; responses — the exact completions you rewarded — come out less often than before training. Not the rejected ones. The preferred ones. Their log-probability went &lt;em&gt;down&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;This is &lt;strong&gt;DPO likelihood displacement&lt;/strong&gt;, and it is not a bug in your data loader. It is baked into the DPO objective. If you fine-tune alignment or preference models with DPO and you have never plotted &lt;code&gt;log π(y_chosen)&lt;/code&gt; across training steps, you are probably shipping it.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DPO optimizes a margin, not an absolute.&lt;/strong&gt; The loss only cares about &lt;code&gt;logπ(chosen) − logπ(rejected)&lt;/code&gt;. The model can grow that gap by pushing the rejected response down &lt;em&gt;faster&lt;/em&gt; than it drags the chosen response down with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chosen and rejected share tokens&lt;/strong&gt;, so the gradient that suppresses the rejected response leaks onto the chosen one. When the two completions are similar, &lt;code&gt;log π(y_chosen)&lt;/code&gt; can fall for hundreds of steps while the margin still improves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The failure mode is invisible in the loss curve.&lt;/strong&gt; Reward margin and accuracy look healthy; probability mass silently drains toward off-distribution tokens (often the empty/EOS or a third unrelated response).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fixes that work:&lt;/strong&gt; add an explicit SFT/NLL term on the chosen response (&lt;code&gt;rpo_alpha&lt;/code&gt; in TRL), use a DPO-positive style anchor that penalizes chosen log-prob dropping below the reference, raise &lt;code&gt;beta&lt;/code&gt;, and curate pairs so chosen and rejected are not near-duplicates.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is DPO likelihood displacement?
&lt;/h2&gt;

&lt;p&gt;Likelihood displacement is when DPO training &lt;em&gt;decreases&lt;/em&gt; the probability the model assigns to the preferred (chosen) response, even though DPO's whole point is to make preferred responses more likely. The probability mass does not move to the chosen response — it moves away from both the chosen and the rejected, toward whatever else the model can put in that region of output space.&lt;/p&gt;

&lt;p&gt;Start from the objective. DPO's per-pair loss is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L = -log σ( β · [ (log π_θ(y_w|x) − log π_ref(y_w|x))
                − (log π_θ(y_l|x) − log π_ref(y_l|x)) ] )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;y_w&lt;/code&gt; is the chosen (winner), &lt;code&gt;y_l&lt;/code&gt; the rejected (loser), &lt;code&gt;π_ref&lt;/code&gt; the frozen reference (your SFT checkpoint), &lt;code&gt;β&lt;/code&gt; the temperature that controls how far you drift from the reference.&lt;/p&gt;

&lt;p&gt;Look at what is inside the sigmoid: a &lt;strong&gt;difference of log-ratios&lt;/strong&gt;. The loss is minimized when the margin&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Δ = (log π_θ(y_w) − log π_ref(y_w)) − (log π_θ(y_l) − log π_ref(y_l))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is large and positive. Nothing in that expression pins &lt;code&gt;log π_θ(y_w)&lt;/code&gt; to a floor. A model that halves the chosen probability and quarters the rejected probability has &lt;em&gt;increased&lt;/em&gt; the margin. The loss is happy. Your users are not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does the chosen response's probability go down?
&lt;/h2&gt;

&lt;p&gt;Because the gradient that suppresses the rejected response also suppresses everything the rejected response has in common with the chosen one — and preference pairs usually overlap heavily.&lt;/p&gt;

&lt;p&gt;Write the DPO gradient. With &lt;code&gt;d = β·Δ&lt;/code&gt; the margin inside the sigmoid, the gradient of the loss is proportional to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;∇L ∝ − β · σ(−d) · [ ∇log π_θ(y_w) − ∇log π_θ(y_l) ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scalar &lt;code&gt;σ(−d)&lt;/code&gt; is just an adaptive weight — large when the model is still wrong, shrinking as the margin grows. The direction is the bracket: &lt;strong&gt;push up the chosen, push down the rejected&lt;/strong&gt;. In isolation that is exactly what you want.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;∇log π_θ(y_w)&lt;/code&gt; and &lt;code&gt;∇log π_θ(y_l)&lt;/code&gt; are not orthogonal. If &lt;code&gt;y_w&lt;/code&gt; and &lt;code&gt;y_l&lt;/code&gt; are "Paris is the capital of France." versus "Paris is the capital of Germany.", they share almost every token and route through almost the same hidden states. The &lt;code&gt;−∇log π_θ(y_l)&lt;/code&gt; term pushes down the shared prefix — "Paris is the capital of" — and that prefix is &lt;em&gt;also&lt;/em&gt; the chosen response's prefix. The suppression bleeds across.&lt;/p&gt;

&lt;p&gt;When the two responses are close in the model's representation space, the negative gradient on the loser dominates the positive gradient on the winner over the shared tokens, and the net effect on &lt;code&gt;log π_θ(y_w)&lt;/code&gt; is negative. Recent analysis formalizes this: the displacement is governed by how aligned the chosen and rejected hidden representations are. High similarity → strong displacement. This is why "harder" preference pairs — the ones where chosen and rejected differ by a word — are the &lt;em&gt;most&lt;/em&gt; dangerous, not the most informative.&lt;/p&gt;

&lt;p&gt;Where does the mass go? Not to the chosen. It leaks to whatever token sits geometrically between suppressing &lt;code&gt;y_l&lt;/code&gt; and not fully committing to &lt;code&gt;y_w&lt;/code&gt; — frequently EOS (the model learns to say less), or a third response entirely. In alignment settings this is how "unintentional unalignment" happens: you train to prefer a safe refusal over an unsafe answer, and DPO quietly raises the probability of a &lt;em&gt;different&lt;/em&gt; unsafe answer you never put in the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I detect likelihood displacement?
&lt;/h2&gt;

&lt;p&gt;Stop trusting the loss and reward-margin curves — they rise even while displacement is happening. Log the absolute quantities:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;log π_θ(y_w)&lt;/code&gt; (mean chosen logprob) per step. This is the canary. If it trends down while reward margin trends up, you have displacement.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;log π_θ(y_w) − log π_ref(y_w)&lt;/code&gt; — the chosen response's drift from the reference. It should stay near zero or positive; a steady decline is the failure.&lt;/li&gt;
&lt;li&gt;The gap between reward margin improvement and chosen-logprob change. Healthy training moves both up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concretely, in TRL the trainer already emits &lt;code&gt;logps/chosen&lt;/code&gt; and &lt;code&gt;logps/rejected&lt;/code&gt;. Watch them directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# In your logging callback / wandb panel, plot BOTH — not just the margin.
# margin = logps/chosen - logps/rejected   (this looks fine even when broken)
# The real signal:
#   logps/chosen  should NOT be steadily decreasing
#   rewards/chosen = beta * (logps/chosen - ref_logps/chosen) should stay &amp;gt;= ~0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;logps/chosen&lt;/code&gt; slides down for more than a warmup's worth of steps, you are displacing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I stop DPO from lowering the preferred probability?
&lt;/h2&gt;

&lt;p&gt;Anchor the absolute chosen probability instead of only the margin. Four levers, roughly in order of impact:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Add an SFT/NLL term on the chosen response.&lt;/strong&gt; This is the most reliable fix and it is one argument in TRL. Setting &lt;code&gt;rpo_alpha&lt;/code&gt; adds a weighted negative-log-likelihood loss on the chosen completion, so the objective becomes "win the margin &lt;em&gt;and&lt;/em&gt; keep assigning high probability to the winner":&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;trl&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DPOConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;DPOTrainer&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DPOConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;beta&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# raise toward 0.3–0.5 if displacement persists
&lt;/span&gt;    &lt;span class="n"&gt;rpo_alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# NLL anchor on the chosen response; 0 = pure DPO
&lt;/span&gt;    &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;5e-7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# DPO wants a much smaller LR than SFT
&lt;/span&gt;    &lt;span class="n"&gt;loss_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sigmoid&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="c1"&gt;# vanilla DPO; see dpop below
&lt;/span&gt;    &lt;span class="n"&gt;max_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;per_device_train_batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gradient_accumulation_steps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;trainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DPOTrainer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;policy_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ref_model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reference_model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# your frozen SFT checkpoint
&lt;/span&gt;    &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;train_dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;pref_dataset&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# columns: prompt, chosen, rejected
&lt;/span&gt;    &lt;span class="n"&gt;processing_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The NLL term costs almost nothing and removes the degenerate solution where both logprobs collapse. The tradeoff: too large an &lt;code&gt;rpo_alpha&lt;/code&gt; and you are basically doing SFT-on-chosen with a preference nudge, which weakens the contrast you were trying to learn. Start at &lt;code&gt;1.0&lt;/code&gt;, tune down if the margin stops separating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Use a DPO-positive (DPOP) style penalty.&lt;/strong&gt; DPOP adds a hinge term that fires when &lt;code&gt;log π_θ(y_w)&lt;/code&gt; drops below &lt;code&gt;log π_ref(y_w)&lt;/code&gt;, directly punishing the model for making the chosen response rarer than the reference already made it. It targets displacement more surgically than a blanket NLL term and is a good choice when your chosen/rejected pairs are genuinely near-duplicates (math, code, single-token edits).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Raise &lt;code&gt;beta&lt;/code&gt;.&lt;/strong&gt; A larger &lt;code&gt;beta&lt;/code&gt; keeps the policy closer to the reference, which limits how far any logprob — including the chosen's — can wander. It is a blunt instrument: too high and the model barely learns the preference at all. But if you are seeing wild displacement at &lt;code&gt;beta=0.1&lt;/code&gt;, &lt;code&gt;0.3&lt;/code&gt; is worth a run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Curate pairs by dissimilarity.&lt;/strong&gt; Displacement scales with how similar chosen and rejected are in representation space. Filter or down-weight pairs where the two responses differ by only a token or two, or where their embeddings are near-identical. Counterintuitively, the razor-thin pairs that feel most informative are the ones most likely to drain probability from the answer you want. If you must keep hard pairs, pair them with the NLL anchor above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this mean DPO is broken?
&lt;/h2&gt;

&lt;p&gt;No. DPO is still a clean, RL-free way to fold preferences into a policy, and for well-separated pairs with a modest &lt;code&gt;beta&lt;/code&gt; it behaves. The trap is treating the loss and reward margin as sufficient monitoring. They are necessary and misleading: both improve &lt;em&gt;while the model gets worse at producing the thing you rewarded&lt;/em&gt;. IPO, DPOP, and the &lt;code&gt;rpo_alpha&lt;/code&gt; NLL anchor all exist because the plain sigmoid objective under-constrains the absolute probabilities. Pick one before you train, not after you notice your aligned model refusing everything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bottom line
&lt;/h2&gt;

&lt;p&gt;Why does DPO push down the probability of your preferred answer? Because the DPO loss optimizes only the &lt;em&gt;margin&lt;/em&gt; between chosen and rejected log-ratios, and it can widen that margin by suppressing the rejected response faster than the correlated chosen response follows it down — so probability mass drains off both and lands on off-distribution tokens like EOS or an unrelated completion. This DPO likelihood displacement is worst when chosen and rejected responses are similar, it is invisible in the loss and reward-margin curves, and you catch it only by plotting the absolute chosen log-probability. Fix it by anchoring that absolute probability: add an NLL term on the chosen response (&lt;code&gt;rpo_alpha&lt;/code&gt; in TRL), apply a DPO-positive penalty, raise &lt;code&gt;beta&lt;/code&gt;, and drop near-duplicate preference pairs.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why Token Logprobs Beat Asking Your LLM How Confident It Is</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 10 Jul 2026 07:39:05 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-token-logprobs-beat-asking-your-llm-how-confident-it-is-4jk1</link>
      <guid>https://dev.to/ji_ai/why-token-logprobs-beat-asking-your-llm-how-confident-it-is-4jk1</guid>
      <description>&lt;p&gt;You built a classifier on GPT-5.1. It hits 92% accuracy on your eval set. Ship it, and the 8% it gets wrong land in production with the same swagger as the 92% it gets right. So you add &lt;code&gt;"confidence": 0-100&lt;/code&gt; to the JSON schema, the model dutifully returns &lt;code&gt;95&lt;/code&gt; on almost everything, and you now have a number that correlates with nothing. That number is theater. The signal you actually want is already in the API response — the &lt;strong&gt;token logprobs&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Asking an LLM for a self-reported confidence score gives you &lt;strong&gt;verbalized confidence&lt;/strong&gt;: badly calibrated, overconfident, and clustered on round numbers like 90/95/100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token logprobs&lt;/strong&gt; — the log-probability the model assigned to each token it emitted — are a real, per-decision confidence signal you can extract from the API for free.&lt;/li&gt;
&lt;li&gt;For a classifier, read the logprob of the &lt;em&gt;label token&lt;/em&gt;, &lt;code&gt;exp()&lt;/code&gt; it to a probability, then apply &lt;strong&gt;temperature scaling&lt;/strong&gt; on a held-out set to make it calibrated (low ECE).&lt;/li&gt;
&lt;li&gt;Use the calibrated probability for &lt;strong&gt;selective prediction&lt;/strong&gt;: auto-accept above a threshold, route the low-confidence tail to a human or a bigger model.&lt;/li&gt;
&lt;li&gt;Claude doesn't expose token logprobs, so approximate confidence there with &lt;strong&gt;self-consistency&lt;/strong&gt; (sample N, measure agreement).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why is asking an LLM for a confidence score a bad idea?
&lt;/h2&gt;

&lt;p&gt;Because you get verbalized confidence, and verbalized confidence is not calibrated. When you prompt "rate your confidence 0-100," the model generates that number the same way it generates any other token: by pattern-matching what a confident-sounding assistant says. The result is systematically overconfident, spikes on human-friendly round numbers (90, 95, 99), and barely moves between an easy example and a genuinely ambiguous one.&lt;/p&gt;

&lt;p&gt;Calibration has a precise meaning. A classifier is calibrated if, among all predictions it makes with confidence &lt;em&gt;p&lt;/em&gt;, a fraction &lt;em&gt;p&lt;/em&gt; are actually correct. A self-reported "95" that is right 70% of the time is not "pretty good" — it is a broken instrument. You measure this with &lt;strong&gt;Expected Calibration Error (ECE)&lt;/strong&gt;: bin predictions by stated confidence, and average the gap between confidence and empirical accuracy in each bin. Verbalized confidence routinely posts high ECE. It feels like a signal and behaves like noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are token logprobs and how do I get them?
&lt;/h2&gt;

&lt;p&gt;A token logprob is the natural log of the probability the model assigned to a specific token at the moment it sampled it. When the model emits &lt;code&gt;positive&lt;/code&gt; for a sentiment label, it computed a distribution over the whole vocabulary first; the logprob tells you how much mass sat on the token it chose. &lt;code&gt;exp(logprob)&lt;/code&gt; converts that back to a probability in &lt;code&gt;[0, 1]&lt;/code&gt;. That probability &lt;em&gt;is&lt;/em&gt; the model's internal confidence in that token — no self-report, no prompt engineering, no theater.&lt;/p&gt;

&lt;p&gt;On the OpenAI-compatible chat completions endpoint you turn this on with two parameters. &lt;code&gt;logprobs=True&lt;/code&gt; returns the chosen token's logprob; &lt;code&gt;top_logprobs=n&lt;/code&gt; also returns the top &lt;em&gt;n&lt;/em&gt; alternatives at each position, so you can see what the model almost said.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-5.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Classify sentiment. Answer with exactly one &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
                        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;word: positive, negative, or neutral.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;logprobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;top_logprobs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;logprobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;prob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logprob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# confidence in THIS label
&lt;/span&gt;    &lt;span class="c1"&gt;# full distribution over the alternatives the model considered
&lt;/span&gt;    &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt; &lt;span class="n"&gt;math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;logprob&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;top_logprobs&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;

&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipping was slow but the product is great&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dist&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices make this clean. Force the label into a &lt;strong&gt;single token&lt;/strong&gt; so one logprob captures the whole decision, and pin &lt;code&gt;temperature=0&lt;/code&gt; so decoding is deterministic while the logprobs still reflect the underlying distribution. &lt;code&gt;top_logprobs&lt;/code&gt; is the bonus: when the model returns &lt;code&gt;positive&lt;/code&gt; at 0.55 with &lt;code&gt;negative&lt;/code&gt; at 0.42 right behind it, you know the example is a coin flip long before a human ever sees it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I turn a raw logprob into a calibrated probability?
&lt;/h2&gt;

&lt;p&gt;Read the label token's logprob, exponentiate it, then correct the model's overconfidence with &lt;strong&gt;temperature scaling&lt;/strong&gt; — a single learned parameter fit on held-out data. Raw LLM logprobs are usually sharper than reality: the model puts 0.97 on answers that are right 85% of the time. Temperature scaling fixes the sharpness without touching accuracy.&lt;/p&gt;

&lt;p&gt;The mechanics: collect logits (or logprobs) on a labeled held-out set, then find a scalar &lt;em&gt;T&lt;/em&gt; that minimizes negative log-likelihood when you divide logits by &lt;em&gt;T&lt;/em&gt; before the softmax. &lt;em&gt;T &amp;gt; 1&lt;/em&gt; softens overconfident distributions; &lt;em&gt;T &amp;lt; 1&lt;/em&gt; sharpens underconfident ones. Because it is one parameter, it needs only a few hundred labeled examples and cannot overfit its way into hurting your top-1 accuracy — the argmax is invariant to scaling.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.optimize&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;minimize_scalar&lt;/span&gt;

&lt;span class="c1"&gt;# logits: (N, K) pre-softmax scores; here, reconstruct from top_logprobs.
# labels: (N,) correct class indices on a held-out set.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fit_temperature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;nll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;T&lt;/span&gt;
        &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;logp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;z&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;z&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;logp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;arange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="n"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;minimize_scalar&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nll&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bounds&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bounded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;

&lt;span class="n"&gt;T&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;fit_temperature&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;val_logits&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;val_labels&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# e.g. 1.7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After fitting, apply the same &lt;em&gt;T&lt;/em&gt; at inference. Verify it worked by recomputing ECE on a separate test split and plotting a reliability diagram — confidence on the x-axis, empirical accuracy on the y-axis. A calibrated model hugs the diagonal. If it does, your 0.9 predictions really are right ~90% of the time, which is the entire point.&lt;/p&gt;

&lt;h2&gt;
  
  
  What if my labels are more than one token?
&lt;/h2&gt;

&lt;p&gt;Multi-token labels break the "one logprob = one decision" shortcut, so either avoid them or aggregate correctly. The cleanest fix is to design label surface forms that tokenize to a single token — &lt;code&gt;pos&lt;/code&gt; / &lt;code&gt;neg&lt;/code&gt; / &lt;code&gt;neu&lt;/code&gt;, or route through a structured-output schema where the discriminating field is a single enum token. Then nothing changes.&lt;/p&gt;

&lt;p&gt;When you can't avoid multi-token labels (&lt;code&gt;refund_request&lt;/code&gt;, &lt;code&gt;billing_dispute&lt;/code&gt;), the sequence probability is the product of per-token probabilities, so sum the logprobs. But raw sums penalize longer labels — a three-token class looks less likely than a one-token class purely from length. Use &lt;strong&gt;length-normalized&lt;/strong&gt; log-probability (the mean per-token logprob) when comparing candidate labels of different lengths, and evaluate a small fixed set of candidates rather than trusting free-form generation. For a closed label set, scoring each candidate's normalized logprob and taking the argmax is more robust than reading whatever the model happened to decode first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I get confidence from Claude, which has no logprobs?
&lt;/h2&gt;

&lt;p&gt;Claude's API does not expose token logprobs, so you approximate confidence behaviorally with &lt;strong&gt;self-consistency&lt;/strong&gt;: sample the same prompt N times at nonzero temperature and measure agreement. If Claude Sonnet 4.5 returns &lt;code&gt;positive&lt;/code&gt; on 9 of 10 samples, treat 0.9 as the confidence estimate; a 6/4 split flags the same ambiguity a logprob split would. The agreement rate is a Monte Carlo estimate of the very distribution logprobs hand you directly — you're paying in tokens for what OpenAI returns free.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Counter&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;claude_confidence&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;votes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;call_claude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# your wrapper
&lt;/span&gt;        &lt;span class="n"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;()]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
    &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;votes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;most_common&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This costs N× the calls, so reserve it for decisions where the confidence actually gates an action. You can still temperature-scale the agreement rate against a held-out set to correct any residual bias. A lighter alternative is an explicit self-evaluation pass — a second call that judges the first — but that inherits the same verbalized-confidence weakness, so prefer sampling-based agreement when you can afford it.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I use calibrated confidence in production?
&lt;/h2&gt;

&lt;p&gt;Turn it into &lt;strong&gt;selective prediction&lt;/strong&gt;: pick a confidence threshold, auto-accept everything above it, and escalate the rest. This is where calibration pays for itself. With a trustworthy probability you can draw a &lt;strong&gt;risk-coverage curve&lt;/strong&gt; — sort predictions by confidence, and for each coverage level (the fraction you auto-handle) read the error rate on that slice.&lt;/p&gt;

&lt;p&gt;Concretely: if calibrated 0.9+ predictions cover 70% of your traffic at a 3% error rate, automate that 70% and route the low-confidence 30% to a stronger model or a human queue. You've converted a flat 92% accuracy into a tunable business dial — trade coverage for accuracy explicitly instead of eating a fixed error rate everywhere. The threshold is a product decision (how expensive is a wrong auto-accept?), and calibration is what makes that decision honest. Tiered routing falls out naturally: cheap model with logprob gating first, escalate the uncertain tail to the expensive model, and only the residual reaches a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;Token logprobs beat asking your LLM how confident it is because a self-reported score is generated by the same next-token machinery as any other text — overconfident, clustered on round numbers, and uncorrelated with correctness — whereas a token logprob is the actual probability the model assigned to the token it chose. On the OpenAI-compatible endpoint, set &lt;code&gt;logprobs=True&lt;/code&gt; with single-token labels, &lt;code&gt;exp()&lt;/code&gt; the label token's logprob, and fit one temperature-scaling parameter on held-out data to drive ECE down. That gives you a calibrated per-decision probability you can threshold for selective prediction: auto-accept the confident majority, escalate the uncertain tail. For Claude, which exposes no logprobs, approximate the same signal with self-consistency sampling. Either way, stop trusting the model's self-graded confidence and start reading the number it already computed.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Multi-Token Prediction: DeepSeek's Built-In Draft Model</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 09 Jul 2026 19:37:02 +0000</pubDate>
      <link>https://dev.to/ji_ai/multi-token-prediction-deepseeks-built-in-draft-model-c2b</link>
      <guid>https://dev.to/ji_ai/multi-token-prediction-deepseeks-built-in-draft-model-c2b</guid>
      <description>&lt;p&gt;Speculative decoding needs two models: a small draft model that guesses ahead and a big verifier that checks. Multi-token prediction (MTP) collapses that into one. DeepSeek-V3 trains extra prediction heads directly into the main model, so it drafts its own future tokens — no separate draft model to train, align, or keep in memory. You get a denser training signal &lt;em&gt;and&lt;/em&gt; a roughly 1.8x decoding speedup from the same weights.&lt;/p&gt;

&lt;p&gt;If you've been treating "next-token prediction" as the only game in town, MTP is the part of modern LLM training that quietly changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Multi-token prediction (MTP)&lt;/strong&gt; adds auxiliary heads that predict tokens &lt;code&gt;t+2&lt;/code&gt;, &lt;code&gt;t+3&lt;/code&gt;, … alongside the standard &lt;code&gt;t+1&lt;/code&gt; head, giving each training position more supervision.&lt;/li&gt;
&lt;li&gt;DeepSeek-V3 uses &lt;strong&gt;sequential, causal MTP modules&lt;/strong&gt; (each conditioned on the previous depth), not the independent parallel heads from Meta's earlier MTP paper.&lt;/li&gt;
&lt;li&gt;The MTP heads are &lt;strong&gt;thrown away or reused&lt;/strong&gt;: discard them and the base model runs normally, or keep them as a built-in draft model for &lt;strong&gt;self-speculative decoding&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;DeepSeek reports a second-token &lt;strong&gt;acceptance rate around 85–90%&lt;/strong&gt; and roughly &lt;strong&gt;1.8x&lt;/strong&gt; faster decoding, with no accuracy loss because every draft is verified.&lt;/li&gt;
&lt;li&gt;MTP helps even without the speedup: predicting further ahead forces the model to plan, which improves benchmark scores.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is multi-token prediction and how does it differ from next-token prediction?
&lt;/h2&gt;

&lt;p&gt;Standard language models are trained with a single objective: given tokens &lt;code&gt;t_1…t_i&lt;/code&gt;, predict &lt;code&gt;t_{i+1}&lt;/code&gt;. One position, one label, one gradient signal per token. Multi-token prediction keeps that head and bolts on additional heads that predict &lt;code&gt;t_{i+2}&lt;/code&gt;, &lt;code&gt;t_{i+3}&lt;/code&gt;, and so on from the same hidden state.&lt;/p&gt;

&lt;p&gt;The immediate payoff is training efficiency. In vanilla next-token prediction, position &lt;code&gt;i&lt;/code&gt; only ever learns to produce one token. With MTP at depth &lt;code&gt;D&lt;/code&gt;, that same position contributes &lt;code&gt;D&lt;/code&gt; cross-entropy terms, so the representation at &lt;code&gt;i&lt;/code&gt; has to encode information about several upcoming tokens, not just the next one. That pushes the model toward planning instead of myopic greedy continuation — which matters most for structured output like code, where the token three steps ahead constrains the one you emit now.&lt;/p&gt;

&lt;p&gt;Meta's 2024 paper (Gloeckle et al., "Better &amp;amp; Faster Large Language Models via Multi-token Prediction") used &lt;code&gt;n&lt;/code&gt; &lt;strong&gt;independent parallel heads&lt;/strong&gt; sharing a trunk. DeepSeek-V3 took a different, more careful route.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does DeepSeek-V3 implement MTP?
&lt;/h2&gt;

&lt;p&gt;DeepSeek-V3 uses &lt;strong&gt;sequential MTP modules that preserve the full causal chain&lt;/strong&gt;, rather than predicting all future tokens in parallel from one shared hidden state. Each module predicts exactly one additional token and is conditioned on the representation from the previous depth.&lt;/p&gt;

&lt;p&gt;Concretely, for MTP depth &lt;code&gt;k&lt;/code&gt;, module &lt;code&gt;k&lt;/code&gt; combines the previous depth's hidden state with the embedding of the token it's now conditioning on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Sketch of one DeepSeek-V3 MTP module at depth k (per position i)
# Shared with the main model: Emb (embedding) and OutHead (unembedding)
# Per-depth and trainable: proj_k (R^{d x 2d}) and block_k (one transformer block)
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;mtp_module_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h_prev&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;next_tok_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;proj_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;block_k&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# h_prev: hidden state from depth k-1 (h^0 = main model's final hidden state)
&lt;/span&gt;    &lt;span class="c1"&gt;# next_tok_id: the token t_{i+k} we now know and condition on
&lt;/span&gt;    &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rms_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h_prev&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                 &lt;span class="c1"&gt;# normalize previous-depth state
&lt;/span&gt;    &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rms_norm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Emb&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next_tok_id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# normalize the newly-known token's embedding
&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;proj_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;concat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# project 2d -&amp;gt; d
&lt;/span&gt;    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;block_k&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# one transformer block (attention + MLP)
&lt;/span&gt;    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OutHead&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                  &lt;span class="c1"&gt;# predicts t_{i+k+1}, using the SHARED head
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;logits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two design choices carry the weight:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The embedding matrix and the output head are shared&lt;/strong&gt; across the main model and every MTP module. Only the projection &lt;code&gt;proj_k&lt;/code&gt; and one transformer block &lt;code&gt;block_k&lt;/code&gt; are added per depth, so the parameter overhead is small.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Causality is kept intact.&lt;/strong&gt; Module 1 sees the real token &lt;code&gt;t_{i+1}&lt;/code&gt;, module 2 sees &lt;code&gt;t_{i+2}&lt;/code&gt;, and so on. Each depth is conditioned on ground truth during training, which is what makes the heads good enough to draft from at inference time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;DeepSeek-V3 ships with &lt;code&gt;D = 1&lt;/code&gt; — a single MTP module predicting one extra token. That's a deliberately conservative depth; the marginal gain from deeper heads shrinks fast while training cost grows.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does the MTP loss look like?
&lt;/h2&gt;

&lt;p&gt;The MTP loss is just the mean cross-entropy across depths, added to the main objective with a small weight. For each depth &lt;code&gt;k&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;L_MTP_k = CrossEntropy( p_k(t_{i+k+1} | context) )   averaged over valid positions i

L_MTP   = (lambda / D) * sum_{k=1..D} L_MTP_k

L_total = L_main + L_MTP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The weight &lt;code&gt;lambda&lt;/code&gt; is what keeps MTP an &lt;em&gt;auxiliary&lt;/em&gt; objective rather than something that distorts the primary next-token distribution. DeepSeek-V3 used a larger &lt;code&gt;lambda&lt;/code&gt; early in training and annealed it down over the run — hard early planning pressure, then back off so the main head dominates by the end. If you set &lt;code&gt;lambda&lt;/code&gt; too high, the shared representation over-optimizes for the harder multi-step targets and single-token quality suffers; too low and the heads never get good enough to draft from.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does MTP turn into a free draft model at inference?
&lt;/h2&gt;

&lt;p&gt;Here's the part that makes MTP more than a training trick. Because the MTP module already predicts &lt;code&gt;t_{i+2}&lt;/code&gt; from information available at step &lt;code&gt;i&lt;/code&gt;, you can use it as a &lt;strong&gt;self-speculative draft&lt;/strong&gt; — the model drafts ahead of itself, then verifies with its own main head.&lt;/p&gt;

&lt;p&gt;The loop, per step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Main head predicts &lt;code&gt;t_{i+1}&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;MTP module 1 predicts a candidate for &lt;code&gt;t_{i+2}&lt;/code&gt; in the same forward pass.&lt;/li&gt;
&lt;li&gt;Run one verification forward pass that scores the drafted token against the main head's distribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accept&lt;/strong&gt; the draft if it matches (greedy) or passes the speculative-sampling acceptance test; otherwise &lt;strong&gt;reject&lt;/strong&gt; and fall back to the main head's token.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every accepted draft means two tokens emerged from what is effectively one decode step. Because rejected drafts are discarded, &lt;strong&gt;output is identical in distribution to plain decoding&lt;/strong&gt; — you never trade accuracy for speed, only wall-clock time. This is the same correctness guarantee as classic speculative decoding, minus the second model.&lt;/p&gt;

&lt;p&gt;DeepSeek reports the second-token acceptance rate lands around 85–90% across generation domains, translating to roughly a 1.8x improvement in tokens per second. The acceptance rate is what to watch: it depends on how predictable your text is. Boilerplate code and repetitive structured output accept at very high rates; dense reasoning with high branching accepts less often.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not just use a separate draft model?
&lt;/h2&gt;

&lt;p&gt;Because a separate draft model is a second system to train, distill, and keep aligned with the target. MTP folds drafting into the model you already have:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Classic speculative decoding&lt;/th&gt;
&lt;th&gt;MTP self-speculation&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Draft source&lt;/td&gt;
&lt;td&gt;Separate small model&lt;/td&gt;
&lt;td&gt;Built-in MTP head&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Extra memory at inference&lt;/td&gt;
&lt;td&gt;Full draft model weights&lt;/td&gt;
&lt;td&gt;One transformer block per depth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Distribution mismatch&lt;/td&gt;
&lt;td&gt;Draft and target can diverge&lt;/td&gt;
&lt;td&gt;Same trunk, tightly coupled&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Training cost&lt;/td&gt;
&lt;td&gt;Train/distill a draft model&lt;/td&gt;
&lt;td&gt;Auxiliary loss during main run&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Benchmark quality&lt;/td&gt;
&lt;td&gt;Neutral&lt;/td&gt;
&lt;td&gt;Improves from denser signal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The distribution-mismatch column is the subtle win. A distilled draft model can drift from the target and tank your acceptance rate; the MTP head shares the same trunk representation and output head, so its guesses stay close to what the main model would do. You spend a little training compute and a small amount of inference memory, and drafting quality comes along for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  When does MTP not help?
&lt;/h2&gt;

&lt;p&gt;MTP is not a universal speedup. Three failure modes to plan around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low-entropy vs high-entropy content.&lt;/strong&gt; Acceptance rate collapses on genuinely unpredictable text (novel reasoning, adversarial prompts). At acceptance rates below ~40%, the verification overhead can eat the gains, and you're paying for drafts you throw away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch pressure.&lt;/strong&gt; Self-speculation shines at low batch sizes where you're latency-bound and the GPU is underutilized. At high batch sizes the machine is already compute-saturated, and the extra verification work competes with real throughput. Serving stacks gate MTP on batch size for exactly this reason.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Depth beyond 1.&lt;/strong&gt; Deeper MTP (predicting &lt;code&gt;t+3&lt;/code&gt;, &lt;code&gt;t+4&lt;/code&gt;) sounds like more free tokens, but each extra depth is conditioned on a &lt;em&gt;drafted&lt;/em&gt; token, so acceptance compounds downward. DeepSeek's choice of depth 1 reflects that the second drafted token rarely pays for itself.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're deploying, instrument the acceptance rate per request class before assuming MTP is a win. It's a distribution-dependent optimization, not a constant.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct answer: what is multi-token prediction and why does it matter?
&lt;/h2&gt;

&lt;p&gt;Multi-token prediction is a training objective where an LLM predicts several future tokens at each position — not just the next one — using lightweight auxiliary heads that share the model's embedding and output layers. DeepSeek-V3's version chains those heads causally so each predicts one more token conditioned on the last, adding a small auxiliary cross-entropy loss during pretraining. The result is two-for-one: the denser supervision improves the model's planning and benchmark scores, and the same heads double as a built-in draft model for self-speculative decoding, delivering roughly 1.8x faster generation at an 85–90% acceptance rate with no change to the output distribution. Unlike classic speculative decoding, MTP needs no separate draft model — the model drafts and verifies itself, which is why it's showing up as a default in frontier training recipes rather than a niche inference hack.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>AWQ: How Activation-Aware Quantization Saves 4-bit LLMs</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 09 Jul 2026 07:40:18 +0000</pubDate>
      <link>https://dev.to/ji_ai/awq-how-activation-aware-quantization-saves-4-bit-llms-38id</link>
      <guid>https://dev.to/ji_ai/awq-how-activation-aware-quantization-saves-4-bit-llms-38id</guid>
      <description>&lt;p&gt;Round every weight in a 7B model down to 4 bits with naive round-to-nearest and perplexity falls off a cliff. Do the same thing but scale up ~1% of the weight channels first, and the model barely notices. That gap — same bit budget, wildly different accuracy — is the whole story of &lt;strong&gt;activation-aware weight quantization (AWQ)&lt;/strong&gt;, and the surprising part is which 1% of channels you have to protect.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Activation-aware weight quantization (AWQ)&lt;/strong&gt; keeps 4-bit LLMs close to FP16 accuracy by protecting a small set of &lt;em&gt;salient&lt;/em&gt; weight channels instead of quantizing all weights equally.&lt;/li&gt;
&lt;li&gt;Salient channels are picked by &lt;strong&gt;activation magnitude&lt;/strong&gt;, not weight magnitude — the channels that see large inputs dominate the output error.&lt;/li&gt;
&lt;li&gt;Instead of storing those channels in mixed precision (hardware-hostile), AWQ scales the salient weight channel up by &lt;code&gt;s&lt;/code&gt; and divides the matching activation by &lt;code&gt;s&lt;/code&gt;, cutting that channel's quantization error by roughly &lt;code&gt;1/s&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The scale &lt;code&gt;s&lt;/code&gt; is found by a cheap per-channel grid search over activation statistics from a &lt;strong&gt;small calibration set&lt;/strong&gt; — no backprop, no weight reconstruction, so it doesn't overfit the calibration data.&lt;/li&gt;
&lt;li&gt;Practical payoff: &lt;code&gt;w4a16&lt;/code&gt; (4-bit weights, 16-bit activations, group size 128) with ~4x smaller weights and faster memory-bound decode, at near-lossless quality. It's what &lt;code&gt;autoawq&lt;/code&gt; and vLLM's AWQ path ship.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does naive 4-bit quantization wreck LLM accuracy?
&lt;/h2&gt;

&lt;p&gt;Because round-to-nearest (RTN) treats every weight as equally disposable, and they aren't. Quantizing a weight &lt;code&gt;w&lt;/code&gt; to N bits with a group scale &lt;code&gt;Δ&lt;/code&gt; means storing &lt;code&gt;Q(w) = Δ · round(w/Δ)&lt;/code&gt;, where &lt;code&gt;Δ = max(|w|) / 2^(N-1)&lt;/code&gt; over the group. The per-weight rounding error is uniform on &lt;code&gt;[0, Δ/2]&lt;/code&gt;. At 4 bits, &lt;code&gt;Δ&lt;/code&gt; is large, so the absolute error is large.&lt;/p&gt;

&lt;p&gt;That error only matters when it lands on a weight that contributes a lot to the output. In &lt;code&gt;y = Wx&lt;/code&gt;, the output error from quantizing a weight in channel &lt;code&gt;j&lt;/code&gt; is proportional to &lt;code&gt;Δ · roundErr · x_j&lt;/code&gt;. The channels where &lt;code&gt;x_j&lt;/code&gt; is consistently large are the ones that turn small rounding errors into large output errors. Transformer activations are famously spiky — a handful of channels carry outlier magnitudes across nearly every token. Those are the channels you cannot afford to round carelessly.&lt;/p&gt;

&lt;p&gt;The naive fix is mixed precision: keep the salient channels in FP16 and quantize the rest to INT4. It works for accuracy and destroys throughput — a GEMM that interleaves FP16 and INT4 lanes wrecks memory coalescing and needs custom kernels. AWQ's contribution is getting the accuracy of protecting those channels &lt;em&gt;without&lt;/em&gt; mixed precision.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does AWQ decide which weights to protect?
&lt;/h2&gt;

&lt;p&gt;By activation magnitude, measured on a calibration set — not by looking at the weights at all. This is the counterintuitive core of the paper (Lin et al., 2023). If you pick salient channels by weight magnitude, you get almost no benefit over RTN. If you pick them by the average magnitude of the &lt;em&gt;activations&lt;/em&gt; feeding each input channel, protecting even ~1% recovers most of the lost accuracy.&lt;/p&gt;

&lt;p&gt;The intuition: a large weight multiplied by a tiny activation contributes little; a modest weight multiplied by a persistent activation outlier contributes a lot. Error at the output is an activation-weighted quantity, so salience has to be defined in activation space.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Per-input-channel activation importance from a small calibration set.
# X_cal: [n_tokens, in_features] activations feeding this linear layer.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;channel_importance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X_cal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Tensor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Mean absolute activation per input channel; this ranks salience.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;X_cal&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; [in_features]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  How does AWQ protect channels without mixed precision?
&lt;/h2&gt;

&lt;p&gt;It rescales. For a salient input channel, multiply its weight column by a scale &lt;code&gt;s &amp;gt; 1&lt;/code&gt; and divide the corresponding activation by &lt;code&gt;s&lt;/code&gt;. The product &lt;code&gt;(w·s)·(x/s) = w·x&lt;/code&gt; is mathematically unchanged in full precision, but the quantization behaves differently.&lt;/p&gt;

&lt;p&gt;Here's why it helps. After scaling, the group's &lt;code&gt;Δ&lt;/code&gt; is set by &lt;code&gt;max(|w·s|)&lt;/code&gt;. If you scale only a few channels and the group maximum is dominated by other channels, &lt;code&gt;Δ&lt;/code&gt; barely moves. The quantized contribution of the scaled channel becomes &lt;code&gt;Δ · roundErr · (x/s)&lt;/code&gt; — the same &lt;code&gt;Δ&lt;/code&gt;, the same rounding error, but the activation term is now divided by &lt;code&gt;s&lt;/code&gt;. Net effect: that channel's output error shrinks by roughly &lt;code&gt;1/s&lt;/code&gt;. You've spent the model's fixed 4-bit budget disproportionately on the channels that matter.&lt;/p&gt;

&lt;p&gt;The trade-off is that pushing &lt;code&gt;s&lt;/code&gt; too high inflates &lt;code&gt;max(|w·s|)&lt;/code&gt;, which raises &lt;code&gt;Δ&lt;/code&gt; for the whole group and hurts everyone else. So &lt;code&gt;s&lt;/code&gt; is not free — it's a per-channel knob you tune.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# AWQ scale search: s = act_importance ** alpha, grid-search alpha.
# W: [out_features, in_features], sx: activation importance [in_features]
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_scale&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;X_cal&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quantize_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_grid&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;best_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
    &lt;span class="n"&gt;y_ref&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X_cal&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;                      &lt;span class="c1"&gt;# FP16 reference output
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_grid&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;alpha&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n_grid&lt;/span&gt;                   &lt;span class="c1"&gt;# 0 -&amp;gt; no scaling, 1 -&amp;gt; full
&lt;/span&gt;        &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1e-4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;alpha&lt;/span&gt;      &lt;span class="c1"&gt;# per-input-channel scale
&lt;/span&gt;        &lt;span class="n"&gt;Ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# scale weight columns up
&lt;/span&gt;        &lt;span class="n"&gt;Wq&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;quantize_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Ws&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;view&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# quantize, then fold s back
&lt;/span&gt;        &lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;X_cal&lt;/span&gt; &lt;span class="o"&gt;@&lt;/span&gt; &lt;span class="n"&gt;Wq&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;T&lt;/span&gt;
        &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;y_ref&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;best_err&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;best_err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;best_s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;best_s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth noting. First, &lt;code&gt;alpha&lt;/code&gt; in &lt;code&gt;[0, 1]&lt;/code&gt; interpolates between "don't scale" and "scale by full activation importance"; the optimum is usually interior, so the grid search matters. Second, the objective is plain output MSE against the FP16 reference — no gradients, no Hessians, no iterative reconstruction. One pass over a small calibration set per layer.&lt;/p&gt;

&lt;h2&gt;
  
  
  How is AWQ different from GPTQ?
&lt;/h2&gt;

&lt;p&gt;GPTQ is reconstruction-based; AWQ is scaling-based. GPTQ quantizes weights column by column and uses second-order (Hessian) information to update the &lt;em&gt;remaining&lt;/em&gt; unquantized weights so they compensate for the error already introduced. It's accurate but expensive, and because it fits weights to minimize error on the calibration set, it can overfit that set — quality on out-of-distribution or instruction-tuned workloads can degrade.&lt;/p&gt;

&lt;p&gt;AWQ never updates weights beyond a per-channel multiply. It searches for scales that minimize output error and stops. That has three consequences:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data efficiency.&lt;/strong&gt; It needs a small calibration set and is far less sensitive to what's in it, because a per-channel scalar can't memorize the calibration distribution the way full weight reconstruction can.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generalization.&lt;/strong&gt; The authors report it holds up across domains and on instruction-tuned models where reconstruction methods can wobble.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Simplicity and speed.&lt;/strong&gt; No Hessian inversion, no backward pass. Quantizing a model is fast, and the folded scales leave you with a clean INT4 weight matrix plus a per-channel scale vector — trivially kernel-friendly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither dominates universally. GPTQ's error compensation can edge ahead in some settings, and modern toolchains support both. But AWQ's robustness on real, instruction-following models is why it became a default for served 4-bit checkpoints.&lt;/p&gt;

&lt;h2&gt;
  
  
  What do you actually deploy — and where does it pay off?
&lt;/h2&gt;

&lt;p&gt;The common target is &lt;code&gt;w4a16&lt;/code&gt;: 4-bit weights, 16-bit activations, group size 128. Activations stay in FP16/BF16; only the weights are compressed. In practice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;awq&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoAWQForCausalLM&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;model_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your/instruct-model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoAWQForCausalLM&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model_path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;quant_config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zero_point&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_group_size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# scale/zero shared per 128 weights
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w_bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GEMM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# kernel variant
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;# Calibration is a few hundred short sequences — small and cheap.
&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;quant_config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;quant_config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_quantized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;your/instruct-model-awq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The win shows up during decode. Autoregressive generation is memory-bandwidth-bound — each token streams the entire weight matrix from HBM. Cut the weights from 16 bits to 4 and you move roughly a quarter of the bytes per token, which is why AWQ INT4 improves decode throughput and shrinks the model to fit smaller GPUs, not just saves disk. Group size 128 is the usual accuracy/overhead balance: finer groups (smaller &lt;code&gt;Δ&lt;/code&gt; per group) recover a bit more accuracy at the cost of storing more scales.&lt;/p&gt;

&lt;p&gt;Where it doesn't help: prefill of long prompts is compute-bound, so 4-bit weights don't speed that up much, and since activations stay FP16, AWQ alone doesn't shrink the KV cache — that's a separate axis (and a separate accuracy cliff). Pair AWQ weight quantization with a KV-cache strategy if long-context memory is your bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does AWQ keep 4-bit LLMs accurate?
&lt;/h2&gt;

&lt;p&gt;Yes — that's the point of activation-aware weight quantization. The reason 4-bit models usually degrade is that round-to-nearest spends its tiny bit budget uniformly, wasting precision on channels that barely affect the output while under-protecting the ~1% of channels that see activation outliers. AWQ identifies those salient channels by activation magnitude, scales them up before quantizing (and folds the inverse scale into the activation), and thereby cuts their output error by roughly &lt;code&gt;1/s&lt;/code&gt; without any mixed-precision storage. Because it only searches per-channel scales against an FP16 reference — no weight reconstruction — it stays cheap to compute and doesn't overfit the calibration set, which is why &lt;code&gt;w4a16&lt;/code&gt; AWQ checkpoints hold near-FP16 quality on real instruction-tuned models and ship as a default in &lt;code&gt;autoawq&lt;/code&gt; and vLLM.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>PagedAttention: Why Static Batching Wastes Your KV Cache</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 08 Jul 2026 19:33:35 +0000</pubDate>
      <link>https://dev.to/ji_ai/pagedattention-why-static-batching-wastes-your-kv-cache-3hdk</link>
      <guid>https://dev.to/ji_ai/pagedattention-why-static-batching-wastes-your-kv-cache-3hdk</guid>
      <description>&lt;p&gt;Take a single A100 running Llama-2-13B. Run it with naive batching and you might serve 8 concurrent requests before you OOM. Swap the serving engine for vLLM, same GPU, same model, same weights — now you serve 20+. Nothing about the math of attention changed. What changed is how the KV cache is &lt;em&gt;allocated&lt;/em&gt; in GPU memory. Old servers threw away most of it to fragmentation. &lt;strong&gt;PagedAttention&lt;/strong&gt; is the trick that stops the bleeding, and it's why your KV cache stops being the bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Naive LLM serving pre-reserves KV cache for each request's &lt;em&gt;maximum&lt;/em&gt; possible length, so a 30-token reply inside a 2048-token reservation wastes ~98% of that block. The vLLM paper measured 60–80% of KV memory lost to fragmentation and over-reservation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PagedAttention&lt;/strong&gt; treats the KV cache like OS virtual memory: split it into fixed-size blocks (default 16 tokens), allocate blocks on demand, and map logical → physical blocks through a per-sequence block table.&lt;/li&gt;
&lt;li&gt;This eliminates external fragmentation entirely and caps internal fragmentation at one partial block per sequence, so you fit far more concurrent sequences in the same VRAM.&lt;/li&gt;
&lt;li&gt;Shared prefixes (system prompts, parallel samples, beam search) share physical blocks via copy-on-write — one copy of the prompt KV, not N.&lt;/li&gt;
&lt;li&gt;Pair it with &lt;strong&gt;continuous batching&lt;/strong&gt; (iteration-level scheduling) and tune &lt;code&gt;gpu_memory_utilization&lt;/code&gt; and &lt;code&gt;block_size&lt;/code&gt;; that combination is where the throughput actually comes from.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does static KV cache allocation waste so much memory?
&lt;/h2&gt;

&lt;p&gt;Because it reserves for the worst case and frees nothing until the sequence ends. Every decode step, a transformer caches the key and value vectors for every token so it doesn't recompute attention over the whole prefix. That cache grows by one token's worth of K and V per step, per layer. The size is not small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;kv_bytes_per_token = 2 (K and V)
                   × num_layers
                   × num_kv_heads
                   × head_dim
                   × dtype_bytes

# Llama-2-13B, fp16, full multi-head attention:
# 2 × 40 × 40 × 128 × 2  ≈  819,200 bytes  ≈  0.8 MB per token
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At ~0.8 MB/token, a single 2048-token sequence needs about 1.6 GB of KV cache. That's the real constraint on concurrency — not FLOPs, memory.&lt;/p&gt;

&lt;p&gt;Now the failure. A traditional server (think early HuggingFace TGI-style or the request-level batching in the original Orca setup) doesn't know how long a reply will be, so it reserves a contiguous slab sized to &lt;code&gt;max_seq_len&lt;/code&gt; up front. Three things go wrong at once:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Internal fragmentation.&lt;/strong&gt; You reserve 2048 tokens, the model emits 30 and hits a stop token. The other 2018 slots are yours, allocated, and useless until the request completes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reservation waste.&lt;/strong&gt; Even tokens you &lt;em&gt;will&lt;/em&gt; generate are reserved before they exist, so they sit idle for most of the request's life.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External fragmentation.&lt;/strong&gt; Contiguous slabs of different sizes leave unusable gaps between them, exactly like &lt;code&gt;malloc&lt;/code&gt; fragmentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Add it up and the paper's number — 60–80% of KV memory wasted — stops sounding surprising. You bought 80 GB of HBM and you're using 20 of it for actual tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does PagedAttention actually do differently?
&lt;/h2&gt;

&lt;p&gt;It borrows virtual memory paging from operating systems. Instead of one contiguous KV region per sequence, the cache is carved into fixed-size &lt;strong&gt;blocks&lt;/strong&gt;, each holding a fixed number of tokens (vLLM's default &lt;code&gt;block_size&lt;/code&gt; is 16). A sequence's KV cache becomes a list of blocks that need not be contiguous in physical GPU memory. A &lt;strong&gt;block table&lt;/strong&gt; per sequence maps logical block numbers to physical block numbers — the exact analogue of a page table mapping virtual pages to physical frames.&lt;/p&gt;

&lt;p&gt;The consequences fall out directly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Blocks are allocated &lt;strong&gt;on demand&lt;/strong&gt;, one at a time as generation crosses a 16-token boundary. Nothing is reserved for tokens that don't exist yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;External fragmentation disappears.&lt;/strong&gt; All blocks are the same size, so any free block fits any sequence. There are no odd-sized gaps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Internal fragmentation is bounded.&lt;/strong&gt; The only waste is the unfilled tail of a sequence's last block — at most &lt;code&gt;block_size - 1&lt;/code&gt; tokens, so ≤15 tokens per sequence regardless of how long the sequence is.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The catch is that attention now reads K and V from physically scattered blocks, which a stock attention kernel can't do — it assumes contiguous memory. So PagedAttention ships a custom CUDA kernel that walks the block table and gathers KV per block during the attention computation. That kernel is the whole reason this is a serving-engine feature and not something you bolt on in Python.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does copy-on-write eliminate duplicate prompt KV cache?
&lt;/h2&gt;

&lt;p&gt;By letting multiple sequences point their block tables at the &lt;em&gt;same&lt;/em&gt; physical blocks until one of them writes. This is where paging pays a second dividend.&lt;/p&gt;

&lt;p&gt;Consider parallel sampling: one prompt, &lt;code&gt;n=4&lt;/code&gt; completions. The prompt's KV cache is identical for all four. Naive allocation stores it four times. With PagedAttention, all four sequences' block tables reference the same physical prompt blocks — one copy. When a sequence needs to diverge (it starts generating its own tokens into a shared block), the engine copies just that block and remaps the pointer. Copy-on-write, per block, exactly like &lt;code&gt;fork()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same mechanism covers beam search and shared system prompts across a batch. If every request in a batch begins with the same 800-token system prompt, you can store that prompt's KV once instead of once per request. For agent workloads that stuff huge identical preambles into every call, this is a large, free memory win — and it composes with prefix caching so the shared prefix isn't even recomputed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Isn't continuous batching the thing that actually boosts throughput?
&lt;/h2&gt;

&lt;p&gt;Yes — and this is the part people conflate with PagedAttention. They're two different ideas that vLLM combines. PagedAttention is about &lt;em&gt;memory layout&lt;/em&gt;. &lt;strong&gt;Continuous batching&lt;/strong&gt; (a.k.a. iteration-level scheduling, from the Orca paper) is about &lt;em&gt;scheduling&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;Static batching processes a batch to completion: 8 requests go in, the batch runs until every one finishes, and the whole batch is gated by the slowest, longest generation. A request that finishes at token 20 keeps its slot occupied while a neighbor grinds to token 2000.&lt;/p&gt;

&lt;p&gt;Continuous batching schedules at the granularity of a single decode iteration. After each forward step, finished sequences leave the batch and waiting requests join immediately. No sequence waits for the batch's slowest member. GPU utilization stays high because the batch is continuously topped up.&lt;/p&gt;

&lt;p&gt;These two features need each other. Continuous batching means sequences constantly enter and leave with wildly different lengths — which would shred a contiguous allocator with fragmentation. PagedAttention's uniform blocks make that churn cheap: a departing sequence frees blocks that any arriving sequence can immediately reuse. Paging makes fluid scheduling affordable; scheduling turns the freed memory into throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  What happens when the GPU runs out of KV blocks?
&lt;/h2&gt;

&lt;p&gt;The scheduler preempts. Because vLLM admits requests aggressively to keep the batch full, it can overcommit and hit zero free blocks mid-generation. It has two recovery strategies:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Recomputation.&lt;/strong&gt; Evict a sequence's KV blocks, and when it's rescheduled, recompute its KV cache from the tokens in one prefill pass. Cheap in memory, costs a recompute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Swapping.&lt;/strong&gt; Copy the evicted blocks to CPU RAM over PCIe, then copy back when rescheduled. No recompute, but you pay PCIe bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Both cost latency, so preemption is a signal you're running too hot. If you see it in logs, either lower concurrency or raise the memory ceiling. Which brings us to config.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I configure vLLM to use this well?
&lt;/h2&gt;

&lt;p&gt;The two knobs that matter most are &lt;code&gt;gpu_memory_utilization&lt;/code&gt; and &lt;code&gt;block_size&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;vllm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;LLM&lt;/span&gt;

&lt;span class="n"&gt;llm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;LLM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meta-llama/Llama-2-13b-hf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Fraction of VRAM vLLM may claim. Default 0.9.
&lt;/span&gt;    &lt;span class="c1"&gt;# Everything left after weights + activations becomes the KV block pool.
&lt;/span&gt;    &lt;span class="n"&gt;gpu_memory_utilization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.92&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="c1"&gt;# Tokens per KV block. Default 16.
&lt;/span&gt;    &lt;span class="c1"&gt;# Smaller  -&amp;gt; less tail waste, larger block tables, more kernel overhead.
&lt;/span&gt;    &lt;span class="c1"&gt;# Larger   -&amp;gt; more internal fragmentation, simpler bookkeeping.
&lt;/span&gt;    &lt;span class="n"&gt;block_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_num_seqs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;256&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;          &lt;span class="c1"&gt;# cap on concurrent sequences
&lt;/span&gt;    &lt;span class="n"&gt;enable_prefix_caching&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt; &lt;span class="c1"&gt;# reuse shared prompt KV across requests
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Same thing for the OpenAI-compatible server&lt;/span&gt;
vllm serve meta-llama/Llama-2-13b-hf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.92 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--block-size&lt;/span&gt; 16 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enable-prefix-caching&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Practical guidance:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;gpu_memory_utilization&lt;/code&gt;&lt;/strong&gt; decides how big the block pool is. vLLM loads weights, measures activation peak, then hands the rest to the KV pool. Push it toward 0.90–0.95 for max concurrency; back off if you see CUDA OOM during activation spikes on long prompts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;block_size&lt;/code&gt;&lt;/strong&gt; trades tail waste against overhead. The default 16 is right for most workloads. Larger blocks help kernel efficiency on very long sequences at the cost of more internal fragmentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;enable_prefix_caching&lt;/code&gt;&lt;/strong&gt; is close to free money whenever requests share a prefix — system prompts, few-shot preambles, RAG templates. It reuses both the memory (via shared blocks) and the compute (skips prefill on the shared span).&lt;/li&gt;
&lt;li&gt;Watch the logs for preemptions and low &lt;strong&gt;KV cache usage %&lt;/strong&gt;. Preemptions mean overcommit; chronically low usage means you can raise &lt;code&gt;max_num_seqs&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The direct answer
&lt;/h2&gt;

&lt;p&gt;Static LLM serving wastes your KV cache because it pre-reserves a contiguous block sized to each request's maximum length, then frees nothing until the request ends — losing 60–80% of KV memory to internal, reservation, and external fragmentation. &lt;strong&gt;PagedAttention&lt;/strong&gt; fixes this by paging the KV cache into fixed-size blocks (default 16 tokens) mapped through per-sequence block tables, so memory is allocated on demand, external fragmentation vanishes, internal fragmentation is capped at one partial block per sequence, and identical prefixes share physical blocks via copy-on-write. Combine it with continuous batching, set &lt;code&gt;gpu_memory_utilization&lt;/code&gt; near 0.9, enable prefix caching, and the same GPU serves roughly 2x the concurrent requests — not because attention got faster, but because you stopped throwing your KV cache away.&lt;/p&gt;

</description>
    </item>
  </channel>
</rss>
