<?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: Jasur Yuldoshev</title>
    <description>The latest articles on DEV Community by Jasur Yuldoshev (@dreamdeck).</description>
    <link>https://dev.to/dreamdeck</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%2F4025043%2F381b89bf-27d3-4845-967d-0ca3706885b6.png</url>
      <title>DEV Community: Jasur Yuldoshev</title>
      <link>https://dev.to/dreamdeck</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dreamdeck"/>
    <language>en</language>
    <item>
      <title>Your local RAG isn't slow — it re-reads every document on every question</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Wed, 26 Aug 2026 10:18:51 +0000</pubDate>
      <link>https://dev.to/dreamdeck/your-local-rag-isnt-slow-it-re-reads-every-document-on-every-question-18jg</link>
      <guid>https://dev.to/dreamdeck/your-local-rag-isnt-slow-it-re-reads-every-document-on-every-question-18jg</guid>
      <description>&lt;p&gt;A user opens a project with nine files in it, types the most obvious question&lt;br&gt;
anyone types at a document app — "what are these documents about?" — and waits.&lt;/p&gt;

&lt;p&gt;291 seconds.&lt;/p&gt;

&lt;p&gt;Then they ask a second question, about one of those documents, and wait again.&lt;br&gt;
Minutes, not seconds. At that point the app has told them something about&lt;br&gt;
itself, and what it has told them is: this model is slow and probably stupid.&lt;/p&gt;

&lt;p&gt;The model was neither. It was reading. It read the entire retrieved corpus,&lt;br&gt;
from token zero, for question one. Then it read it again for question two.&lt;/p&gt;
&lt;h2&gt;
  
  
  Where the 291 seconds went: prefill, not generation
&lt;/h2&gt;

&lt;p&gt;Setup, for symptom matching: an offline desktop app on llama.cpp, M4 Pro with&lt;br&gt;
24 GB, a 14B at Q5, nine files in the project.&lt;/p&gt;

&lt;p&gt;Two throughput numbers, both from my own logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;generation:  ~8 tokens/second     &amp;lt;- normal for a 14B at Q5 on this box
prefill:   ~100 tokens/second     &amp;lt;- also normal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither is a bug. The bug is the ratio, and what got multiplied by it. That one&lt;br&gt;
question assembled a &lt;strong&gt;13,773-token prompt&lt;/strong&gt;, and the turn as a whole pushed&lt;br&gt;
&lt;strong&gt;17,373 prompt tokens&lt;/strong&gt; through the model once you count the service calls it&lt;br&gt;
makes on the side. At a hundred tokens a second, reading dominated writing by&lt;br&gt;
roughly five to one; the profile came out north of 85% prefill.&lt;/p&gt;

&lt;p&gt;Divide those numbers yourself and you'll land a few dozen seconds off. Prefill&lt;br&gt;
throughput sags as the context grows, and one "question" is more than one model&lt;br&gt;
call. The shape is the point, not the arithmetic: the machine spent its evening&lt;br&gt;
reading, and the part the user was waiting for — the answer — was cheap.&lt;/p&gt;

&lt;p&gt;I want to be precise about the thing I had gotten wrong for a long time,&lt;br&gt;
because I don't think I'm alone in it. I had indexing. Chunks, embeddings, a&lt;br&gt;
vector store, the whole ritual, run once when files are added. What I assumed&lt;br&gt;
that ritual bought me was that the documents were, in some sense, &lt;em&gt;already&lt;br&gt;
read&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;It buys nothing of the kind. Retrieval is a table of contents, not a memory. It&lt;br&gt;
finds the right pieces quickly; it does not make the model read them faster,&lt;br&gt;
and it does not make the model remember having read them. Every question ships&lt;br&gt;
fresh text into the context window and the model chews it from the first token,&lt;br&gt;
at prefill speed, every time. In classical RAG, indexing time and reading time&lt;br&gt;
are separate budgets, and only one of them is ever spent in advance.&lt;/p&gt;

&lt;p&gt;Three things came out of the logs before I got to the interesting part.&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding 1: two retrievers, one prompt, ~9,000 duplicated tokens
&lt;/h2&gt;

&lt;p&gt;I run lexical search by default and vector search behind a flag. On the&lt;br&gt;
machines where both are on, both contribute passages to the prompt. They union&lt;br&gt;
their results, deduplicated by chunk id.&lt;/p&gt;

&lt;p&gt;Chunk id. Not content. The two engines index with different chunk boundaries,&lt;br&gt;
so the same paragraph arrives as two different ids with substantially the same&lt;br&gt;
text, and the union happily keeps both.&lt;/p&gt;

&lt;p&gt;Roughly &lt;strong&gt;9,000 tokens per question&lt;/strong&gt; were the same passages, twice. Not once&lt;br&gt;
in a while — on every question, for as long as both engines had been on. Nobody&lt;br&gt;
caught it by reading the prompt, because nobody reads the prompt; it is a wall&lt;br&gt;
of text that scrolls past in a debug log and looks exactly like a wall of text&lt;br&gt;
is supposed to look.&lt;/p&gt;

&lt;p&gt;Deduplicating on normalized content instead of id took the question from &lt;strong&gt;291&lt;br&gt;
to 150 seconds&lt;/strong&gt;. That is the least interesting bug here and it was&lt;br&gt;
worth more seconds than anything I did on purpose that week.&lt;/p&gt;
&lt;h2&gt;
  
  
  Finding 2: the prefix cache hit 3 tokens out of 17,373
&lt;/h2&gt;

&lt;p&gt;llama.cpp keeps a per-slot prompt cache. Send a prompt that shares a prefix&lt;br&gt;
with the last one that slot saw, and it skips prefill for the shared part. In a&lt;br&gt;
chat app that should be most of the system prompt, most of the time.&lt;/p&gt;

&lt;p&gt;Mine, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;slot update_slots: id  0 | task 412 | n_past = 3, cache_tokens = 3, n_prompt_tokens = 13773
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three tokens. Beginning-of-sequence and a bit of chat template. The cache had&lt;br&gt;
never once helped, on any turn, since the feature existed.&lt;/p&gt;

&lt;p&gt;The cause was a helper I'd been pleased with. Before retrieval, a small service&lt;br&gt;
call rewrites the user's question into a better search query — resolving "it"&lt;br&gt;
and "that contract" against the conversation. It's a good feature. It ran with&lt;br&gt;
its own system prompt, and it ran &lt;strong&gt;into the same slot&lt;/strong&gt; as the conversation.&lt;/p&gt;

&lt;p&gt;Slot caches key on the longest common prefix. Two different system prompts&lt;br&gt;
diverge at token three. So the service call evicted the conversation's cached&lt;br&gt;
prefix, the conversation's next turn evicted the service call's, and the two&lt;br&gt;
took turns doing this forever. The cache was working exactly as designed. It&lt;br&gt;
was caching a conversation that alternated, every single turn, with a&lt;br&gt;
completely different conversation, in the same chair.&lt;/p&gt;

&lt;p&gt;Splitting the slots — a pool for conversations, a pool for service calls, never&lt;br&gt;
shared — moved the hit rate from &lt;strong&gt;0% to 45-48%&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding 3: the study queue lived in RAM and died with the process
&lt;/h2&gt;

&lt;p&gt;There was already a background "study" pass in the codebase, meant to&lt;br&gt;
pre-summarize documents after import. It kept its queue in process memory.&lt;/p&gt;

&lt;p&gt;Close the app, and every unfinished item is gone. Not retried — gone, with no&lt;br&gt;
record that it had been scheduled. And it only ran while the app was open and&lt;br&gt;
otherwise idle, which on a desktop app is a narrow and unreliable window.&lt;/p&gt;

&lt;p&gt;Net effect: summaries were essentially never present when a question arrived,&lt;br&gt;
so every question fell back to reading document bodies. A background job that&lt;br&gt;
doesn't survive a restart is a background job that never finishes, because the&lt;br&gt;
user closes the window constantly and does not consider this an unusual thing&lt;br&gt;
to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  Study at indexing: pay the minutes once per file, not once per question
&lt;/h2&gt;

&lt;p&gt;Here is the reframe the whole thing turned on. Prefill is not a cost you can&lt;br&gt;
optimize away — a 14B reading N tokens has to read N tokens. It is a cost you&lt;br&gt;
can &lt;strong&gt;move&lt;/strong&gt;. The question is whether the model reads a document at question&lt;br&gt;
time, while a human watches a spinner, or at indexing time, when nobody is&lt;br&gt;
waiting.&lt;/p&gt;

&lt;p&gt;Three pieces, in order of how cheap they are.&lt;/p&gt;

&lt;h3&gt;
  
  
  The document passport, ~200 tokens, no model involved
&lt;/h3&gt;

&lt;p&gt;For each file, a small structured record: document type, parties, dates,&lt;br&gt;
amounts, page count, and a table of contents extracted from heading patterns&lt;br&gt;
with a regular expression. Not with the model — with a regex, at import, in&lt;br&gt;
milliseconds.&lt;/p&gt;

