<?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 Repetition Penalty Breaks JSON and Code Generation</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Tue, 04 Aug 2026 21:15:05 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-repetition-penalty-breaks-json-and-code-generation-4bbk</link>
      <guid>https://dev.to/ji_ai/why-repetition-penalty-breaks-json-and-code-generation-4bbk</guid>
      <description>&lt;p&gt;A batch job of mine emitted arrays of 200 objects. The first ~30 were perfect. Then &lt;code&gt;user_id&lt;/code&gt; became &lt;code&gt;userId&lt;/code&gt;. Then &lt;code&gt;userld&lt;/code&gt;. Then an object closed with &lt;code&gt;'&lt;/code&gt; instead of &lt;code&gt;"&lt;/code&gt; and the whole parse died. Same model, same prompt, same seed family — the corruption always started &lt;em&gt;deep&lt;/em&gt; into the array and got worse monotonically.&lt;/p&gt;

&lt;p&gt;The cause was one line copied from a chat preset: &lt;code&gt;frequency_penalty=0.3&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Repetition penalty is the single most misapplied sampling parameter in production LLM serving. It was designed to stop degenerate loops in open-ended prose, and in structured output it does something close to the opposite of what you want: it applies its strongest downward pressure to the tokens your grammar &lt;em&gt;requires&lt;/em&gt; you to repeat.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repetition penalty scales with token count, and structural tokens have the highest counts.&lt;/strong&gt; In JSON, &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; and repeated field names dominate the output distribution — so they absorb almost all the penalty. Content tokens, which appear once, absorb almost none.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;frequency_penalty&lt;/code&gt; is unbounded.&lt;/strong&gt; OpenAI-style penalties subtract &lt;code&gt;frequency_penalty * count&lt;/code&gt; from the logit. After 60 quote characters at &lt;code&gt;0.3&lt;/code&gt;, that's an &lt;strong&gt;18-logit&lt;/strong&gt; subtraction — an effective ban on &lt;code&gt;"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Low temperature amplifies it.&lt;/strong&gt; Penalties run on raw logits &lt;em&gt;before&lt;/em&gt; temperature, so a penalty of δ changes the probability ratio by &lt;code&gt;exp(-δ/T)&lt;/code&gt;. At &lt;code&gt;temperature=0.2&lt;/code&gt;, a 0.5 penalty behaves like a 2.5 penalty. Structured-output configs use low temperature, which is exactly where the penalty bites hardest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HF &lt;code&gt;repetition_penalty&lt;/code&gt; is multiplicative and asymmetric&lt;/strong&gt; (&lt;code&gt;score/p&lt;/code&gt; if positive, &lt;code&gt;score*p&lt;/code&gt; if negative) and by default includes &lt;strong&gt;prompt tokens&lt;/strong&gt; — so your few-shot examples and inline schema are pre-penalized before generation starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix:&lt;/strong&gt; set &lt;code&gt;frequency_penalty=0&lt;/code&gt;, &lt;code&gt;presence_penalty=0&lt;/code&gt;, &lt;code&gt;repetition_penalty=1.0&lt;/code&gt; for any JSON, code, or tool-call output. Kill real loops with a DRY sampler or a streaming n-gram detector instead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does repetition penalty break JSON and code generation?
&lt;/h2&gt;

&lt;p&gt;Because the penalty's ranking of "most repetitive tokens" is nearly identical to the ranking of "most syntactically mandatory tokens."&lt;/p&gt;

&lt;p&gt;Token frequency in structured output is brutally Zipfian, and the head of that distribution is pure syntax. Serialize an array of objects with six fields and the token &lt;code&gt;"&lt;/code&gt; appears roughly &lt;code&gt;12 × n&lt;/code&gt; times for &lt;code&gt;n&lt;/code&gt; elements. Every field name repeats &lt;code&gt;n&lt;/code&gt; times. Indentation tokens in Python repeat once per line. &lt;code&gt;self&lt;/code&gt;, &lt;code&gt;=&lt;/code&gt;, &lt;code&gt;(&lt;/code&gt;, &lt;code&gt;)&lt;/code&gt;, &lt;code&gt;return&lt;/code&gt; dominate a class body.&lt;/p&gt;

&lt;p&gt;Meanwhile the actual content — the values you care about — appears once or twice each and receives essentially zero penalty.&lt;/p&gt;

&lt;p&gt;So the penalty gradient points away from syntax and toward novelty. The model, forced to pick &lt;em&gt;something&lt;/em&gt;, picks the nearest unpenalized neighbor of the token it wanted:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"&lt;/code&gt; → &lt;code&gt;'&lt;/code&gt; or a Unicode smart quote&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;user_id&lt;/code&gt; → &lt;code&gt;userId&lt;/code&gt; → &lt;code&gt;userID&lt;/code&gt; → &lt;code&gt;usr_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;four-space indent → three-space or two-space indent&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;}&lt;/code&gt; → nothing, and the object never closes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are hallucinations in the usual sense. They're the arithmetic consequence of subtracting a growing constant from the correct token's logit.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do the two penalty formulas actually differ?
&lt;/h2&gt;