&lt;p&gt;That's about 200 tokens per document. For a nine-file project, "what are these&lt;br&gt;
documents about?" now has a &lt;strong&gt;1,800-token&lt;/strong&gt; answer surface where it used to&lt;br&gt;
have 13,773.&lt;/p&gt;

&lt;p&gt;The honest limit: heading extraction works on documents that have formatting&lt;br&gt;
and fails flat on an unstructured wall of text. Those fall through to the pass&lt;br&gt;
below.&lt;/p&gt;

&lt;h3&gt;
  
  
  Map-reduce summaries, in the background
&lt;/h3&gt;

&lt;p&gt;Per file: chunk it, summarize each chunk, summarize the summaries. This costs&lt;br&gt;
exactly the minutes you were paying before — the model still reads the whole&lt;br&gt;
document — except it costs them &lt;strong&gt;once&lt;/strong&gt;, on import, and never again.&lt;/p&gt;

&lt;p&gt;The queue rules matter more than the summarization prompt:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it yields to live questions, always; a user typing outranks it instantly&lt;/li&gt;
&lt;li&gt;it goes silent on battery&lt;/li&gt;
&lt;li&gt;it checkpoints per chunk and resumes after a restart, because see Finding 3&lt;/li&gt;
&lt;li&gt;it stores state in the database, not in a variable&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The user does pay for this. They pay in fan noise, once per file, at a moment&lt;br&gt;
when they are not staring at a progress bar.&lt;/p&gt;