&lt;p&gt;They differ in a way that matters: one is additive and unbounded, the other is multiplicative and confidence-proportional.&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 (also vLLM's presence_penalty / frequency_penalty).
# Additive, applied over *output* tokens only. Unbounded in count.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;openai_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;counts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;presence&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;span class="n"&gt;frequency&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;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;frequency&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;counts&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;presence&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counts&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="c1"&gt;# HuggingFace-style repetition_penalty (Keskar et al., CTRL).
# Multiplicative and ASYMMETRIC. Applied over prompt + output tokens.
&lt;/span&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hf_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;seen_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;penalty&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.1&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;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;seen_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;seen_ids&lt;/span&gt;&lt;span class="p"&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;where&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;&amp;lt;&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;s&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;penalty&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;penalty&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;logits&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The additive form has no ceiling.&lt;/strong&gt; &lt;code&gt;frequency_penalty=0.3&lt;/code&gt; sounds tiny. It is tiny at count 1. At count 60 it is a 18-nat subtraction, which in a softmax is a factor of &lt;code&gt;e^-18&lt;/code&gt; ≈ 1.5e-8. The quote character is gone. Longer output = stronger corruption, which is exactly the "gets worse deep into the array" signature.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The multiplicative form is confidence-proportional&lt;/strong&gt;, which is subtler and arguably nastier. Dividing a logit by 1.1 removes 9% of its magnitude — so a token the model is &lt;em&gt;certain&lt;/em&gt; about (logit 20 → 18.2) loses 1.8 nats, while a marginal token (logit 2 → 1.8) loses 0.2. The penalty is strongest precisely where the model is most confident, which in structured output is always the mandatory next character.&lt;/p&gt;

&lt;p&gt;The asymmetry compounds it: negative logits get &lt;em&gt;multiplied&lt;/em&gt; by the penalty, pushing them further down. A token with logit −5 becomes −5.5. So the operation isn't a uniform shift in log-space at all; it's a stretch away from zero in both directions, and its effect depends on where the model's logit scale happens to sit — which varies by layer norm, by model, and by temperature calibration.&lt;/p&gt;

&lt;p&gt;Also note the scope difference. In vLLM, &lt;code&gt;presence_penalty&lt;/code&gt; and &lt;code&gt;frequency_penalty&lt;/code&gt; count &lt;strong&gt;output&lt;/strong&gt; tokens; &lt;code&gt;repetition_penalty&lt;/code&gt; counts &lt;strong&gt;prompt + output&lt;/strong&gt; tokens. In HF &lt;code&gt;generate()&lt;/code&gt;, &lt;code&gt;repetition_penalty&lt;/code&gt; applies to everything in &lt;code&gt;input_ids&lt;/code&gt;. If your prompt contains the JSON schema, a few-shot example, or retrieved documents that mention your field names, those field names start the generation already penalized.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does low temperature make repetition penalty worse?
&lt;/h2&gt;

&lt;p&gt;Because penalties are applied to raw logits before the temperature warper, so temperature divides the penalty too — and dividing by a number less than 1 makes it bigger.&lt;/p&gt;

&lt;p&gt;In both HF &lt;code&gt;LogitsProcessorList&lt;/code&gt; and vLLM's sampler, the order is: penalties → temperature → top-k/top-p. The final probability ratio between a penalized token &lt;em&gt;a&lt;/em&gt; and an unpenalized competitor &lt;em&gt;b&lt;/em&gt; is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;p_a / p_b  =  exp( (z_a - z_b) / T ) * exp( -δ / T )
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The penalty's contribution is &lt;code&gt;exp(-δ/T)&lt;/code&gt;. At &lt;code&gt;T=1.0&lt;/code&gt;, &lt;code&gt;δ=0.5&lt;/code&gt; is a 1.6× handicap. At &lt;code&gt;T=0.2&lt;/code&gt; — the sort of value everyone uses for extraction and codegen — the same &lt;code&gt;δ=0.5&lt;/code&gt; is a &lt;strong&gt;12×&lt;/strong&gt; handicap. Low temperature does not "stabilize" the output against penalties; it multiplies their effect by &lt;code&gt;1/T&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;There's a second-order effect too. Because penalties run &lt;em&gt;before&lt;/em&gt; nucleus truncation, a heavily penalized token can fall out of the top-p mass entirely. Once it's masked, it has probability zero. Raising temperature afterward cannot bring it back — the token isn't merely improbable, it's been removed from the candidate set.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which failure modes should I look for?
&lt;/h2&gt;

&lt;p&gt;Four signatures, in rough order of how often they show up:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Key drift in repeated objects.&lt;/strong&gt; The same schema field rendered differently across array elements — &lt;code&gt;user_id&lt;/code&gt; / &lt;code&gt;userId&lt;/code&gt; / &lt;code&gt;user id&lt;/code&gt;. Distinctive because early elements are fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quote and delimiter substitution.&lt;/strong&gt; &lt;code&gt;'&lt;/code&gt; or &lt;code&gt;’&lt;/code&gt; in place of &lt;code&gt;"&lt;/code&gt;; missing closing &lt;code&gt;}&lt;/code&gt; or &lt;code&gt;]&lt;/code&gt;. Produces hard parse failures at the tail of long outputs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Indentation collapse in code.&lt;/strong&gt; Python that starts at 4 spaces and drifts to 2, or a &lt;code&gt;for&lt;/code&gt; body that silently dedents. The whitespace token has the highest count of any token in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Premature EOS.&lt;/strong&gt; Every legal continuation is penalized; EOS has appeared zero times and so is penalized zero. Its &lt;em&gt;relative&lt;/em&gt; probability rises with output length. Truncated-but-syntactically-plausible output is the hardest version of this to catch.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you're debugging a "the model gets worse the longer it writes" report, check the penalty parameters before you touch the prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does structured output or constrained decoding fix it?
&lt;/h2&gt;

&lt;p&gt;No — it hides it, and that's worse.&lt;/p&gt;

&lt;p&gt;Grammar-constrained decoding (outlines, XGrammar, llguidance, &lt;code&gt;response_format: json_schema&lt;/code&gt;) masks all tokens that would violate the schema, then samples from what's left. So the penalty can no longer produce invalid JSON. What it can still do is bias &lt;em&gt;which valid token&lt;/em&gt; wins:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Enum values drift toward whichever member hasn't been emitted yet.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;additionalProperties&lt;/code&gt; or a union of key names, the model picks the unused key.&lt;/li&gt;
&lt;li&gt;Numeric digits get skewed — digits are individually low-count and the mask leaves several legal, so the penalty tilts the choice between them.&lt;/li&gt;
&lt;li&gt;With optional fields, the mask permits closing the object early, and every non-EOS continuation is penalized. Fields go missing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You get 100% parse success and quietly wrong values. That failure mode survives every schema-validation test you have.&lt;/p&gt;

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

&lt;p&gt;Turn it off for structured output, and attack real loops with something that understands &lt;em&gt;sequences&lt;/em&gt; rather than &lt;em&gt;counts&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The DRY sampler&lt;/strong&gt; (llama.cpp, exllamav2, text-generation-webui) penalizes based on the length of the verbatim suffix the model is about to extend, not on raw token counts:&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="n"&gt;penalty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;multiplier&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;match_length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;allowed_length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It only fires when the model is genuinely about to repeat a long span, and it takes &lt;em&gt;sequence breakers&lt;/em&gt; (newline, &lt;code&gt;"&lt;/code&gt;, &lt;code&gt;:&lt;/code&gt;, &lt;code&gt;,&lt;/code&gt;) that reset matching at structural boundaries. Typical settings: &lt;code&gt;dry_multiplier=0.8&lt;/code&gt;, &lt;code&gt;dry_base=1.75&lt;/code&gt;, &lt;code&gt;dry_allowed_length=2&lt;/code&gt;. For code, raise &lt;code&gt;dry_allowed_length&lt;/code&gt; to 4–6 — &lt;code&gt;for i in range(&lt;/code&gt; is a legitimate repeat.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Or detect loops outside the sampler.&lt;/strong&gt; This is what I ship, because it's deterministic and doesn't perturb the distribution at all:&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;loop_detected&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;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;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;Stop when any n-gram of characters repeats `threshold` times.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;4000&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;grams&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="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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tail&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;i&lt;/span&gt; &lt;span class="o"&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;grams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;grams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&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="mi"&gt;1&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;grams&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;g&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;threshold&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on the streaming buffer, abort, and retry with a nudged prompt. A retry costs less than a corrupted 200-object payload downstream.&lt;/p&gt;

&lt;p&gt;Note also that &lt;strong&gt;Anthropic's API doesn't expose frequency or presence penalties at all&lt;/strong&gt; — Claude Opus 4.x and Sonnet 4.x take &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, and &lt;code&gt;top_k&lt;/code&gt;. So this class of bug is confined to OpenAI-compatible endpoints, vLLM/SGLang/TGI deployments, and local runtimes. The risk there is that gateways, framework defaults, and copy-pasted chat presets inject nonzero penalties without you noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's the safe configuration?
&lt;/h2&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;SamplingParams&lt;/span&gt;

&lt;span class="c1"&gt;# Structured output / codegen: penalties OFF, no exceptions.
&lt;/span&gt;&lt;span class="n"&gt;structured&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SamplingParams&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;span class="n"&gt;top_p&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="n"&gt;repetition_penalty&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;# multiplicative — 1.0 is the no-op
&lt;/span&gt;    &lt;span class="n"&gt;frequency_penalty&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;span class="c1"&gt;# additive — 0.0 is the no-op
&lt;/span&gt;    &lt;span class="n"&gt;presence_penalty&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;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Open-ended prose, if you must. Presence over frequency: bounded, count-independent.
&lt;/span&gt;&lt;span class="n"&gt;prose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SamplingParams&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="n"&gt;top_p&lt;/span&gt;&lt;span class="o"&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="n"&gt;presence_penalty&lt;/span&gt;&lt;span class="o"&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;# fires once per token, then stops
&lt;/span&gt;    &lt;span class="n"&gt;frequency_penalty&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;span class="c1"&gt;# grows without bound — leave it alone
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two assertions worth putting in CI: any request whose &lt;code&gt;response_format&lt;/code&gt; is a JSON schema, or whose route is tagged codegen, must have all three penalty knobs at their no-op values. And log the effective sampling params per request — most of these incidents trace back to a default set three layers up the stack by someone configuring a chat UI.&lt;/p&gt;

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

&lt;p&gt;Repetition penalty breaks JSON and code generation because it penalizes tokens by how often they've appeared, and in structured output the most frequent tokens are the mandatory ones: quotes, braces, commas, repeated field names, indentation. OpenAI-style &lt;code&gt;frequency_penalty&lt;/code&gt; subtracts &lt;code&gt;penalty × count&lt;/code&gt; with no ceiling, so corruption grows with output length; HuggingFace-style &lt;code&gt;repetition_penalty&lt;/code&gt; scales with logit magnitude, so it hits the model's most confident predictions hardest and also covers prompt tokens by default. Because penalties are applied before the temperature warper, a low &lt;code&gt;temperature&lt;/code&gt; multiplies their effect by &lt;code&gt;1/T&lt;/code&gt;. Constrained decoding masks the syntax errors but converts them into silently wrong enum values, skewed numbers, and dropped optional fields. Set &lt;code&gt;repetition_penalty=1.0&lt;/code&gt;, &lt;code&gt;frequency_penalty=0.0&lt;/code&gt;, and &lt;code&gt;presence_penalty=0.0&lt;/code&gt; for every structured or code output path, and handle genuine degenerate loops with a DRY sampler or a streaming n-gram detector instead.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>RoPE Scaling: Why Raising rope_theta Breaks Short Context</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 03 Aug 2026 21:12:23 +0000</pubDate>
      <link>https://dev.to/ji_ai/rope-scaling-why-raising-ropetheta-breaks-short-context-gbb</link>
      <guid>https://dev.to/ji_ai/rope-scaling-why-raising-ropetheta-breaks-short-context-gbb</guid>
      <description>&lt;p&gt;You edit one line in &lt;code&gt;config.json&lt;/code&gt; — &lt;code&gt;rope_theta: 10000.0&lt;/code&gt; becomes &lt;code&gt;1000000.0&lt;/code&gt; — restart vLLM with &lt;code&gt;--max-model-len 131072&lt;/code&gt;, and your needle-in-a-haystack test at 64k goes green. Then your function-calling eval, which averages 900 tokens per prompt, drops a couple of points. Nothing else changed. No weights moved. No sampling params changed.&lt;/p&gt;

&lt;p&gt;That is not noise, and it is not a bug in your serving stack. RoPE scaling buys long-range positional coverage by spending short-range positional resolution, and the exchange rate is computable from the config you just edited.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;RoPE gives dimension pair &lt;code&gt;i&lt;/code&gt; of each head a wavelength &lt;code&gt;λ_i = 2π · base^(2i/d)&lt;/code&gt;. Raising &lt;code&gt;rope_theta&lt;/code&gt; stretches every wavelength except &lt;code&gt;i=0&lt;/code&gt;, and stretches the high-index dims the most.&lt;/li&gt;
&lt;li&gt;Stretching wavelengths moves whole bands of dimensions out of the range where they can discriminate nearby positions. With &lt;code&gt;d=128&lt;/code&gt;, going from base 1e4 to 1e6 drops the number of dimension pairs with sub-2048-token wavelength from 40 to 27 — a third of your short-range positional bandwidth, gone.&lt;/li&gt;
&lt;li&gt;Linear position interpolation (dividing positions by &lt;code&gt;s&lt;/code&gt;) is worse for short context: it compresses the high-frequency dims that encode adjacency.&lt;/li&gt;
&lt;li&gt;YaRN (NTK-by-parts) fixes this by only interpolating the dims that never completed enough rotations during training, leaving the fast dims alone, plus an attention temperature of &lt;code&gt;0.1·ln(s) + 1&lt;/code&gt; to re-sharpen logits.&lt;/li&gt;
&lt;li&gt;Static YaRN applies the scaling to every request, including your 400-token ones. Enable it only when you actually need the length, or use dynamic scaling and accept that it complicates prefix-cache reuse.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does rope_theta actually control?
&lt;/h2&gt;

&lt;p&gt;Rotary position embedding splits each head's &lt;code&gt;d&lt;/code&gt;-dimensional query and key into &lt;code&gt;d/2&lt;/code&gt; 2-D pairs and rotates pair &lt;code&gt;i&lt;/code&gt; at position &lt;code&gt;m&lt;/code&gt; by angle &lt;code&gt;m · θ_i&lt;/code&gt;, where &lt;code&gt;θ_i = base^(-2i/d)&lt;/code&gt;. The dot product between a query at &lt;code&gt;m&lt;/code&gt; and a key at &lt;code&gt;n&lt;/code&gt; then depends only on &lt;code&gt;m - n&lt;/code&gt;. That relative property is why RoPE extrapolates at all.&lt;/p&gt;

&lt;p&gt;The useful way to read that formula is as a wavelength per dimension pair: &lt;code&gt;λ_i = 2π / θ_i = 2π · base^(2i/d)&lt;/code&gt;. With &lt;code&gt;d = 128&lt;/code&gt; and &lt;code&gt;base = 10000&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;dim pair&lt;/th&gt;
&lt;th&gt;wavelength (tokens)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;6.3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;32&lt;/td&gt;
&lt;td&gt;628&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;td&gt;6,283&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;63&lt;/td&gt;
&lt;td&gt;~57,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Each head carries a bank of positional "clocks" spanning four orders of magnitude. Fast dims resolve adjacency — which of these two tokens came first. Slow dims resolve document-scale position — beginning versus middle. A dim is only informative over roughly half a wavelength; past that it wraps and starts aliasing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does raising rope_theta break short-context accuracy?
&lt;/h2&gt;

&lt;p&gt;Because base scaling is not uniform, and the dims it hurts are the ones doing mid-range work.&lt;/p&gt;

&lt;p&gt;Note that &lt;code&gt;λ_0 = 2π · base^0 = 2π&lt;/code&gt; regardless of base. The very fastest dim is untouched. The multiplier on &lt;code&gt;λ_i&lt;/code&gt; is &lt;code&gt;(new/old)^(2i/d)&lt;/code&gt;, so it grows with &lt;code&gt;i&lt;/code&gt;. Going from 1e4 to 1e6 at &lt;code&gt;d=128&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;dim 16: 63 → 199 tokens (3.2×)&lt;/li&gt;
&lt;li&gt;dim 32: 628 → 6,283 tokens (10×)&lt;/li&gt;
&lt;li&gt;dim 48: 6,283 → 198,700 tokens (31.6×)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dim 32 used to be the clock for "roughly where in this 600-token span are we." Now it takes 6,000 tokens to complete a rotation, so inside a 900-token prompt it barely moves. Its contribution to the attention logit is nearly constant across the whole prompt — a bias, not a signal.&lt;/p&gt;

&lt;p&gt;Count the dims that still complete a full rotation inside 2048 tokens. Solve &lt;code&gt;2π · base^(i/64) &amp;lt; 2048&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;base 1e4: &lt;code&gt;i &amp;lt; 40&lt;/code&gt; → 40 of 64 pairs&lt;/li&gt;
&lt;li&gt;base 1e6: &lt;code&gt;i &amp;lt; 27&lt;/code&gt; → 27 of 64 pairs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You deleted a third of the positional bandwidth available at typical prompt lengths, and the model's attention heads were trained against the old allocation. The heads that learned to read dim 32-40 for paragraph-level ordering now read a nearly-DC signal. That is the mechanism behind the eval drop, and it shows up hardest on tasks with position-sensitive structure: multi-item ordering, "the third tool result", diff-style comparisons, long system prompts with numbered rules.&lt;/p&gt;

&lt;p&gt;Fine-tuning after the base change largely repairs this — the model reallocates which dims it trusts. Llama 3.1 shipping with &lt;code&gt;rope_theta = 500000&lt;/code&gt; and Qwen with &lt;code&gt;1e6&lt;/code&gt; are trained that way, not patched at inference time. What does not work is editing the number on a checkpoint trained at 10000 and expecting short prompts to behave.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't linear position interpolation fix it either?
&lt;/h2&gt;

&lt;p&gt;Position interpolation divides positions by the scale factor &lt;code&gt;s&lt;/code&gt; before rotation, so a 32k sequence maps into the 8k range the model saw in training. Every angle shrinks by &lt;code&gt;s&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That is strictly worse for short context than base scaling. Base scaling leaves the fast dims alone; PI squeezes them the most in relative terms. Adjacent tokens that were separated by a rotation of &lt;code&gt;2π/6.3&lt;/code&gt; now differ by &lt;code&gt;2π/(6.3·s)&lt;/code&gt;. At &lt;code&gt;s=4&lt;/code&gt; the model must resolve token order from a quarter of the angular separation it trained on. PI works, but only with fine-tuning, and it is why naive PI reports degraded local-detail behavior.&lt;/p&gt;

&lt;p&gt;NTK-aware scaling was the first fix: instead of scaling positions, scale the base so that the highest-frequency dims are almost untouched and only the slow dims get effectively interpolated. Better zero-shot, but it is still a blunt instrument — every dim is adjusted by a smooth function of &lt;code&gt;i&lt;/code&gt;, including the ones that needed nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does YaRN do differently?
&lt;/h2&gt;

&lt;p&gt;YaRN makes the interpolate-or-extrapolate decision per dimension, based on how many rotations that dim completed inside the original training context.&lt;/p&gt;

&lt;p&gt;Define &lt;code&gt;r_i = L_orig / λ_i&lt;/code&gt;, the rotation count. If &lt;code&gt;r_i &amp;gt; β&lt;/code&gt; (default 32), the dim has seen many full periods during training; it can extrapolate safely, so leave it alone. If &lt;code&gt;r_i &amp;lt; α&lt;/code&gt; (default 1), the dim never completed even one rotation, so every long-context position is genuinely out of distribution; interpolate it fully by &lt;code&gt;s&lt;/code&gt;. Between them, ramp linearly.&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;math&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;yarn_inv_freq&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;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;10000.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;L_orig&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8192&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                  &lt;span class="n"&gt;beta_fast&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;beta_slow&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Per-dimension-pair inverse frequencies under YaRN (NTK-by-parts).&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;exps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&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;dim&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;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;extrap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exps&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;           &lt;span class="c1"&gt;# untouched
&lt;/span&gt;    &lt;span class="n"&gt;interp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;exps&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;  &lt;span class="c1"&gt;# stretched by s
&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dim_at_rotations&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="c1"&gt;# index whose wavelength completes exactly r rotations in L_orig
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;dim&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;L_orig&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="mi"&gt;2&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="n"&gt;pi&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="mi"&gt;2&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

    &lt;span class="n"&gt;low&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;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dim_at_rotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beta_fast&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# fast side
&lt;/span&gt;    &lt;span class="n"&gt;high&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;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;dim_at_rotations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;beta_slow&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# slow side
&lt;/span&gt;
    &lt;span class="n"&gt;out&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;)&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="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;extrap&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;interp&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;ramp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;low&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;high&lt;/span&gt; &lt;span class="nf"&gt;else &lt;/span&gt;&lt;span class="p"&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;low&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;high&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;low&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;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;*&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;ramp&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;ramp&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;out&lt;/span&gt;

&lt;span class="c1"&gt;# attention temperature: multiply cos/sin tables by this
&lt;/span&gt;&lt;span class="n"&gt;attn_factor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.1&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;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt;   &lt;span class="c1"&gt;# ~1.139 at s=4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run the boundaries for &lt;code&gt;d=128, base=1e4, L_orig=8192&lt;/code&gt;: &lt;code&gt;low = 25&lt;/code&gt;, &lt;code&gt;high = 50&lt;/code&gt;. Dims 0-25 are left exactly as trained. Dims 50-63 are fully interpolated. Only the 24 dims in between get a blend. Your adjacency clocks survive untouched — that is the whole trick.&lt;/p&gt;

&lt;p&gt;The second half of YaRN is the attention temperature. Longer sequences mean more keys competing in the softmax, which raises attention entropy and flattens the distribution. YaRN counters it by scaling logits, implemented for free by multiplying the cos/sin tables by &lt;code&gt;sqrt(1/t) = 0.1·ln(s) + 1&lt;/code&gt;. At &lt;code&gt;s=4&lt;/code&gt; that is a 1.139× sharpening. It costs nothing at runtime and it is the difference between "works" and "works well" in the paper's perplexity numbers.&lt;/p&gt;

&lt;p&gt;Config side, HF and vLLM take the same shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rope_scaling"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"rope_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"yarn"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"factor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;4.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"original_max_position_embeddings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;32768&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"beta_fast"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"beta_slow"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max_position_embeddings"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;131072&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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;vllm serve Qwen/Qwen3-8B &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 131072 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--rope-scaling&lt;/span&gt; &lt;span class="s1"&gt;'{"rope_type":"yarn","factor":4.0,"original_max_position_embeddings":32768}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Does static YaRN hurt short prompts too?
&lt;/h2&gt;

&lt;p&gt;Yes, and this is the part people miss. Static YaRN applies the same interpolation to every request. A 400-token prompt gets its slow dims compressed by 4× for no reason — you paid the long-context tax on traffic that never needed it. Qwen's own docs say as much: turn YaRN on only when long inputs are actually expected.&lt;/p&gt;

&lt;p&gt;Dynamic scaling computes &lt;code&gt;s = max(1, L_current / L_orig)&lt;/code&gt; per sequence, so short prompts run the identity transform. The cost is that the frequency table changes as a sequence grows, meaning keys already in the cache were rotated under a different &lt;code&gt;s&lt;/code&gt; than new ones. Implementations paper over this in various ways, but it complicates clean prefix-cache reuse, which is exactly the optimization you want on long prompts. If you serve mixed traffic, the cheap answer is two deployments: an unscaled one for the short high-QPS path, a YaRN one for the long path, routed on token count.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I test whether my RoPE change hurt short context?
&lt;/h2&gt;

&lt;p&gt;Needle tests are not enough — they measure retrieval, not positional resolution, and they only probe the long regime. Bucket your eval by prompt length and report deltas per bucket, with 0-2k as its own line. Then add at least one task that requires ordering rather than presence: reproduce the k-th item from a list, identify which of two near-identical spans came first, or apply numbered rules from a long system prompt in order. Those are the tasks that read the mid-frequency dims you just stretched.&lt;/p&gt;

&lt;p&gt;For hosted models — Claude Opus 4.x, Sonnet 4.x, GPT-5.x — none of this is a knob you own. The provider ships one trained configuration. The corollary still applies though: keep prompts as short as the task allows, because you are always somewhere on this trade-off curve, whoever set the dial.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does raising rope_theta break short-context accuracy?
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;rope_theta&lt;/code&gt; sets a bank of positional wavelengths, &lt;code&gt;λ_i = 2π · base^(2i/d)&lt;/code&gt;, and raising it stretches the mid and high dims far more than the fast ones. Dimensions that used to resolve position within a few hundred tokens now need thousands, so inside an ordinary prompt they emit a near-constant signal instead of a positional one — at &lt;code&gt;d=128&lt;/code&gt;, base 1e4 → 1e6 cuts the dims with sub-2048-token wavelength from 40 to 27. The attention heads were trained against the old allocation and were never retrained for the new one. Fine-tuning at the new base fixes it; an inference-time config edit does not. If you need the length without retraining, use YaRN, which interpolates only the dims that never completed a full rotation during training and leaves the adjacency clocks alone — and turn it on per request rather than globally.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Claude Prompt Caching: Why cache_read_input_tokens Stays 0</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:11:03 +0000</pubDate>
      <link>https://dev.to/ji_ai/claude-prompt-caching-why-cachereadinputtokens-stays-0-2c7d</link>
      <guid>https://dev.to/ji_ai/claude-prompt-caching-why-cachereadinputtokens-stays-0-2c7d</guid>
      <description>&lt;p&gt;A support agent I reviewed ran 40-turn conversations against Claude Opus 4.8 with a 12,000-token system prompt and eight tool definitions. Every request carried &lt;code&gt;cache_control&lt;/code&gt;. The team had "enabled prompt caching" three months earlier and moved on.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;cache_read_input_tokens&lt;/code&gt; was &lt;code&gt;0&lt;/code&gt; on every single request. Every turn re-billed the full 12K prefix at full price, plus a 1.25x cache-write premium on top. They were paying roughly 25% &lt;em&gt;more&lt;/em&gt; than if they had never touched caching at all.&lt;/p&gt;

&lt;p&gt;The cause was a single line: &lt;code&gt;f"Current date: {datetime.now():%Y-%m-%d %H:%M}"&lt;/code&gt; in the system prompt header. Claude prompt caching is a byte-exact prefix match, and that timestamp sat at byte 200 of a 12,000-token prefix.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Claude prompt caching is a byte-exact prefix match.&lt;/strong&gt; Render order is &lt;code&gt;tools&lt;/code&gt; → &lt;code&gt;system&lt;/code&gt; → &lt;code&gt;messages&lt;/code&gt;. Any byte change at position N invalidates every cache breakpoint at position ≥ N. A timestamp in your system prompt makes the whole request uncacheable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidation is tiered, not all-or-nothing.&lt;/strong&gt; Changing &lt;code&gt;tool_choice&lt;/code&gt;, toggling &lt;code&gt;thinking&lt;/code&gt;, or adding an image preserves the tools+system cache. Only tool-definition edits and model switches force a full rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Breakpoints look back at most 20 content blocks.&lt;/strong&gt; An agentic turn that appends more than 20 tool_use/tool_result blocks silently misses the previous turn's cache. Place an intermediate breakpoint roughly every 15 blocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The minimum cacheable prefix is model-dependent and not monotonic&lt;/strong&gt; — 512 tokens on Claude Opus 5, but 4096 on Opus 4.6 and Haiku 4.5. Below it you get no error, just &lt;code&gt;cache_creation_input_tokens: 0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reads cost ~0.1x base input; writes cost 1.25x (5-minute TTL) or 2x (1-hour TTL).&lt;/strong&gt; Five-minute TTL breaks even at two requests; one-hour needs three.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why is cache_read_input_tokens zero?
&lt;/h2&gt;

&lt;p&gt;Because something in your prefix changed between requests, and the cache key is derived from the exact rendered bytes up to each breakpoint.&lt;/p&gt;

&lt;p&gt;The API renders your request in a fixed order: &lt;code&gt;tools&lt;/code&gt;, then &lt;code&gt;system&lt;/code&gt;, then &lt;code&gt;messages&lt;/code&gt;. A &lt;code&gt;cache_control&lt;/code&gt; marker on the last system block therefore caches tools &lt;em&gt;and&lt;/em&gt; system together. It does not cache them independently — there is one linear byte stream, and the breakpoint is a position in it.&lt;/p&gt;

&lt;p&gt;This makes the design question simple: &lt;strong&gt;does your prompt-building code emit stable content strictly before volatile content?&lt;/strong&gt; Everything else is detail.&lt;/p&gt;

&lt;p&gt;Grep your prompt assembly path for these. Each one silently kills caching:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pattern&lt;/th&gt;
&lt;th&gt;Why it breaks&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datetime.now()&lt;/code&gt; / &lt;code&gt;Date.now()&lt;/code&gt; in system prompt&lt;/td&gt;
&lt;td&gt;Prefix differs every request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;uuid4()&lt;/code&gt; or request IDs early in content&lt;/td&gt;
&lt;td&gt;Every request is a unique prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;json.dumps(d)&lt;/code&gt; without &lt;code&gt;sort_keys=True&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Non-deterministic key order → different bytes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Iterating a &lt;code&gt;set&lt;/code&gt; to build tool descriptions&lt;/td&gt;
&lt;td&gt;Non-deterministic ordering&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tools=build_tools(user)&lt;/code&gt; varying per user&lt;/td&gt;
&lt;td&gt;Tools render at position 0; nothing caches across users&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;if flag: system += "..."&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Each flag combination is a distinct prefix&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix is always the same shape: make it deterministic, move it after the last breakpoint, or delete it.&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;json&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_msg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;session_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;history&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;return&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;claude-opus-5&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;max_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;16000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="c1"&gt;# Position 0. Sorted, frozen, identical across every user and session.
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;tools&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="k"&gt;lambda&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;t&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;system&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="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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="c1"&gt;# No f-strings. No dates. No user IDs. Byte-identical forever.
&lt;/span&gt;                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;FROZEN_SYSTEM_PROMPT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&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;ephemeral&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;history&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="p"&gt;[&lt;/span&gt;
                    &lt;span class="c1"&gt;# Volatile content lives here, AFTER the breakpoint.
&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;text&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;context&amp;gt;&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;session_ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/context&amp;gt;&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;text&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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;user_msg&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="p"&gt;],&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify with the response, not by inspection:&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="n"&gt;u&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&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="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache miss: read=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_read_input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;write=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cache_creation_input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; uncached=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;input_tokens&lt;/span&gt;&lt;span class="si"&gt;}&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;One trap in reading &lt;code&gt;usage&lt;/code&gt;: &lt;strong&gt;&lt;code&gt;input_tokens&lt;/code&gt; is the uncached remainder only.&lt;/strong&gt; Total prompt size is &lt;code&gt;input_tokens + cache_creation_input_tokens + cache_read_input_tokens&lt;/code&gt;. If your agent ran for an hour and &lt;code&gt;input_tokens&lt;/code&gt; shows 4K, that is not the whole prompt — check the sum.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually invalidates a Claude prompt cache?
&lt;/h2&gt;

&lt;p&gt;Not everything. The API has three cache tiers, and a change only invalidates its own tier and everything below it. This is the part most teams get wrong in the conservative direction — they avoid harmless per-request changes and then break the cache with something structural.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Tools cache&lt;/th&gt;
&lt;th&gt;System cache&lt;/th&gt;
&lt;th&gt;Messages cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tool definitions (add/remove/reorder)&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model switch&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;speed&lt;/code&gt;, web-search or citations toggle&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;System prompt content&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;tool_choice&lt;/code&gt;, images, &lt;code&gt;thinking&lt;/code&gt; on/off&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Message content&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;td&gt;❌&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two practical consequences:&lt;/p&gt;

&lt;p&gt;You can flip &lt;code&gt;tool_choice&lt;/code&gt; per request or toggle &lt;code&gt;thinking&lt;/code&gt; without losing the tools+system cache. Don't build elaborate machinery to keep those stable.&lt;/p&gt;

&lt;p&gt;You cannot swap the tool set for "modes." A mode switch that adds three tools and removes two invalidates position 0 and re-bills the entire conversation. If you need dynamic capability, use tool search (schemas are &lt;em&gt;appended&lt;/em&gt;, preserving the prefix) rather than rebuilding &lt;code&gt;tools&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same logic applies to sub-agents and compaction calls. A fork that rebuilds &lt;code&gt;system&lt;/code&gt;/&lt;code&gt;tools&lt;/code&gt;/&lt;code&gt;model&lt;/code&gt; with any difference from its parent misses the parent's cache entirely. Copy the parent's three fields verbatim and append fork-specific content at the end.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two of these rows have an escape hatch&lt;/strong&gt;, and they're gated separately. For system-prompt changes: on Claude Opus 5, Opus 4.8, Fable 5, and Mythos 5, append a &lt;code&gt;{"role": "system", "content": "..."}&lt;/code&gt; message to &lt;code&gt;messages[]&lt;/code&gt; instead of editing top-level &lt;code&gt;system&lt;/code&gt;. No beta header required. The instruction lands &lt;em&gt;after&lt;/em&gt; the cached history, so the prefix survives, and unlike a &lt;code&gt;&amp;lt;system-reminder&amp;gt;&lt;/code&gt; stuffed into a user turn, it's a non-spoofable operator channel.&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="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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;history&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;user_msg&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;Terse mode enabled — keep responses under 40 words.&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Constraints: it must follow a user message (or an assistant message ending in server-tool use), it can't be &lt;code&gt;messages[0]&lt;/code&gt;, and it must be last or followed by an assistant turn. Unsupported models — including Claude Sonnet 5 — return a 400; catch it and fall back to a user-turn reminder block.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does my agent lose the cache after a long tool-calling turn?
&lt;/h2&gt;

&lt;p&gt;Because each breakpoint walks backward &lt;strong&gt;at most 20 content blocks&lt;/strong&gt; looking for a prior cache entry. Beyond that, it stops looking and writes a fresh entry.&lt;/p&gt;

&lt;p&gt;This is the failure mode that survives every other fix, and it's specific to agentic loops. A single assistant turn that fires twelve parallel tool calls produces twelve &lt;code&gt;tool_use&lt;/code&gt; blocks plus twelve &lt;code&gt;tool_result&lt;/code&gt; blocks — 24 blocks in one turn. The next request's breakpoint scans back 20, never reaches the previously cached position, and misses. No error, no warning, just a cache-write charge where you expected a read.&lt;/p&gt;

&lt;p&gt;The fix is cheap: place an intermediate breakpoint roughly every 15 blocks in long turns. You get four breakpoints per request, so budget them — one on the frozen tools+system prefix, and up to three floating through recent history.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't a 3,000-token prompt cache at all?
&lt;/h2&gt;

&lt;p&gt;Because the minimum cacheable prefix is model-dependent, and &lt;strong&gt;it isn't monotonic across generations&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Minimum prefix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Opus 5, Fable 5, Mythos 5&lt;/td&gt;
&lt;td&gt;512 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.8, Sonnet 5, Sonnet 4.6, Sonnet 4.5&lt;/td&gt;
&lt;td&gt;1024 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.7, Haiku 3.5&lt;/td&gt;
&lt;td&gt;2048 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Opus 4.6, Opus 4.5, Haiku 4.5&lt;/td&gt;
&lt;td&gt;4096 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A 3,000-token prompt caches fine on Claude Opus 5, Opus 4.8, and Sonnet 5 — and silently doesn't on Opus 4.6 or Haiku 4.5. There's no error. &lt;code&gt;cache_creation_input_tokens&lt;/code&gt; comes back &lt;code&gt;0&lt;/code&gt; and you conclude caching is broken.&lt;/p&gt;

&lt;p&gt;Claude Opus 5 halved the Opus 4.8 minimum from 1024 to 512, so prompts you previously wrote off as uncacheable are worth re-checking with no code change. These minimums now apply on every platform where the model is available — the old Bedrock override for Fable 5 was removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Should I use the 1-hour TTL?
&lt;/h2&gt;

&lt;p&gt;Only if traffic is bursty with gaps longer than five minutes. The doubled write cost is real.&lt;/p&gt;

&lt;p&gt;Cache reads cost about &lt;strong&gt;0.1x&lt;/strong&gt; the base input price. Cache writes cost &lt;strong&gt;1.25x for the default 5-minute TTL&lt;/strong&gt; and &lt;strong&gt;2x for the 1-hour TTL&lt;/strong&gt; (&lt;code&gt;{"type": "ephemeral", "ttl": "1h"}&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Run the break-even:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;5-minute TTL:&lt;/strong&gt; 1.25x write + 0.1x read = 1.35x for two requests, versus 2.0x uncached. Profitable at request two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1-hour TTL:&lt;/strong&gt; 2.0x write + 0.1x + 0.1x = 2.2x for three requests, versus 3.0x uncached. Needs three.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If requests arrive more often than every five minutes, the default TTL is kept warm by real traffic and the 1-hour option is pure overpayment. Reach for it when you have long idle gaps you'd otherwise re-warm across.&lt;/p&gt;

&lt;p&gt;On pre-warming: send a &lt;code&gt;max_tokens: 0&lt;/code&gt; request at startup. The API runs prefill, writes the cache at your breakpoint, and returns immediately with &lt;code&gt;content: []&lt;/code&gt;, &lt;code&gt;stop_reason: "max_tokens"&lt;/code&gt;, and zero output tokens billed. Put the &lt;code&gt;cache_control&lt;/code&gt; on the block shared with the real request — the system prompt — not on the placeholder user message, and not via top-level auto-caching, which would key the entry to the placeholder. It's rejected with &lt;code&gt;stream: true&lt;/code&gt;, &lt;code&gt;thinking.type: "enabled"&lt;/code&gt;, &lt;code&gt;output_config.format&lt;/code&gt;, forced &lt;code&gt;tool_choice&lt;/code&gt;, and inside Batches.&lt;/p&gt;

&lt;p&gt;Skip pre-warming when traffic is continuous, the prefix is small, or you'd be speculatively warming many distinct prefixes at 1.25x each.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do parallel requests all miss the cache?
&lt;/h2&gt;

&lt;p&gt;Because a cache entry becomes readable only once the first response &lt;strong&gt;begins streaming&lt;/strong&gt;. Fire N identical-prefix requests simultaneously and all N pay full price — none can read what the others are still writing.&lt;/p&gt;

&lt;p&gt;For fan-out over a shared document or shared few-shot block: send one request, await the &lt;em&gt;first streamed token&lt;/em&gt; (not the full response), then release the remaining N−1. They'll read the entry the first one just wrote. On a 50K-token shared prefix with 20 parallel branches, that ordering is the difference between 20 full-price prefills and one.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;cache_read_input_tokens&lt;/code&gt; stays at zero because Claude prompt caching matches the exact bytes of your rendered prefix — &lt;code&gt;tools&lt;/code&gt;, then &lt;code&gt;system&lt;/code&gt;, then &lt;code&gt;messages&lt;/code&gt; — and a single differing byte at position N invalidates every breakpoint after it. In practice the culprit is one of five things: volatile content (a timestamp, UUID, or unsorted JSON) sitting ahead of your breakpoint; a tool set or model that changes mid-conversation; an agentic turn appending more than 20 content blocks and blowing past the lookback window; a prefix below the model's minimum (512 to 4096 tokens depending on the model); or parallel requests racing to write the same entry. Diff the rendered prompt bytes between two consecutive requests and the answer shows up in seconds. Then assert on &lt;code&gt;cache_read_input_tokens&lt;/code&gt; in your integration tests, because this failure is silent and it costs 1.35x forever.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Attention Sinks: Why Evicting Token 0 Wrecks Sliding-Window KV</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 02 Aug 2026 21:07:51 +0000</pubDate>
      <link>https://dev.to/ji_ai/attention-sinks-why-evicting-token-0-wrecks-sliding-window-kv-3f94</link>
      <guid>https://dev.to/ji_ai/attention-sinks-why-evicting-token-0-wrecks-sliding-window-kv-3f94</guid>
      <description>&lt;p&gt;You ship a KV cache eviction policy: keep the last 4k tokens, drop the rest. Perplexity on your 3k-token eval set is unchanged. You roll it out, and long sessions start producing fluent nonsense — grammatical, on-topic-ish, completely detached from the conversation. The degradation begins exactly when the sliding window passes the start of the sequence.&lt;/p&gt;

&lt;p&gt;You didn't hit a context-length limit. You evicted the model's &lt;strong&gt;attention sinks&lt;/strong&gt;, and the softmax had nowhere to put the mass it is mathematically obligated to spend.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Softmax attention weights must sum to 1, so a head with nothing relevant to read still has to distribute full attention mass somewhere. Trained transformers dump that mass on the first few tokens, whose value vectors have near-zero norm — a learned no-op.&lt;/li&gt;
&lt;li&gt;Drop those tokens from the KV cache and the mass gets redistributed onto real tokens with real value vectors, injecting spurious content into the residual stream. Perplexity blows up within a few hundred tokens.&lt;/li&gt;
&lt;li&gt;Keeping just &lt;strong&gt;4 initial tokens&lt;/strong&gt; plus a rolling window restores stable streaming quality (StreamingLLM, Xiao et al. 2023). The fix is cheap; the failure is catastrophic.&lt;/li&gt;
&lt;li&gt;Position IDs must be assigned by &lt;strong&gt;cache index, not original text index&lt;/strong&gt;. Get this wrong and RoPE sees a hole where the evicted tokens were.&lt;/li&gt;
&lt;li&gt;Sink tokens carry massive activations, so they also break per-tensor KV quantization, H2O-style importance eviction, and any agent loop that trims the top of its own transcript.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What are attention sinks in a transformer?
&lt;/h2&gt;

&lt;p&gt;An attention sink is a token position that soaks up a large fraction of attention probability across many heads and layers, regardless of what the query is asking about. In practice it's the first token of the sequence — BOS if you use one, or whatever token happens to be at index 0 — plus the two or three positions after it.&lt;/p&gt;

&lt;p&gt;This is not a bug in training. It's the only way a softmax head can express "nothing here is relevant."&lt;/p&gt;

&lt;p&gt;Consider one head, one query. Scores &lt;code&gt;s_i = q·k_i / sqrt(d)&lt;/code&gt;, weights &lt;code&gt;a = softmax(s)&lt;/code&gt;, output &lt;code&gt;o = Σ a_i v_i&lt;/code&gt;. There is no &lt;code&gt;a_i = 0 for all i&lt;/code&gt; state. The head must spend its full unit of probability mass. If the honest answer for this query is "skip this operation," the head needs a target whose value vector is approximately zero — a token it can attend to &lt;em&gt;hard&lt;/em&gt; while contributing nothing to the residual stream.&lt;/p&gt;

&lt;p&gt;Trained models converge on the same solution: make &lt;code&gt;v_0 ≈ 0&lt;/code&gt;, make &lt;code&gt;k_0&lt;/code&gt; easy to score highly, and route all idle mass there. Measure it and you'll see heads in mid-to-late layers putting the majority of their attention on the first handful of positions on most queries.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does dropping token 0 break sliding-window attention?
&lt;/h2&gt;

&lt;p&gt;Because the remaining weights get rescaled by &lt;code&gt;1 / (1 - a_sink)&lt;/code&gt;, and everything left in the window has a non-trivial value vector.&lt;/p&gt;

&lt;p&gt;Say a head puts 0.80 of its mass on the sink and spreads 0.20 over 4,000 real tokens. Evict the sink and renormalize: every real token's weight is multiplied by &lt;strong&gt;5x&lt;/strong&gt;. The head that was supposed to be a no-op now writes a 5x-amplified average of whatever happens to sit in the window into the residual stream.&lt;/p&gt;

&lt;p&gt;That output feeds the next layer's queries and keys. The corruption compounds depth-wise, then step-wise as the poisoned states get cached. The output stays locally fluent — the language modeling head still produces well-formed tokens — while the actual retrieval and instruction-following signal drowns.&lt;/p&gt;

&lt;p&gt;The tell is that quality collapses at a &lt;em&gt;position&lt;/em&gt;, not at a &lt;em&gt;length&lt;/em&gt;. A 40k-token session with a 4k window fails; a 4k-token session with a 4k window is fine. Same model, same window, same prompt style.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do sinks land on the first tokens specifically?
&lt;/h2&gt;

&lt;p&gt;Causal masking. Position 0 is the only position visible to every subsequent query in every layer. If the model needs a globally reachable dump site, that's the only candidate that always exists.&lt;/p&gt;

&lt;p&gt;Two consequences follow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sinks are positional, not semantic.&lt;/strong&gt; Cut the first sentence off your prompt and the &lt;em&gt;new&lt;/em&gt; first token becomes the sink. The model doesn't care that it's &lt;code&gt;"The"&lt;/code&gt; instead of &lt;code&gt;&amp;lt;|begin_of_text|&amp;gt;&lt;/code&gt; — but the KV entry it learned to lean on is gone, and a fresh prefill has to rebuild the role from a token that wasn't trained for it. Fresh prefill mostly recovers; mid-stream eviction does not, because the surviving cache entries were computed against the original sink.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sinks carry massive activations.&lt;/strong&gt; The hidden states at sink positions have norms orders of magnitude above the rest of the sequence. This is the same phenomenon that forces outlier handling in activation quantization, and it means a sink token is the single most expensive thing in your cache to quantize naively.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you evict KV cache without losing the sink?
&lt;/h2&gt;

&lt;p&gt;Reserve the first N entries permanently, roll the rest, and re-derive positions from cache slot index. N = 4 is the standard choice.&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;class&lt;/span&gt; &lt;span class="nc"&gt;SinkKVCache&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Rolling KV cache that pins the first `n_sink` tokens.

    Positions are re-derived from cache slot index, not the original
    text index. RoPE must see a contiguous 0..len-1 run, otherwise the
    evicted span shows up as a gap in relative distance.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_sink&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;window&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4096&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;n_sink&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;window&lt;/span&gt;
        &lt;span class="n"&gt;self&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="bp"&gt;None&lt;/span&gt;   &lt;span class="c1"&gt;# [B, H_kv, S, D]
&lt;/span&gt;        &lt;span class="n"&gt;self&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="bp"&gt;None&lt;/span&gt;

    &lt;span class="k"&gt;def&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;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k_new&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_new&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&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;k_new&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;k&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&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;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&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;k_new&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;self&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;v_new&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&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;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&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;v_new&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;cap&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;window&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;self&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;shape&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;keep_tail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cap&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt;
            &lt;span class="n"&gt;self&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self&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="p"&gt;:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;keep_tail&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;self&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cat&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;self&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="p"&gt;:,&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;n_sink&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;self&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="p"&gt;:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;keep_tail&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;query_position&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="c1"&gt;# The next query's position = current cache length, NOT the
&lt;/span&gt;        &lt;span class="c1"&gt;# number of tokens actually generated so far.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&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;shape&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;p&gt;The &lt;code&gt;query_position&lt;/code&gt; detail is the part people get wrong. If you evicted 30k tokens and keep using absolute text positions, RoPE rotates the incoming query as if it were 34k positions away from the sink while the surviving neighbors sit at 30k+. Every relative distance in the window is now wrong, and you're extrapolating far past the trained range for no reason.&lt;/p&gt;

&lt;p&gt;Two other requirements that bite in real serving code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cache keys with rotation already applied are not relocatable.&lt;/strong&gt; If you store post-RoPE keys (most implementations do), you cannot re-index them after eviction without un-rotating. Either store pre-rotation keys for the tail, or accept that only &lt;em&gt;contiguous suffix&lt;/em&gt; eviction is safe. Pinning a prefix plus a contiguous suffix satisfies this; arbitrary token-level eviction does not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Paged/block allocators need a pinned block.&lt;/strong&gt; In a block-based KV allocator, the sink lives in block 0. Make it non-evictable explicitly, or your LRU policy will reclaim it precisely because nothing "recently wrote" to it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I check whether my model has attention sinks?
&lt;/h2&gt;

&lt;p&gt;Read the attention weights directly. One forward pass with &lt;code&gt;output_attentions=True&lt;/code&gt; is enough to see the structure.&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;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;AutoModelForCausalLM&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;AutoTokenizer&lt;/span&gt;

&lt;span class="n"&gt;name&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-8B&lt;/span&gt;&lt;span class="sh"&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;name&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;AutoModelForCausalLM&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;torch_dtype&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="n"&gt;bfloat16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;device_map&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;attn_implementation&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eager&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# required to get attention probs back
&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;The mitochondrion is the powerhouse of the cell. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
          &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&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="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;with&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;no_grad&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;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_attentions&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;for&lt;/span&gt; &lt;span class="n"&gt;layer_idx&lt;/span&gt; &lt;span class="ow"&gt;in&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="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;24&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;31&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;attn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attentions&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;layer_idx&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;# [H, S, S]
&lt;/span&gt;    &lt;span class="n"&gt;last_q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;attn&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="p"&gt;:]&lt;/span&gt;                   &lt;span class="c1"&gt;# final query row per head
&lt;/span&gt;    &lt;span class="n"&gt;sink_mass&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;last_q&lt;/span&gt;&lt;span class="p"&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="nf"&gt;sum&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;# mass on first 4 positions
&lt;/span&gt;    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;L&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;layer_idx&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  median sink mass &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sink_mass&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
          &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sink_mass&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="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  heads&amp;gt;0.5: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sink_mass&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mf"&gt;0.5&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="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="si"&gt;}&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;Layer 0 is usually clean. From roughly the second layer onward you'll see a large share of heads with most of their final-row mass parked on the first four positions, on a prompt with zero informational reason to look there. That's your sink budget. Also print the value-vector norms at those positions — they'll be conspicuously small relative to the sequence median, which is the "no-op" half of the mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where else do attention sinks bite?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Importance-based eviction.&lt;/strong&gt; H2O-style "heavy hitter" policies score tokens by accumulated attention and usually retain the sink for free, since it's the heaviest hitter by construction. Policies that score by &lt;em&gt;recency&lt;/em&gt;, &lt;em&gt;semantic salience&lt;/em&gt;, or embedding similarity will happily throw it away. If you built a custom eviction heuristic, check that the sink survives it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV cache quantization.&lt;/strong&gt; Sink keys and values sit far outside the distribution of the rest of the cache. Per-tensor scales get dragged toward the outlier and everything else loses precision. Keep the pinned sink entries in fp16/bf16 — it's 4 tokens, the memory cost rounds to zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trained-in sliding windows are different.&lt;/strong&gt; Models trained with a rolling buffer from the start (Mistral-style sliding window attention, or interleaved local/global layer stacks) learned to operate without a permanently visible token 0 in their local layers. Retrofitting a window onto a model trained with full attention is where this fails. Don't assume the architecture rescues you; check the training config.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Learned sink logits.&lt;/strong&gt; Several recent open-weight models, including OpenAI's gpt-oss family, add a learned per-head scalar to the softmax denominator — an explicit "attend to nothing" slot that costs no KV entry. If your model has it, the head has a real no-op and the positional-sink pressure drops. Make sure your inference stack actually implements that extra denominator term; silently ignoring it renormalizes every attention distribution in the model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted APIs are not exposed to this.&lt;/strong&gt; With Claude Opus 4.x or GPT-5.x you don't own the KV cache, and every request re-prefills whatever prefix you send, so a token 0 always exists. The analogous cost when you trim the top of an agent transcript is prefix-cache invalidation and lost instructions, not sink loss. Different problem, different fix — don't apply this article's reasoning to your API context-window trimmer.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So why does evicting token 0 wreck sliding-window attention?
&lt;/h2&gt;

&lt;p&gt;Because softmax attention weights are forced to sum to 1, and trained transformers solve the "nothing here is relevant" case by dumping that mandatory mass onto the first few tokens, whose value vectors are near zero. Those positions are attention sinks: high attention, no content. Evict them mid-stream and the same mass is redistributed onto real tokens with real value vectors — scaled up by &lt;code&gt;1/(1 - a_sink)&lt;/code&gt;, often a 5x amplification — which writes garbage into the residual stream and compounds across layers and decode steps. Pin the first 4 KV entries, roll the rest, assign positions by cache slot index rather than original text index, and keep those pinned entries out of your quantizer. The fix costs 4 tokens of cache; skipping it costs the whole session.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why temperature=0 Still Gives Different Answers: Batch Invariance</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sun, 02 Aug 2026 09:05:45 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-temperature0-still-gives-different-answers-batch-invariance-49h</link>
      <guid>https://dev.to/ji_ai/why-temperature0-still-gives-different-answers-batch-invariance-49h</guid>
      <description>&lt;p&gt;Set &lt;code&gt;temperature=0&lt;/code&gt;, send the same prompt twice, get two different answers. Most engineers blame "GPU nondeterminism" and move on. That explanation is wrong, and being wrong about it means you'll never fix it.&lt;/p&gt;

&lt;p&gt;The real cause is &lt;strong&gt;batch invariance&lt;/strong&gt; — or rather, the lack of it. Your request's logits depend on what &lt;em&gt;other requests&lt;/em&gt; happened to be in the same batch, because the kernel picked a different reduction order for a different batch shape. Nothing about your request changed. The floating-point arithmetic did.&lt;/p&gt;

&lt;p&gt;This is now visible at the API layer too: on Claude Opus 5, Sonnet 5, and Opus 4.7/4.8, &lt;code&gt;temperature&lt;/code&gt; is no longer an accepted parameter at all — sending it returns a 400. Anthropic's own migration guidance says the quiet part out loud: &lt;code&gt;temperature = 0&lt;/code&gt; never guaranteed identical outputs on prior models either.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Greedy decoding is deterministic; your serving stack is not.&lt;/strong&gt; &lt;code&gt;argmax&lt;/code&gt; over identical logits always returns the same token. The logits aren't identical run to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Floating-point addition is non-associative.&lt;/strong&gt; &lt;code&gt;(a+b)+c != a+(b+c)&lt;/code&gt; in fp16/bf16. Every matmul, RMSNorm, and softmax is a reduction, and the summation order is chosen by kernel heuristics keyed to &lt;em&gt;batch shape&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Continuous batching makes batch shape depend on server load.&lt;/strong&gt; Your request gets batched with whatever else arrived that millisecond, so the reduction tree — and the last bits of every logit — changes with unrelated traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A 1e-6 logit perturbation is harmless until two tokens are nearly tied.&lt;/strong&gt; Then argmax flips, and autoregressive feedback amplifies one flipped token into a completely different answer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The fix is batch-invariant kernels&lt;/strong&gt; (fixed split sizes, no atomics, one config per op) at a real throughput cost — or, for hosted APIs, accepting nondeterminism and testing semantically instead of byte-wise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does temperature=0 still produce different outputs?
&lt;/h2&gt;

&lt;p&gt;Because "deterministic sampling" and "deterministic inference" are different claims. &lt;code&gt;temperature=0&lt;/code&gt; makes token selection a pure function of the logit vector. It says nothing about whether the logit vector is reproducible.&lt;/p&gt;

&lt;p&gt;Consider what the forward pass actually is: a very long chain of reductions. A single logit is a dot product over the hidden dimension. RMSNorm sums squares across the row. Attention normalizes over the KV sequence. Every one of these sums thousands of floating-point values.&lt;/p&gt;

&lt;p&gt;And floating-point addition is not associative:&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;x&lt;/span&gt; &lt;span class="o"&gt;=&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;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="o"&gt;*&lt;/span&gt; &lt;span class="mf"&gt;0.02&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;seq&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;seq&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt;                     &lt;span class="c1"&gt;# strictly sequential
&lt;/span&gt;
&lt;span class="n"&gt;tree4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reshape&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="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="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;dtype&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;float32&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;dtype&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;float32&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tree8&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reshape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&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="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;dtype&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;float32&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;dtype&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;float32&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;seq&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tree4&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;tree8&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;4-way vs 8-way differ:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tree4&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;tree8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same numbers, same values, three answers differing in the last few bits. The only thing that changed was how the sum was partitioned. That partition is exactly what a GPU kernel picks at launch time.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is batch invariance in LLM inference?
&lt;/h2&gt;

&lt;p&gt;A kernel is &lt;strong&gt;batch-invariant&lt;/strong&gt; if the result computed for a given row is bitwise identical regardless of what other rows are in the batch. Most production LLM kernels are not.&lt;/p&gt;

&lt;p&gt;The reason is performance tuning. To saturate a GPU, a matmul kernel splits work across streaming multiprocessors. When the batch is small, there aren't enough output tiles to fill the device, so the kernel splits along the &lt;em&gt;reduction&lt;/em&gt; dimension instead (split-K) and combines partial sums afterward. Larger batches don't need the split. Which strategy runs — and how many splits — is chosen by a heuristic or an autotuner reading the tensor shapes.&lt;/p&gt;

&lt;p&gt;Same for attention decode kernels: FlashDecoding-style implementations partition the KV cache into chunks, run online softmax per chunk, then rescale and merge. The number of KV splits is commonly derived from batch size, head count, and sequence length so the kernel fills all SMs. Different batch → different split count → different merge order → different last bits.&lt;/p&gt;

&lt;p&gt;Note what's &lt;em&gt;not&lt;/em&gt; on this list: race conditions. Individual kernels are typically run-to-run deterministic for fixed inputs and fixed launch config. Atomics-based split-K reductions are a real second source of nondeterminism, but they're the smaller problem. The dominant one is that your batch composition is set by other people's traffic.&lt;/p&gt;

&lt;p&gt;Additional shape-dependent sources worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Prefix caching.&lt;/strong&gt; A cache hit reuses KV computed under a different chunking than a cache miss recomputes. Same prompt, different numerics, before you've sampled a single token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunked prefill boundaries.&lt;/strong&gt; Where a long prompt gets sliced depends on scheduler state.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tensor parallelism degree.&lt;/strong&gt; A different TP size means a different all-reduce tree.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MoE routing.&lt;/strong&gt; Expert assignment and per-expert batching shift with batch composition, which is why MoE models feel noticeably flakier than dense ones.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How does a 1e-6 logit difference change the whole answer?
&lt;/h2&gt;

&lt;p&gt;It usually doesn't. Argmax is robust when the top-1 margin is comfortable. The failure mode is narrow but structural.&lt;/p&gt;

&lt;p&gt;Define the margin at step &lt;em&gt;t&lt;/em&gt; as &lt;code&gt;logit[top1] - logit[top2]&lt;/code&gt;. Numerical noise on the order of 1e-6 relative flips the decision only when the margin falls below that noise floor. On confident tokens — punctuation, the second half of a word, a memorized fact — the margin is enormous. On genuinely uncertain tokens — a synonym choice, a formatting decision, whether to open a code block — the margin can be near zero.&lt;/p&gt;

&lt;p&gt;Two properties make this bite in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ties are not rare over long outputs.&lt;/strong&gt; A per-token flip probability of even 0.1% compounds to roughly a 40% chance of at least one divergence across 500 tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divergence is absorbing.&lt;/strong&gt; One different token becomes part of the context for every subsequent token. There's no reconvergence mechanism. A single flipped connective can send an agent down a different tool call.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That's the whole story: a last-bit arithmetic difference, amplified by argmax at a near-tie, amplified again by autoregressive feedback.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I measure nondeterminism in my own stack?
&lt;/h2&gt;

&lt;p&gt;Don't argue about it — quantify it. The metric you want is &lt;strong&gt;divergence rate under load&lt;/strong&gt;, plus the index of first divergence. This probe works against any endpoint, hosted or self-hosted:&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;asyncio&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&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;prompt&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="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&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;messages&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="n"&gt;model&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;512&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="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;prompt&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&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;text&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;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;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;probe&lt;/span&gt;&lt;span class="p"&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;prompt&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="n"&gt;n&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="n"&gt;concurrency&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;sem&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;concurrency&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;guarded&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;sem&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;one&lt;/span&gt;&lt;span class="p"&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;prompt&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="n"&gt;outs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&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="nf"&gt;guarded&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;counts&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="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()[:&lt;/span&gt;&lt;span class="mi"&gt;12&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;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;outs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;base&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;outs&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;firsts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;i&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="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="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="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;o&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;a&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;b&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;outs&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="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;firsts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;firsts&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unique outputs : &lt;/span&gt;&lt;span class="si"&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;counts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;}&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;modal share    : &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;counts&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="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="n"&gt;n&lt;/span&gt;&lt;span class="si"&gt;:&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="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&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;firsts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;firsts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sort&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first divergence char (min/median): &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;firsts&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="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; / &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;firsts&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;firsts&lt;/span&gt;&lt;span class="p"&gt;)&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="si"&gt;}&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;Run it twice: once at &lt;code&gt;concurrency=1&lt;/code&gt;, once at &lt;code&gt;concurrency=32&lt;/code&gt;. If unique-output count climbs with concurrency, you have a batch-invariance problem, not a sampling problem. On a self-hosted stack, also run it with prefix caching on and off — the delta tells you how much of your instability is cache-boundary numerics.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I make LLM inference reproducible?
&lt;/h2&gt;

&lt;p&gt;If you control the server, you have three tiers of fix, in increasing order of cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 1 — pin the shape.&lt;/strong&gt; Batch size 1, one replica, fixed TP degree, prefix caching off, CUDA graphs off. This is what your reproducibility test suite should run against, not production.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vllm serve &amp;lt;model&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-num-seqs&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--no-enable-prefix-caching&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--enforce-eager&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Throughput will be terrible. That's fine — this configuration exists to make regression diffs meaningful, not to serve users.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 2 — batch-invariant kernels.&lt;/strong&gt; Replace the shape-sensitive ops with versions whose reduction structure is fixed. The core rule is to make the split a function of a &lt;em&gt;constant chunk size&lt;/em&gt; rather than a &lt;em&gt;target split count&lt;/em&gt;: process the reduction dimension in fixed 256-element chunks and combine in a fixed order, so a row's reduction tree is identical whether it ships alone or with 63 neighbors. Concretely that means data-parallel RMSNorm (one block per row, no cross-block reduce), a single matmul tile configuration with no atomic split-K, and an attention kernel with a fixed KV chunk size that treats cached and freshly-computed KV identically.&lt;/p&gt;

&lt;p&gt;Thinking Machines published this analysis and a &lt;code&gt;batch_invariant_ops&lt;/code&gt; library in late 2025; vLLM has since grown a batch-invariant mode behind an environment flag (check your build — the flag name and coverage have moved). The honest tradeoff: the naive deterministic path was substantially slower, and an optimized attention kernel recovered much of the gap without fully closing it. You are trading throughput for bitwise reproducibility. Buy it deliberately, for eval and RL-rollout paths, not for your whole fleet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tier 3 — accept it, and change what you assert.&lt;/strong&gt; Which brings us to hosted APIs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about hosted APIs like Claude and GPT-5?
&lt;/h2&gt;

&lt;p&gt;You have zero control over batch composition on a hosted endpoint, so bitwise reproducibility is not on the menu. The API surface now reflects this. Frontier Claude models (Opus 5, Sonnet 5, Opus 4.8/4.7) reject &lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, and &lt;code&gt;top_k&lt;/code&gt; outright — the documented guidance is to steer behavior with prompting instead. Anthropic's API has never exposed a &lt;code&gt;seed&lt;/code&gt;. OpenAI's &lt;code&gt;seed&lt;/code&gt; plus &lt;code&gt;system_fingerprint&lt;/code&gt; is explicitly best-effort, and the fingerprint exists precisely to tell you when the backend changed underneath you.&lt;/p&gt;

&lt;p&gt;So stop writing tests that hash the completion. Write these instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Structured output, field-level assertions.&lt;/strong&gt; Constrain the response to a schema and assert on the fields that matter. Prose varies; &lt;code&gt;{"risk": "high"}&lt;/code&gt; shouldn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Divergence rate as an SLO, not a bug.&lt;/strong&gt; Measure it, set a threshold, alert when it moves. A jump in divergence rate is a genuine signal that something changed on the provider side.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never cache on output hash.&lt;/strong&gt; Key caches on the request, not the response.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for it in evals.&lt;/strong&gt; Run &lt;em&gt;n&lt;/em&gt; samples per item and report a confidence interval. A 1-point accuracy difference between two prompts, measured at n=1, is noise.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;&lt;code&gt;temperature=0&lt;/code&gt; isn't deterministic because greedy decoding only guarantees the same token for the same logits — and your logits are not reproducible. Floating-point addition is non-associative, GPU kernels choose their reduction order from batch shape, and continuous batching makes that shape depend on unrelated concurrent traffic. The result is a last-bit perturbation that flips &lt;code&gt;argmax&lt;/code&gt; whenever two candidate tokens are nearly tied, and autoregressive decoding turns one flipped token into a completely different response. Fixing it requires batch-invariant kernels with fixed reduction structure, at a real throughput cost — an investment that makes sense for evals, regression tests, and RL rollouts. Everywhere else, and on every hosted API, the right move is to measure divergence rate and assert on meaning instead of bytes.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Speculative Decoding: Why 80% Acceptance Still Loses at Batch 64</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 01 Aug 2026 21:03:06 +0000</pubDate>
      <link>https://dev.to/ji_ai/speculative-decoding-why-80-acceptance-still-loses-at-batch-64-4pd4</link>
      <guid>https://dev.to/ji_ai/speculative-decoding-why-80-acceptance-still-loses-at-batch-64-4pd4</guid>
      <description>&lt;p&gt;A 1B draft in front of a 70B target. 80% token acceptance. Four speculative tokens per step. Your dashboard says the draft model is doing its job. Then you raise concurrency from 8 to 64 and throughput drops &lt;em&gt;below&lt;/em&gt; the non-speculative baseline you were trying to beat.&lt;/p&gt;

&lt;p&gt;Nothing broke. Speculative decoding is a latency optimization that you pay for in FLOPs, and at batch 64 you ran out of free FLOPs to spend. Acceptance rate was never the number that decided this.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance rate is exactly &lt;code&gt;1 - TV(p, q)&lt;/code&gt;&lt;/strong&gt;, the total variation distance between target and draft distributions at that position. It is not "how often the draft is right."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected tokens per step is a geometric sum&lt;/strong&gt;, not &lt;code&gt;γ × α&lt;/code&gt;. At α = 0.8 and γ = 4 you get ~3.36 tokens per verify step, not 3.2 accepted plus wishful thinking.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-position acceptance decays&lt;/strong&gt; because the draft conditions on its own drift. The 5th draft token is worth ~0.1 tokens of expected output while costing a full draft forward pass. γ = 3–4 is usually the peak.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speculative decoding trades memory bandwidth for compute.&lt;/strong&gt; It wins only while decode is memory-bound. Batch B with γ speculative tokens puts &lt;code&gt;B × (γ+1)&lt;/code&gt; tokens in flight; past roughly 300 on an H100 you are compute-bound and rejected tokens become pure waste.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure &lt;code&gt;tokens_out / target_forward_FLOPs&lt;/code&gt;, not acceptance.&lt;/strong&gt; If that ratio drops, turn it off for that traffic shape.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you call Claude Opus 4.5 or GPT-5.x over an API, this is your provider's problem. If you run vLLM, SGLang, or TensorRT-LLM yourself, it's yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is the speculative decoding acceptance rate, exactly?
&lt;/h2&gt;

&lt;p&gt;The acceptance rate is &lt;code&gt;1 - TV(p, q)&lt;/code&gt; where &lt;code&gt;p&lt;/code&gt; is the target distribution and &lt;code&gt;q&lt;/code&gt; is the draft distribution at the same position. That identity is exact, not an approximation.&lt;/p&gt;

&lt;p&gt;The standard scheme (Leviathan et al., Chen et al., 2023) samples &lt;code&gt;x ~ q(x)&lt;/code&gt; and accepts with probability &lt;code&gt;min(1, p(x)/q(x))&lt;/code&gt;. Integrate over the draft's own sampling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P(accept) = Σ_x q(x) · min(1, p(x)/q(x))
          = Σ_x min(q(x), p(x))
          = 1 - TV(p, q)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On rejection you resample from the normalized residual &lt;code&gt;max(0, p - q)&lt;/code&gt;. That correction is what makes the output distribution &lt;em&gt;identical&lt;/em&gt; to the target's — speculative decoding is not an approximation, and you should refuse any implementation that claims a speed win by relaxing it.&lt;/p&gt;

&lt;p&gt;Two consequences people get wrong:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Greedy is a special case, not the easy case.&lt;/strong&gt; At temperature 0 both distributions collapse to one-hot, so acceptance equals top-1 agreement. Nothing about the tail matters. At T = 1 you are matching the whole distribution, and a draft that agrees on argmax 85% of the time can still have TV = 0.35. Acceptance is not monotone in temperature. Measure it per temperature; don't extrapolate from your greedy eval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sampler mismatch silently destroys both properties.&lt;/strong&gt; If your stack applies top-p to the target but not the draft, or applies them in a different order relative to temperature, you get a different &lt;code&gt;q&lt;/code&gt; than the acceptance test assumes. Acceptance falls, and the distributional guarantee is gone. This is the single most common real bug I see in hand-rolled speculative loops.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why doesn't 80% acceptance mean a 5x speedup?
&lt;/h2&gt;

&lt;p&gt;Because a run of accepted tokens terminates on the first rejection, so expected output per step is a geometric sum, and because the draft passes aren't free.&lt;/p&gt;

&lt;p&gt;Let &lt;code&gt;α_i&lt;/code&gt; be acceptance at draft position &lt;code&gt;i&lt;/code&gt;. The target's verify pass always emits at least one token: either the correction at the first rejection, or the &lt;strong&gt;bonus token&lt;/strong&gt; from the target's final position when all γ drafts are accepted. So:&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;expected_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alphas&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;E[tokens emitted per verify step]. The leading 1.0 is the token the
    target always produces: correction on rejection, or bonus on full accept.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run&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="mf"&gt;1.0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;alphas&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;*=&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;
        &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;speedup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alphas&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;c = draft forward cost / target forward cost, in the memory-bound regime.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;expected_tokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alphas&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;alphas&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;c&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With uniform α = 0.8, γ = 4: &lt;code&gt;1 + .8 + .64 + .512 + .4096 = 3.36&lt;/code&gt; tokens per step. With a draft costing 10% of a target pass, the step costs 1.4 target-equivalents. Net: &lt;strong&gt;2.40x&lt;/strong&gt;, not 5x.&lt;/p&gt;

&lt;p&gt;Forgetting the bonus token is a real regression, not a rounding error. It costs you a full token on every fully-accepted block — at α = 0.8, γ = 4 that is 41% of blocks and roughly 12% of total throughput.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does per-position acceptance decay make γ = 5 pointless?
&lt;/h2&gt;

&lt;p&gt;Because the draft conditions on tokens it generated itself, so it drifts further from the target with each speculative step. Acceptance is a decaying sequence, and the marginal token requires &lt;em&gt;every&lt;/em&gt; prior one to be accepted.&lt;/p&gt;

&lt;p&gt;A realistic measured profile for a small draft against a large target looks like &lt;code&gt;[0.80, 0.72, 0.66, 0.60, 0.55]&lt;/code&gt;. Run the model:&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;E[tokens]&lt;/th&gt;
&lt;th&gt;step cost (c=0.1)&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;2.376&lt;/td&gt;
&lt;td&gt;1.2&lt;/td&gt;
&lt;td&gt;1.98x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;2.756&lt;/td&gt;
&lt;td&gt;1.3&lt;/td&gt;
&lt;td&gt;2.12x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;2.984&lt;/td&gt;
&lt;td&gt;1.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;2.13x&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;3.109&lt;/td&gt;
&lt;td&gt;1.5&lt;/td&gt;
&lt;td&gt;2.07x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 4th draft token contributes &lt;code&gt;0.8 × 0.72 × 0.66 × 0.60 = 0.228&lt;/code&gt; expected tokens for 0.1 target-equivalents of cost. The 5th contributes 0.125 and loses money. γ = 4 is the peak and γ = 3 is within noise of it, which means &lt;strong&gt;the cheaper config is the better config&lt;/strong&gt; once you account for the extra KV memory the longer draft window holds.&lt;/p&gt;

&lt;p&gt;One genuinely good piece of news: acceptance is bursty, not i.i.d. Long runs of easy tokens (JSON scaffolding, indentation, copied spans from the prompt) alternate with hard, high-entropy decisions. &lt;code&gt;Σ α^i&lt;/code&gt; is convex in α, so by Jensen's inequality a bursty α with mean 0.7 beats a flat α of 0.7. Your average acceptance understates your real throughput. Log the &lt;em&gt;distribution&lt;/em&gt; of accepted-run lengths, not the mean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does speculative decoding lose at batch 64?
&lt;/h2&gt;

&lt;p&gt;Because it converts a memory-bandwidth problem into a compute problem, and at batch 64 you no longer have spare compute.&lt;/p&gt;

&lt;p&gt;Single-token decode is bandwidth-bound. You stream every weight from HBM to produce one token per sequence. Verifying γ+1 tokens in one forward pass reads the same weights, so the extra positions are nearly free. That is the entire trick — not "the draft is smart," but "the target's verify pass was already idle on FLOPs."&lt;/p&gt;

&lt;p&gt;Now the roofline. An H100 SXM does roughly 990 TFLOP/s dense BF16 against about 3.35 TB/s of HBM3, so the crossover is near &lt;strong&gt;300 FLOP per byte&lt;/strong&gt;. A BF16 GEMM with B rows does about B FLOP per weight byte. You need on the order of 300 tokens in flight to saturate compute.&lt;/p&gt;

&lt;p&gt;Speculative decoding multiplies tokens in flight by &lt;code&gt;γ+1&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Batch 8, γ = 4 → 40 tokens in flight. Deep in the bandwidth-bound regime. Free lunch.&lt;/li&gt;
&lt;li&gt;Batch 64, γ = 4 → 320 tokens in flight. Past the crossover. Verification now costs ~5x the FLOPs of a plain decode step, and at α = 0.8 roughly a third of those positions are discarded.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Continuous batching already gave you the throughput win that speculation was faking. Stacking them means paying twice for the same thing. GQA, MoE routing, and FP8 all move the crossover, so measure yours — but the shape holds everywhere.&lt;/p&gt;

&lt;p&gt;The practical rule: &lt;strong&gt;speculative decoding is for low-concurrency, latency-sensitive traffic.&lt;/strong&gt; Interactive coding agents, single-user local inference, tail-latency SLOs. It is not for batch summarization jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you configure this in vLLM?
&lt;/h2&gt;

&lt;p&gt;Keep the draft small enough that &lt;code&gt;c&lt;/code&gt; stays under ~0.15, and gate speculation on concurrency.&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="c1"&gt;# Draft-model speculation: good for low-concurrency interactive serving.
&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;tensor_parallel_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;max_num_seqs&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="c1"&gt;# keep tokens-in-flight under the roofline
&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="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;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# peak, not max
&lt;/span&gt;    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# N-gram speculation: no draft model, no extra weights, no extra KV.
&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;method&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;ngram&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;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;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_lookup_max&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="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flag names have moved across vLLM versions (the old &lt;code&gt;--speculative-model&lt;/code&gt; family folded into &lt;code&gt;speculative_config&lt;/code&gt;); check yours. What matters is that &lt;code&gt;max_num_seqs × (num_speculative_tokens + 1)&lt;/code&gt; stays on the bandwidth-bound side of your hardware's crossover.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you use n-gram speculation instead of a draft model?
&lt;/h2&gt;

&lt;p&gt;When the output copies substantially from the input. Then the best draft model is no model at all.&lt;/p&gt;

&lt;p&gt;N-gram (prompt-lookup) speculation matches the last few generated tokens against the prompt and proposes the continuation verbatim. Zero draft parameters, zero draft KV, &lt;code&gt;c ≈ 0&lt;/code&gt;. On workloads where the output quotes the input, acceptance runs long and the cost model has no denominator to speak of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;RAG answers that quote retrieved passages&lt;/li&gt;
&lt;li&gt;Code edits that reproduce most of the original file&lt;/li&gt;
&lt;li&gt;Structured extraction echoing field values from a document&lt;/li&gt;
&lt;li&gt;Any "rewrite this with X changed" task&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On free-form generation it does nothing, and because &lt;code&gt;c ≈ 0&lt;/code&gt; it costs nothing when it fails. That asymmetry makes it the default I'd reach for first. Only when it demonstrably doesn't fire do I add a draft model.&lt;/p&gt;

&lt;p&gt;EAGLE-style methods sit between the two: they draft in the target's feature space and verify a &lt;em&gt;tree&lt;/em&gt; of candidate branches instead of a chain, which raises acceptance for a given γ. Note the cost-model implication — tree verification puts even more tokens in flight per step, so it hits the compute roofline at a &lt;em&gt;lower&lt;/em&gt; batch size than chain speculation does. Higher acceptance, narrower operating window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure modes worth a runbook entry
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;KV rollback off-by-one.&lt;/strong&gt; Rejected positions leave KV entries that must be truncated before the next step. Off-by-one here produces fluent, subtly wrong output that no unit test catches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Draft/target tokenizer mismatch.&lt;/strong&gt; Different vocabularies make &lt;code&gt;p(x)/q(x)&lt;/code&gt; meaningless. Use a draft from the same family, or do explicit vocab mapping.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Acceptance measured on the wrong traffic.&lt;/strong&gt; Chat evals overstate acceptance for agentic tool-call traffic, where outputs are short, schema-constrained, and high-entropy at exactly the tokens that matter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TTFT regression.&lt;/strong&gt; Draft weights and draft KV shrink the KV budget for real sequences, cutting how many requests fit and pushing queueing delay up. Watch TTFT, not just inter-token latency.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  So does speculative decoding actually help?
&lt;/h2&gt;

&lt;p&gt;Speculative decoding helps when decode is memory-bound and hurts when it is compute-bound, and acceptance rate tells you almost nothing about which side you're on. The acceptance rate is exactly &lt;code&gt;1 - TV(p, q)&lt;/code&gt;; expected output per verify step is the geometric sum &lt;code&gt;1 + Σ Π α_i&lt;/code&gt;, which caps out near γ = 3–4 once per-position decay is accounted for; and the real speedup is that sum divided by &lt;code&gt;γc + 1&lt;/code&gt;. The decisive variable is tokens in flight: &lt;code&gt;batch × (γ+1)&lt;/code&gt;. Below your hardware's roofline crossover — roughly 300 on an H100 — the extra verified positions ride along for free and you get a genuine 2–2.5x on inter-token latency. Above it, you are spending real FLOPs on tokens you will throw away, and 80% acceptance at batch 64 loses to plain continuous batching. Run speculation on your low-concurrency latency tier, use n-gram speculation on copy-heavy workloads where it's nearly free, and instrument &lt;code&gt;tokens_out / target_forward_FLOPs&lt;/code&gt; so the regression shows up before your throughput graph does.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Why JSON Schema Field Order Breaks Structured Output Accuracy</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Sat, 01 Aug 2026 09:00:10 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-json-schema-field-order-breaks-structured-output-accuracy-2985</link>
      <guid>https://dev.to/ji_ai/why-json-schema-field-order-breaks-structured-output-accuracy-2985</guid>
      <description>&lt;p&gt;Someone on your team reorders a Pydantic model so the API response reads better — &lt;code&gt;label&lt;/code&gt; first, &lt;code&gt;rationale&lt;/code&gt; last. No prompt change, no model change, no temperature change. The classification eval drops. The rationales still look great; they just now describe a decision the model had already made.&lt;/p&gt;

&lt;p&gt;That is not a coincidence and it is not prompt superstition. With strict structured outputs, JSON Schema field order is a &lt;strong&gt;decoding constraint&lt;/strong&gt;. The grammar compiler turns your property order into a finite-state machine, and that machine assigns probability zero to any token that would start the wrong key. The model cannot reorder. It cannot think first and answer second unless your schema lets it.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON Schema field order determines generation order&lt;/strong&gt; under constrained decoding (OpenAI strict structured outputs, vLLM &lt;code&gt;guided_json&lt;/code&gt;, Outlines/XGrammar/llguidance). Objects are emitted in declaration order because tracking arbitrary property order needs 2^k FSM states for k required keys.&lt;/li&gt;
&lt;li&gt;Autoregressive models compute in the token stream. A field declared &lt;em&gt;before&lt;/em&gt; the answer is scratchpad; a field declared &lt;em&gt;after&lt;/em&gt; it is post-hoc rationalization that cannot influence the answer at all.&lt;/li&gt;
&lt;li&gt;Put &lt;code&gt;evidence&lt;/code&gt; → &lt;code&gt;rationale&lt;/code&gt; → &lt;code&gt;label&lt;/code&gt; → &lt;code&gt;confidence&lt;/code&gt;. Never &lt;code&gt;label&lt;/code&gt; first. The only cost is time-to-first-useful-token, not accuracy.&lt;/li&gt;
&lt;li&gt;Forced key tokens are free prompt real estate: &lt;code&gt;step_by_step_reasoning&lt;/code&gt; injects that phrase into the KV cache right before the model writes the value. Their logprobs are meaningless — don't compute confidence over forced spans.&lt;/li&gt;
&lt;li&gt;Design enum values to differ at the &lt;strong&gt;first&lt;/strong&gt; token (&lt;code&gt;urgent&lt;/code&gt;/&lt;code&gt;normal&lt;/code&gt;/&lt;code&gt;low&lt;/code&gt;, not &lt;code&gt;p0&lt;/code&gt;/&lt;code&gt;p1&lt;/code&gt;/&lt;code&gt;p2&lt;/code&gt;) so the masked, renormalized logprobs at that position give you a usable class posterior.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does JSON Schema field order change structured output accuracy?
&lt;/h2&gt;

&lt;p&gt;Because a decoder-only model has exactly one place to do intermediate computation: the tokens it has already emitted. There is no hidden scratch buffer that persists across steps. Everything the model "works out" has to exist as tokens in the prefix, or it does not exist.&lt;/p&gt;

&lt;p&gt;So when the schema forces this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rationale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"The stack trace shows ..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;the token for &lt;code&gt;p0&lt;/code&gt; is sampled from a context that contains the ticket, the system prompt, and the seven characters &lt;code&gt;{"label":&lt;/code&gt;. That is it. Whatever the rationale says afterwards was conditioned on &lt;code&gt;p0&lt;/code&gt;, not the other way around. You did not get reasoning. You got a language model doing what it is extremely good at — writing a persuasive defense of a position already on the page.&lt;/p&gt;

&lt;p&gt;Flip the order and the same tokens become causal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"evidence"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"OOM at 03:12 on the payments pod"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"rationale"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"p0"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now every token of evidence and rationale is in the prefix that conditions &lt;code&gt;label&lt;/code&gt;. This is chain-of-thought, just wearing a JSON hat.&lt;/p&gt;

&lt;p&gt;The part people miss: with an unconstrained model you might get away with a bad order, because a strong model will sometimes ignore your schema and emit keys in the order it prefers. Strict mode removes that escape hatch. The constraint is enforced at the logit level.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does constrained decoding actually enforce the order?
&lt;/h2&gt;

&lt;p&gt;The schema is compiled into a grammar, the grammar into a state machine over the token vocabulary. At each step the engine computes the set of tokens that keep the output on a valid path, masks everything else to &lt;code&gt;-inf&lt;/code&gt;, and renormalizes:&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;# What every guided-decoding backend does, stripped down.
&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;fsm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;allowed_token_mask&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="c1"&gt;# bool[vocab_size]
&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;]&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="n"&gt;probs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;softmax&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="c1"&gt;# renormalized over the legal set only
&lt;/span&gt;&lt;span class="n"&gt;tok&lt;/span&gt; &lt;span class="o"&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;probs&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;state&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Consider the step right after &lt;code&gt;{&lt;/code&gt;. The model's unconstrained top token might be &lt;code&gt;"reason&lt;/code&gt; with high probability — it &lt;em&gt;wants&lt;/em&gt; to think first. If your schema declared &lt;code&gt;label&lt;/code&gt; first, that token is masked. The mass gets redistributed over legal tokens, and the model emits &lt;code&gt;"label&lt;/code&gt;. You never see the preference; you only see the degraded downstream answer.&lt;/p&gt;

&lt;p&gt;Why does declaration order win? Because permitting properties in any order is expensive. To allow k required properties in arbitrary order, the automaton must remember which subset has already been emitted — that is 2^k states, before you nest anything. Some engines support unordered objects; the hosted strict modes generally pin declaration order instead, and it is the sane default. Which means your schema file is a program, and property order is control flow.&lt;/p&gt;

&lt;p&gt;Two more strict-mode consequences worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI's strict mode requires every property in &lt;code&gt;required&lt;/code&gt; and &lt;code&gt;additionalProperties: false&lt;/code&gt;. If a field has no evidence in the input, the model must still emit &lt;em&gt;something&lt;/em&gt; — that is where placeholder hallucinations come from. Model genuine optionality as a nullable union (&lt;code&gt;"type": ["string", "null"]&lt;/code&gt;), not by omitting the key.&lt;/li&gt;
&lt;li&gt;The grammar is compiled and cached per schema. The first request with a brand-new schema pays extra latency. Generating schemas dynamically per request throws that cache away every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does the fix look like in code?
&lt;/h2&gt;

&lt;p&gt;Same fields, same descriptions, same model. Only the order changes.&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;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Literal&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&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="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="c1"&gt;# WRONG — the decoder commits to `label` before writing one token of analysis.
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageBad&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&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="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&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;normal&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;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;
    &lt;span class="n"&gt;rationale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;

&lt;span class="c1"&gt;# RIGHT — evidence and rationale are in the prefix that conditions `label`.
&lt;/span&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TriageGood&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;evidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Verbatim quotes from the ticket. No paraphrase.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;ruled_out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="p"&gt;...,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Which severity you considered and rejected, and why.&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;urgent&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;normal&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;low&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&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;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&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="nb"&gt;input&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;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;ticket_text&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="n"&gt;text_format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TriageGood&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output_parsed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Note &lt;code&gt;ruled_out&lt;/code&gt;. Under strict mode the model &lt;em&gt;must&lt;/em&gt; fill it, so you have effectively forced a contrastive step into the decode path. You can attach compute to a task by declaring a field, and remove it by deleting one. That is a stronger lever than most prompt edits.&lt;/p&gt;

&lt;p&gt;The self-hosted equivalent is the same idea through vLLM:&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="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;sampling_params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;SamplingParams&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;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;guided_decoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;GuidedDecodingParams&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;TriageGood&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_json_schema&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;model_json_schema()&lt;/code&gt; preserves Pydantic field order, and XGrammar walks the &lt;code&gt;properties&lt;/code&gt; map in that order. Reorder the class, reorder the decode.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does the same thing happen with Claude tool use?
&lt;/h2&gt;

&lt;p&gt;Yes, with a softer mechanism and the same fix. Claude Sonnet 4.5 and Opus 4.x tool use is schema-guided rather than compiled to a hard token-level grammar the way OpenAI strict mode is, so Claude has more freedom to deviate. But your &lt;code&gt;input_schema&lt;/code&gt; is serialized into the request in property order, and the model overwhelmingly writes keys in the order it read them. Answer-first schemas produce answer-first generation, which produces post-hoc rationales.&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="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;triage_ticket&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;Record a triage decision for one support ticket.&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="c1"&gt;# Order is the plan. Cheap observations first, commitments last.
&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;evidence&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;array&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;items&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;ruled_out&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;label&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;urgent&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;normal&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;low&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;evidence&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;ruled_out&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;label&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With Claude there is a better option available: turn on extended thinking and let the reasoning happen in thinking blocks &lt;em&gt;before&lt;/em&gt; the tool call, then keep the tool input lean. That gives you a real scratchpad with no schema contortions, and the tool result stays clean for downstream consumers. Use in-schema reasoning fields when you are on a non-thinking configuration or when you need the reasoning persisted as structured data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why are forced key tokens worth designing?
&lt;/h2&gt;

&lt;p&gt;Because they are prompt tokens you get for free, positioned exactly where they matter. The model does not choose &lt;code&gt;"rationale"&lt;/code&gt; — the FSM does — but those tokens still enter the KV cache and still condition everything after them. Renaming a field from &lt;code&gt;notes&lt;/code&gt; to &lt;code&gt;contradicting_evidence_from_the_ticket&lt;/code&gt; inserts that phrase directly before the value is generated. It is the highest-leverage prompt edit that costs nothing at the call site.&lt;/p&gt;

&lt;p&gt;The flip side: &lt;strong&gt;never treat logprobs over forced spans as signal.&lt;/strong&gt; The key tokens, the colons, the braces, the closing quotes are all masked down to a single legal option, so their logprob is ~0 by construction. Averaging token logprobs across a structured output to get "confidence" mostly measures how much punctuation your schema has.&lt;/p&gt;

&lt;p&gt;Where logprobs &lt;em&gt;are&lt;/em&gt; meaningful is the first token that distinguishes one legal continuation from another. For a &lt;code&gt;Literal["urgent","normal","low"]&lt;/code&gt; enum, the FSM has already forced the opening quote, so the next token's renormalized distribution is a genuine posterior over your classes. That only works if the values diverge at the first token. &lt;code&gt;p0&lt;/code&gt;/&lt;code&gt;p1&lt;/code&gt;/&lt;code&gt;p2&lt;/code&gt; all start with the same &lt;code&gt;p&lt;/code&gt; token, so the informative step is buried one position deeper and may split unevenly across the vocabulary. Pick lexically distinct enum values and you get a calibratable classifier out of a generative call.&lt;/p&gt;

&lt;p&gt;Also drop the self-reported &lt;code&gt;confidence: float&lt;/code&gt;. A model writing &lt;code&gt;0.9&lt;/code&gt; after its own label is producing a plausible-looking number, not a probability. The enum logprobs are the real thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you measure this in your own stack?
&lt;/h2&gt;

&lt;p&gt;Change one variable. Duplicate the schema, permute only the property order, hold everything else — same model snapshot, same prompt, same temperature, same seed if available — and run both over the same N examples.&lt;/p&gt;

&lt;p&gt;Then analyze it as a &lt;strong&gt;paired&lt;/strong&gt; experiment, not two independent accuracy numbers. Look only at the examples where the two orders disagree, and test whether the disagreements are lopsided (McNemar's test on the discordant pairs). Paired analysis needs far fewer examples to reach significance than comparing two marginal accuracies, which matters because per-call eval noise on a few hundred items is large enough to swallow a real effect.&lt;/p&gt;

&lt;p&gt;The cost of reasoning-first is real but narrow: you cannot stream the answer early, and you pay for the extra tokens. Bound the scratchpad fields (&lt;code&gt;maxItems&lt;/code&gt; on evidence, an explicit "one sentence" in the description) and the tax stays small. Latency to the field you care about is the trade — accuracy is not.&lt;/p&gt;

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

&lt;p&gt;JSON Schema field order breaks structured output accuracy because constrained decoding compiles your property order into a state machine that emits keys in declaration order and masks every other token to zero probability. A decoder-only model can only compute in tokens it has already written, so any field declared before the answer becomes usable scratchpad and any field declared after it is a post-hoc justification with zero causal effect on the answer. Declare evidence and rationale first, the label and any derived numbers last, model optional fields as nullable unions rather than omissions, name fields so the forced key tokens act as instructions, and read confidence from the enum's first divergent token instead of asking the model for a number. Reordering four lines of a Pydantic model is the cheapest accuracy fix in a structured-output pipeline.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
    </item>
    <item>
      <title>Why INT4 Weight-Only Quantization Doesn't Speed Up Prefill</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 31 Jul 2026 20:57:34 +0000</pubDate>
      <link>https://dev.to/ji_ai/why-int4-weight-only-quantization-doesnt-speed-up-prefill-1b45</link>
      <guid>https://dev.to/ji_ai/why-int4-weight-only-quantization-doesnt-speed-up-prefill-1b45</guid>
      <description>&lt;p&gt;You benchmark a 70B model with &lt;code&gt;batch_size=1&lt;/code&gt;, one prompt, one stream. FP16 gives you 18 tokens/sec. You swap in an AWQ INT4 checkpoint and get 55 tokens/sec. Three times faster, same GPU, ~1 point of accuracy lost. You ship it.&lt;/p&gt;

&lt;p&gt;Then production traffic arrives: 8k-token RAG prompts, 40 concurrent users. Time-to-first-token gets &lt;em&gt;worse&lt;/em&gt; than the FP16 build, and your throughput at high concurrency is flat or slightly down. Nothing is broken. INT4 weight-only quantization did exactly what it does — it reduced bytes moved, and prefill was never bottlenecked on bytes.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;INT4 weight-only quantization (W4A16) reduces memory traffic, not FLOPs.&lt;/strong&gt; The kernel dequantizes INT4 back to FP16 and runs the same FP16 tensor-core MMA. Peak compute is unchanged.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode is memory-bandwidth-bound&lt;/strong&gt;, so cutting weight bytes 4x gives close to a 4x speedup at low concurrency. &lt;strong&gt;Prefill is compute-bound&lt;/strong&gt;, so it gets nothing — and pays the dequantization overhead.&lt;/li&gt;
&lt;li&gt;The crossover is arithmetic: on an H100 SXM (~990 TFLOPS dense BF16, ~3.35 TB/s HBM3), the roofline ridge point is &lt;strong&gt;~295 FLOP/byte&lt;/strong&gt;. BF16 weights need ~295 concurrent decode tokens to become compute-bound; INT4 weights need only &lt;strong&gt;~74&lt;/strong&gt;. Above that, INT4's advantage is gone.&lt;/li&gt;
&lt;li&gt;Chunked prefill makes it worse: mixing prefill chunks into decode batches pushes every batch into the compute-bound regime, which is precisely where W4A16 stops paying.&lt;/li&gt;
&lt;li&gt;If you are compute-bound, quantize &lt;strong&gt;activations too&lt;/strong&gt; — FP8 (W8A8) on Hopper, or W4A8 — or accept INT4 purely as a &lt;strong&gt;capacity&lt;/strong&gt; play (more KV cache, fewer GPUs, no tensor-parallel comms).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does INT4 weight-only quantization speed up decode but not prefill?
&lt;/h2&gt;

&lt;p&gt;Because decode and prefill sit on opposite sides of the roofline, and INT4 weight-only quantization only moves the memory axis.&lt;/p&gt;

&lt;p&gt;A transformer's linear layers are the whole story here. During decode with &lt;code&gt;B&lt;/code&gt; concurrent sequences, each weight matrix &lt;code&gt;[K, N]&lt;/code&gt; is read from HBM once and used for &lt;code&gt;B&lt;/code&gt; token-vectors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FLOPs: &lt;code&gt;2 · B · K · N&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Bytes: &lt;code&gt;K · N · b&lt;/code&gt; where &lt;code&gt;b&lt;/code&gt; = bytes per weight (2 for BF16, 0.5 for INT4)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Arithmetic intensity is therefore &lt;code&gt;2B / b&lt;/code&gt; FLOP/byte. At &lt;code&gt;B = 1&lt;/code&gt;, BF16 gives you &lt;strong&gt;1 FLOP per byte&lt;/strong&gt;. An H100 wants ~295. You are running the tensor cores at roughly a third of a percent of peak and burning the entire kernel time on HBM reads. Shrink the weights 4x and the kernel gets ~4x faster — the math never had to change.&lt;/p&gt;

&lt;p&gt;Prefill runs the same weight matrix against &lt;code&gt;S&lt;/code&gt; tokens at once. With &lt;code&gt;S = 2048&lt;/code&gt;, intensity is &lt;code&gt;2·2048/2 ≈ 2048&lt;/code&gt; FLOP/byte — an order of magnitude past the ridge point. The weight read is amortized to nothing. You are limited by tensor-core throughput, and W4A16 does not raise tensor-core throughput. It lowers it slightly, because you now spend cycles unpacking nibbles, applying per-group scales and zero-points, and writing FP16 into the MMA path.&lt;/p&gt;

&lt;p&gt;That is the sentence to remember: &lt;strong&gt;W4A16 buys bytes, not FLOPs.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What batch size kills the INT4 advantage?
&lt;/h2&gt;

&lt;p&gt;Roughly the roofline ridge point scaled by bytes-per-weight. On an H100 SXM it is about 74 concurrent decode tokens for INT4 versus ~295 for BF16.&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;# Where does a weight-stationary GEMM stop being memory-bound?
# intensity = 2*B*K*N / (K*N*bytes_per_weight) = 2B / bytes_per_weight
&lt;/span&gt;
&lt;span class="n"&gt;SPECS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;                      &lt;span class="c1"&gt;# dense tensor-core FLOPS, HBM bandwidth
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;H100-SXM&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="mf"&gt;990e12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;3.35e12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# BF16
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A100-80G&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="mf"&gt;312e12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.039e12&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# BF16
&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;crossover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bytes_per_weight&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;flops&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;bw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SPECS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;ridge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;flops&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;bw&lt;/span&gt;                       &lt;span class="c1"&gt;# FLOP/byte the GPU wants
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ridge&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;bytes_per_weight&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;      &lt;span class="c1"&gt;# concurrent tokens B
&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;gpu&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;SPECS&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;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BF16&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;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FP8&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INT4&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.5&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;gpu&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; weights -&amp;gt; compute-bound at B ~= &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;crossover&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gpu&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="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;5.0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# H100-SXM  BF16  weights -&amp;gt; compute-bound at B ~=   296
# H100-SXM  FP8   weights -&amp;gt; compute-bound at B ~=   148
# H100-SXM  INT4  weights -&amp;gt; compute-bound at B ~=    74
# A100-80G  BF16  weights -&amp;gt; compute-bound at B ~=   153
# A100-80G  INT4  weights -&amp;gt; compute-bound at B ~=    38
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This model ignores activation traffic and assumes large &lt;code&gt;K, N&lt;/code&gt; — fine for a 70B's 8192×28672 MLP projections, less fine for tiny models. Treat it as the right order of magnitude, not a promise.&lt;/p&gt;

&lt;p&gt;The practical reading: quantization &lt;em&gt;lowers&lt;/em&gt; the concurrency at which you become compute-bound. That is counterintuitive until you see the formula. Fewer bytes per FLOP means you hit the compute wall sooner. So the regime where INT4 gives its headline speedup — small &lt;code&gt;B&lt;/code&gt; — is exactly the regime a well-utilized production server tries to leave.&lt;/p&gt;

&lt;p&gt;If your serving fleet runs at 60+ concurrent decodes on H100, you have already crossed the line. Your INT4 build is doing the same FLOPs as FP16 plus dequantization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does prefill sometimes get slower after quantizing to INT4?
&lt;/h2&gt;

&lt;p&gt;Three reasons, in descending order of impact.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dequantization is real work in the inner loop.&lt;/strong&gt; A W4A16 kernel loads packed nibbles, unpacks, multiplies by a per-group FP16 scale (group size 128 is the common default), subtracts a zero-point, then feeds FP16 MMA. In the memory-bound regime that work hides behind HBM latency for free. In the compute-bound regime there is nothing to hide behind — it lands directly on the critical path.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kernel maturity and shape sensitivity.&lt;/strong&gt; Marlin-class kernels closed most of the large-&lt;code&gt;M&lt;/code&gt; gap for GPTQ/AWQ weights, but they still tune for specific tile shapes. Feed them a 6k-token prefill with an odd hidden size and you can fall off the fast path into a slower fallback. FP16 cuBLAS/CUTLASS GEMMs have no such cliff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No fused epilogue advantage.&lt;/strong&gt; FP8 paths on Hopper get native tensor-core support and can keep activations in 8-bit through the layer. W4A16 has to materialize FP16 activations regardless, so it saves nothing on the activation side of prefill, where activations are large.&lt;/p&gt;

&lt;p&gt;Net effect in practice: TTFT moves the wrong way by a modest but measurable amount, while inter-token latency improves substantially. If your SLO is dominated by TTFT on long RAG prompts, INT4 is a regression dressed up as an optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does chunked prefill make INT4 weight-only quantization worse?
&lt;/h2&gt;

&lt;p&gt;Yes, structurally. Chunked prefill exists to stop long prompts from stalling decode, and it works by slicing a prompt into chunks and co-scheduling them with in-flight decode tokens in a single batched forward pass.&lt;/p&gt;

&lt;p&gt;That merged batch has a token count of &lt;code&gt;B_decode + chunk_size&lt;/code&gt;. With a 512-token chunk, every batch is at &lt;code&gt;B ≥ 512&lt;/code&gt; — far past the ~74-token crossover. The GEMMs are compute-bound essentially all the time, so the weight-byte savings buy nothing, and the dequantization overhead is paid on every step.&lt;/p&gt;

&lt;p&gt;You can still win here, but the win comes from the freed HBM (bigger KV cache → more concurrency → better throughput), not from faster matmuls.&lt;/p&gt;

&lt;h2&gt;
  
  
  Is INT4 still worth it if it doesn't speed up prefill?
&lt;/h2&gt;

&lt;p&gt;Often yes — as a &lt;strong&gt;capacity&lt;/strong&gt; decision rather than a latency one. Three concrete payoffs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;KV cache headroom.&lt;/strong&gt; A 70B model at BF16 needs ~140 GB of weights; at INT4, ~35 GB. On a single 80GB H100 that is the difference between "doesn't fit" and "40+ GB of KV cache." More KV cache means higher concurrency, which means better throughput — a second-order effect, but usually the largest one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fewer GPUs, no tensor-parallel comms.&lt;/strong&gt; Dropping from TP=4 to TP=2 (or TP=1) removes all-reduce traffic from every layer. On nodes without NVLink this can dominate everything else in the decode loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cold-start and multi-model density.&lt;/strong&gt; Loading 35 GB instead of 140 GB matters when you swap models or autoscale.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of those are "prefill is faster." Be honest about which one you are buying.&lt;/p&gt;

&lt;h2&gt;
  
  
  What should I run instead when I'm compute-bound?
&lt;/h2&gt;

&lt;p&gt;Quantize the activations, not just the weights.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# vLLM, H100 fleet, high concurrency, long RAG prompts.&lt;/span&gt;
&lt;span class="c1"&gt;# FP8 (W8A8) uses native Hopper tensor cores: halves weight bytes AND&lt;/span&gt;
&lt;span class="c1"&gt;# roughly doubles compute peak vs BF16.&lt;/span&gt;
&lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;meta-llama/Llama-3.3-70B-Instruct&lt;/span&gt;
&lt;span class="na"&gt;quantization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fp8&lt;/span&gt;              &lt;span class="c1"&gt;# W8A8, native on Hopper/Blackwell&lt;/span&gt;
&lt;span class="na"&gt;kv_cache_dtype&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fp8_e4m3&lt;/span&gt;       &lt;span class="c1"&gt;# also halves KV traffic during decode&lt;/span&gt;
&lt;span class="na"&gt;max_model_len&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;32768&lt;/span&gt;
&lt;span class="na"&gt;enable_chunked_prefill&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="na"&gt;max_num_batched_tokens&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;2048&lt;/span&gt;   &lt;span class="c1"&gt;# tune against TTFT vs ITL SLOs&lt;/span&gt;
&lt;span class="na"&gt;gpu_memory_utilization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.92&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rules of thumb:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Interactive, low concurrency, one user per GPU&lt;/strong&gt; (local inference, agents with &lt;code&gt;B ≈ 1–8&lt;/code&gt;): INT4 weight-only is the right call. This is where the 3x single-stream number is real.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-concurrency serving on Hopper/Blackwell&lt;/strong&gt;: FP8 W8A8. You get the byte reduction &lt;em&gt;and&lt;/em&gt; the compute-peak increase, which is the only way to speed up prefill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You need INT4 for capacity but serve at high concurrency&lt;/strong&gt;: look at W4A8 schemes, which keep 4-bit weights for memory but use 8-bit tensor cores for the math.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-Hopper (A100)&lt;/strong&gt;: no native FP8. INT8 W8A8 with SmoothQuant-style outlier handling is the compute-side option; INT4 remains capacity-only.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do I measure this on my own workload?
&lt;/h2&gt;

&lt;p&gt;Stop benchmarking with &lt;code&gt;batch_size=1&lt;/code&gt;. Sweep concurrency and separate the two phases:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Sweep concurrency; watch TTFT (prefill) and ITL (decode) separately.&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;c &lt;span class="k"&gt;in &lt;/span&gt;1 4 16 64 128&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;vllm bench serve &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--model&lt;/span&gt; &lt;span class="nv"&gt;$MODEL&lt;/span&gt; &lt;span class="nt"&gt;--dataset-name&lt;/span&gt; random &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--random-input-len&lt;/span&gt; 4096 &lt;span class="nt"&gt;--random-output-len&lt;/span&gt; 256 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-concurrency&lt;/span&gt; &lt;span class="nv"&gt;$c&lt;/span&gt; &lt;span class="nt"&gt;--num-prompts&lt;/span&gt; &lt;span class="k"&gt;$((&lt;/span&gt;c &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="m"&gt;8&lt;/span&gt;&lt;span class="k"&gt;))&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log &lt;strong&gt;TTFT p50/p99&lt;/strong&gt; and &lt;strong&gt;inter-token latency p50/p99&lt;/strong&gt; as separate series, then plot each against concurrency for both checkpoints. The signature of the failure mode is unmistakable: the INT4 ITL curve sits well below FP16 at low concurrency and converges to it somewhere in the 50–100 range, while the INT4 TTFT curve sits &lt;em&gt;above&lt;/em&gt; FP16 everywhere. If you only ever look at end-to-end throughput at one concurrency level, both effects average into a single number that tells you nothing.&lt;/p&gt;

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

&lt;p&gt;INT4 weight-only quantization doesn't speed up prefill because prefill is compute-bound and W4A16 only reduces memory traffic — the kernel still dequantizes to FP16 and runs the same tensor-core matmuls, so it does the same FLOPs plus unpacking overhead. Decode at low concurrency has an arithmetic intensity near 1 FLOP/byte against a GPU that wants ~295, so removing 75% of the weight bytes gives a near-linear speedup there; prefill runs at hundreds or thousands of FLOP/byte, where those bytes were already free. The crossover is around 74 concurrent decode tokens on an H100, and chunked prefill pushes every batch past it. Use INT4 for single-stream latency and for memory capacity; use FP8 or another activation-quantized scheme when you need prefill and high-concurrency throughput to actually get faster.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>performance</category>
    </item>
    <item>
      <title>DPO Likelihood Displacement: Why Chosen Responses Get Rarer</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Fri, 31 Jul 2026 08:55:07 +0000</pubDate>
      <link>https://dev.to/ji_ai/dpo-likelihood-displacement-why-chosen-responses-get-rarer-118a</link>
      <guid>https://dev.to/ji_ai/dpo-likelihood-displacement-why-chosen-responses-get-rarer-118a</guid>
      <description>&lt;p&gt;You run DPO on a clean preference set. The margin goes up, the win rate on your pairwise eval goes up, and then you scroll back through the training logs: &lt;code&gt;logps/chosen&lt;/code&gt; started at -142 and ended at -231. The response you were rewarding is now &lt;em&gt;less&lt;/em&gt; likely than it was before training. Not relatively — absolutely.&lt;/p&gt;

&lt;p&gt;This is DPO likelihood displacement, and it is not a bug in your data loader. It is a direct consequence of what the DPO objective optimizes, and it is the mechanism behind the most annoying DPO failure mode in practice: you train on "helpful and safe" versus "harmful," and you ship a model that refuses everything.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DPO only constrains the *margin&lt;/strong&gt;* between chosen and rejected log-probs. Both can fall together and the loss still decreases — nothing in the objective anchors &lt;code&gt;log π(y_w)&lt;/code&gt; upward.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The exact condition:&lt;/strong&gt; &lt;code&gt;log π(y_w)&lt;/code&gt; decreases when &lt;code&gt;⟨∇log π(y_w), ∇log π(y_l)⟩ &amp;gt; ‖∇log π(y_w)‖²&lt;/code&gt; — i.e. when the rejected response's gradient is aligned with the chosen one's &lt;em&gt;and&lt;/em&gt; larger. Similar-looking pairs are the danger zone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Displaced probability mass doesn't vanish.&lt;/strong&gt; It flows to whatever is geometrically adjacent — usually a short refusal, a hedge, or an empty answer, none of which appear in your dataset.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Detect it&lt;/strong&gt; by logging &lt;code&gt;logps/chosen&lt;/code&gt; in absolute terms (not just the margin) plus a fixed "escape probe" string, and watch whether the probe's log-prob rises.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix it&lt;/strong&gt; with an NLL anchor on chosen (&lt;code&gt;rpo_alpha&lt;/code&gt; in TRL), by filtering high-similarity pairs (CHES), and by making sure your reference model actually assigns decent probability to the chosen responses.&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 reduces the absolute log-probability of the preferred response &lt;code&gt;y_w&lt;/code&gt; while still increasing the preference margin, pushing the freed probability mass onto completions that were never in the training data.&lt;/p&gt;

&lt;p&gt;The margin looks great. &lt;code&gt;rewards/margins&lt;/code&gt; climbs monotonically. &lt;code&gt;rewards/chosen&lt;/code&gt; — which in TRL is &lt;code&gt;β(log π_θ(y_w) − log π_ref(y_w))&lt;/code&gt; — goes &lt;em&gt;negative&lt;/em&gt; and stays there. That negative number is the whole story: the policy assigns less mass to the chosen response than the reference did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does DPO push down the probability of the chosen response?
&lt;/h2&gt;

&lt;p&gt;Because the DPO loss is a function of a difference, and a difference is invariant to shifting both terms down.&lt;br&gt;
&lt;/p&gt;

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

∇_θ L_DPO = −β · σ(−β·Δ) · [ ∇_θ log π_θ(y_w|x) − ∇_θ log π_θ(y_l|x) ]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update direction is &lt;code&gt;g_w − g_l&lt;/code&gt; with &lt;strong&gt;equal weight on both terms&lt;/strong&gt;. Lowering &lt;code&gt;log π(y_l)&lt;/code&gt; by 3 nats and lowering &lt;code&gt;log π(y_w)&lt;/code&gt; by 1 nat is a perfectly good descent step. The reference model appears only inside &lt;code&gt;Δ&lt;/code&gt; as a constant offset; it shifts &lt;em&gt;when&lt;/em&gt; the sigmoid saturates, not &lt;em&gt;which direction&lt;/em&gt; the gradient points.&lt;/p&gt;

&lt;p&gt;Now do the first-order analysis. Under gradient flow, the rate of change of the chosen log-prob is the inner product of its own gradient with the update direction:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;d/dt log π_θ(y_w) ∝ ⟨ g_w , g_w − g_l ⟩ = ‖g_w‖² − ⟨g_w, g_l⟩
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So &lt;code&gt;log π(y_w)&lt;/code&gt; &lt;strong&gt;decreases&lt;/strong&gt; exactly when &lt;code&gt;⟨g_w, g_l⟩ &amp;gt; ‖g_w‖²&lt;/code&gt;. Two things drive that: the gradients must point in similar directions, and &lt;code&gt;‖g_l‖&lt;/code&gt; must be large relative to &lt;code&gt;‖g_w‖&lt;/code&gt;. Both are common. Preference pairs are usually near-duplicates — same prompt, same topic, often the same opening 40 tokens — so &lt;code&gt;g_w&lt;/code&gt; and &lt;code&gt;g_l&lt;/code&gt; are highly aligned. And if the rejected response is already low-probability under the policy, its gradient norm is large. You get a step that suppresses &lt;code&gt;y_l&lt;/code&gt; hard and drags &lt;code&gt;y_w&lt;/code&gt; down with it.&lt;/p&gt;

&lt;p&gt;Look at the last layer to see why alignment is so high. For unembedding matrix &lt;code&gt;W&lt;/code&gt;, the gradient of a token's log-prob is an outer product &lt;code&gt;(e_token − p) hᵀ&lt;/code&gt;, where &lt;code&gt;h&lt;/code&gt; is the final hidden state. The inner product of two such gradients factorizes into a &lt;strong&gt;token-geometry term&lt;/strong&gt; times a &lt;strong&gt;hidden-state similarity term&lt;/strong&gt;. When chosen and rejected differ only by near-synonyms — "Never" vs "No", "I'd suggest" vs "You should" — the token vectors are close and the hidden states are nearly identical. The suppression signal on the rejected token bleeds straight onto the chosen token through the shared softmax normalizer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where does the displaced probability mass go?
&lt;/h2&gt;

&lt;p&gt;Softmax conserves mass. If you push down the rejected continuation and the chosen one comes down with it, that mass lands on whatever the model considers the next-nearest neighbor in output space — and the objective has no term describing that region at all.&lt;/p&gt;

&lt;p&gt;In practice the recipient is almost always a short, generic, high-prior string: a refusal, "I don't have enough information," a truncated one-liner. These sit at high probability under the base model and are geometrically far from both members of a typical preference pair, so they absorb displaced mass without ever contributing to the loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does DPO on safety data cause over-refusal?
&lt;/h2&gt;

&lt;p&gt;Because safety preference sets are the worst case for the condition above. The chosen response is typically a nuanced, careful, &lt;em&gt;non-refusing&lt;/em&gt; answer; the rejected one is a harmful answer to the same prompt. They share the prompt, the topic, and often the framing — maximum gradient alignment. The rejected response is far off-policy after SFT, so it has a large gradient norm.&lt;/p&gt;

&lt;p&gt;Result: DPO suppresses the harmful answer, drags the careful answer down with it, and the mass lands on a blanket refusal that was never labeled as preferred. Razin et al.'s work on unintentional unalignment documents this in both directions — the same mechanism can also move mass &lt;em&gt;toward&lt;/em&gt; unsafe outputs when the pair geometry flips. The lesson generalizes past safety: any dataset where chosen and rejected are minimally-edited variants of each other is a displacement machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I detect likelihood displacement in a training run?
&lt;/h2&gt;

&lt;p&gt;Log absolute log-probs, not just the margin, and add a probe string that isn't in your data. Three columns tell you almost everything:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;code&gt;logps/chosen&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;&lt;code&gt;logps/rejected&lt;/code&gt;&lt;/th&gt;
&lt;th&gt;Reading&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;↑&lt;/td&gt;
&lt;td&gt;↓&lt;/td&gt;
&lt;td&gt;Healthy. This is what you want.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;↓ slightly&lt;/td&gt;
&lt;td&gt;↓↓ steeply&lt;/td&gt;
&lt;td&gt;Displacement. Margin is a lie.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;↓↓&lt;/td&gt;
&lt;td&gt;↓↓↓&lt;/td&gt;
&lt;td&gt;Severe. Check probe mass immediately.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;↑&lt;/td&gt;
&lt;td&gt;↑&lt;/td&gt;
&lt;td&gt;Reference/policy mismatch — verify &lt;code&gt;π_ref&lt;/code&gt; is your actual SFT checkpoint.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The probe is the part people skip. Pick a string you never want to see more of — a canned refusal — and track its log-prob under the policy on a fixed set of benign prompts:&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;transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TrainerCallback&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;PROBE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;m sorry, but I can&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;t help with that.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;EscapeMassProbe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TrainerCallback&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&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;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;self&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;every&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;prompts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;every&lt;/span&gt;

    &lt;span class="nd"&gt;@torch.no_grad&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;on_step_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&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="n"&gt;control&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="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kw&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;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;global_step&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;every&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.0&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&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;PROBE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&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="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;n_probe&lt;/span&gt; &lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PROBE&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="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;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="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="nf"&gt;log_softmax&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;tgt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ids&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
            &lt;span class="n"&gt;lp&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="nf"&gt;gather&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;tgt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&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="nf"&gt;squeeze&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;total&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;lp&lt;/span&gt;&lt;span class="p"&gt;[:,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n_probe&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="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;   &lt;span class="c1"&gt;# log p(PROBE | prompt)
&lt;/span&gt;        &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;log_history&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;step&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;global_step&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;probe/refusal_logp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt; &lt;span class="o"&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;prompts&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;probe/refusal_logp&lt;/code&gt; climbs while &lt;code&gt;logps/chosen&lt;/code&gt; falls, you are watching mass move in real time. That number correlates with over-refusal on your held-out benign set far earlier than any win-rate eval will show it.&lt;/p&gt;

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

&lt;p&gt;Four interventions, roughly in order of effort-to-payoff.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Anchor the chosen response with an NLL term.&lt;/strong&gt; This is the single highest-value change. Adding &lt;code&gt;λ · −log π_θ(y_w|x)&lt;/code&gt; to the loss gives the optimizer an explicit reason to keep &lt;code&gt;log π(y_w)&lt;/code&gt; high instead of only maximizing the margin. TRL exposes it as &lt;code&gt;rpo_alpha&lt;/code&gt;; Llama 3's post-training used the same idea, and CPO/RPO variants formalize it.&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;cfg&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="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 chosen — the fix that matters most
&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="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 an order of magnitude less than SFT
&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;2048&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_prompt_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;logging_steps&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="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&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;sft_checkpoint&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# must be the SFT model, not the base
&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;cfg&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;pairs&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;callbacks&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nc"&gt;EscapeMassProbe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;benign_prompts&lt;/span&gt;&lt;span class="p"&gt;)],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Filter high-similarity pairs before training.&lt;/strong&gt; The gradient condition says similar pairs are the problem, so measure similarity in the space that actually drives the gradient — final hidden states, not text. CHES (centered hidden embedding similarity) is a cheap proxy for &lt;code&gt;⟨g_w, g_l⟩&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="nd"&gt;@torch.no_grad&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;ches&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="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;y_l&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;resp_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;y&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&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="n"&gt;device&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="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y&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;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;output_hidden_states&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;hidden_states&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;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;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;n&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                     &lt;span class="c1"&gt;# sum over response tokens
&lt;/span&gt;    &lt;span class="n"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;resp_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_w&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;resp_embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;y_l&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;mu&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hw&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;hl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;                           &lt;span class="c1"&gt;# center within the pair
&lt;/span&gt;    &lt;span class="n"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hw&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hl&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;mu&lt;/span&gt;
    &lt;span class="k"&gt;return&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;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hl&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="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;pairs&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;ches&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ref&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="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chosen&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.75&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dropping the top-similarity slice — the pairs whose chosen and rejected are minimal edits of each other — removes most of the displacement pressure while keeping the pairs that carry real preference signal. Text-level edit distance is a weaker but zero-cost first pass.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Check your reference model.&lt;/strong&gt; If chosen responses were distilled from a stronger model, &lt;code&gt;log π_ref(y_w)&lt;/code&gt; is already low, &lt;code&gt;‖g_w‖&lt;/code&gt; is large in a direction the policy can't cheaply follow, and the optimizer takes the easy route of crushing &lt;code&gt;y_l&lt;/code&gt;. Run one SFT epoch on the chosen responses first and use &lt;em&gt;that&lt;/em&gt; as &lt;code&gt;π_ref&lt;/code&gt;. This is why "SFT on chosen, then DPO" is the standard recipe and not a ritual.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Raise β, carefully.&lt;/strong&gt; Larger β saturates the sigmoid sooner, so the effective step shrinks once the margin is achieved, limiting how far both log-probs can drift. It also slows learning. Treat it as a damper, not a cure — β from 0.1 to 0.3 is a reasonable sweep before you conclude the data is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does this happen with PPO or GRPO too?
&lt;/h2&gt;

&lt;p&gt;Not the same way. Policy-gradient methods increase &lt;code&gt;log π(y)&lt;/code&gt; directly for sampled completions with positive advantage — there's no paired difference term that can be satisfied by lowering both sides. Sampling is on-policy, so the mass that gets redistributed lands near the current policy rather than on some distant refusal mode, and the explicit KL penalty against the reference bounds total drift. You trade the displacement failure mode for reward hacking and a much heavier training loop, which is a real trade, not a free win.&lt;/p&gt;

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

&lt;p&gt;DPO likelihood displacement happens because the DPO loss constrains only the difference between chosen and rejected log-probabilities, never their absolute level — so gradient descent is free to satisfy the objective by pushing both down, which it does whenever the rejected response's gradient is aligned with and larger than the chosen response's (&lt;code&gt;⟨g_w, g_l⟩ &amp;gt; ‖g_w‖²&lt;/code&gt;), the common case for near-duplicate preference pairs. The vacated probability mass moves to whatever is geometrically adjacent and high-prior, typically a generic refusal that never appears in your training set — which is why safety-focused DPO runs so often produce over-refusing models with excellent margin curves. Log &lt;code&gt;logps/chosen&lt;/code&gt; in absolute terms alongside a refusal probe, add an NLL anchor on the chosen response (&lt;code&gt;rpo_alpha=1.0&lt;/code&gt;), filter out high-CHES pairs, and make sure your reference model is the SFT checkpoint that already fits the chosen responses.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>ColBERT Late Interaction: Why MaxSim Beats One Vector Per Chunk</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 30 Jul 2026 20:52:50 +0000</pubDate>
      <link>https://dev.to/ji_ai/colbert-late-interaction-why-maxsim-beats-one-vector-per-chunk-e8e</link>
      <guid>https://dev.to/ji_ai/colbert-late-interaction-why-maxsim-beats-one-vector-per-chunk-e8e</guid>
      <description>&lt;p&gt;A single dense vector per chunk asks your encoder to do something absurd: compress everything anyone might ever ask about a 250-token passage into one point in 1024-dimensional space, before it has seen the question. It works fine until your query mentions a part number, a drug interaction, or a clause that occupies 8 of those 250 tokens. Then the pooled vector — dominated by the other 242 — sits nowhere near the query, and your retriever silently returns the wrong chunk.&lt;/p&gt;

&lt;p&gt;ColBERT late interaction attacks this by refusing to pool. It keeps one embedding per token and scores with MaxSim. Here's the mechanism, the real storage bill, and the four things that break in production.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ColBERT late interaction&lt;/strong&gt; stores one vector per token instead of one per chunk, and scores with &lt;strong&gt;MaxSim&lt;/strong&gt;: every query token independently finds its best-matching document token, and those maxima are summed.&lt;/li&gt;
&lt;li&gt;It's "late" because query and document never meet inside the transformer — document embeddings are precomputed offline, unlike a cross-encoder. You get term-level evidence at bi-encoder indexing cost.&lt;/li&gt;
&lt;li&gt;The win is out-of-domain robustness. Pooling destroys rare-entity signal; MaxSim preserves it, so late interaction degrades far more gracefully on domains you never fine-tuned on.&lt;/li&gt;
&lt;li&gt;Storage is ~30x a single-vector index uncompressed, but ColBERTv2-style residual compression (centroid + 1–2 bits/dim) cuts it to roughly 4x. That's the difference between shippable and not.&lt;/li&gt;
&lt;li&gt;MaxSim scores are &lt;strong&gt;unnormalized sums over query tokens&lt;/strong&gt;. They are not comparable across queries, so fixed thresholds and naive score fusion are broken by construction.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is late interaction in ColBERT, and how does MaxSim score a document?
&lt;/h2&gt;

&lt;p&gt;Late interaction encodes the query and the document separately into &lt;em&gt;sets&lt;/em&gt; of token vectors, then computes their similarity with a cheap, non-neural operator afterwards. The operator is MaxSim:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S(q, d) = Σ_{i=1..|q|}  max_{j=1..|d|}  q_i · d_j
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both sides are L2-normalized, so each dot product is a cosine in [-1, 1]. Query token &lt;em&gt;i&lt;/em&gt; scans every document token, takes its single best match, and contributes that number. Nothing else about the document matters to that query token.&lt;/p&gt;

&lt;p&gt;Compare the three regimes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bi-encoder&lt;/strong&gt;: &lt;code&gt;score = pooled_q · pooled_d&lt;/code&gt;. One dot product. Interaction happens after total compression — too late, too lossy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-encoder&lt;/strong&gt;: query and document go through the transformer together, full attention between every pair. Best quality, but you cannot precompute anything, so it only works on a top-k shortlist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Late interaction&lt;/strong&gt;: interaction after encoding but before pooling. Document vectors are precomputable; you still get per-term matching.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The scoring itself is a small matmul:&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;maxsim&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="n"&gt;doc_mask&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:        [nq, dim]           L2-normalized query token embeddings
                                  (includes [MASK] expansion tokens)
    D:        [ndocs, dlen, dim]  L2-normalized doc token embeddings
    doc_mask: [ndocs, dlen]       True for real tokens, False for padding
    returns:  [ndocs]             MaxSim scores
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="c1"&gt;# pairwise cosine 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;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;qh,nlh-&amp;gt;nql&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;# [ndocs, nq, dlen]
&lt;/span&gt;
    &lt;span class="c1"&gt;# padding must never win a max
&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;doc_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;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="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;# max over doc, sum over query
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a 32-token query, a 220-token document and dim=128, that's a 32x220x128 matmul per candidate — nothing. The cost of late interaction is never the arithmetic. It's the memory traffic to get those document vectors into registers, which is why the entire engineering story is about compression and candidate pruning.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why does MaxSim beat a single dense vector out of domain?
&lt;/h2&gt;

&lt;p&gt;Because pooling is a lossy summary chosen before the question is known, and MaxSim isn't.&lt;/p&gt;

&lt;p&gt;Take a chunk that covers three things: a product's warranty terms, its power requirements, and a shipping note. A mean-pooled vector lands at the centroid of those three topics — close to none of them. A query about power requirements has to be near that centroid to retrieve the chunk, and it isn't. Under MaxSim, only the ~15 tokens describing power draw need to match. The other 200 tokens contribute nothing and cost nothing.&lt;/p&gt;

&lt;p&gt;This is why late interaction behaves like a soft, contextualized BM25. It has the term-matching precision of lexical search — a rare token in the query lands on a rare token in the document — but the tokens are contextual embeddings, so synonyms and morphological variants still match. You get lexical robustness without lexical brittleness.&lt;/p&gt;

&lt;p&gt;The practical consequence: on domains you never trained on, a fine-tuned single-vector bi-encoder falls apart in a way late interaction does not. Single-vector models learn what to keep during pooling &lt;em&gt;for their training distribution&lt;/em&gt;. Shift the distribution and they keep the wrong things. ColBERT-style models have much less to unlearn, because the decision of what matters is deferred to query time. If you have no labeled data for your corpus, this is the strongest argument for the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does per-token indexing actually cost in storage?
&lt;/h2&gt;

&lt;p&gt;Do the arithmetic before you fall in love with the quality numbers.&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="n"&gt;DIM&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;     &lt;span class="c1"&gt;# ColBERT output dim (projected down from the encoder's 768)
&lt;/span&gt;&lt;span class="n"&gt;DOC_MAXLEN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;220&lt;/span&gt;     &lt;span class="c1"&gt;# tokens kept per chunk; everything past this is invisible
&lt;/span&gt;&lt;span class="n"&gt;NBITS&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;       &lt;span class="c1"&gt;# residual bits per dimension (ColBERTv2-style compression)
&lt;/span&gt;
&lt;span class="n"&gt;single_vector&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;                       &lt;span class="c1"&gt;# 1024-dim fp16 = 2,048 B / chunk
&lt;/span&gt;&lt;span class="n"&gt;uncompressed&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DOC_MAXLEN&lt;/span&gt; &lt;span class="o"&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="c1"&gt;# fp16 per token = 56,320 B / chunk
&lt;/span&gt;&lt;span class="n"&gt;compressed&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;DOC_MAXLEN&lt;/span&gt; &lt;span class="o"&gt;*&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="n"&gt;NBITS&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;  &lt;span class="c1"&gt;# residual = 32 B
&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="c1"&gt;# centroid id = 4 B  -&amp;gt; 7,920 B / chunk
&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;uncompressed&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;single_vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ~27x
&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;compressed&lt;/span&gt;  &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;single_vector&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# ~3.9x
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Naively, one vector per token is ~30x the footprint of a single-vector index. Nobody ships that at scale.&lt;/p&gt;

&lt;p&gt;ColBERTv2's residual compression is what makes it tractable. Run k-means over all token embeddings in the corpus to get a centroid codebook. For each token, store the nearest centroid's id plus the residual (token minus centroid) quantized to 1 or 2 bits per dimension. Because contextual token embeddings cluster hard — the same word in the same sense lands in the same place — the residuals are small and survive brutal quantization. At 2 bits you're at ~36 bytes per token; at 1 bit, ~20 bytes. That's ~4x a single-vector index, not 30x, and it's the whole reason the architecture is deployable.&lt;/p&gt;

&lt;p&gt;Note the second-order effect: the codebook itself gives you a candidate generation mechanism for free.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you retrieve candidates without scoring every document?
&lt;/h2&gt;

&lt;p&gt;You don't scan the corpus. You probe the centroid index with each query token separately, and let the union of their neighborhoods define the candidate set.&lt;/p&gt;

&lt;p&gt;The PLAID-style pipeline runs in stages:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Centroid probe.&lt;/strong&gt; Each of the ~32 query token embeddings does an ANN lookup against the centroid codebook (tens of thousands of centroids, not billions of tokens). Collect the document ids that touch those centroids.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Centroid-only scoring.&lt;/strong&gt; Approximate MaxSim using &lt;em&gt;only&lt;/em&gt; centroids, never decompressing residuals. This is cheap and prunes aggressively.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full scoring.&lt;/strong&gt; Decompress residuals for the survivors and compute exact MaxSim.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each stage narrows by roughly an order of magnitude. Stage 3 touches a tiny fraction of the corpus, which is what keeps latency in the same ballpark as HNSW despite the vector count.&lt;/p&gt;

&lt;p&gt;Also worth knowing: &lt;strong&gt;token pooling&lt;/strong&gt;. Adjacent or near-duplicate document token embeddings can be clustered and collapsed before indexing, cutting vector count meaningfully with modest quality loss. It's the first knob to reach for when your index doesn't fit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What breaks in production?
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MaxSim scores are unnormalized sums, so thresholds are meaningless.&lt;/strong&gt; The score is a sum over |q| terms. A 40-token query scores structurally higher than a 6-token query on the same document. There is no fixed cutoff that means "relevant." Two consequences: never hard-threshold raw MaxSim, and never feed raw MaxSim into a weighted score fusion with BM25 — use rank-based fusion, or divide by query length first. Teams port a &lt;code&gt;score &amp;gt; 0.7&lt;/code&gt; filter from cosine-similarity search and quietly retrieve nothing for short queries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query padding is query expansion, not padding.&lt;/strong&gt; ColBERT pads queries to a fixed &lt;code&gt;query_maxlen&lt;/code&gt; (32 is the usual default) with &lt;code&gt;[MASK]&lt;/code&gt; tokens, and those masks are &lt;em&gt;encoded&lt;/em&gt;, kept, and scored. They learn to act as contextual expansion terms. So &lt;code&gt;query_maxlen&lt;/code&gt; is a retrieval-behavior knob, not a buffer size. Doubling it doesn't just cost memory; it changes what the model retrieves. Tune it, and be aware very short queries get proportionally more expansion.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Long documents win.&lt;/strong&gt; Max over more tokens means more chances at a spuriously high match. Late interaction has a length bias toward long chunks, which matters if your corpus mixes 50-token FAQ entries with 400-token manual sections. Keep chunk lengths roughly uniform, or the length distribution becomes part of your ranking function. (ColBERT filters punctuation from document embeddings for a related reason — junk tokens that can win a max are pure noise.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;doc_maxlen&lt;/code&gt; truncation is silent.&lt;/strong&gt; Tokens past the limit are simply not indexed. Set &lt;code&gt;doc_maxlen&lt;/code&gt; to 180 with 400-token chunks and half of every chunk is unretrievable, with no error and no log line. Measure your chunk token-length distribution against &lt;code&gt;doc_maxlen&lt;/code&gt; before you trust any eval.&lt;/p&gt;

&lt;h2&gt;
  
  
  When should you not use ColBERT late interaction?
&lt;/h2&gt;

&lt;p&gt;When your chunks are short and single-topic, your queries are in-domain, and you have labeled pairs to fine-tune on. A well-tuned single-vector retriever plus a cross-encoder reranker on the top 50 will match it, costs a quarter of the storage, and runs on any vector database you already operate. Late interaction pays for itself specifically when you have domain shift and no training data, heterogeneous multi-topic chunks, or entity-heavy queries where a single missing term should sink a result.&lt;/p&gt;

&lt;p&gt;The infrastructure constraint is real too: this needs an engine that understands multi-vector indexing and residual codebooks. That's a narrower set of options than "anything that speaks HNSW," and it's a genuine operational cost, not a footnote.&lt;/p&gt;

&lt;h2&gt;
  
  
  So why does MaxSim beat one vector per chunk?
&lt;/h2&gt;

&lt;p&gt;ColBERT late interaction wins because it defers the compression decision until the query arrives. A single-vector bi-encoder must decide what a passage "means" during indexing, and mean-pooling 250 contextual token embeddings destroys exactly the rare, specific signal that distinguishes one chunk from its near-duplicates. MaxSim keeps every token vector and lets each query token pick its own best evidence, giving lexical-grade precision with contextual matching — which is why it degrades so much more gracefully out of domain. You pay for it in index size (~30x raw, ~4x after centroid-plus-residual compression) and in a retrieval pipeline that must prune through a centroid codebook rather than a plain ANN index. Take the trade when you have domain shift and no labeled data; skip it when a fine-tuned bi-encoder plus a reranker already covers your queries.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Surface Form Competition: Why Log-Prob Answer Scoring Fails</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Thu, 30 Jul 2026 08:50:31 +0000</pubDate>
      <link>https://dev.to/ji_ai/surface-form-competition-why-log-prob-answer-scoring-fails-3i7n</link>
      <guid>https://dev.to/ji_ai/surface-form-competition-why-log-prob-answer-scoring-fails-3i7n</guid>
      <description>&lt;p&gt;Your eval harness scores four candidate answers by summing token log-probabilities and picks the highest. The model "knows" the answer is &lt;code&gt;a nuclear power plant&lt;/code&gt;, but the harness returns &lt;code&gt;coal&lt;/code&gt; — because the right answer is four tokens longer and every extra token multiplies in another probability less than one. Nothing is broken. The scoring function is doing exactly what you asked. It's just measuring the wrong thing.&lt;/p&gt;

&lt;p&gt;This failure has a name: &lt;strong&gt;surface form competition&lt;/strong&gt;. If you rank answer options by &lt;code&gt;log P(option | prompt)&lt;/code&gt;, you are ranking strings, not meanings, and strings compete with each other in ways that have nothing to do with whether the model knows the answer.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Log-prob answer scoring conflates three things&lt;/strong&gt;: whether the answer is right, how long its surface form is, and how a-priori likely that string was anyway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Length bias is the easy half.&lt;/strong&gt; Normalize by &lt;em&gt;bytes&lt;/em&gt;, not tokens — token counts are tokenizer-dependent, so per-token normalization is not comparable across models or even across options.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surface form competition is the hard half.&lt;/strong&gt; Probability mass splits across paraphrases (&lt;code&gt;computer&lt;/code&gt; / &lt;code&gt;a computer&lt;/code&gt; / &lt;code&gt;the computer&lt;/code&gt;), so no length normalization can rescue a correct answer whose mass is spread thin.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PMI / domain-conditional normalization is the standard fix&lt;/strong&gt;: score &lt;code&gt;log P(a | question) − log P(a | domain premise)&lt;/code&gt; to subtract out the option's a-priori plausibility.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Switching to A/B/C/D doesn't dodge the problem&lt;/strong&gt; — it trades length bias for token-prior and position bias. Fix that with contextual calibration and cyclic permutation, not by hoping it averages out.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What is surface form competition in LLM evaluation?
&lt;/h2&gt;

&lt;p&gt;Surface form competition is what happens when one meaning has many valid spellings and each spelling gets its own slice of the probability mass. An autoregressive LM must put mass on &lt;em&gt;strings&lt;/em&gt;. If the correct concept can be written as &lt;code&gt;a nuclear power plant&lt;/code&gt;, &lt;code&gt;nuclear power&lt;/code&gt;, &lt;code&gt;nuclear plants&lt;/code&gt;, or &lt;code&gt;nuclear energy&lt;/code&gt;, each of those gets a fraction, and each competes against a single-form distractor like &lt;code&gt;coal&lt;/code&gt; that hogs all of its own mass.&lt;/p&gt;

&lt;p&gt;The model can be perfectly calibrated about the concept and still rank the wrong string first. This is a measurement artifact of your scoring function, not a knowledge failure — and it means an eval regression can appear when nothing about the model's understanding changed.&lt;/p&gt;

&lt;p&gt;Length bias rides along on top of it. Under the chain rule:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;log P(a | q) = Σ_i log P(a_i | q, a_&amp;lt;i)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every additional token adds a negative term. If the model's average per-token log-probability in this context is roughly &lt;code&gt;−H&lt;/code&gt;, the sequence score scales like &lt;code&gt;−H · L&lt;/code&gt;. Longer answers lose by construction. This is why &lt;code&gt;lm-evaluation-harness&lt;/code&gt; reports both &lt;code&gt;acc&lt;/code&gt; and &lt;code&gt;acc_norm&lt;/code&gt;, and why on datasets with long, uneven endings (HellaSwag is the canonical case) the two numbers are visibly different — sometimes by enough to reorder a leaderboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Does normalizing by length fix it?
&lt;/h2&gt;

&lt;p&gt;It fixes the length half, and only if you normalize by the right unit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do not divide by token count.&lt;/strong&gt; Tokenization is model-specific: the same answer string is 3 tokens under one tokenizer and 6 under another, and within a single option set, an answer full of rare proper nouns fragments far more than a common phrase. Per-token normalization silently rewards options that tokenize efficiently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Divide by byte length&lt;/strong&gt; (&lt;code&gt;len(option.encode("utf-8"))&lt;/code&gt;). Bytes are tokenizer-invariant, so the same normalized score is comparable across models — this is exactly what &lt;code&gt;acc_norm&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;What byte normalization does &lt;em&gt;not&lt;/em&gt; fix is mass splitting. If &lt;code&gt;a computer&lt;/code&gt; and &lt;code&gt;the computer&lt;/code&gt; and &lt;code&gt;computer&lt;/code&gt; each take a third of the concept's mass, dividing each by its own length leaves all three below a distractor that never had competition. You need a second correction.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does PMI normalization actually do?
&lt;/h2&gt;

&lt;p&gt;It divides out the option's prior. Instead of ranking by &lt;code&gt;log P(a | q)&lt;/code&gt;, rank by a pointwise-mutual-information-style score:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;score(a) = log P(a | q) − log P(a | domain_premise)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second term is the option's log-probability under a content-free premise from the same domain — &lt;code&gt;"Answer:"&lt;/code&gt; for a QA task, &lt;code&gt;"The sentence continues:"&lt;/code&gt; for a completion task. Subtracting it asks a sharper question: &lt;em&gt;how much did seeing the question raise this string's likelihood?&lt;/em&gt; A string that was already probable (short, frequent, generic) gets docked; a string that only became probable because of the question gets rewarded.&lt;/p&gt;

&lt;p&gt;Use a &lt;strong&gt;domain-conditional&lt;/strong&gt; premise rather than the fully unconditional &lt;code&gt;P(a)&lt;/code&gt;. Unconditional normalization over-corrects toward rare, weird strings, because rarity alone maximizes the ratio. Keeping the premise inside the task's domain and format cancels the domain prior without handing the win to nonsense.&lt;/p&gt;

&lt;p&gt;Here's all four scorers, computed from one forward pass per (prompt, option) pair:&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="nd"&gt;@torch.no_grad&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;seq_logprob&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="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Sum of log P(cont | prompt) plus its token and byte lengths.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;p_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;to&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="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;full_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;cont&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_tensors&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pt&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;to&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="n"&gt;device&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;n_p&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;p_ids&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="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;# Boundary check: BPE can merge the last prompt char with the first
&lt;/span&gt;    &lt;span class="c1"&gt;# continuation char, which shifts every index below by one.
&lt;/span&gt;    &lt;span class="k"&gt;assert&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;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_ids&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="p"&gt;:&lt;/span&gt;&lt;span class="n"&gt;n_p&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;p_ids&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokenizer merged across the boundary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;logits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;full_ids&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;logits&lt;/span&gt;                       &lt;span class="c1"&gt;# [1, T, V]
&lt;/span&gt;    &lt;span class="n"&gt;logprobs&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;log_softmax&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="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="nf"&gt;float&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;targets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;full_ids&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;# next-token targets
&lt;/span&gt;    &lt;span class="n"&gt;tok_lp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;logprobs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&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;targets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&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="nf"&gt;squeeze&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;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

    &lt;span class="n"&gt;cont_lp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tok_lp&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n_p&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;# predictions for the continuation tokens
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cont_lp&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="nf"&gt;item&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;cont_lp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;numel&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;cont&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&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;score_option&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="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;premise&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Answer:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;lp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_bytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;seq_logprob&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="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;lp_prior&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;seq_logprob&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="n"&gt;tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;premise&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;option&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;lp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;               &lt;span class="c1"&gt;# length-biased; do not ship this
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per_token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;lp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n_tok&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;# tokenizer-dependent; not comparable
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;per_byte&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="n"&gt;lp&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;n_bytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# this is acc_norm
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pmi_dc&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="n"&gt;lp&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lp_prior&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# domain-conditional PMI
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details that quietly corrupt results if you skip them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Own the whitespace.&lt;/strong&gt; Strip trailing spaces from the prompt and put the leading space on the continuation (&lt;code&gt;" a nuclear power plant"&lt;/code&gt;). A prompt ending in a space makes the model predict a token that almost never starts a word, and it tanks the score of every option equally &lt;em&gt;except&lt;/em&gt; the ones where the merge happens to work out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert the prefix property.&lt;/strong&gt; The &lt;code&gt;assert&lt;/code&gt; above is not paranoia. Byte-pair merges across the prompt/continuation boundary shift &lt;code&gt;n_p&lt;/code&gt; and make you score the wrong slice — silently, with plausible-looking numbers.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Why doesn't the A/B/C/D format avoid this?
&lt;/h2&gt;

&lt;p&gt;Because it swaps one bias for two. Reformatting as "answer with a single letter" makes every option exactly one token long, which kills length bias and mass splitting outright. What you get instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Token prior bias.&lt;/strong&gt; The model has a baseline preference over &lt;code&gt;" A"&lt;/code&gt;, &lt;code&gt;" B"&lt;/code&gt;, &lt;code&gt;" C"&lt;/code&gt;, &lt;code&gt;" D"&lt;/code&gt; before it reads anything. That prior is often far from uniform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Position bias.&lt;/strong&gt; Move the correct answer to a different slot and the prediction changes. This is the same class of artifact as position bias in LLM-as-judge setups, and it does not average out on small eval sets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Symbol binding failure.&lt;/strong&gt; Weaker or base models often "know" the content but can't reliably map it to the letter, which reads as a knowledge failure and isn't one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The fix for the prior is &lt;strong&gt;contextual calibration&lt;/strong&gt;: measure the model's letter distribution on a content-free input, then apply a diagonal affine correction that flattens it.&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;LETTERS&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;A&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;B&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;C&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;D&lt;/span&gt;&lt;span class="sh"&gt;"&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;letter_probs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logprob_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ndarray&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Softmax restricted to the four letter tokens.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;lps&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;array&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;logprob_fn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;letter&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;letter&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;LETTERS&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;e&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;lps&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;lps&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;e&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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;calibrated_pick&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logprob_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="n"&gt;content_free&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;N/A&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="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[MASK]&lt;/span&gt;&lt;span class="sh"&gt;"&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="nf"&gt;letter_probs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logprob_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="c1"&gt;# Same template, same options, no question content.
&lt;/span&gt;    &lt;span class="n"&gt;p_cf&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;mean&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;letter_probs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;logprob_fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;options&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;cf&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content_free&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;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;W&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;1.0&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;p_cf&lt;/span&gt;                       &lt;span class="c1"&gt;# diag(p_cf)^-1, b = 0
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;LETTERS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;int&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;argmax&lt;/span&gt;&lt;span class="p"&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;p&lt;/span&gt;&lt;span class="p"&gt;))]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calibration set matters: use the &lt;em&gt;same&lt;/em&gt; rendered template and the &lt;em&gt;same&lt;/em&gt; option strings, with only the question replaced. You are estimating the template's bias, not the model's general letter preference.&lt;/p&gt;

&lt;p&gt;If your provider doesn't return token log-probabilities, this method isn't available directly. OpenAI-style APIs expose &lt;code&gt;logprobs&lt;/code&gt; / &lt;code&gt;top_logprobs&lt;/code&gt; and you can pin the output to the letter set with &lt;code&gt;logit_bias&lt;/code&gt;. Anthropic's Messages API does not return token log-probs, so for Claude Opus 4.x or Sonnet 4.x you get the equivalent debiasing behaviorally: run each item under &lt;strong&gt;cyclic permutations of the option order&lt;/strong&gt; and take a majority vote. Four permutations for a four-way item costs 4× the calls, and it removes both position bias and letter-prior bias without needing scores at all. Structured output (a single-field schema constrained to the letter set) keeps parsing clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which scorer should you actually ship?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setup&lt;/th&gt;
&lt;th&gt;Scorer&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Base model, cloze/completion format&lt;/td&gt;
&lt;td&gt;per-byte + PMI-DC&lt;/td&gt;
&lt;td&gt;Length and prior both bite; report both, they disagree informatively&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Base model, uneven-length options&lt;/td&gt;
&lt;td&gt;PMI-DC&lt;/td&gt;
&lt;td&gt;Length norm alone leaves mass splitting untouched&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Instruction-tuned model, letter format&lt;/td&gt;
&lt;td&gt;contextual calibration + cyclic permutation&lt;/td&gt;
&lt;td&gt;No length bias; prior and position bias dominate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No log-prob access (Claude, most hosted chat APIs)&lt;/td&gt;
&lt;td&gt;cyclic permutation + majority vote&lt;/td&gt;
&lt;td&gt;Behavioral equivalent of calibration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two operating rules. First, &lt;strong&gt;report the scorer alongside the number&lt;/strong&gt; — "72% on ARC-Challenge" is not a claim until you say whether that's &lt;code&gt;acc&lt;/code&gt;, &lt;code&gt;acc_norm&lt;/code&gt;, or PMI. Second, &lt;strong&gt;fix the scorer before you fix the model&lt;/strong&gt;: a scoring change can move a benchmark several points, which is comfortably larger than most real gains you're chasing, and you don't want to spend a week attributing one to the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Failure-mode checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Prompt ends in a space → whitespace collides with the continuation's leading token.&lt;/li&gt;
&lt;li&gt;Prompt is not a token-level prefix of prompt+continuation → your slice indices are off by one.&lt;/li&gt;
&lt;li&gt;Normalizing by token count → results not comparable across tokenizers.&lt;/li&gt;
&lt;li&gt;Unconditional PMI instead of domain-conditional → rare, malformed options win.&lt;/li&gt;
&lt;li&gt;Letter format with no calibration → you're partly measuring the model's fondness for &lt;code&gt;" C"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Fixed option order across the whole eval set → position bias baked into the headline number.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Log-prob answer scoring fails because of &lt;strong&gt;surface form competition&lt;/strong&gt;: &lt;code&gt;log P(option | prompt)&lt;/code&gt; measures the likelihood of a &lt;em&gt;string&lt;/em&gt;, which bundles together correctness, length, and the string's a-priori frequency. Longer answers accumulate more negative log terms, and correct answers with many valid paraphrases have their probability mass split across them, so a short single-form distractor can outrank an answer the model actually knows. Normalize by byte length to remove the length term, subtract a domain-conditional prior (PMI) to remove the frequency term, and if you switch to A/B/C/D letters, apply contextual calibration and permute the option order — because that format doesn't eliminate the bias, it just moves it somewhere less visible.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Activation Outliers: Why W8A8 INT8 Quantization Needs SmoothQuant</title>
      <dc:creator>jidonglab</dc:creator>
      <pubDate>Wed, 29 Jul 2026 20:47:01 +0000</pubDate>
      <link>https://dev.to/ji_ai/activation-outliers-why-w8a8-int8-quantization-needs-smoothquant-1j35</link>
      <guid>https://dev.to/ji_ai/activation-outliers-why-w8a8-int8-quantization-needs-smoothquant-1j35</guid>
      <description>&lt;p&gt;Take a 7B+ decoder, dump the input activations to &lt;code&gt;down_proj&lt;/code&gt; in layer 20, and look at the per-channel absolute max. Most of the 11008 channels sit around 0.3–1.0. Six or seven of them sit at 40–90. Same channel indices, every token, every prompt, across most layers.&lt;/p&gt;