&lt;h3&gt;
  
  
  A cascading router that picks the cheapest lane that can answer
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Broad question&lt;/strong&gt; ("what are these documents about", "summarize the
project") — answer from passports plus summaries. 1-2k tokens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Specific question&lt;/strong&gt; ("what's the termination clause in the lease") — vector
retrieval, narrow set of chunks, as before but smaller.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Explicit deep read&lt;/strong&gt; — full bodies, no shortcuts, and the user knows they
asked for the slow path because they had to say so.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Routing is the risky part and I'd rather name the risk than sell around it.&lt;br&gt;
Misroute a specific question into the digest lane and you answer from a summary&lt;br&gt;
that dropped the exact number the user wanted, confidently and wrongly. Two&lt;br&gt;
mitigations, both boring: the router is biased to escalate — if a question&lt;br&gt;
mentions a term that appears in a passport's table of contents but not in the&lt;br&gt;
summary, it goes to chunks — and the app shows which lane answered, so a thin&lt;br&gt;
answer has an obvious "go read it properly" next to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the numbers look like now
&lt;/h2&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;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"what are these documents about?", 9 files&lt;/td&gt;
&lt;td&gt;291 s&lt;/td&gt;
&lt;td&gt;102 s&lt;/td&gt;
&lt;td&gt;2.9x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prompt for that question&lt;/td&gt;
&lt;td&gt;13,773 tok&lt;/td&gt;
&lt;td&gt;5,709 tok&lt;/td&gt;
&lt;td&gt;2.4x&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;the same question asked verbatim again&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;td&gt;~1 s&lt;/td&gt;
&lt;td&gt;exact-match cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;prefix cache hit rate, specific questions&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;45-48%&lt;/td&gt;
&lt;td&gt;slot split&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;102 seconds is not a good number. It is a much better number, and it is an&lt;br&gt;
honest one: a 14B on a laptop reading five thousand tokens has to read five&lt;br&gt;
thousand tokens, and no amount of architecture argues with that.&lt;/p&gt;

&lt;p&gt;The exact-match cache is the cheapest line in the table and it exists because&lt;br&gt;
of a behavior I did not predict. Users re-ask the identical question. They&lt;br&gt;
close the app, come back, and type the same words to see whether it's still&lt;br&gt;
right. Hashing the normalized question plus the resolved context set and&lt;br&gt;
keeping the answer turns that into a second.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why 100% prefix-cache hits are not on the table
&lt;/h2&gt;

&lt;p&gt;The reusable part of a prompt is the part that doesn't change. System prompt&lt;br&gt;
and passports are stable, so they cache. Retrieved excerpts change with the&lt;br&gt;
question — by construction, since changing them is the entire job of retrieval.&lt;/p&gt;

&lt;p&gt;You could force them to cache. Retrieve once per conversation, freeze the&lt;br&gt;
context, and every subsequent turn shares a long identical prefix. I sat with&lt;br&gt;
that for a while and turned it down. The second question in a conversation is&lt;br&gt;
usually about something the first question didn't retrieve; freezing the&lt;br&gt;
context buys cache hits and pays for them in wrong answers. 45-48% is what the&lt;br&gt;
stable prefix is genuinely worth in this layout, and I'd rather report that&lt;br&gt;
number than a better one I bought with accuracy.&lt;/p&gt;

&lt;p&gt;One thing that is free: &lt;strong&gt;order the prompt by volatility&lt;/strong&gt;. Stable first&lt;br&gt;
(system prompt, passports), volatile last (excerpts, then the question). Get&lt;br&gt;
that backwards and your hit rate is zero no matter how much of the prompt is&lt;br&gt;
technically stable.&lt;/p&gt;

&lt;h2&gt;
  
  
  I went and checked what everyone else does
&lt;/h2&gt;

&lt;p&gt;Before claiming any of this was novel I looked at what the neighbors ship: LM&lt;br&gt;
Studio, AnythingLLM, Jan, GPT4All, Open WebUI with Ollama.&lt;/p&gt;

&lt;p&gt;All of them chunk and embed at index time. Not one of them precomputes&lt;br&gt;
per-document digests or summaries. The best of them keep a prompt cache, which&lt;br&gt;
helps with the system prompt and does nothing for the retrieved half.&lt;/p&gt;

&lt;p&gt;Which means that on "what are these documents about?" — the single most common&lt;br&gt;
opening question a human asks a document app, the one they type before they&lt;br&gt;
type anything else — every one of these re-reads the corpus at full prefill&lt;br&gt;
cost, every time.&lt;/p&gt;

&lt;p&gt;I'm supposed to call that a gap in the market. It's really a gap in the default&lt;br&gt;
architecture: the reference RAG design does retrieval at question time and&lt;br&gt;
nothing at index time except embeddings, everybody copied it faithfully, and&lt;br&gt;
the copy is correct. It's just that "correct" and "the user waited five&lt;br&gt;
minutes" are compatible states.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building a local RAG
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Measure prefill against generation before you optimize anything.&lt;/strong&gt; If 85% of&lt;br&gt;
your wall clock is reading, a faster sampler and a smaller quant are noise. The&lt;br&gt;
engine prints both numbers; find the line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deduplicate context sources by content, not by id.&lt;/strong&gt; Two retrievers with&lt;br&gt;
different chunk boundaries will hand you the same paragraph twice and neither&lt;br&gt;
will look wrong in isolation. This was 9,000 tokens a question in my app.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Give service LLM calls their own KV slot.&lt;/strong&gt; Query rewriting, classification,&lt;br&gt;
title generation — anything with its own system prompt sharing a slot with the&lt;br&gt;
conversation will zero your prefix cache and the logs will still say the cache&lt;br&gt;
is enabled.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Precompute passports and summaries at index time.&lt;/strong&gt; A ~200-token structured&lt;br&gt;
passport per document, with headings pulled by regex rather than by the model,&lt;br&gt;
answers most broad questions on its own and costs no GPU at all.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put the study queue in the database.&lt;/strong&gt; In-memory queues on a desktop app do&lt;br&gt;
not survive contact with users, who close windows. Checkpoint per chunk, resume&lt;br&gt;
on launch, yield to live traffic, stop on battery.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cache the literal repeats.&lt;/strong&gt; People re-ask the same question verbatim more&lt;br&gt;
than you'd think. Hash the normalized question plus the resolved context set.&lt;/p&gt;

&lt;p&gt;If you want to know whether any of this is worth your afternoon, the arithmetic&lt;br&gt;
is short enough to run before you commit to it: file size, prefill speed,&lt;br&gt;
number of questions, and it tells you how many seconds of pure reading you have&lt;br&gt;
already signed up for. That's &lt;code&gt;how_long_will_my_rag_wait.py&lt;/code&gt; — &lt;a href="https://github.com/JackYU96/rag-rereads-every-question" rel="noopener noreferrer"&gt;https://github.com/JackYU96/rag-rereads-every-question&lt;/a&gt; —  in the repo next&lt;br&gt;
to this post — no dependencies, one file, bring your own numbers.&lt;/p&gt;

&lt;p&gt;The last time I chased a number like this, the KV cache itself turned out to be&lt;br&gt;
eating it: &lt;a href="https://dev.to/dreamdeck/v-cache-quantization-requires-flashattn-the-llamacpp-error-that-quietly-halves-your-context-1kdb"&gt;"V cache quantization requires flash_attn" — the llama.cpp error&lt;br&gt;
that quietly halves your context&lt;br&gt;
window&lt;/a&gt;.&lt;br&gt;
Different layer, same lesson. The engine will tell you where your seconds went.&lt;br&gt;
It tells you in a log line that scrolls past at startup, in a field you have&lt;br&gt;
never once looked at.&lt;/p&gt;

</description>
      <category>rag</category>
      <category>llm</category>
      <category>performance</category>
      <category>localllama</category>
    </item>
    <item>
      <title>"V cache quantization requires flash_attn" — the llama.cpp error that quietly halves your context window</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Fri, 21 Aug 2026 05:23:22 +0000</pubDate>
      <link>https://dev.to/dreamdeck/v-cache-quantization-requires-flashattn-the-llamacpp-error-that-quietly-halves-your-context-1kdb</link>
      <guid>https://dev.to/dreamdeck/v-cache-quantization-requires-flashattn-the-llamacpp-error-that-quietly-halves-your-context-1kdb</guid>
      <description>&lt;p&gt;I did not meet this error while debugging a crash. I met it while writing a&lt;br&gt;
calculator.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llama_context: quantized V cache requires flash_attn to be enabled
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is a second wording, thrown as an exception a little later in startup and&lt;br&gt;
surfacing as &lt;code&gt;failed to initialize the context&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;quantized V cache was requested, but this requires Flash Attention
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and a third, older one — &lt;code&gt;V cache quantization requires flash_attn&lt;/code&gt; — which is&lt;br&gt;
no longer in the tree but is what most of the search results still show you,&lt;br&gt;
because most of the world runs llama.cpp through something that vendors a build&lt;br&gt;
from six months ago.&lt;/p&gt;

&lt;p&gt;All three read like a configuration nag: you asked for one thing, turn on the&lt;br&gt;
other thing, move along. That framing is why almost nobody asks the interesting&lt;br&gt;
question, which is &lt;em&gt;why&lt;/em&gt; those two settings are welded together. The answer is a&lt;br&gt;
memory-layout decision several levels below the flag you typed, and it is worth&lt;br&gt;
knowing, because it tells you precisely which half of the cache you can still&lt;br&gt;
quantize when flash attention isn't available to you.&lt;/p&gt;

&lt;p&gt;But first the calculator, because that is how I got here and it is the part that&lt;br&gt;
cost me real time.&lt;/p&gt;
&lt;h2&gt;
  
  
  The number I actually needed
&lt;/h2&gt;

&lt;p&gt;I ship an offline desktop app that runs llama.cpp locally. Users have whatever&lt;br&gt;
machine they have. Before the app picks a context window it has to answer one&lt;br&gt;
question honestly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;window = (RAM - model weights - reserve) / cost_per_token_of_KV
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three of those four terms are easy. RAM you ask the OS. Weights you take from&lt;br&gt;
the file. The reserve is a policy number you choose — mine is deliberately fat,&lt;br&gt;
because on macOS unified memory, overshooting what the GPU can wire does not&lt;br&gt;
politely hand you an allocation failure. It panics the kernel. I have the scars&lt;br&gt;
and the commit history.&lt;/p&gt;

&lt;p&gt;The fourth term is where I went wrong. &lt;code&gt;cost_per_token_of_KV&lt;/code&gt; looks like&lt;br&gt;
something you compute from model metadata: layers, KV heads, head dimension, two&lt;br&gt;
tensors, two bytes each. Multiply, done. Every context-size calculator on the&lt;br&gt;
internet does exactly this.&lt;/p&gt;

&lt;p&gt;On the model I care about it was wrong by a factor of four.&lt;/p&gt;

&lt;p&gt;That model is a Gemma-family 12B, and Gemma interleaves its attention: a&lt;br&gt;
minority of layers attend over the full context, the rest run a short sliding&lt;br&gt;
window that does not grow with &lt;code&gt;n_ctx&lt;/code&gt; at all. Metadata math doesn't know that.&lt;br&gt;
It multiplies one per-layer cost by every layer and confidently describes a&lt;br&gt;
model that does not exist. On a 24 GB box a 4x overestimate is not a rounding&lt;br&gt;
error — it is the difference between offering the user 16k of context and&lt;br&gt;
telling them their machine can manage 4k.&lt;/p&gt;

&lt;p&gt;Metadata describes the model. I needed a number that describes the &lt;em&gt;allocation&lt;/em&gt;.&lt;br&gt;
Those are different things, and only one of them gets printed at runtime.&lt;/p&gt;
&lt;h2&gt;
  
  
  So I stopped computing and started booting
&lt;/h2&gt;

&lt;p&gt;llama.cpp already knows the answer. It says it at startup, in a line most people&lt;br&gt;
scroll past on the way to the prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llama_kv_cache: size =  160.00 MiB (  4096 cells,   8 layers,  1 seqs), K (f16): ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Bytes, cells, layers. No metadata, no architecture assumptions — this is the&lt;br&gt;
allocator reporting what it actually took. Note the layer count: eight, on a&lt;br&gt;
model with far more layers than that. The interleaved model gets more than one&lt;br&gt;
of these lines, one per cache, and you want the sum.&lt;/p&gt;

&lt;p&gt;So the probe is dumb and reliable: boot the engine with a small context, parse&lt;br&gt;
its own log, divide, kill it. Two seconds, no inference, nothing downloaded. My&lt;br&gt;
app does this once per model on first run and caches the result.&lt;/p&gt;

&lt;p&gt;One trap, and it's why the probe takes a context argument instead of using the&lt;br&gt;
smallest number that loads. Until late 2025, llama.cpp padded the cache size&lt;br&gt;
itself, and the multiple depended on flash attention:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// the FA kernels require padding to avoid extra runtime boundary checks&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cparams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flash_attn&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;256u&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;32u&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's gone — &lt;a href="https://github.com/ggml-org/llama.cpp/pull/16812" rel="noopener noreferrer"&gt;PR #16812&lt;/a&gt;&lt;br&gt;
removed KV cache size padding in October 2025, and the only rounding left is on&lt;br&gt;
the per-graph &lt;code&gt;n_kv&lt;/code&gt; view, a flat 256 whether or not flash attention is on. Good&lt;br&gt;
news you should not rely on, because the llama.cpp inside your LM Studio or your&lt;br&gt;
ollama is quite possibly older than that commit. Probe well above the padding&lt;br&gt;
floor regardless. It costs nothing, and it's the difference between measuring a&lt;br&gt;
model and measuring a rounding rule with beautiful precision.&lt;/p&gt;
&lt;h2&gt;
  
  
  The probe was lying too
&lt;/h2&gt;

&lt;p&gt;First real run, the probe reported:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;368,640 bytes per token.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Meanwhile the production config, same machine, same model, was demonstrably&lt;br&gt;
holding a window that this number says is impossible. So I read the production&lt;br&gt;
allocation directly:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;182,784 bytes per token.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Ratio: 2.02x. My careful runtime measurement was off by more than the metadata&lt;br&gt;
error I had built it to fix — same direction, same machine, same model.&lt;/p&gt;

&lt;p&gt;The reason is embarrassing and took ten minutes to find. The probe booted the&lt;br&gt;
engine with &lt;em&gt;default&lt;/em&gt; flags: f16 K, f16 V, flash attention off. The app boots it&lt;br&gt;
with q8_0 K, q8_0 V, flash attention on. I had measured, very rigorously, a&lt;br&gt;
configuration I do not ship.&lt;/p&gt;

&lt;p&gt;The fix is one line of "pass the same flags." The lesson outlived the fix,&lt;br&gt;
because the arithmetic doesn't land where you'd guess. f16 is 2 bytes per value;&lt;br&gt;
q8_0 is 34 bytes per 32 values, or 1.0625. That predicts a 1.88x gap. I measured&lt;br&gt;
2.02x. The remainder comes from layout and padding differences that ride along&lt;br&gt;
with flash attention, which appear nowhere in the dtype arithmetic and which I&lt;br&gt;
would never have thought to include.&lt;/p&gt;

&lt;p&gt;Which is the argument for measuring, made better by how nearly I missed it: had&lt;br&gt;
the gap come out at exactly 1.88x, I'd have hardcoded the ratio and shipped a&lt;br&gt;
formula that drifts silently every time llama.cpp changes its padding.&lt;/p&gt;

&lt;p&gt;The confirmation was that with the honest number, the formula reproduces the&lt;br&gt;
16,384-token ceiling my app had been running for months — a figure originally&lt;br&gt;
arrived at by hand, by trial, by someone getting tired of crashes. The&lt;br&gt;
measurement agreed with the scar tissue. That's when I believed it.&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;probe under defaults&lt;/th&gt;
&lt;th&gt;probe under production flags&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;K cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;V cache&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;flash attention&lt;/td&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;measured cost&lt;/td&gt;
&lt;td&gt;368,640 B/token&lt;/td&gt;
&lt;td&gt;182,784 B/token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;window from the same ~2.8 GiB KV budget&lt;/td&gt;
&lt;td&gt;8,123 tokens&lt;/td&gt;
&lt;td&gt;16,384 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same 24 GB, same model, same afternoon. One of those rows is a product decision&lt;br&gt;
and the other is a support ticket with a head start.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why quantized V needs flash attention at all
&lt;/h2&gt;

&lt;p&gt;Now the part that sent me into the source, and the part I couldn't find written&lt;br&gt;
down anywhere.&lt;/p&gt;

&lt;p&gt;Last time I wrote about llama.cpp, the internet's confident answer to my problem&lt;br&gt;
was "it's the quantized KV cache," and &lt;a href="https://dev.to/dreamdeck/streaming-returned-0-tokens-and-llamadecode-died-asynciowaitfor-was-killing-my-generator-1ame"&gt;it wasn't&lt;/a&gt;. So it seems only fair&lt;br&gt;
that I now explain what the quantized KV cache is legitimately guilty of.&lt;/p&gt;

&lt;p&gt;The classic, non-flash attention path ends like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;ggml_tensor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;kqv&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ggml_mul_mat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx0&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;kq&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;ggml_mul_mat&lt;/code&gt; reduces over &lt;code&gt;ne[0]&lt;/code&gt;, the first dimension. So V has to arrive&lt;br&gt;
with the KV-position axis as its row axis — that is, V transposed. llama.cpp&lt;br&gt;
could transpose on the fly, and the code comments explain why it doesn't: that&lt;br&gt;
means a &lt;code&gt;ggml_cont(ggml_transpose(...))&lt;/code&gt; over the whole cache every single step.&lt;br&gt;
So V is stored pre-transposed instead, behind a flag declared exactly like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="n"&gt;bool&lt;/span&gt; &lt;span class="n"&gt;v_trans&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// the value tensor is transposed&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and set, at every single cache construction site, to literally&lt;br&gt;
&lt;code&gt;!cparams.flash_attn&lt;/code&gt;. That flag is the entire story. Flash attention on, V is&lt;br&gt;
stored naturally, because &lt;code&gt;ggml_flash_attn_ext&lt;/code&gt; wants it the other way round.&lt;br&gt;
Flash attention off, V is stored transposed.&lt;/p&gt;

&lt;p&gt;Now consider what transposed storage does to a write. Appending one token in the&lt;br&gt;
natural layout means writing one contiguous row of &lt;code&gt;n_embd_v_gqa&lt;/code&gt; values. In the&lt;br&gt;
transposed layout those same values scatter: one element into each of&lt;br&gt;
&lt;code&gt;n_embd_v_gqa&lt;/code&gt; different rows, striding by &lt;code&gt;kv_size&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;llama.cpp expresses that scatter with &lt;code&gt;ggml_set_rows&lt;/code&gt;, and the transposed branch&lt;br&gt;
does something that looks unhinged until you see why — it reshapes the&lt;br&gt;
destination so that every row is exactly one element long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="c1"&gt;// in this branch the v_idxs are constructed in such a way that each row is a single head element&lt;/span&gt;
&lt;span class="n"&gt;ggml_tensor&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;v_view&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ggml_reshape_2d&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ggml_nelements&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_cur&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ggml_reshape_2d&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_cur&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;ggml_nelements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v_cur&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;ggml_set_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_view&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_cur&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;v_idxs&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And &lt;code&gt;ggml_set_rows&lt;/code&gt; quantizes &lt;strong&gt;one whole row at a time&lt;/strong&gt; — it calls the type's&lt;br&gt;
&lt;code&gt;from_float(src, dst, nc)&lt;/code&gt; with &lt;code&gt;nc&lt;/code&gt; equal to the row length. With &lt;code&gt;nc == 1&lt;/code&gt; and&lt;br&gt;
q8_0's 32-element blocks there is simply nothing to quantize:&lt;br&gt;
&lt;code&gt;quantize_row_q8_0&lt;/code&gt; asserts that the count is a multiple of 32. &lt;code&gt;ggml_set_rows&lt;/code&gt;&lt;br&gt;
also hard-asserts that its source is F32 or F16.&lt;/p&gt;

&lt;p&gt;The operation you'd need instead is read the 32-element block, dequantize it,&lt;br&gt;
replace one value, recompute the shared scale, requantize. ggml does not have&lt;br&gt;
that operation, and you would not want it in the hot path anyway — every new&lt;br&gt;
token would rewrite a block whose scale then shifts underneath values written&lt;br&gt;
several tokens ago.&lt;/p&gt;

&lt;p&gt;So it isn't a policy or an unfinished feature. There is no quantized write path&lt;br&gt;
in ggml with sub-block granularity, and the non-flash-attention V layout offers&lt;br&gt;
nothing &lt;em&gt;but&lt;/em&gt; sub-block writes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;K is a different tensor with a different fate.&lt;/strong&gt; K is never transposed. Its&lt;br&gt;
update writes whole rows of &lt;code&gt;n_embd_k_gqa&lt;/code&gt; values, one row per token, contiguous&lt;br&gt;
and block-aligned by construction — the same code with or without flash&lt;br&gt;
attention. And the non-FA path consumes it as &lt;code&gt;ggml_mul_mat(ctx0, k, q)&lt;/code&gt;, where&lt;br&gt;
a quantized first operand is the ordinary, thoroughly supported case.&lt;/p&gt;

&lt;p&gt;There is no guard anywhere in llama.cpp rejecting a quantized &lt;code&gt;type_k&lt;/code&gt; without&lt;br&gt;
flash attention. The only &lt;code&gt;type_k&lt;/code&gt; check is gated on flash attention &lt;em&gt;not&lt;/em&gt; being&lt;br&gt;
disabled, and all it verifies is that the head dimension divides evenly by the&lt;br&gt;
block size — a constraint that exists because the FA path views K per-head,&lt;br&gt;
while the non-FA path only ever needs whole rows.&lt;/p&gt;

&lt;p&gt;Which means the workaround people trade in the issue threads — drop &lt;code&gt;-ctv q8_0&lt;/code&gt;,&lt;br&gt;
keep &lt;code&gt;-ctk q8_0&lt;/code&gt; — isn't folklore. It falls straight out of the layout.&lt;/p&gt;
&lt;h2&gt;
  
  
  What changed in 2026, and who this actually bites
&lt;/h2&gt;

&lt;p&gt;Flash attention is no longer a boolean. Since&lt;br&gt;
&lt;a href="https://github.com/ggml-org/llama.cpp/pull/15434" rel="noopener noreferrer"&gt;PR #15434&lt;/a&gt; (merged 30 August&lt;br&gt;
2025) it's a tri-state, and the default is AUTO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;enum&lt;/span&gt; &lt;span class="n"&gt;llama_flash_attn_type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;LLAMA_FLASH_ATTN_TYPE_AUTO&lt;/span&gt;     &lt;span class="o"&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;LLAMA_FLASH_ATTN_TYPE_DISABLED&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;LLAMA_FLASH_ATTN_TYPE_ENABLED&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the command line that's &lt;code&gt;-fa on|off|auto&lt;/code&gt;. And in AUTO, the engine resolves&lt;br&gt;
the conflict for you rather than complaining about it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight c"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ggml_is_quantized&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;type_v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flash_attn_type&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;LLAMA_FLASH_ATTN_TYPE_ENABLED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flash_attn_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LLAMA_FLASH_ATTN_TYPE_AUTO&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;LLAMA_LOG_INFO&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s: enabling flash_attn since it is required for quantized V cache&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__func__&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flash_attn_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;LLAMA_FLASH_ATTN_TYPE_ENABLED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flash_attn_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;LLAMA_FLASH_ATTN_TYPE_DISABLED&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;LLAMA_LOG_ERROR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s: quantized V cache requires flash_attn to be enabled&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;__func__&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;nullptr&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;So on a current build, typing &lt;code&gt;-ctv q8_0&lt;/code&gt; and touching nothing else never&lt;br&gt;
produces the error. You get the info line and a working model.&lt;/p&gt;

&lt;p&gt;Which means, in 2026, essentially everyone who &lt;em&gt;does&lt;/em&gt; hit this had flash&lt;br&gt;
attention turned off by something. There are three somethings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You turned it off.&lt;/strong&gt; Someone told you flash attention was unstable on your&lt;br&gt;
backend, you set &lt;code&gt;-fa off&lt;/code&gt;, and the quantized V flag stayed in your config from&lt;br&gt;
an earlier experiment. Own goal, thirty-second fix, and honestly the nicest&lt;br&gt;
version of this problem to have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The model forced it off.&lt;/strong&gt; Grok is hardcoded to disable flash attention&lt;br&gt;
(&lt;code&gt;flash_attn is not compatible with Grok - forcing off&lt;/code&gt;) and that happens&lt;br&gt;
&lt;em&gt;before&lt;/em&gt; the quantized-V check. So the state arriving at the check is DISABLED,&lt;br&gt;
not AUTO, and you get the hard error rather than the friendly promotion. That is&lt;br&gt;
exactly &lt;a href="https://github.com/ollama/ollama/issues/15043" rel="noopener noreferrer"&gt;ollama#15043&lt;/a&gt; — "when&lt;br&gt;
flash attention is not supported, quantized KV cache should be disregarded&lt;br&gt;
instead of aborting the model run," which is a reasonable request phrased with&lt;br&gt;
impressive restraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your backend turned it off.&lt;/strong&gt; This is the big one.&lt;br&gt;
&lt;a href="https://github.com/lmstudio-ai/lmstudio-bug-tracker/issues/1943" rel="noopener noreferrer"&gt;LM Studio bug tracker #1943&lt;/a&gt;:&lt;br&gt;
the Vulkan runtime 2.15.0 silently force-disables flash attention while the&lt;br&gt;
quantized KV config stays exactly as it was, so a setup that loaded yesterday&lt;br&gt;
fails today, and rolling back to 2.14.4 fixes it. llama.cpp itself can also drop&lt;br&gt;
flash attention late, during graph resolution, when the FA node ends up on a&lt;br&gt;
device that can't take it — it logs &lt;code&gt;... not supported, set to disabled&lt;/code&gt; and&lt;br&gt;
then the exception fires after the fact, which is why one of the two error&lt;br&gt;
strings arrives suspiciously late in startup.&lt;/p&gt;