&lt;p&gt;Now quantize that tensor to INT8 with one scale: &lt;code&gt;s = 70 / 127 ≈ 0.55&lt;/code&gt;. Your typical activation of 0.5 rounds to 1. Your 0.4 rounds to 1. Your 0.2 rounds to 0. You just replaced a 4096-dimensional vector with a 3-bit vector plus a few giant spikes, and your model now writes fluent nonsense while perplexity looks "only a bit worse."&lt;/p&gt;

&lt;p&gt;That is the whole story of why W8A8 INT8 quantization fails on large language models. It is not a rounding-mode problem, and no amount of better calibration search fixes it. Activation outliers are structural, and the fix is an algebraic one.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Activation outliers&lt;/strong&gt; are systematic: a handful of hidden channels carry magnitudes one to two orders of magnitude above the rest, at the same indices across tokens and layers. LLM.int8() reported them emerging consistently around the ~6.7B parameter mark.&lt;/li&gt;
&lt;li&gt;Per-tensor INT8 activation quantization collapses because one channel sets the scale for all of them. Per-token scaling does not help — the outlier is inside every row.&lt;/li&gt;
&lt;li&gt;You &lt;strong&gt;cannot&lt;/strong&gt; quantize activations per input channel in an INT8 GEMM: the channel axis is the reduction axis, so the scale does not factor out of the int32 accumulator.&lt;/li&gt;
&lt;li&gt;SmoothQuant exploits &lt;code&gt;Y = (X diag(s)⁻¹)(diag(s) W)&lt;/code&gt; to migrate range from activations into weights per channel, then folds &lt;code&gt;diag(s)⁻¹&lt;/code&gt; into the preceding RMSNorm/LayerNorm affine. Zero runtime cost, mathematically exact.&lt;/li&gt;
&lt;li&gt;If your decode is memory-bound at small batch, skip activation quantization entirely and use weight-only 4-bit (AWQ/GPTQ). W8A8 only pays off when you are compute-bound: large-batch serving and prefill.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why do activation outliers break per-tensor INT8 quantization?
&lt;/h2&gt;