&lt;p&gt;On Apple Silicon the same family shows up as&lt;br&gt;
&lt;a href="https://github.com/ggml-org/llama.cpp/issues/21450" rel="noopener noreferrer"&gt;ggml-org/llama.cpp#21450&lt;/a&gt;:&lt;br&gt;
Metal fails on mixed quantized KV when flash attention is unavailable, while&lt;br&gt;
uniform &lt;code&gt;q4_0&lt;/code&gt;/&lt;code&gt;q4_0&lt;/code&gt; and &lt;code&gt;f16&lt;/code&gt;/&lt;code&gt;f16&lt;/code&gt; load fine — which is a good reminder to&lt;br&gt;
keep K and V symmetric. That code is moving, too: as recently as 20 August 2026,&lt;br&gt;
Metal gained a pass that dequantizes quantized KV to F16 before flash attention&lt;br&gt;
(&lt;a href="https://github.com/ggml-org/llama.cpp/pull/27390" rel="noopener noreferrer"&gt;#27390&lt;/a&gt;). Pin your build if&lt;br&gt;
you're measuring.&lt;/p&gt;

&lt;p&gt;The common thread is that the error names neither the model nor the backend.&lt;br&gt;
It's why those threads are full of people insisting they never disabled flash&lt;br&gt;
attention. They didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about it
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Don't disable flash attention while asking for a quantized V cache&lt;/strong&gt; — and if&lt;br&gt;
you didn't disable it yourself, find out what did. That's now the most common&lt;br&gt;
route to this error, and the message really ought to say "your Vulkan runtime&lt;br&gt;
made this decision for you."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;If flash attention genuinely isn't available, quantize K only.&lt;/strong&gt;&lt;br&gt;
&lt;code&gt;--cache-type-k q8_0&lt;/code&gt; with V left at &lt;code&gt;f16&lt;/code&gt; works without flash attention,&lt;br&gt;
because K lives in the layout that quantizes cleanly. That keeps about half the&lt;br&gt;
saving — both halves quantized puts the cache at roughly 53% of f16, K alone at&lt;br&gt;
about 77% — and, more valuable than the bytes, the engine starts. A partial win&lt;br&gt;
that boots beats a total win that aborts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Measure the per-token cost under the flags you ship.&lt;/strong&gt; Not from metadata,&lt;br&gt;
which describes a model rather than an allocation and overshot mine by 4x. Not&lt;br&gt;
under default flags, which cost me a clean 2.02x. Boot the engine the way your&lt;br&gt;
users will boot it, read the line it prints, divide.&lt;/p&gt;

&lt;p&gt;The probe is about forty lines of bash — two boots, one &lt;code&gt;awk&lt;/code&gt;, no models&lt;br&gt;
bundled, bring your own GGUF. It's in the repo: &lt;a href="https://github.com/JackYU96/v-cache-requires-flash-attn" rel="noopener noreferrer"&gt;https://github.com/JackYU96/v-cache-requires-flash-attn&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>llamacpp</category>
      <category>llm</category>
      <category>performance</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Streaming returned 0 tokens and llama_decode died: asyncio.wait_for was killing my generator</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Tue, 11 Aug 2026 07:30:09 +0000</pubDate>
      <link>https://dev.to/dreamdeck/streaming-returned-0-tokens-and-llamadecode-died-asynciowaitfor-was-killing-my-generator-1ame</link>
      <guid>https://dev.to/dreamdeck/streaming-returned-0-tokens-and-llamadecode-died-asynciowaitfor-was-killing-my-generator-1ame</guid>
      <description>&lt;p&gt;Short prompts streamed fine. Anything document-sized came back empty: the route&lt;br&gt;
logged &lt;code&gt;done (~0 chars streamed)&lt;/code&gt;, the UI showed a canned fallback line, and if&lt;br&gt;
anything retried on the same instance the whole backend went down with&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;llama_decode: failed to decode, ret = -3
GGML_ASSERT: tensor buffer not set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I spent two days on the runtime. The runtime was fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  What everyone tells you to look at
&lt;/h2&gt;

&lt;p&gt;Search that symptom and you get one answer, from GitHub issues, from forum&lt;br&gt;
threads, and — I checked while writing this — from the search engine's own&lt;br&gt;
summary: your KV cache is too small, or your quantized KV cache needs flash&lt;br&gt;
attention, or llama-cpp-python is broken again. Increase &lt;code&gt;n_ctx&lt;/code&gt;. Drop the&lt;br&gt;
batch. Turn off the quantized cache and go back to &lt;code&gt;f16&lt;/code&gt;, the safe side.&lt;/p&gt;

&lt;p&gt;It is a good story. It fits the evidence: only big prompts die, big prompts use&lt;br&gt;
more KV, therefore KV. I believed it for a day and a half.&lt;/p&gt;
&lt;h2&gt;
  
  
  The measurements
&lt;/h2&gt;

&lt;p&gt;Eventually I stopped reasoning and started booting. One configuration per boot,&lt;br&gt;
same 17k-token prompt, same model (Gemma-4-12B-Q4), M4 Pro with 24 GB:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;flash attention&lt;/th&gt;
&lt;th&gt;KV type&lt;/th&gt;
&lt;th&gt;n_ctx&lt;/th&gt;
&lt;th&gt;result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;streams fine, 259 chunks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt;, no tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;off&lt;/td&gt;
&lt;td&gt;&lt;code&gt;f16&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;32k&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt;, no tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Read that table twice, because it says the opposite of the advice.&lt;/p&gt;

&lt;p&gt;The "risky" configuration — flash attention plus a quantized key/value cache,&lt;br&gt;
the one every thread warns you about — is the only one that worked. The "safe&lt;br&gt;
side" I was being told to retreat to is the broken one. &lt;code&gt;f16&lt;/code&gt; KV simply does not&lt;br&gt;
fit a 32k sliding-attention window on a 24 GB box, so retreating there swaps a&lt;br&gt;
bug you can fix for an out-of-memory you cannot.&lt;/p&gt;

&lt;p&gt;That table killed the KV theory. It also meant I had been tuning the wrong&lt;br&gt;
component for a day and a half, which is a specific kind of annoying.&lt;/p&gt;
&lt;h2&gt;
  
  
  What it actually was
&lt;/h2&gt;

&lt;p&gt;The route streams tokens to the client and sends a heartbeat while it waits, so&lt;br&gt;
the client's idle timer stays fed. The waiting looked like this:&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;chunk&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;wait_for&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&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;asyncio.wait_for&lt;/code&gt; does not merely stop waiting when the timeout expires. &lt;strong&gt;It&lt;br&gt;
cancels the thing it was waiting on.&lt;/strong&gt; The coroutine here is &lt;code&gt;__anext__()&lt;/code&gt; of an&lt;br&gt;
async generator, so cancelling it does not cancel one step — it kills the&lt;br&gt;
generator. The next &lt;code&gt;__anext__()&lt;/code&gt; on a dead generator raises&lt;br&gt;
&lt;code&gt;StopAsyncIteration&lt;/code&gt;, which to the &lt;code&gt;async for&lt;/code&gt; above it is indistinguishable&lt;br&gt;
from a model that finished with nothing to say.&lt;/p&gt;

&lt;p&gt;So the route did exactly what it was written to do: the stream ended, zero&lt;br&gt;
characters had arrived, it logged that honestly and served the fallback.&lt;/p&gt;

&lt;p&gt;Meanwhile llama.cpp was still inside &lt;code&gt;llama_decode&lt;/code&gt;, holding a context that now&lt;br&gt;
belonged to nobody. Any later decode on that instance walked into the torn state&lt;br&gt;
and hit the &lt;code&gt;GGML_ASSERT&lt;/code&gt;, which does not raise — it aborts the process. That is&lt;br&gt;
why the crash looked like a &lt;em&gt;runtime&lt;/em&gt; crash: by the time it happened, my bug was&lt;br&gt;
several seconds in the past.&lt;/p&gt;

&lt;p&gt;And the reason only long prompts died is the least mysterious part of the whole&lt;br&gt;
story. Prefill on a document-sized prompt takes longer than six seconds.&lt;br&gt;
Heartbeat fires, &lt;code&gt;wait_for&lt;/code&gt; cancels, generator dies — before the model has&lt;br&gt;
produced its first token. Short prompts finish prefill inside one heartbeat and&lt;br&gt;
never meet the bug.&lt;/p&gt;

&lt;p&gt;A keep-alive that kills the thing it is keeping alive. I have written better&lt;br&gt;
code.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Keep one task alive across heartbeats and poll it with &lt;code&gt;asyncio.wait&lt;/code&gt;, which&lt;br&gt;
returns on timeout and leaves the task running:&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;pending&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="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;while&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;done&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="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;wait&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;done&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="nf"&gt;heartbeat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;          &lt;span class="c1"&gt;# the task is still alive, still prefilling
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pending&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;result&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;StopAsyncIteration&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;                      &lt;span class="c1"&gt;# a real end, not a cancelled one
&lt;/span&gt;    &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt;
    &lt;span class="n"&gt;pending&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="nf"&gt;create_task&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_aiter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;__anext__&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the whole difference. &lt;code&gt;wait_for&lt;/code&gt; cancels; &lt;code&gt;wait&lt;/code&gt; does not. One of them&lt;br&gt;
is a timeout, the other is a kill switch with a timeout-shaped name.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell whether it is you or the runtime
&lt;/h2&gt;

&lt;p&gt;If you are staring at an empty stream right now, this ordering would have saved&lt;br&gt;
me most of two days:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run the same prompt without streaming.&lt;/strong&gt; If non-stream produces text, the
model, the weights, the KV config and the context size are all fine. You have
a plumbing bug. I had this evidence on day one and explained it away.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grep your own code for &lt;code&gt;wait_for&lt;/code&gt; anywhere near an async generator.&lt;/strong&gt; Also
&lt;code&gt;async_timeout&lt;/code&gt;, also any framework middleware with a request timeout. All of
them cancel.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlate with prompt length, not prompt content.&lt;/strong&gt; "Only big prompts" says
&lt;em&gt;something takes too long&lt;/em&gt;, which points at a timer, not at a tensor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Change one thing per boot.&lt;/strong&gt; Two of my configurations differed by two
variables and told me nothing; the table above is boring precisely because
each row moved one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distrust the safe-sounding fallback.&lt;/strong&gt; &lt;code&gt;f16&lt;/code&gt; KV was the retreat everyone
recommended, and on this machine it is strictly worse than the configuration
it was supposed to rescue me from. Measure your own box.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I would say to the version of me on day one
&lt;/h2&gt;

&lt;p&gt;The symptom appeared in the model layer, so I searched in the model layer, and&lt;br&gt;
the internet had a confident, popular, wrong answer waiting there. Nothing about&lt;br&gt;
&lt;code&gt;llama_decode -3&lt;/code&gt; points at an &lt;code&gt;await&lt;/code&gt; in a web route thirty files away.&lt;/p&gt;

&lt;p&gt;The thing that finally broke it open was the least clever step available: stop&lt;br&gt;
theorising, boot once per configuration, write down what happened. The table&lt;br&gt;
took an afternoon and ended the argument. The two days before it were spent&lt;br&gt;
being smart.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Config, for anyone matching symptoms: llama-cpp-python 0.3.33, Metal, M4 Pro&lt;br&gt;
24 GB, Gemma-4-12B-Q4, 32k context, K and V both &lt;code&gt;q8_0&lt;/code&gt; — keep them symmetric,&lt;br&gt;
a q8/q4 mix with flash attention crashes on Metal for real, and that one is not&lt;br&gt;
a heartbeat.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>asyncio</category>
      <category>llm</category>
      <category>debugging</category>
    </item>
    <item>
      <title>Hot-swapping GGUF models kernel-panicked my M4 Mac: wired memory, llama.cpp, and why we restart the server instead</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Sat, 18 Jul 2026 13:43:17 +0000</pubDate>
      <link>https://dev.to/dreamdeck/the-model-switcher-that-kernel-panicked-my-mac-1o4j</link>
      <guid>https://dev.to/dreamdeck/the-model-switcher-that-kernel-panicked-my-mac-1o4j</guid>
      <description>&lt;p&gt;I shipped a model switcher last week. A settings screen, two local GGUF models on disk, a "Make active" button. I clicked it, watched a spinner for about ninety seconds, and then my Mac rebooted.&lt;/p&gt;

&lt;p&gt;Not the app. The Mac.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;panic(cpu 0 caller 0xfffffe004ac0433c): watchdog timeout: no checkins
from watchdogd in 93 seconds (299 total checkins since monitoring last enabled)
...
Compressor Info: 12% of compressed pages limit (OK) and 8% of segments
limit (OK) with 7 swapfiles and OK swap space
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A kernel panic is the operating system filing a formal complaint. This particular flavor — a watchdog timeout — is not about my code crashing. It means userspace as a whole stopped responding, and after 93 seconds of silence the kernel concluded the machine was beyond saving and pulled the plug itself.&lt;/p&gt;

&lt;p&gt;My settings button did that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the watchdog actually saw
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;watchdogd&lt;/code&gt; is a tiny daemon with one job: telling the kernel "userspace is still alive" every few seconds. It doesn't do anything heavy. For it to miss checkins for a minute and a half, the system has to be starved so badly that a trivial process can't get scheduled or can't allocate a page.&lt;/p&gt;

&lt;p&gt;The panic log has the tell: &lt;strong&gt;7 swapfiles&lt;/strong&gt;, and a memory compressor reporting "OK" with plenty of headroom. The machine spent its final minutes frantically paging out everything that could be paged. It wasn't enough — because the memory that mattered couldn't be paged at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two models, one process
&lt;/h2&gt;

&lt;p&gt;The arithmetic is embarrassingly simple in hindsight. My app runs llama.cpp in a Python sidecar (llama-cpp-python 0.3.33), all layers on Metal, on a 24 GB Apple Silicon machine. The active model was a 12B at Q4_K_M — 7.4 GB of weights, call it ~9 GB resident with the KV cache. The user switches to a 14B at Q5_K_M — 9.8 GB of weights, ~11 GB resident.&lt;/p&gt;

&lt;p&gt;The switch endpoint did the obvious thing:&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;_llama&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;# release the old model
&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;_llama&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Llama&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;      &lt;span class="c1"&gt;# load the new one
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Drop the reference, load the replacement. It works in every tutorial, because every tutorial has one model and enough RAM.&lt;/p&gt;

&lt;p&gt;Here's what actually happened: 9 GB of "released" old model + 11 GB of new model loading + macOS + my app + a browser, on a 24 GB machine. And the crucial detail: model weights on Metal are &lt;strong&gt;wired&lt;/strong&gt; memory — physical pages pinned so the GPU can address them. Wired pages don't swap. They don't compress. When wired allocations eat the machine, the kernel has nothing left to steal, userspace grinds to a halt, and the watchdog does what watchdogs do.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Released" is not "returned"
&lt;/h2&gt;

&lt;p&gt;That &lt;code&gt;self._llama = None&lt;/code&gt; line does less than it appears to. On our stack — llama-cpp-python 0.3.33, Metal backend — we had already observed this once in a different corner: our idle unloader drops the model object after a few minutes of inactivity, and the process footprint... doesn't come down. The Python object dies; the wired pages stay attached to the process. The only event we've found that reliably returns that memory to the OS is &lt;strong&gt;process exit&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;I won't claim this is a law of nature. Maybe an explicit &lt;code&gt;close()&lt;/code&gt; at exactly the right moment behaves better in your version, maybe a future release fixes it. But we measured ours, twice, on the machine that matters: memory comes back when the process dies, and not before. If your swap design depends on the old model's memory being available for the new one, you're betting your users' machines on a deallocation you don't control.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is boring, and that's the point
&lt;/h2&gt;

&lt;p&gt;The switch button no longer touches the loaded model at all. It writes the chosen model path to a small state file and returns &lt;code&gt;{"restart_required": true}&lt;/code&gt;. The app then restarts the sidecar process: the old process dies (taking every wired byte with it, guaranteed, by the only mechanism that guarantees it), the new process reads the state file and loads the chosen model.&lt;/p&gt;

&lt;p&gt;Cost to the user: 10–20 seconds of "Restarting the server…" instead of occasionally costing them their entire machine. I'll take that trade every day of the week.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three traps between me and the boring fix
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. &lt;code&gt;terminate()&lt;/code&gt; doesn't wait.&lt;/strong&gt; Signalling the old process and immediately launching the new one reintroduces the exact bug through a different door — for a few seconds both processes hold their models. You have to wait for actual death: SIGTERM, poll until the process is gone, SIGKILL after a deadline (a load stuck inside C code never reaches a signal handler). Only then launch.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The port lies after death.&lt;/strong&gt; Our supervisor probes the sidecar's port before launching, with a plain &lt;code&gt;bind()&lt;/code&gt;. The connections a just-killed process leaves in FIN_WAIT_2 make that probe fail — port "busy" — so the supervisor helpfully relocated to the next port on every single swap. The server itself binds with SO_REUSEADDR and retakes the port without complaint. The probe now does the same, plus a &lt;code&gt;connect()&lt;/code&gt; check — because SO_REUSEADDR alone will happily bind 127.0.0.1 right over someone else's wildcard listener, and shadowing a stranger's server is worse than moving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Gate by total RAM, not available.&lt;/strong&gt; We added a pre-flight check: refuse to load a model that can't fit. First version used &lt;em&gt;available&lt;/em&gt; memory. Wrong metric — at swap time the old model is still resident, so "available" is tiny and the gate refused every legitimate swap. Whether a model fits this machine is a property of &lt;strong&gt;total&lt;/strong&gt; RAM (the weights are wired; the OS will evict everything else to make room). "Available" is only good for a soft warning: close some apps, this will be tight.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you're building a model switcher
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Assume wired model memory returns on process exit and at no other time. Design the swap as a restart.&lt;/li&gt;
&lt;li&gt;Wait for the old process to actually die before starting the new one. Poll, then SIGKILL. No overlap, ever.&lt;/li&gt;
&lt;li&gt;Probe ports the way your server binds them (SO_REUSEADDR), and verify with connect(), or enjoy your app quietly migrating ports.&lt;/li&gt;
&lt;li&gt;Hard-refuse models by total RAM. Warn by available. Never block on available.&lt;/li&gt;
&lt;li&gt;If the refusal message says "needs ≥N GB", compute N from the actual numbers. Users on a 24 GB machine reading "requires ≥24 GB" will have questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The repro script and the full panic log are here: &lt;a href="https://github.com/JackYU96/swap-models-restart-process" rel="noopener noreferrer"&gt;https://github.com/JackYU96/swap-models-restart-process&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The Mac survived. The hot-swap didn't.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>macos</category>
      <category>debugging</category>
      <category>llamacpp</category>
    </item>
    <item>
      <title>Your Hugging Face download isn't stuck — you're being rate-limited</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Mon, 13 Jul 2026 10:39:24 +0000</pubDate>
      <link>https://dev.to/dreamdeck/your-hugging-face-download-isnt-stuck-youre-being-rate-limited-44dm</link>
      <guid>https://dev.to/dreamdeck/your-hugging-face-download-isnt-stuck-youre-being-rate-limited-44dm</guid>
      <description>&lt;p&gt;I killed a perfectly healthy 2 GB model download three times before I understood what was happening. Each time the same picture: the first 100–200 MB fly by in about a minute, then the progress bar freezes at 0 B/s. Not slow — zero. A minute passes. Five. Ten. Any reasonable person concludes the download is dead, kills the process, and tries again. And again the first 200 MB arrive instantly, and again everything stops.&lt;/p&gt;

&lt;p&gt;The download was fine. I was being rate-limited, and everything about how that presents itself is designed — unintentionally, I assume — to convince you it's a hang.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part where I blamed the VPN
&lt;/h2&gt;

&lt;p&gt;My setup at the time went through a VPN, so naturally the VPN got blamed first. It's the obvious suspect: flaky route, dropped connection, MTU weirdness, pick your favorite.&lt;/p&gt;

&lt;p&gt;But the evidence didn't fit, and it took me embarrassingly long to notice. &lt;strong&gt;The first burst always came through at full speed.&lt;/strong&gt; A broken network path doesn't hand you 200 MB in a minute and then die at exactly the same point every retry. Broken networks are random. This was punctual. Whatever was stopping the download lived on the other end and had a policy about it.&lt;/p&gt;

&lt;p&gt;That's the diagnostic worth remembering, because it applies to a lot more than Hugging Face: &lt;em&gt;a fast start followed by a consistent stall is a limiter, not a failure.&lt;/em&gt; Random is hardware; punctual is policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  The warning everyone scrolls past
&lt;/h2&gt;

&lt;p&gt;Once I stopped blaming infrastructure and read my own logs from the top, the answer was sitting in plain sight, printed once at the very beginning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are sending unauthenticated requests to the HF Hub.
Please set a HF_TOKEN to enable higher rate limits and faster downloads.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It scrolls by in the first second, right before hundreds of progress-bar updates bury it. It reads like boilerplate — every tool prints some variation of "log in for a better experience", and we've all learned to ignore that sentence shape. But this one is literal.&lt;/p&gt;

&lt;p&gt;Since then Hugging Face has actually published &lt;a href="https://huggingface.co/docs/hub/en/rate-limits" rel="noopener noreferrer"&gt;official numbers&lt;/a&gt;, and they explain the shape of what I saw. All quotas run in &lt;strong&gt;fixed 5-minute windows&lt;/strong&gt; — which is precisely why the pattern is burst, wall, burst. File downloads ("resolver" requests) get 3,000 requests per window for anonymous users, and a big model is nowhere near one request: multiple files, ranged chunks, retries and redirects all count. Blow through the window and the Hub answers &lt;strong&gt;429&lt;/strong&gt; with a &lt;code&gt;RateLimit&lt;/code&gt; header saying exactly how long until reset.&lt;/p&gt;

&lt;p&gt;And here's the detail that turns a rate limit into a "hang": recent &lt;code&gt;huggingface_hub&lt;/code&gt; (1.2+) reads that header and &lt;strong&gt;silently sleeps until the window resets, then retries&lt;/strong&gt;. Older versions sit in exponential backoff. Either way, what you see is a progress bar frozen at 0 B/s while the client obediently waits out its penalty. Nothing is printed. It's the correct behavior, and it looks exactly like a dead download. For the model I was pulling — about 2.2 GB, stored as both safetensors and pytorch bins, so effectively downloaded twice — that turned an expected 3–5 minutes into 15–25.&lt;/p&gt;

&lt;p&gt;One more trap hiding in there: &lt;strong&gt;the anonymous quota is shared per IP address.&lt;/strong&gt; Behind an office NAT, a university network or a busy CI runner, you're splitting those 3,000 requests with everyone else on the same address. That's how you get "works from home, stalls at work" — and, to be fair, the reverse mystery too: some anonymous users pull terabytes and &lt;a href="https://discuss.huggingface.co/t/downloads-intermittently-fail-403-low-bandwidth/140198" rel="noopener noreferrer"&gt;never see a stall&lt;/a&gt;. Whether you hit the wall depends on who you share an IP with. It can even get sillier than a slow download: there's a &lt;a href="https://github.com/ggml-org/llama.cpp/issues/21677" rel="noopener noreferrer"&gt;llama.cpp issue&lt;/a&gt; where hitting the limit made the client unable to use a model it had &lt;em&gt;already downloaded&lt;/em&gt;, because it checked freshness against the Hub before loading the cache.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to check whether it's alive
&lt;/h2&gt;

&lt;p&gt;Before killing anything, look at the cache. Hugging Face downloads land in content-addressed blobs:&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="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lh&lt;/span&gt; ~/.cache/huggingface/hub/models--&amp;lt;org&amp;gt;--&amp;lt;name&amp;gt;/blobs/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There'll be a file ending in &lt;code&gt;.incomplete&lt;/code&gt;. Note its size, wait a few minutes, look again. Flat, then a jump of a few hundred megabytes, then flat again — that's the window cycle, and the process (sitting quietly in sleep/IO-wait, not spinning CPU) will eventually get there. And because blob names are content hashes, partial files survive a restart — resume actually resumes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fixes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The token.&lt;/strong&gt; A free account moves you from the shared anonymous pool to a &lt;strong&gt;per-user&lt;/strong&gt; quota (and bumps resolvers to 5,000 per window). That per-user part is the real win — nobody else's CI can eat your budget anymore:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hf auth login          &lt;span class="c"&gt;# huggingface_hub 1.0+&lt;/span&gt;
huggingface-cli login  &lt;span class="c"&gt;# older installs&lt;/span&gt;
&lt;span class="c"&gt;# or just: export HF_TOKEN=hf_...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If this had been the first line of that warning message in bold red, this article wouldn't exist.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The mirror.&lt;/strong&gt; For public open-weight models there's a fix that needs no account at all:&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="nv"&gt;HF_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://hf-mirror.com python your_script.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://hf-mirror.com/" rel="noopener noreferrer"&gt;hf-mirror.com&lt;/a&gt; is a long-running public mirror (still alive and growing as of 2026). It speaks the same protocol, so resume picks up your existing partial blobs. Since you're downloading tensors, not executable code, and integrity is checked against hashes, the trust story is manageable — a judgment call I'd only make for public weights, though, never for anything sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The speed flag — but check your version.&lt;/strong&gt; This one changed under everyone's feet. The classic advice — &lt;code&gt;pip install hf_transfer&lt;/code&gt; + &lt;code&gt;HF_HUB_ENABLE_HF_TRANSFER=1&lt;/code&gt; — is &lt;strong&gt;dead in &lt;code&gt;huggingface_hub&lt;/code&gt; 1.0+&lt;/strong&gt;: the Xet backend replaced it, and the old env var is now &lt;a href="https://github.com/huggingface/huggingface_hub/issues/4219" rel="noopener noreferrer"&gt;silently ignored&lt;/a&gt;, which is its own little gotcha-inside-a-gotcha. On current installs the equivalent knob is &lt;code&gt;HF_XET_HIGH_PERFORMANCE=1&lt;/code&gt; (meant for fat pipes and machines with RAM to spare). On pre-1.0 installs, the old &lt;code&gt;hf_transfer&lt;/code&gt; advice still applies. Neither raises your rate limit — they just make the bytes flow faster between penalties.&lt;/p&gt;

&lt;h2&gt;
  
  
  The product-grade conclusion
&lt;/h2&gt;

&lt;p&gt;I hit all of this building a desktop app whose backend pulls models on first run — which means my users would hit it too, on their machines, on their network routes, sharing IPs with strangers, with no idea what an &lt;code&gt;HF_TOKEN&lt;/code&gt; is.&lt;/p&gt;

&lt;p&gt;So the durable lesson for anything you ship to end users: &lt;strong&gt;don't make your users talk to Hugging Face at all.&lt;/strong&gt; Host the models you depend on yourself — object storage behind a CDN, a manifest with SHA-256 checksums, resumable downloads. First-run should depend on your infrastructure, not on the rate-limit policy of a third party toward an anonymous user you'll never get to debug.&lt;/p&gt;

&lt;p&gt;For your own machine, though, the whole fix is one line in your shell profile. Set the token, or set the mirror — and the next time a download sits at 0 B/s, check the &lt;code&gt;.incomplete&lt;/code&gt; file before you reach for Ctrl-C. It's probably not dead. It's waiting for a five-minute window to roll over, and unlike you, it knows exactly how long that takes.&lt;/p&gt;

</description>
      <category>huggingface</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>debugging</category>
    </item>
    <item>
      <title>My local RAG silently returned nothing: a missing Ollama daemon, and moving embeddings in-process with llama.cpp</title>
      <dc:creator>Jasur Yuldoshev</dc:creator>
      <pubDate>Sat, 11 Jul 2026 10:41:08 +0000</pubDate>
      <link>https://dev.to/dreamdeck/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</link>
      <guid>https://dev.to/dreamdeck/in-process-embeddings-for-a-desktop-ai-app-or-how-a-missing-daemon-silently-broke-my-rag-4jbh</guid>
      <description>&lt;p&gt;I'm building a desktop AI app for non-technical users — the kind of person who double-clicks an icon and expects search to work, and who will never open a terminal in their life. Under the hood it does local RAG: ingest documents, embed them, retrieve on each question. For a while, the embedding step went through Ollama.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug that didn't look like a bug
&lt;/h2&gt;

&lt;p&gt;On my machine everything was fine, because on my machine Ollama is always running. Then I ran the flow a real user would hit — fresh login, Ollama not started — and watched the app quietly fall apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;/ask&lt;/code&gt; returned an answer with no retrieved context.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/search&lt;/code&gt; returned nothing at all.&lt;/li&gt;
&lt;li&gt;Ingestion accepted documents and indexed none of them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;No stack trace. No red banner. The app looked healthy and did nothing useful.&lt;/p&gt;

&lt;p&gt;To be clear, this wasn't Ollama being sneaky. Ollama fails loudly — you get connection refused the instant you hit &lt;code&gt;localhost:11434&lt;/code&gt; with nothing behind it. The failure was mine: my error handling caught that exception and turned it into an empty result, and every caller downstream treated "empty" as "no matches." A user asking a perfectly good question got a confident, sourceless answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens: the daemon is a dependency you can't see
&lt;/h2&gt;

&lt;p&gt;The swallowed exception is the easy part to fix. The architectural lesson is the one worth keeping: by routing embeddings through Ollama, I had made a separate background process's liveness a hard requirement for my app to function correctly. That's a fine trade when &lt;em&gt;you&lt;/em&gt; are the operator. It's a terrible trade when your user doesn't know a daemon exists, can't tell whether it's running, and certainly won't restart it after a reboot.&lt;/p&gt;

&lt;p&gt;For a desktop app aimed at non-technical people, "keep this background service alive or your search silently breaks" is not a requirement you get to impose.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Load the same model in-process. bge-m3, 1024-dim, normalized — identical to what I was pulling from Ollama, just running inside my own Python backend via sentence-transformers.&lt;/p&gt;

&lt;p&gt;Before:&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;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&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;-&amp;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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]]:&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/api/embed&lt;/span&gt;&lt;span class="sh"&gt;"&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="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;bge-m3&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&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&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;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;embeddings&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;After:&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;sentence_transformers&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SentenceTransformer&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;SentenceTransformer&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&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;_get_model&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;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;global&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                       &lt;span class="c1"&gt;# lazy singleton
&lt;/span&gt;        &lt;span class="n"&gt;_model&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SentenceTransformer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;BAAI/bge-m3&lt;/span&gt;&lt;span class="sh"&gt;"&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;_model&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&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;-&amp;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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&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="nf"&gt;_get_model&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;model&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="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;normalize_embeddings&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;tolist&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;encode&lt;/code&gt; is blocking and CPU/GPU-heavy, so in a FastAPI backend you don't call it on the event loop. Push it to a worker thread:&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;anyio&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;embed_async&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;texts&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;-&amp;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;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&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="n"&gt;anyio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;to_thread&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And make health deterministic instead of guessing. A tiny status enum beats a boolean, because "loading a 2 GB model" is a real state that lasts several seconds:&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;enum&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Enum&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ModelStatus&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;Enum&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;NOT_LOADED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NOT_LOADED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;LOADING&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOADING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;READY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;READY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;FAILED&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FAILED&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;/health&lt;/code&gt; can tell the UI exactly where it stands, and the UI can disable search until the answer is &lt;code&gt;READY&lt;/code&gt; instead of returning empty nonsense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not a hybrid fallback
&lt;/h2&gt;