&lt;p&gt;Because INT8 has 256 levels of &lt;em&gt;uniform&lt;/em&gt; resolution and outliers force nearly all of them to be spent on values that almost never occur.&lt;/p&gt;

&lt;p&gt;Weights are easy to quantize. Per output channel, weight distributions are close to Gaussian with a tame dynamic range — INT8 per-channel weight quantization is essentially free in accuracy. Activations are not. The residual stream in a trained transformer develops a small set of channels that behave like fixed, high-magnitude biases. They correlate with the "massive activation" / attention-sink phenomenon: the model uses a few dimensions as a near-constant reference signal rather than as content.&lt;/p&gt;

&lt;p&gt;The damage math is simple. For symmetric absmax quantization, the step size is &lt;code&gt;Δ = max|X| / 127&lt;/code&gt;. Signal-to-noise for the non-outlier bulk scales with &lt;code&gt;E|x| / Δ&lt;/code&gt;. Push &lt;code&gt;max|X|&lt;/code&gt; up 100x and you lose ~6.6 bits of effective precision on the values that actually carry the token's meaning.&lt;/p&gt;

&lt;p&gt;Clipping the outliers is not an escape hatch. Those channels are load-bearing; clip them and you break the sink behavior, which shows up as degraded long-context retrieval and unstable attention at exactly the point where quantization was supposed to be invisible.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why can't you just quantize activations per channel?
&lt;/h2&gt;

&lt;p&gt;Because of where the scale sits relative to the summation. This is the part most people get wrong, and it is pure linear algebra.&lt;/p&gt;

&lt;p&gt;For &lt;code&gt;Y = X W&lt;/code&gt; with &lt;code&gt;X ∈ ℝ^{T×Cin}&lt;/code&gt;, &lt;code&gt;W ∈ ℝ^{Cin×Cout}&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;Y[t, o] = Σ_c  X[t, c] * W[c, o]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A per-token activation scale &lt;code&gt;a[t]&lt;/code&gt; and a per-output-channel weight scale &lt;code&gt;b[o]&lt;/code&gt; both pull straight out of the sum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Y[t, o] ≈ a[t] * b[o] * Σ_c  Xq[t, c] * Wq[c, o]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is why INT8 kernels are happy with per-token × per-out-channel: the int32 accumulator is computed once, then rescaled once in the epilogue.&lt;/p&gt;

&lt;p&gt;A per-input-channel activation scale &lt;code&gt;g[c]&lt;/code&gt; does &lt;strong&gt;not&lt;/strong&gt; pull out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Y[t, o] = Σ_c  g[c] * Xq[t, c] * W[c, o]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scale is trapped inside the reduction. Applying it would mean dequantizing before accumulation — which is exactly what tensor cores refuse to do, and what would erase the throughput win you quantized for in the first place.&lt;/p&gt;