&lt;p&gt;The obvious next thought is: keep both. Try Ollama, fall back to in-process if the daemon is down. For embeddings, that is a bug — not a robustness feature.&lt;/p&gt;

&lt;p&gt;Ollama serves a quantized GGUF. sentence-transformers runs fp32. Same model name, different numerics, and therefore a different vector space. The two backends do not produce interchangeable vectors.&lt;/p&gt;

&lt;p&gt;That matters because your index is written once and queried many times. If some vectors were written by the GGUF path and you query with the fp32 path — or you re-ingest under a different backend than you started with — the cosine distances between them are quietly meaningless. Nothing throws. Retrieval just gets subtly, unpredictably worse, and you'll waste days blaming your chunking or your reranker.&lt;/p&gt;

&lt;p&gt;So the rule is one backend per index. If you want to switch backends, you re-embed the whole corpus once, deliberately. You never mix them and hope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-offs
&lt;/h2&gt;

&lt;p&gt;I'm not going to pretend this is free.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PyTorch is heavy.&lt;/strong&gt; sentence-transformers pulls in PyTorch — gigabytes on disk. You're trading a daemon dependency for a large Python dependency. This only makes sense if your app already ships a Python backend or sidecar, which mine does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The first run downloads ~2.2 GB.&lt;/strong&gt; The bge-m3 weights come from Hugging Face on first use. For real users you bundle them with the app or self-host them, because anonymous HF downloads get throttled once you're past a few hundred megabytes, and "the app hangs on first launch" is a terrible first impression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;fp32 eats RAM.&lt;/strong&gt; In-process fp32 uses more memory than Ollama's quantized GGUF, and it competes with the LLM for that memory on the same machine. On an 8 GB laptop, that competition is real.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When Ollama is still the right call
&lt;/h2&gt;

&lt;p&gt;If your users are developers running their own stack — people who already have Ollama up, who want bring-your-own-model, who treat the daemon as infrastructure they control — then routing through it is the right design. The daemon stops being a hidden liability and becomes a feature. My users aren't developers, so for me it wasn't.&lt;/p&gt;

&lt;p&gt;The code and a longer write-up are here: &lt;a href="https://github.com/JackYU96/embed-without-daemon" rel="noopener noreferrer"&gt;https://github.com/JackYU96/embed-without-daemon&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The app itself isn't launched yet — this is a pattern I settled on while building it, not a shipped product. If you've solved the desktop-embeddings problem a different way, I'd genuinely like to hear it.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>python</category>
      <category>fastapi</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