&lt;p&gt;This asymmetry also explains why &lt;strong&gt;weight-only&lt;/strong&gt; 4-bit quantization gets away with group-wise scales along &lt;code&gt;Cin&lt;/code&gt; (group size 128 is standard in AWQ/GPTQ kernels). W4A16 kernels dequantize weights to FP16 and accumulate in FP16/FP32, so per-group scales are applied &lt;em&gt;before&lt;/em&gt; the multiply-accumulate. INT8 W8A8 has no such freedom.&lt;/p&gt;

&lt;p&gt;So per-channel is where the outliers are, and per-channel is the one axis you cannot use. That is the trap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does SmoothQuant actually do?
&lt;/h2&gt;

&lt;p&gt;It moves the problem to an axis you &lt;em&gt;can&lt;/em&gt; scale. For a diagonal &lt;code&gt;diag(s)&lt;/code&gt; with positive entries:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Y = X W = (X diag(s)⁻¹) (diag(s) W)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Divide activation channel &lt;code&gt;c&lt;/code&gt; by &lt;code&gt;s[c]&lt;/code&gt;, multiply weight row &lt;code&gt;c&lt;/code&gt; by &lt;code&gt;s[c]&lt;/code&gt;. Exact, not an approximation. Choose &lt;code&gt;s&lt;/code&gt; so both operands become quantizable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;s[c] = max|X[:, c]|^α  /  max|W[c, :]|^(1-α)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;α = 0.5&lt;/code&gt; splits the difficulty evenly; higher &lt;code&gt;α&lt;/code&gt; (0.75–0.85) is what the paper needed for the most outlier-heavy models like OPT-175B. SmoothQuant reports near-lossless W8A8 across OPT/BLOOM-class models with roughly 1.5x latency improvement and half the memory versus FP16.&lt;/p&gt;

&lt;p&gt;The runtime trick is the good part: &lt;code&gt;diag(s)⁻¹&lt;/code&gt; never executes as a kernel. In a pre-norm transformer, the tensor feeding &lt;code&gt;q/k/v&lt;/code&gt; and &lt;code&gt;gate/up&lt;/code&gt; is an RMSNorm output, and RMSNorm's affine weight is an elementwise per-channel multiply &lt;em&gt;after&lt;/em&gt; normalization. Divide that weight by &lt;code&gt;s&lt;/code&gt; and the scaling is free and exact. The normalization statistic is unaffected because you changed the affine, not the input.&lt;/p&gt;

&lt;p&gt;Two structural constraints follow directly:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Every linear consuming the same norm output must share one &lt;code&gt;s&lt;/code&gt;. &lt;code&gt;q_proj&lt;/code&gt;, &lt;code&gt;k_proj&lt;/code&gt;, &lt;code&gt;v_proj&lt;/code&gt; all read the same tensor, so you take the elementwise max over their weight ranges.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;down_proj&lt;/code&gt;'s input has no preceding norm — it comes from &lt;code&gt;act(gate) * up&lt;/code&gt;. Fold &lt;code&gt;s⁻¹&lt;/code&gt; into &lt;code&gt;up_proj&lt;/code&gt;'s &lt;strong&gt;output&lt;/strong&gt; channels instead. That is safe only because &lt;code&gt;up_proj&lt;/code&gt;'s output feeds nothing but the elementwise multiply. Never fold into a projection whose output also lands on a residual add.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you profile outliers and fold the scales?
&lt;/h2&gt;

&lt;p&gt;Collect per-channel absmax over a calibration set, then apply the fold in place:&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;collections&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="nd"&gt;@torch.no_grad&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;collect_channel_absmax&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="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;targets&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;q_proj&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;gate_proj&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;down_proj&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;Per-input-channel absmax of the activations entering each target Linear.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;stats&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&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="n"&gt;hooks&lt;/span&gt; &lt;span class="o"&gt;=&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;make_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_mod&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;inp&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="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;inp&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="nf"&gt;detach&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;x&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;reshape&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;x&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="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="nf"&gt;amax&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="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&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;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&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="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hook&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mod&lt;/span&gt; &lt;span class="ow"&gt;in&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;named_modules&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mod&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;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;(&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;name&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;targets&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;hooks&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;mod&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;register_forward_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;make_hook&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&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;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;batches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="nf"&gt;model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;b&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;h&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;hooks&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="nf"&gt;remove&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;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stats&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;outlier_ratio&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;absmax&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x_sample&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;absmax / p99.9 — above ~10 means per-tensor INT8 will not survive.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;p999&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;quantile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x_sample&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;flatten&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="mf"&gt;0.999&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;absmax&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="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;p999&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="nd"&gt;@torch.no_grad&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;smooth_norm_linears&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;linears&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;act_absmax&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="mf"&gt;0.5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fold diag(s)^-1 into the norm affine, diag(s) into the linear weights.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;w_absmax&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;stack&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;
        &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&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;amax&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;linears&lt;/span&gt;   &lt;span class="c1"&gt;# [Cin] per linear
&lt;/span&gt;    &lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;amax&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="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-5&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;act_absmax&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-5&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="n"&gt;alpha&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;w_absmax&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;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;alpha&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-5&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;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&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;norm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;div_&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="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bias&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="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;norm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;div_&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;l&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;linears&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;weight&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mul_&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;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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things this code makes concrete. First, &lt;code&gt;act_absmax&lt;/code&gt; is a &lt;em&gt;calibration&lt;/em&gt; artifact — if your calibration data does not cover the real input distribution, you underestimate &lt;code&gt;s&lt;/code&gt; on some channels and those channels clip in production. Use a few hundred sequences drawn from actual traffic, including your longest prompts and your tool-call-heavy turns, not just WikiText. Second, verify layer-by-layer output MSE after folding; a silent &lt;code&gt;nan&lt;/code&gt; from a zero-range weight channel is the classic bug, which is why every term is clamped.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you pick alpha without wrecking the weights?
&lt;/h2&gt;

&lt;p&gt;Search it per layer against output error, not globally against perplexity.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;α&lt;/code&gt; is a dial between two failure modes. Too low and activations stay spiky. Too high and you dump so much range onto weights that per-channel INT8 weights start clipping, which is worse because weight error is systematic rather than per-token noise. Sweep &lt;code&gt;α ∈ {0.5, 0.6, 0.7, 0.8, 0.85}&lt;/code&gt; per decoder layer and keep the value minimizing &lt;code&gt;‖Y_fp16 − Y_int8‖²&lt;/code&gt; on calibration activations. This is the same objective AWQ optimizes for weight-only quantization, where activation magnitude is used to identify salient weight channels and scale them up before rounding.&lt;/p&gt;

&lt;p&gt;Do not tune on perplexity. Perplexity is dominated by high-frequency tokens and hides precision loss beautifully. Outlier damage shows up first in exact-copy behavior: long identifiers, JSON field names, base64, digit sequences, multi-hop retrieval from mid-context. Build your quantization eval out of those.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is W8A8 worth it at all?
&lt;/h2&gt;

&lt;p&gt;Only when you are compute-bound. This is the decision most teams skip.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small-batch decode&lt;/strong&gt; is memory-bandwidth-bound on weight loads. Quantizing activations buys nothing; the GEMMs are skinny. Use W4A16 (AWQ/GPTQ, group 128) and get a near-4x cut in weight traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill and large-batch serving&lt;/strong&gt; are compute-bound and arithmetic-intensive. Here INT8/FP8 tensor-core throughput is the point, and W8A8 earns its complexity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KV cache&lt;/strong&gt; is a separate axis problem with its own asymmetry between keys and values; do not assume a W8A8 recipe transfers to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MoE routers&lt;/strong&gt; should stay in higher precision. They are tiny, and a rounding flip in router logits changes expert assignment, which is a discrete, non-recoverable error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does FP8 make activation outliers a non-issue?
&lt;/h2&gt;

&lt;p&gt;Mostly, and for a specific reason: E4M3 spends four bits on the exponent, so its dynamic range spans several orders of magnitude. An outlier channel 100x above the bulk no longer forces the bulk toward zero — it just costs you mantissa precision. That is why per-tensor FP8 on Hopper-class hardware behaves so much better than per-tensor INT8, and why the standard FP8 recipe (per-token activations, per-channel weights) is usually near-lossless without any smoothing pass.&lt;/p&gt;

&lt;p&gt;Blocked formats push this further and, in effect, solve the problem in hardware. MXFP4 attaches an E8M0 scale to every block of 32 values; NVFP4 uses blocks of 16 with an E4M3 scale. Those blocks run &lt;em&gt;along the reduction axis&lt;/em&gt; — precisely the per-channel scaling that INT8 GEMMs cannot express, made legal because the hardware applies the block scale inside the MMA pipeline. The outlier problem was never about bit width. It was about scale granularity on the wrong axis.&lt;/p&gt;

&lt;p&gt;Even so, 4-bit blocked formats still want a smoothing/rotation pass (SmoothQuant-style scaling or a Hadamard rotation à la QuaRot/SpinQuant) to spread outlier energy before rounding. The axis constraint relaxes; the distribution problem does not fully disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  Direct answer
&lt;/h2&gt;

&lt;p&gt;W8A8 INT8 quantization needs SmoothQuant because large language models concentrate activation magnitude in a few fixed hidden channels, and the channel axis is the one axis an INT8 GEMM cannot scale — a per-input-channel scale sits inside the int32 accumulator's reduction, so it cannot be factored out the way per-token and per-output-channel scales can. One outlier channel therefore sets the scale for the entire tensor and quantizes the meaningful values into two or three effective bits. SmoothQuant sidesteps the constraint algebraically: divide activations by a per-channel &lt;code&gt;s&lt;/code&gt;, multiply the corresponding weight rows by &lt;code&gt;s&lt;/code&gt;, and fold the division into the preceding RMSNorm affine so it costs nothing at inference. Tune &lt;code&gt;α&lt;/code&gt; per layer against output MSE, calibrate on real traffic, and check whether you are compute-bound before doing any of it — if you are bandwidth-bound at small batch, weight-only 4-bit is the better trade, and on FP8 or NVFP4 hardware the wide exponent and block-level scaling absorb most of the outlier damage for you.&lt;/p&gt;

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