<?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: byeongsoo kang</title>
    <description>The latest articles on DEV Community by byeongsoo kang (@sysoft).</description>
    <link>https://dev.to/sysoft</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%2F3962195%2F319da065-5968-4b38-8a95-40de82b3394d.png</url>
      <title>DEV Community: byeongsoo kang</title>
      <link>https://dev.to/sysoft</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sysoft"/>
    <language>en</language>
    <item>
      <title>The Open-Model Cost Chart Everyone's Sharing Is API Prices. Here's What Self-Hosting Actually Gets You (Measured)</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Tue, 23 Jun 2026 04:17:41 +0000</pubDate>
      <link>https://dev.to/sysoft/the-open-model-cost-chart-everyones-sharing-is-api-prices-heres-what-self-hosting-actually-gets-346l</link>
      <guid>https://dev.to/sysoft/the-open-model-cost-chart-everyones-sharing-is-api-prices-heres-what-self-hosting-actually-gets-346l</guid>
      <description>&lt;p&gt;There's a chart going around: intelligence on the y-axis, cost to run on the x-axis, and a green "most attractive" quadrant in the upper left where high intelligence meets low cost. The takeaway everyone's posting is that the green quadrant is almost entirely open source. DeepSeek, GLM, MiniMax, Kimi, Qwen all show up smart-enough and cheap, while the closed frontier models sit expensive on the right.&lt;/p&gt;

&lt;p&gt;It's a real trend and the chart isn't wrong. But read the x-axis label: cost to run is a &lt;em&gt;blended API price&lt;/em&gt;. That number answers "what does it cost to call this model through somebody's API," which is a different question from "what does it cost to run this yourself." For those of us who self-host, the second question is the whole point, and the chart quietly hides the answer.&lt;/p&gt;

&lt;p&gt;So here's what it skips — measured on the two cards I own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The catch: you can't self-host the green quadrant
&lt;/h2&gt;

&lt;p&gt;The open models winning that value quadrant aren't small. Take GLM-5.2 — the one everyone points to when they say the open frontier finally caught up. It's coding-first, currently the strongest open weight on the coding benchmarks: a ~744B-parameter MoE with about 40B active per token. And unlike the closed three, the weights are actually MIT-licensed. That's its whole pitch: you can run it yourself, no per-token fee, weights on your own box. The cheap API price (around $1.40 in and $4.40 out per million tokens, roughly a sixth of GPT-5.5) is the headline. But the thing that sets it apart is the other half: you can run it yourself.&lt;/p&gt;

&lt;p&gt;Then you try to. 744B at Q4 is roughly 372GB of weights. The other value-quadrant models are the same class, DeepSeek and Kimi running from the high hundreds of billions up toward a trillion. None of that fits a desktop GPU, or two, or four. "Self-hostable" here means a server with several 80GB datacenter cards, the exact infra headache the "open is cheap" story was supposed to spare you. So the option is real, it's just not real on the hardware most people own.&lt;/p&gt;

&lt;p&gt;So when you self-host, you don't get the green quadrant. You get whatever fits on the card in front of you, which is a tier below. The useful question is: how far below, and is it good enough? That part I can answer with numbers instead of a chart.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually runs on a consumer card
&lt;/h2&gt;

&lt;p&gt;Two tiers, both single consumer GPUs, models running fully on the GPU through Ollama. These are my own measured runs from earlier write-ups, pulled into one place:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;GPU (used price)&lt;/th&gt;
&lt;th&gt;best model that fits well&lt;/th&gt;
&lt;th&gt;gen tok/s&lt;/th&gt;
&lt;th&gt;prefill tok/s&lt;/th&gt;
&lt;th&gt;context headroom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;11GB — GTX 1080 Ti (~$200)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Gemma 4 12B QAT&lt;/td&gt;
&lt;td&gt;~32&lt;/td&gt;
&lt;td&gt;~315&lt;/td&gt;
&lt;td&gt;12B at 16k with q8 KV&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;/td&gt;
&lt;td&gt;Qwen3 8B&lt;/td&gt;
&lt;td&gt;~46&lt;/td&gt;
&lt;td&gt;~1390&lt;/td&gt;
&lt;td&gt;comfortable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;24GB — RTX 3090 (~$800)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Qwen3.6 27B Q4 + MTP&lt;/td&gt;
&lt;td&gt;~75&lt;/td&gt;
&lt;td&gt;—¹&lt;/td&gt;
&lt;td&gt;dense 27B fits in VRAM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;¹ Prefill doesn't reduce to one number on this card; it scales hard with context. At 64k the first token took about 59s. See "Long context is the real tax" below.&lt;/p&gt;

&lt;p&gt;The 11GB card tops out comfortably at a 12B. A dense 27B doesn't fit one of them at all. The 24GB card moves you up to a dense 27B at a fast ~75 tok/s once speculative decoding is on, and that's the sweet spot: a 27B is a real step up in capability from a 12B, and it still lives entirely in VRAM.&lt;/p&gt;

&lt;p&gt;On the intelligence chart, those are the mid-tier models, well below the green-quadrant frontier-open ones. So that's the real answer to "what does self-hosting get you": solid, useful, a tier under the cheap-API winners.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the API number hides
&lt;/h2&gt;

&lt;p&gt;Three costs that never show up as a dollar figure on that chart, and all three bit me at some point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The VRAM ceiling is a wall, not a slope.&lt;/strong&gt; A model either fits or it doesn't. The 27B that flies on a 3090 simply won't load on an 11GB card — no "a bit slower" middle ground at the boundary, it just fails, and your only move is a smaller model or a bigger card.&lt;/p&gt;

&lt;p&gt;Spilling a MoE to system RAM looks like the obvious escape hatch when a model is too big. It isn't. I tried it with a 35B-A3B across two 1080 Tis and got about 17 tok/s — once the experts get mmapped to system RAM the whole thing goes memory-bandwidth-bound, and a CPU nearly tied it. A 12B living entirely in VRAM often feels snappier than a 35B that spills, which isn't what the parameter count would tell you.&lt;/p&gt;

&lt;p&gt;The 3090's catch shows up at long context. It generates fast, but prompt processing scales hard: at 64k tokens the first token took about 59 seconds before generation even started. That latency never appears in a tokens-per-dollar number, and for anything retrieval-heavy it's the thing you feel.&lt;/p&gt;

&lt;h2&gt;
  
  
  So is it worth self-hosting?
&lt;/h2&gt;

&lt;p&gt;If you're chasing the cheapest intelligence-per-token, the chart is right and the answer is often no. A cheap API to something like GLM-5.2 will beat your 3090 on raw capability per dollar, because you're not paying to keep a card idle between prompts, and you're getting a 744B model instead of a 27B.&lt;/p&gt;

&lt;p&gt;Self-hosting is a bad way to win the cost game. What it buys you is the stuff that axis never measures: your data stays on the box, it runs offline, you can fine-tune and pin versions, and nobody deprecates a model out from under you. That last one is less abstract than it sounds. A weight already sitting on your disk under MIT is the one version nobody can reprice, retire, or region-lock on you later, which is part of why the open releases are starting to get talked about as insurance and not just a cheaper API. I run a local research assistant over my own papers for exactly that reason, and "a tier below the frontier" is completely fine for it. That's what you're paying for — privacy, control, a version nobody can pull out from under you. The per-token math is a side issue.&lt;/p&gt;

&lt;p&gt;So that's the bit the chart leaves out. On API the open models do win on price — no argument there. But once the weights are on your own card you drop a tier, you hit the VRAM wall, and long prompts crawl. Nobody self-hosting at home is doing it to shave a few dollars a month. They do it because the weights are theirs, sitting on a disk nobody can reprice or retire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;p&gt;These are two cards I actually own, an 11GB Pascal and a 24GB Ampere, single-GPU, Ollama, the specific quants from my earlier posts. I don't have a 4090, a 5090, or a multi-card rig, so I can't speak to those tiers and I'm not going to guess at them. The model sizes for the big MoEs are approximate; if you're quoting them, check the current model cards. Numbers are from my own runs and are stable, not claimed to the decimal.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>opensource</category>
      <category>selfhosting</category>
    </item>
    <item>
      <title>I Added a Verify Layer to My Local RAG to Catch Hallucinations. It Caught Me Being Wrong Twice About My Own Corpus</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Fri, 19 Jun 2026 04:18:14 +0000</pubDate>
      <link>https://dev.to/sysoft/i-added-a-verify-layer-to-my-local-rag-to-catch-hallucinations-it-caught-me-being-wrong-twice-1jm</link>
      <guid>https://dev.to/sysoft/i-added-a-verify-layer-to-my-local-rag-to-catch-hallucinations-it-caught-me-being-wrong-twice-1jm</guid>
      <description>&lt;p&gt;I'm building a small, fully-local research assistant: a RAG over my own papers, running on Ollama, nothing leaving the box. The risk that actually worries me isn't speed or cost. A research tool that cites a wrong number while sounding sure of itself is worse than no tool, because you'll believe it.&lt;/p&gt;

&lt;p&gt;Andrej Karpathy's &lt;code&gt;llm-wiki&lt;/code&gt; note had a piece I kept thinking about. Instead of re-retrieving from scratch each query, you have the model build a persistent wiki, and during ingest a lint pass checks the pages against each other for contradictions. I wanted something adjacent at answer time: after the RAG drafts an answer, break it into claims and check each against the sources, then flag whatever a source doesn't actually support.&lt;/p&gt;

&lt;p&gt;I should be precise about what that is, since the post is partly about citation accuracy. Karpathy's lint compares wiki pages to each other during ingest. What I built compares each answer-claim to its retrieved passage at answer time. That's groundedness (or faithfulness) checking, the same family as the RAGAS faithfulness metric and various self-check methods. The idea to bolt it on came from &lt;code&gt;llm-wiki&lt;/code&gt;; the mechanism is standard groundedness checking, run locally on a small model.&lt;/p&gt;

&lt;p&gt;I built it, measured it, and the honest version of the result is better than a clean win would have been. It includes the part where I was wrong twice about what was in my own corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  The verify layer
&lt;/h2&gt;

&lt;p&gt;About 80 lines on top of my existing RAG. After the normal retrieve-and-answer step:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Decompose the draft into atomic claims (one local LLM call).&lt;/li&gt;
&lt;li&gt;For each claim, an LLM-as-judge call returns &lt;code&gt;{supported, cite, why}&lt;/code&gt;. Supported only when a specific excerpt states it.&lt;/li&gt;
&lt;li&gt;Flag the unsupported ones.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's one verdict verbatim, so it's concrete. The claim was a deliberately corrupted "AUROC is 0.92," checked against the passage that reports 0.804:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"supported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cite"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"why"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"passage states AUROC 0.804; 0.92 does not appear"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cost, since this is a local-8B context and it matters: verify turns one answer call into roughly N+2 calls (one to decompose, one per claim to check). For a five-claim answer on my 1080 Ti that's about 15-20 extra seconds. Not free, not painful.&lt;/p&gt;

&lt;h2&gt;
  
  
  First eval, and almost shipping a false finding
&lt;/h2&gt;

&lt;p&gt;To see if it catches fabrication I wrote some questions I assumed weren't answerable from my three-paper corpus. One asked for the AUROC of the synergy model "on the held-out test set." The baseline answered "0.804 [1]," and my verify layer passed it. I wrote it up as a miss: &lt;em&gt;verify let a fabricated statistic through.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Then I grepped my own corpus for &lt;code&gt;0.804&lt;/code&gt;. It was there, seven times. So I rewrote it the other way: the number was real, the model was right, verify passed it correctly. A tidier story, and I almost shipped that one too.&lt;/p&gt;

&lt;p&gt;It's also wrong. Look at what the passages actually say. Every &lt;code&gt;0.804&lt;/code&gt; is reported as a GroupKFold cross-validation result, and one line states it outright: "No separate held-out test set was used due to the limited sample size." My question asked for a held-out test AUROC. There is no held-out test set. The model took a real cross-validation number and pinned it to an evaluation that doesn't exist, and verify passed it because the digits &lt;code&gt;0.804&lt;/code&gt; were sitting right there in the context.&lt;/p&gt;

&lt;p&gt;So I was wrong twice about my own corpus, in opposite directions, before landing on what happened: a right-number-wrong-context hallucination that claim-checking sailed straight past. The first lesson is awkward but worth stating plainly: you can't measure hallucination without ground truth, and "the number is real" is not the same as "the answer is right."&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it properly
&lt;/h2&gt;

&lt;p&gt;I threw out the vibe-based questions and built a controlled benchmark: eight pairs of claims. Each pair has one true claim (a fact I confirmed by grep) and one false claim, the same statement with a number or entity corrupted to something I confirmed was absent from the corpus. &lt;code&gt;AUROC 0.804&lt;/code&gt; against &lt;code&gt;AUROC 0.92&lt;/code&gt;. "pathway membership and gene essentiality scores" against "patient age, BMI, and smoking status." "implicates the hippocampus, amygdala, prefrontal cortex" against "leaves the hippocampus and amygdala unaffected."&lt;/p&gt;

&lt;p&gt;That labeling step earned its keep immediately. Three of my first-draft "false" claims used terms (&lt;code&gt;cerebellum&lt;/code&gt;, &lt;code&gt;CRISPR&lt;/code&gt;, &lt;code&gt;Loewe&lt;/code&gt;) that grep found were actually in the corpus, so they weren't false at all. The same mistake as the AUROC, caught before it counted.&lt;/p&gt;

&lt;p&gt;Then I feed the verifier the context that supports the true claim and ask it to judge both claims against that same context.&lt;/p&gt;

&lt;h2&gt;
  
  
  The benchmark result
&lt;/h2&gt;

&lt;p&gt;Both the lenient prompt and a stricter "numbers must match verbatim" prompt scored the same: 8 of 8 fabrications caught, 0 of 8 true claims wrongly flagged.&lt;/p&gt;

&lt;p&gt;Two caveats keep that honest. With n=8 it's a point estimate, not a guarantee; the Wilson 95% interval on 8/8 runs from about 67% to 100%, so read it as "no failures in eight trials." And note the setup is the easiest possible one: the supporting passage is guaranteed present and the corrupted value is guaranteed absent. This measures the judge when retrieval is already perfect, not the pipeline. The strict prompt changing nothing isn't evidence it's useless either, because no pair in the set would separate the two prompts. Every corruption is far from its real value. &lt;code&gt;0.804&lt;/code&gt; vs &lt;code&gt;0.81&lt;/code&gt; would be the test, and I don't have one.&lt;/p&gt;

&lt;p&gt;So: given perfect context and a blatant corruption, a local 8B model judges reliably. Good to know, and not the hard case.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard case the benchmark can't reach
&lt;/h2&gt;

&lt;p&gt;The benchmark used false claims I wrote by hand. That sidesteps the scenario I actually care about for a co-scientist: a hallucination the model itself produces, judged by a verifier that is the same model and may share its blind spots.&lt;/p&gt;

&lt;p&gt;So I ran a second eval. Six false-premise or absent-specific questions, ground-truth checked. The model's own draft is the unit under test, and I judge each draft twice: once with the same model (qwen3:8b) and once with a different one (gemma4 12B QAT). One caveat I owe you: that judge is larger than the 8B answerer, so any cross-model gain here is "different and bigger," not cleanly "different." A same-size cross-model check would isolate that, and I didn't run one.&lt;/p&gt;

&lt;p&gt;On four of six the model abstained correctly. Asked the dose of meropenem (never in the corpus), the sample size of a meta-analysis (the stress paper is a scoping review with none), it said the context doesn't contain it. Grounding works when the gap is obvious.&lt;/p&gt;

&lt;p&gt;On the other two it fabricated, and both were right-number-wrong-context:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"AUROC on the held-out test set" → "0.804" again. The held-out AUROC the question presumes does not exist.&lt;/li&gt;
&lt;li&gt;"AUROC after removing the pathway-membership features" → "0.627." That 0.627 is real, but it's the number for removing &lt;em&gt;ribosome-targeting&lt;/em&gt; combinations, a different ablation. The model grabbed a nearby real value and reattributed it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same-model judge caught zero of those two. It rubber-stamped both of its own fabrications. The cross-model judge caught one: gemma flagged the 0.627, with the reason "that AUROC resulted from removing ribosome-targeting combinations." A different model checked what the number was attached to; the original model, having produced the misattribution, did not re-examine it. Neither judge caught the held-out one. The digits &lt;code&gt;0.804&lt;/code&gt; are real, so there's nothing in the number to contradict, and catching it means knowing that no held-out set exists. When I checked, the sentence that says so, "no separate held-out test set was used," wasn't even in the retrieved context. The model never saw the thing that made the premise false.&lt;/p&gt;

&lt;p&gt;That's the measured core of this whole exercise, and the failures split three ways, not two. A value simply absent from the context (&lt;code&gt;0.92&lt;/code&gt;) gets caught reliably, even when the same model judges. A value that's real but pinned to the wrong thing (&lt;code&gt;0.627&lt;/code&gt;, from a different ablation) slips past the same-model judge and is only sometimes recovered by a second one. A false premise, like a held-out AUROC when no held-out set exists, gets through both, and that one bottomed out in retrieval since the sentence refuting it never reached the context. The layer I built reliably catches only the first kind. A model refereeing its own output inherits its own blind spots; a second model recovers some of the misattributions, not the premise-level error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the flags were actually pointing at
&lt;/h2&gt;

&lt;p&gt;One more thread from the messy first eval. Back then the verifier had flagged a couple of true claims ("prefrontal cortex," "gene essentiality scores") as unsupported. After the clean benchmark, where it never did that, those looked inconsistent.&lt;/p&gt;

&lt;p&gt;The resolution: verify checks claims against the &lt;em&gt;retrieved&lt;/em&gt; context, not the whole corpus. Those claims were true and in the corpus, but the passage proving them wasn't among the chunks retrieved for that question. The verifier said "not in what you gave me," correctly.&lt;/p&gt;

&lt;p&gt;A flag, then, can mean three things, and at answer time you can't tell them apart without ground truth: a retrieval miss, a real fabrication, or a true fact that lives outside the corpus. In the cases from my first eval that I could actually check, the flags were retrieval misses, which is why "re-retrieve and re-check" is a better default reaction than "delete the claim." I didn't measure the proportion, so I won't put a number on how often each happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;What I actually measured, on one corpus with &lt;code&gt;qwen3:8b&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Given good context and a blatantly corrupted value, the verifier is reliable (8/8, with the n=8 caveat).&lt;/li&gt;
&lt;li&gt;The model abstains on clearly-absent information (4/6 here).&lt;/li&gt;
&lt;li&gt;Both fabrications were real-number-wrong-context: one a misattributed value (&lt;code&gt;0.627&lt;/code&gt;, the wrong ablation), one a false premise (a held-out AUROC where no held-out set exists). The same-model judge caught neither (0/2); it rubber-stamped its own output.&lt;/li&gt;
&lt;li&gt;The cross-model judge caught the misattributed value but not the false premise (1/2). The premise error was the harder kind, and the sentence that would refute it wasn't even retrieved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I suspect but did not measure, so take it as an impression: in a grounded RAG, most of what looks like the model inventing facts is really retrieval not surfacing the passage, or the prompt not grounding hard enough. If that holds, the leverage is in retrieval and grounding, not a bigger model. I'd want a labeled run before saying it harder.&lt;/p&gt;

&lt;p&gt;And the practical version: claim-checking reliably catches only values that are absent from the context. It misses a real number attached to the wrong question, and misses a false premise outright. Use a different model to judge than to answer, not on the strength of one recovered case out of two, but because a model that produced a misattribution won't re-examine it while an unrelated one cross-checks what the number is attached to. Expect it to recover some misattributed values, not false premises. Treat a flag as "go re-retrieve," not "delete." And you cannot evaluate any of this without ground-truth labels. I almost shipped two false findings about my own corpus, and &lt;code&gt;grep&lt;/code&gt; fixed both.&lt;/p&gt;

&lt;p&gt;Limitations are most of the honesty here: one small corpus, an 8B answerer, eight hand-built pairs plus six probes, no held-out scoring, no subtle-but-true corruptions like "significant" vs "trending." This is a first measurement, not a verdict.&lt;/p&gt;

&lt;p&gt;Credit: the inspiration is Karpathy's &lt;code&gt;llm-wiki&lt;/code&gt; pattern and the full desktop implementation at &lt;a href="https://github.com/nashsu/llm_wiki" rel="noopener noreferrer"&gt;nashsu/llm_wiki&lt;/a&gt;. What I built is plain groundedness checking moved to answer time, run on a local box. The code and eval scripts are in my &lt;a href="https://github.com/shoo99/paper-rag" rel="noopener noreferrer"&gt;paper-rag repo&lt;/a&gt; if you want to poke holes in the setup. Please do.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>rag</category>
      <category>llm</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>What Actually Runs Well on a GTX 1080 Ti in 2026 (Measured)</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Fri, 12 Jun 2026 07:27:00 +0000</pubDate>
      <link>https://dev.to/sysoft/what-actually-runs-well-on-a-gtx-1080-ti-in-2026-measured-21p5</link>
      <guid>https://dev.to/sysoft/what-actually-runs-well-on-a-gtx-1080-ti-in-2026-measured-21p5</guid>
      <description>&lt;p&gt;The "GPU poor" narrative has flipped this year: 24GB-and-below cards are suddenly fine, thanks to quantization-aware training (near-bf16 quality at Q4 size) and MTP (free decode speed). But most of those posts are running 3090s and 4080s. I wanted the floor: what actually runs well on a &lt;strong&gt;GTX 1080 Ti&lt;/strong&gt; — an 8-year-old card with 11GB — in 2026? So I measured it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers
&lt;/h2&gt;

&lt;p&gt;Single GTX 1080 Ti (11GB), Ollama with flash-attention, &lt;code&gt;num_ctx 8192&lt;/code&gt;, all running &lt;strong&gt;100% on the GPU&lt;/strong&gt; (no CPU offload):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;size&lt;/th&gt;
&lt;th&gt;gen tok/s&lt;/th&gt;
&lt;th&gt;prefill tok/s&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3 8B&lt;/td&gt;
&lt;td&gt;5.2 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~46&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~1390&lt;/td&gt;
&lt;td&gt;6.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B QAT (unsloth UD-Q4_K_XL)&lt;/td&gt;
&lt;td&gt;6.7 GB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~32&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~316&lt;/td&gt;
&lt;td&gt;8.0 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B QAT (Google)&lt;/td&gt;
&lt;td&gt;7.0 GB&lt;/td&gt;
&lt;td&gt;~31&lt;/td&gt;
&lt;td&gt;~314&lt;/td&gt;
&lt;td&gt;8.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B (regular Q4)&lt;/td&gt;
&lt;td&gt;7.1 GB&lt;/td&gt;
&lt;td&gt;~29&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;8.4 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The headline: &lt;strong&gt;a 12B model runs at ~30 tok/s on an 8-year-old card&lt;/strong&gt;, comfortably inside 11GB, fully on the GPU. That's faster than most people read, and well into "usable for daily work" territory. An 8B sits around 46 tok/s with much faster prefill.&lt;/p&gt;

&lt;h2&gt;
  
  
  A few things worth noting
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;QAT buys a small but real speed edge.&lt;/strong&gt; The Gemma 4 12B QAT builds (~31–32 tok/s) come in a bit faster than the regular Q4 (~29) and slightly smaller — about a 9% gen-speed gain, consistent with what I measured &lt;a href="https://bric.pe.kr/blog/gemma-4-qat-1080ti-8gb-12b-16k-measured" rel="noopener noreferrer"&gt;earlier on the same card&lt;/a&gt;. Not magic, but free.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prefill scales with size, hard.&lt;/strong&gt; Qwen3 8B processes the prompt at ~1390 tok/s; the Gemma 12Bs at ~315 — roughly 4× slower. On a Pascal card the prompt-processing stage is where you feel the model size, so for long prompts the smaller model's lead widens beyond the gen-speed gap. (This is the same &lt;a href="https://bric.pe.kr/blog/mtp-prefill-wall-long-context-latency-rtx-3090" rel="noopener noreferrer"&gt;prefill wall&lt;/a&gt; story, scaled to old hardware.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;12B is the comfortable ceiling for one card.&lt;/strong&gt; A 12B Q4 lands around 8GB and leaves room for a real context. The QAT 12B even &lt;a href="https://bric.pe.kr/blog/gemma-4-qat-1080ti-8gb-12b-16k-measured" rel="noopener noreferrer"&gt;fits 16k context on an 8GB card&lt;/a&gt; with KV-cache quantization, so an 11GB 1080 Ti has comfortable headroom.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the ceiling actually is
&lt;/h2&gt;

&lt;p&gt;A dense &lt;strong&gt;27B&lt;/strong&gt; (Q4 ≈ 17GB) does &lt;em&gt;not&lt;/em&gt; fit one 11GB card — you either split across two cards or it spills to system RAM and crawls. And spilling is worse than it sounds on this class of hardware: I &lt;a href="https://dev.to/sysoft"&gt;benchmarked the 35B-A3B MoE on 2× 1080 Ti&lt;/a&gt; and it ran at only ~17 tok/s, because the experts get mmapped to system RAM and the whole thing goes memory-bandwidth-bound — a CPU nearly tied it. So "more VRAM via a second old card" helps you &lt;em&gt;fit&lt;/em&gt; bigger models, but the bandwidth ceiling means a dense 12B that lives entirely on one card often feels snappier than a 35B that spills.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;If you've got a 1080 Ti gathering dust: in 2026 it runs a 12B at ~30 tok/s and an 8B at ~46, fully on the GPU, no cloud, no rate limits. QAT made the quality competitive and the size friendly; the card was always fast enough for this once the models got small enough. The "GPU poor are eating well" story reaches all the way back to 2017 silicon — you just stay at or below 12B and let it sit entirely in VRAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  Caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Single GTX 1080 Ti, single request, Ollama + flash-attention, &lt;code&gt;num_ctx 8192&lt;/code&gt;, the specific quants above. Gen tok/s from the server's own token timings; numbers are stable but not claimed to ±0.1.&lt;/li&gt;
&lt;li&gt;The 35B-A3B 2× 1080 Ti figure is from my earlier write-up, not this run.&lt;/li&gt;
&lt;li&gt;27B+ "doesn't fit" assumes a single 11GB card and Q4-class quant; a second card or heavier KV-quant changes the math (at a speed cost).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>gemma</category>
      <category>localllama</category>
    </item>
    <item>
      <title>MTP Isn't Always a Win: 1.95x on My 3090, but Speculative Decoding Is Hardware-Dependent</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Thu, 11 Jun 2026 07:26:13 +0000</pubDate>
      <link>https://dev.to/sysoft/mtp-isnt-always-a-win-195x-on-my-3090-but-speculative-decoding-is-hardware-dependent-2d5b</link>
      <guid>https://dev.to/sysoft/mtp-isnt-always-a-win-195x-on-my-3090-but-speculative-decoding-is-hardware-dependent-2d5b</guid>
      <description>&lt;p&gt;In &lt;a href="https://bric.pe.kr/blog/qwen3-27b-rtx-3090-llama-cpp-mtp-doubling-tokens" rel="noopener noreferrer"&gt;my MTP post&lt;/a&gt;, speculative decoding roughly doubled Qwen3.6-27B generation on a 3090. It's tempting to read that as "turn on MTP, go faster." So I measured it on a different model — Gemma 4 12B QAT — and it's a big win on my 3090. But the same model with the same MTP draft runs &lt;em&gt;slower&lt;/em&gt; on an M1 Max. MTP isn't a free switch; it's a hardware-dependent lever.&lt;/p&gt;

&lt;h2&gt;
  
  
  My 3090 numbers
&lt;/h2&gt;

&lt;p&gt;Gemma 4 12B QAT (&lt;code&gt;UD-Q4_K_XL&lt;/code&gt;) + an MTP draft head (&lt;code&gt;Q8_0-MTP&lt;/code&gt;, a 0.47 GB nextn head, not a full second model), single RTX 3090, decode tok/s, 3 runs each:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;mean tok/s&lt;/th&gt;
&lt;th&gt;speedup&lt;/th&gt;
&lt;th&gt;draft acceptance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;baseline (no MTP)&lt;/td&gt;
&lt;td&gt;85.9&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MTP &lt;code&gt;n-max 2&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;159.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.86×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.77&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MTP &lt;code&gt;n-max 3&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;167.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.95×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.69&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A clean ~1.9× on the 3090, and unusually &lt;em&gt;stable&lt;/em&gt; — run-to-run CV was under 0.5% (my earlier Qwen3.6-27B MTP runs were far noisier at 5–7%, needing a dozen runs; Gemma here settled in three). Same &lt;code&gt;n-max 3&lt;/code&gt; sweet spot as Qwen, same counterintuitive shape: deeper speculation has &lt;em&gt;lower&lt;/em&gt; per-token acceptance (0.69 vs 0.77) but &lt;em&gt;higher&lt;/em&gt; throughput, because more tokens land per verify step. Per-category, the win ranged 1.8×–2.2× (RAG and coding best at ~2.2×). The whole thing fit in ~8 GB of VRAM.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same model, slower on an M1 Max
&lt;/h2&gt;

&lt;p&gt;Here's the part worth the post. A cross-hardware benchmark (another tester's runs, same &lt;code&gt;speed_bench.py&lt;/code&gt; harness and &lt;code&gt;--jinja&lt;/code&gt; settings, so it's comparable) put Gemma 4 12B QAT+MTP at:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;hardware&lt;/th&gt;
&lt;th&gt;MTP speedup (n-max 2)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;RTX 3090&lt;/strong&gt; (mine)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1.86×&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RTX 5070 Ti laptop&lt;/td&gt;
&lt;td&gt;1.74×&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;M1 Max (16", 64 GB)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.87× — slower&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same MTP draft, and the M1 Max actually loses ~13% by turning MTP &lt;em&gt;on&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why MTP can make you slower
&lt;/h2&gt;

&lt;p&gt;Speculative decoding wins when &lt;strong&gt;verifying&lt;/strong&gt; a batch of drafted tokens is cheap relative to generating them one at a time. The draft head proposes several tokens, the main model checks them in one parallel pass, and accepted drafts give you multiple tokens for about the cost of one verify. That only pays off when you have spare compute and the verify pass is cheap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Capable CUDA GPU (3090, 5070 Ti):&lt;/strong&gt; lots of compute headroom, the parallel verify is cheap, the drafts land → 1.7–1.9×.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apple Silicon (M1 Max), unified memory:&lt;/strong&gt; running the draft adds compute the architecture doesn't have to spare relative to its memory bandwidth, and that overhead outweighs the parallel-verify gain. Net result: slower than just decoding normally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So MTP's speedup is a function of the &lt;strong&gt;draft-cost-to-verify-cost ratio on your specific hardware&lt;/strong&gt;, not a property of the technique. Capable CUDA GPU: yes. Apple Silicon: measure first — it can backfire.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Only the 3090 numbers are mine; the M1 Max / 5070 Ti figures are another tester's runs. Same harness and settings, so the comparison is fair, but it isn't a single controlled rig — read the cross-machine table as directional.&lt;/li&gt;
&lt;li&gt;Gemma 4 12B &lt;code&gt;it&lt;/code&gt; runs as a &lt;em&gt;thinking&lt;/em&gt; model under &lt;code&gt;--jinja&lt;/code&gt; (output routes to a reasoning channel); this doesn't affect the decode-tok/s measurement, which comes from the server's own token timings, and all machines used the same &lt;code&gt;--jinja&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;MTP throughput can vary run to run; the 3090 numbers were stable (CV &amp;lt; 0.5%), but the M1 Max's 0.87× is close enough to 1.0 that it's worth re-running before treating it as exact — the &lt;em&gt;direction&lt;/em&gt; (a net loss) is the point.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it (3090)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;llama.cpp mainline, commit &lt;code&gt;e3471b3&lt;/code&gt; (already accepts the Gemma MTP draft — no special build), CUDA sm86.&lt;/li&gt;
&lt;li&gt;Models from &lt;code&gt;unsloth/gemma-4-12B-it-qat-GGUF&lt;/code&gt;: main &lt;code&gt;gemma-4-12B-it-qat-UD-Q4_K_XL.gguf&lt;/code&gt;, draft &lt;code&gt;gemma-4-12B-it-Q8_0-MTP.gguf&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Server: &lt;code&gt;llama-server -m &amp;lt;main&amp;gt; --model-draft &amp;lt;draft&amp;gt; --spec-type draft-mtp --spec-draft-n-max 3 -np 1 -ub 512 -c 16384 -ngl 99 -fa on --jinja&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Client: &lt;code&gt;speed_bench.py --bench qualitative --category all --osl 1024 --concurrency 1&lt;/code&gt;, compared with &lt;code&gt;speed_bench_compare.py&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;MTP is one of the best generation-speed levers on a capable CUDA GPU — ~1.9× here on a 3090, for free quality-wise (the verify keeps the output exact). But "speculative decoding makes you faster" is a hardware claim, not a universal one. On Apple Silicon it can be pure overhead. Measure it on your own box before you commit to it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>performance</category>
      <category>gemma</category>
    </item>
    <item>
      <title>Gemma 4 QAT on a 1080 Ti: What 'Quantization-Aware' Actually Buys — and Fitting the 12B on 8 GB at 16k</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Thu, 11 Jun 2026 02:39:05 +0000</pubDate>
      <link>https://dev.to/sysoft/gemma-4-qat-on-a-1080-ti-what-quantization-aware-actually-buys-and-fitting-the-12b-on-8-gb-at-2bg5</link>
      <guid>https://dev.to/sysoft/gemma-4-qat-on-a-1080-ti-what-quantization-aware-actually-buys-and-fitting-the-12b-on-8-gb-at-2bg5</guid>
      <description>&lt;p&gt;Quantization-Aware Training (QAT) is the headline feature of the Gemma 4 release: models &lt;em&gt;trained&lt;/em&gt; to survive 4-bit quantization, so the Q4 version stays close to full quality instead of degrading the way a naive post-training quant does. The pitch is great. I wanted to know what it actually buys on hardware most people would call obsolete — a GTX 1080 Ti — and whether it makes the 12B usable on an 8 GB card. So I measured three things: quality, speed, and footprint.&lt;/p&gt;

&lt;p&gt;Short version: the &lt;strong&gt;quality claim is real&lt;/strong&gt; (against naive Q4), the &lt;strong&gt;speed win is modest&lt;/strong&gt; (~9% over a regular Q4), and the &lt;strong&gt;12B fits an 8 GB GPU at 16k context&lt;/strong&gt; if you quantize the KV cache. Details below.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Quality: the part QAT is actually about
&lt;/h2&gt;

&lt;p&gt;QAT's whole point is quality retention at Q4. Unsloth publishes a clean way to see it — top-1 token agreement with the full model, their dynamic &lt;code&gt;UD-Q4_K_XL&lt;/code&gt; vs a naive &lt;code&gt;Q4_0&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;model&lt;/th&gt;
&lt;th&gt;UD-Q4_K_XL&lt;/th&gt;
&lt;th&gt;naive Q4_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 E2B&lt;/td&gt;
&lt;td&gt;98.16%&lt;/td&gt;
&lt;td&gt;89.29%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 12B&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;88.76%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;74.08%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemma 4 31B&lt;/td&gt;
&lt;td&gt;96.67%&lt;/td&gt;
&lt;td&gt;87.91%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the 12B that's a ~15-point gap — naive 4-bit drops a lot of the model's token choices, QAT + dynamic quant keeps most of them, at ~72% less memory than BF16 (6.72 GB vs 23.8 GB). That's the real optimization.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Honest caveat on this:&lt;/strong&gt; that big gap is against &lt;strong&gt;naive &lt;code&gt;Q4_0&lt;/code&gt;&lt;/strong&gt;. Against a &lt;em&gt;good&lt;/em&gt; modern quant like &lt;code&gt;Q4_K_M&lt;/code&gt;, the difference is much smaller — which matched my own experience: on my coarse hands-on probes (token-glitch counts, a niche domain question) I couldn't reliably separate the QAT build from a solid &lt;code&gt;Q4_K_M&lt;/code&gt;. So I trust the benchmark numbers for the quality story, not my eyeballs — and the practical read is "QAT is the best quality-per-byte option, but if you're already on a good Q4_K_M the day-to-day difference is subtle."&lt;/p&gt;

&lt;p&gt;One useful, slightly counterintuitive tip from Unsloth: stick to &lt;strong&gt;&lt;code&gt;UD-Q4_K_XL&lt;/code&gt;&lt;/strong&gt; — going to higher precision (Q5/Q6/Q8) of these QAT models actually &lt;em&gt;degrades&lt;/em&gt; accuracy, because the QAT was tuned for the 4-bit target.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Speed and size on a 1080 Ti
&lt;/h2&gt;

&lt;p&gt;I ran Gemma 4 12B three ways on a single 8-year-old GTX 1080 Ti (num_ctx 8192, 100% GPU):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;build&lt;/th&gt;
&lt;th&gt;gen tok/s&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;regular Q4&lt;/td&gt;
&lt;td&gt;28.3&lt;/td&gt;
&lt;td&gt;7.6 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google QAT&lt;/td&gt;
&lt;td&gt;31.0&lt;/td&gt;
&lt;td&gt;7.5 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Unsloth QAT (UD-Q4_K_XL)&lt;/td&gt;
&lt;td&gt;30.8&lt;/td&gt;
&lt;td&gt;7.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the QAT builds are &lt;strong&gt;~9% faster and slightly smaller&lt;/strong&gt; than a regular Q4 — a real but modest win, and all three run fully on one old card. Don't expect the quality numbers above to also make it dramatically faster; the speed/size gain is incremental. The headline is "a 12B runs comfortably at ~30 tok/s on a 1080 Ti," which is itself a nice statement about QAT-sized models on old hardware.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The useful part: fitting the 12B on 8 GB at 16k context
&lt;/h2&gt;

&lt;p&gt;This is the question I actually get asked: can you run the 12B on an &lt;strong&gt;8 GB&lt;/strong&gt; card with a &lt;strong&gt;16k&lt;/strong&gt; context and keep it fast? The model weights are ~7 GB, so on an 8 GB card you have ~1 GB for the KV cache — and 16k of KV is the squeeze. I measured the footprint at 16k with each KV-cache type (single GPU, flash-attention on):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;KV cache&lt;/th&gt;
&lt;th&gt;VRAM @ 16k&lt;/th&gt;
&lt;th&gt;fits 8 GB?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;f16 (default)&lt;/td&gt;
&lt;td&gt;7.7 GB&lt;/td&gt;
&lt;td&gt;❌ no (driver reserve pushes it over)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;q8_0&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.4 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ yes (tight, ~0.5 GB headroom)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;q4_0&lt;/td&gt;
&lt;td&gt;7.2 GB&lt;/td&gt;
&lt;td&gt;✅ yes (more margin)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All three stayed &lt;strong&gt;100% GPU&lt;/strong&gt; at 16k. The default f16 KV (7.7 GB) won't reliably fit an 8 GB card once you count the driver/display reserve — which is why a naive attempt spills to CPU and crawls. Quantize the KV to &lt;strong&gt;&lt;code&gt;q8_0&lt;/code&gt;&lt;/strong&gt; and you're at 7.4 GB with negligible quality cost; that's the sweet spot. Drop to &lt;code&gt;q4_0&lt;/code&gt; if you've got a display attached and want margin.&lt;/p&gt;

&lt;p&gt;A neat detail: the KV cache at 16k is &lt;em&gt;small&lt;/em&gt; here — q8 and q4 differ by only ~0.2 GB — because Gemma interleaves sliding-window (local) and global attention, so most layers cap their KV at the window size regardless of context length. The footprint is dominated by the ~7 GB weights, and KV quantization just buys the last ~0.3–0.7 GB you need to slip under 8 GB. (This is the flip side of &lt;a href="https://bric.pe.kr/blog/mtp-prefill-wall-long-context-latency-rtx-3090" rel="noopener noreferrer"&gt;the prefill wall&lt;/a&gt;: cheap KV doesn't make the prompt &lt;em&gt;process&lt;/em&gt; faster, it just makes it &lt;em&gt;fit&lt;/em&gt;.)&lt;/p&gt;

&lt;p&gt;The recipe that works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# ollama&lt;/span&gt;
&lt;span class="nv"&gt;OLLAMA_FLASH_ATTENTION&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nv"&gt;OLLAMA_KV_CACHE_TYPE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;q8_0 ollama serve
&lt;span class="c"&gt;# then run gemma-4-12b-qat with num_ctx 16384, all layers on GPU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# llama.cpp equivalent&lt;/span&gt;
llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; gemma-4-12b-qat-UD-Q4_K_XL.gguf &lt;span class="nt"&gt;-c&lt;/span&gt; 16384 &lt;span class="nt"&gt;-fa&lt;/span&gt; on &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cache-type-k&lt;/span&gt; q8_0 &lt;span class="nt"&gt;--cache-type-v&lt;/span&gt; q8_0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check &lt;code&gt;ollama ps&lt;/code&gt; / &lt;code&gt;nvidia-smi&lt;/code&gt; shows &lt;strong&gt;100% GPU&lt;/strong&gt; — if any layer offloads to CPU, throughput tanks, and that's your signal to drop the KV to &lt;code&gt;q4_0&lt;/code&gt; or trim the context.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Speed numbers are at num_ctx 8192; the 8 GB/16k footprint numbers are at 16k — different tests, both on the same model/quant.&lt;/li&gt;
&lt;li&gt;The 8 GB fit was measured on a 1080 Ti (11 GB) constrained to one GPU; I'm reporting the actual VRAM used, from which 8 GB fit is clear, but a real 8 GB card with a display attached has slightly less usable VRAM — so &lt;code&gt;q8_0&lt;/code&gt; is "fits headless," and &lt;code&gt;q4_0&lt;/code&gt; is the safer bet with a monitor plugged in.&lt;/li&gt;
&lt;li&gt;The quality numbers are Unsloth's published top-1 agreement, not my own benchmark run; my hands-on probes were too coarse to add to them.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Is Gemma 4 QAT a good model? Yes — it's the best quality-per-byte way to run Gemma 4 locally, the quality retention vs naive Q4 is real and measured, and on practical hardware it's genuinely useful: a 12B at ~30 tok/s on a 1080 Ti, and a 12B at 16k on an 8 GB card if you quantize the KV. Just don't expect the "near-BF16 quality" story to also mean a big speedup — the speed/size win over a good Q4_K_M is modest. The real story is accessibility: QAT is what lets a 12B feel comfortable on a card most people wrote off years ago.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>gemma</category>
      <category>quantization</category>
    </item>
    <item>
      <title>The Prefill Wall: Why MTP's 2 Barely Moves Long-Context Latency (Qwen3.6-27B, RTX 3090)</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Wed, 10 Jun 2026 02:23:43 +0000</pubDate>
      <link>https://dev.to/sysoft/the-prefill-wall-why-mtps-2x-barely-moves-long-context-latency-qwen36-27b-rtx-3090-185i</link>
      <guid>https://dev.to/sysoft/the-prefill-wall-why-mtps-2x-barely-moves-long-context-latency-qwen36-27b-rtx-3090-185i</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;a href="https://bric.pe.kr/blog/qwen3-27b-rtx-3090-llama-cpp-mtp-doubling-tokens" rel="noopener noreferrer"&gt;My MTP post&lt;/a&gt; showed multi-token prediction roughly doubling Qwen3.6-27B's &lt;em&gt;generation&lt;/em&gt; on a 3090. A reader asked the question I'd skipped: what about &lt;strong&gt;prompt processing at long context&lt;/strong&gt;? So I measured it — and that turns out to be the real wall, the one MTP can't climb.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;On a single RTX 3090, prefill (prompt processing) for Qwen3.6-27B drops from ~1,575 tok/s at 1k context to ~852 at 128k — so a 64k-token prompt takes &lt;strong&gt;~59 seconds before the first token appears&lt;/strong&gt;, and 128k takes ~2.5 minutes. MTP speeds the &lt;em&gt;decode&lt;/em&gt; phase, not prefill, so on a long-context / short-answer request (the typical RAG shape) its 2× generation win shrinks to &lt;strong&gt;~3% of total latency&lt;/strong&gt;. MTP is real; it just stops mattering exactly where long-context RAG lives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefill vs context size
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;llama-bench&lt;/code&gt;, Qwen3.6-27B IQ4_XS, prefill only (&lt;code&gt;-n 0&lt;/code&gt;), flash-attention on, single RTX 3090:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;context&lt;/th&gt;
&lt;th&gt;prefill tok/s&lt;/th&gt;
&lt;th&gt;time to first token&lt;/th&gt;
&lt;th&gt;peak VRAM&lt;/th&gt;
&lt;th&gt;fits 24 GB?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1,024&lt;/td&gt;
&lt;td&gt;1,575&lt;/td&gt;
&lt;td&gt;0.65 s&lt;/td&gt;
&lt;td&gt;16.0 GB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;16,384&lt;/td&gt;
&lt;td&gt;1,432&lt;/td&gt;
&lt;td&gt;11.4 s&lt;/td&gt;
&lt;td&gt;16.5 GB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;65,536&lt;/td&gt;
&lt;td&gt;1,111&lt;/td&gt;
&lt;td&gt;59.0 s&lt;/td&gt;
&lt;td&gt;19.6 GB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;131,072&lt;/td&gt;
&lt;td&gt;852&lt;/td&gt;
&lt;td&gt;153.8 s&lt;/td&gt;
&lt;td&gt;23.6 GB&lt;/td&gt;
&lt;td&gt;barely (98.5%)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prefill throughput falls ~46% from 1k to 128k as the attention cost grows with sequence length, and time-to-first-token climbs roughly linearly with prompt size. These numbers are rock-stable (CV &amp;lt; 0.5%) — prefill is compute-bound, unlike the noisier MTP generation numbers from last time.&lt;/p&gt;

&lt;h2&gt;
  
  
  MTP speeds decode, not prefill
&lt;/h2&gt;

&lt;p&gt;Speculative decoding (MTP) works during &lt;em&gt;generation&lt;/em&gt;: a draft proposes several tokens ahead and the main model verifies them in one pass. Prefill is a different phase — a single forward pass over the whole prompt to build the KV cache, before any token is generated. MTP doesn't touch that pass, so &lt;strong&gt;it can't reduce time-to-first-token&lt;/strong&gt;. What it reduces is the per-token cost of everything &lt;em&gt;after&lt;/em&gt; the first token.&lt;/p&gt;

&lt;p&gt;That's not the same as "MTP doesn't help long context." If you generate a lot of tokens, MTP still cuts the generation portion. The honest question is: how big is the generation portion &lt;em&gt;relative to&lt;/em&gt; prefill?&lt;/p&gt;

&lt;h2&gt;
  
  
  When MTP actually matters: the latency math
&lt;/h2&gt;

&lt;p&gt;Using the measured prefill above and generation from the &lt;a href="https://bric.pe.kr/blog/qwen3-27b-rtx-3090-llama-cpp-mtp-doubling-tokens" rel="noopener noreferrer"&gt;last post&lt;/a&gt; (~75 tok/s with MTP vs ~45 without), total latency = time-to-first-token + generation time:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;request shape&lt;/th&gt;
&lt;th&gt;prefill (TTFT)&lt;/th&gt;
&lt;th&gt;generation (MTP / off)&lt;/th&gt;
&lt;th&gt;total (MTP / off)&lt;/th&gt;
&lt;th&gt;MTP saves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1k context, 200-token answer&lt;/td&gt;
&lt;td&gt;0.65 s&lt;/td&gt;
&lt;td&gt;2.7 s / 4.4 s&lt;/td&gt;
&lt;td&gt;3.3 s / 5.1 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~35%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64k context, 200-token answer&lt;/td&gt;
&lt;td&gt;59 s&lt;/td&gt;
&lt;td&gt;2.7 s / 4.4 s&lt;/td&gt;
&lt;td&gt;61.7 s / 63.4 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~3%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;64k context, 2,000-token answer&lt;/td&gt;
&lt;td&gt;59 s&lt;/td&gt;
&lt;td&gt;27 s / 44 s&lt;/td&gt;
&lt;td&gt;86 s / 103 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~17%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So MTP's value is entirely a function of the &lt;strong&gt;generation-to-prefill ratio&lt;/strong&gt;. Short prompt, long answer → MTP shines (a third off total latency). Long prompt, short answer → prefill swallows everything and the 2× barely registers. Same speedup on the decoder; completely different impact on the wall clock.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this means for RAG
&lt;/h2&gt;

&lt;p&gt;The middle row — long prompt, short answer — is exactly the shape of retrieval-augmented generation: you stuff thousands of tokens of retrieved context in and ask for a short, grounded answer. That's the case where MTP helps &lt;em&gt;least&lt;/em&gt;, and it's why a fat-context RAG can feel sluggish even on a setup that benchmarks fast on generation. The thing you actually wait for is the one-time prefill of the context, once per query.&lt;/p&gt;

&lt;p&gt;This is directly relevant to my own &lt;a href="https://bric.pe.kr/blog/fully-local-paper-rag-1080ti-3090-hybrid-rerank-mcp" rel="noopener noreferrer"&gt;local paper-RAG&lt;/a&gt;: the lever that improves &lt;em&gt;its&lt;/em&gt; latency isn't a faster decoder — it's keeping the retrieved context tight (good chunking and reranking so you pass fewer, better tokens), which keeps prefill cheap. A reranker that lets you send 4k of relevant context instead of 40k of marginal context buys more real-world latency than MTP does.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 24 GB wall
&lt;/h2&gt;

&lt;p&gt;128k context fit — barely. At 23.6 GB it used 98.5% of the card, leaving ~380 MiB of headroom and nothing for anything else. The model's native context goes higher (~256k), but on a 24 GB 3090 this quant tops out around 128k before the KV cache spills or OOMs. So if you're planning long-context work on a single 3090: ~128k is the practical ceiling, and the prefill at that point is a 2.5-minute wait before the model says a word.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Single RTX 3090, single request, Qwen3.6-27B IQ4_XS. Batching / concurrency is a different story and changes the prefill economics (chunked prefill, prefix caching, etc.).&lt;/li&gt;
&lt;li&gt;The generation figures (~75 / ~45 tok/s) carry the run-to-run variance from the last post (MTP CV ~5–7%), so the latency-math rows are illustrative round numbers, not claimed to ±0.1 s. The &lt;em&gt;pattern&lt;/em&gt; — MTP's share collapsing as context grows — is the robust part.&lt;/li&gt;
&lt;li&gt;Prefill numbers themselves are tight (CV &amp;lt; 0.5%).&lt;/li&gt;
&lt;li&gt;"Time to first token" here is pure prompt processing; real TTFT also includes a little sampling and setup overhead.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;RTX 3090 24 GB (sm86), llama.cpp commit &lt;code&gt;e3471b3&lt;/code&gt;, Qwen3.6-27B &lt;code&gt;IQ4_XS&lt;/code&gt; (bartowski).&lt;/li&gt;
&lt;li&gt;Prefill sweep: &lt;code&gt;llama-bench -m &amp;lt;iq4xs.gguf&amp;gt; -p 1024,16384,65536,131072 -n 0 -ngl 99 -fa 1 -r 2&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Time-to-first-token = context_size ÷ prefill_tok_s.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;MTP is still the best single lever for generation speed on this card — but "generation speed" and "how long until I see an answer" are different questions, and at long context they diverge hard. If your workload is long-context RAG, the number that owns your latency is prefill, and no amount of speculative decoding will move it. The cheapest win there isn't a faster decoder; it's sending fewer, better tokens. Thanks to the reader who asked the question that made me measure it.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>performance</category>
      <category>machinelearning</category>
      <category>rag</category>
    </item>
    <item>
      <title>Doubling Qwen3.6-27B on One RTX 3090: ollama llama.cpp + MTP, Lever by Lever (35.7 ~75 tok/s)</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Tue, 09 Jun 2026 09:21:43 +0000</pubDate>
      <link>https://dev.to/sysoft/doubling-qwen36-27b-on-one-rtx-3090-ollama-llamacpp-mtp-lever-by-lever-357-802-toks-4i8</link>
      <guid>https://dev.to/sysoft/doubling-qwen36-27b-on-one-rtx-3090-ollama-llamacpp-mtp-lever-by-lever-357-802-toks-4i8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A reader on my &lt;a href="https://bric.pe.kr/blog/fully-local-paper-rag-1080ti-3090-hybrid-rerank-mcp" rel="noopener noreferrer"&gt;last post&lt;/a&gt; said Ollama was leaving a lot on the table — that a tuned backend with multi-token prediction (MTP) could roughly double my 3090's throughput. So I went and measured it, one lever at a time. The short version: they were right that MTP roughly doubles it, and below is the exact path that got me there on my box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Update (2026-06-10) — corrected after community feedback.&lt;/strong&gt; Two things in the first version were off, and r/LocalLLaMA was right to flag them. (1) &lt;strong&gt;ik_llama &lt;em&gt;does&lt;/em&gt; support MTP&lt;/strong&gt; — I'd used the deprecated &lt;code&gt;-mtp&lt;/code&gt; flag; the canonical form is &lt;code&gt;--spec-type mtp:n_max=3,p_min=0.0&lt;/code&gt;. (2) My headline &lt;strong&gt;80.2 was a lucky 3-run draw&lt;/strong&gt; — re-running both engines at &lt;strong&gt;n=12&lt;/strong&gt; gives &lt;strong&gt;ik_llama 75.2 and mainline llama.cpp 74.6: a tie at ~75 tok/s (≈2.1× over Ollama)&lt;/strong&gt;. So the honest headline is &lt;strong&gt;~75 tok/s&lt;/strong&gt;, both engines support MTP, and they're statistically identical. I've updated the numbers below and kept the story. Thanks to the folks who caught it.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;On a single RTX 3090, Qwen3.6-27B generation went from &lt;strong&gt;35.7 tok/s (Ollama)&lt;/strong&gt; to &lt;strong&gt;~75 tok/s (llama.cpp + MTP)&lt;/strong&gt; — a measured &lt;strong&gt;≈2.1×&lt;/strong&gt; — by stacking three independent levers: a leaner engine, a smaller quant, and speculative decoding. The interesting part isn't the headline; it's &lt;em&gt;which&lt;/em&gt; lever bought &lt;em&gt;how much&lt;/em&gt;, and a couple of things that tripped me up on the way. (To be precise up front: MTP on its own is &lt;strong&gt;~1.6×&lt;/strong&gt; at the same quant — the ≈2.1× is what you get when all three levers stack. ik_llama and mainline llama.cpp both do MTP and land within noise of each other at ~75.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The lever table
&lt;/h2&gt;

&lt;p&gt;All on one RTX 3090, Qwen3.6-27B, 200 tokens generated, flash-attention on:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;step&lt;/th&gt;
&lt;th&gt;what changed&lt;/th&gt;
&lt;th&gt;backend&lt;/th&gt;
&lt;th&gt;quant&lt;/th&gt;
&lt;th&gt;MTP&lt;/th&gt;
&lt;th&gt;gen tok/s&lt;/th&gt;
&lt;th&gt;vs Ollama&lt;/th&gt;
&lt;th&gt;VRAM&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;baseline&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;Q4_K_M&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.7&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;1.00×&lt;/td&gt;
&lt;td&gt;23.2 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;engine&lt;/td&gt;
&lt;td&gt;ik_llama.cpp&lt;/td&gt;
&lt;td&gt;Q4_K_M&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;41.9&lt;/td&gt;
&lt;td&gt;1.17×&lt;/td&gt;
&lt;td&gt;17.3 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;+ quant&lt;/td&gt;
&lt;td&gt;ik_llama.cpp&lt;/td&gt;
&lt;td&gt;IQ4_XS&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;47.5&lt;/td&gt;
&lt;td&gt;1.33×&lt;/td&gt;
&lt;td&gt;15.1 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;+ MTP&lt;/td&gt;
&lt;td&gt;llama.cpp / ik_llama&lt;/td&gt;
&lt;td&gt;IQ4_XS&lt;/td&gt;
&lt;td&gt;on&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~75&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;≈2.1×&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~15 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A note on fairness (and sample size): rows 0–2 use each engine's own native bench path, and row 3 is &lt;code&gt;llama-server&lt;/code&gt;. For a clean apples-to-apples read of MTP alone, I re-ran both engines at &lt;strong&gt;n=12&lt;/strong&gt;: &lt;strong&gt;mainline llama.cpp 45.1 (off) → 74.6 (on) = 1.65×&lt;/strong&gt;, and &lt;strong&gt;ik_llama 47.2 (off) → 75.2 (on) = 1.59×&lt;/strong&gt; — statistically a tie at ~75 tok/s (MTP-on has a CV of ~5–7%; that variance is inherent to speculative decoding, since draft acceptance fluctuates run to run). My very first run reported 80.2, but that was a lucky high draw from a 3-run sample; the 12-run mean is ~75, so that's the honest number. (Both the Ollama baseline and the llama.cpp runs fit fully in VRAM; the baseline ran at &lt;code&gt;num_ctx 8192&lt;/code&gt; and the llama.cpp runs at &lt;code&gt;-c 4096&lt;/code&gt; — generation throughput is largely insensitive to that as long as nothing spills to CPU, though it accounts for part of the VRAM difference in the table.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Levers 1 and 2: engine and quant
&lt;/h2&gt;

&lt;p&gt;Moving the same Q4_K_M model from Ollama to a bare-metal &lt;code&gt;ik_llama.cpp&lt;/code&gt; build (CUDA, flash-attention, compiled for the 3090's &lt;code&gt;sm86&lt;/code&gt;) took me from 35.7 → &lt;strong&gt;41.9 tok/s&lt;/strong&gt;, and dropped VRAM from 23.2 → 17.3 GB. Ollama is convenience-first — it sizes things generously and doesn't expose the lower-level knobs — so a hand-built engine is faster out of the gate. Swapping the quant from &lt;code&gt;Q4_K_M&lt;/code&gt; to &lt;code&gt;IQ4_XS&lt;/code&gt; added a bit more and shrank VRAM further: &lt;strong&gt;47.5 tok/s&lt;/strong&gt;, 15.1 GB. Roughly a third faster, and nothing exotic yet. (Does IQ4_XS cost quality? I checked perplexity on wikitext-2 after a reader asked: &lt;strong&gt;Q4_K_M = 6.996, IQ4_XS = 6.997&lt;/strong&gt; — a +0.01% difference, comfortably inside the error bars (±0.046). IQ4_XS can regress more on other architectures, but for Qwen3.6-27B the quant swap was effectively free.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Lever 3: MTP (where the real jump is)
&lt;/h2&gt;

&lt;p&gt;Multi-token prediction / speculative decoding is the big one. The idea: a small, fast draft predicts several tokens ahead, and the main model verifies them in one pass — when the drafts are accepted, you get multiple tokens for roughly the cost of one. Because the main model verifies every drafted token before it's emitted, the output is preserved — this is a throughput win, not a quality tradeoff.&lt;/p&gt;

&lt;p&gt;Two things were worth knowing for my setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Both ik_llama and mainline &lt;code&gt;llama.cpp&lt;/code&gt; do MTP — but the flag matters.&lt;/strong&gt; I first tried ik_llama's &lt;code&gt;-mtp&lt;/code&gt;, which it rejected as legacy, and wrongly concluded ik_llama couldn't do MTP. A reader set me straight: the canonical form is &lt;code&gt;--spec-type mtp:n_max=3,p_min=0.0&lt;/code&gt;, and with it ik_llama runs MTP fine (~75 tok/s, matching mainline). Mainline &lt;code&gt;llama.cpp&lt;/code&gt; added MTP recently (&lt;a href="https://github.com/ggml-org/llama.cpp/pull/22673" rel="noopener noreferrer"&gt;PR #22673&lt;/a&gt;, merged 2026-05-16) and uses &lt;code&gt;--spec-type draft-mtp&lt;/code&gt;. Either engine gets you there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama's GGUF couldn't be reused.&lt;/strong&gt; Qwen3.6 changed &lt;code&gt;rope.dimension_sections&lt;/code&gt; from 3 to 4 elements; Ollama's stored blob still has the older 3-element layout, so &lt;code&gt;llama.cpp&lt;/code&gt; refused it (&lt;code&gt;expected 4, got 3&lt;/code&gt;). I grabbed a properly-converted GGUF instead (bartowski / a &lt;code&gt;nextn&lt;/code&gt;-equipped MTP build) — a small heads-up if you're tempted to point &lt;code&gt;llama.cpp&lt;/code&gt; at your existing Ollama blob.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With an MTP-equipped &lt;code&gt;IQ4_XS&lt;/code&gt; GGUF and &lt;code&gt;n-max 3&lt;/code&gt;, generation lands around &lt;strong&gt;~75 tok/s&lt;/strong&gt; — whether via mainline &lt;code&gt;llama.cpp&lt;/code&gt;'s &lt;code&gt;--spec-type draft-mtp&lt;/code&gt; or ik_llama's &lt;code&gt;--spec-type mtp:n_max=3,p_min=0.0&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tuning MTP: more accepted drafts isn't more speed
&lt;/h2&gt;

&lt;p&gt;The one knob that mattered for me was the draft depth (&lt;code&gt;n-max&lt;/code&gt;, how many tokens to draft ahead):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;config&lt;/th&gt;
&lt;th&gt;gen tok/s&lt;/th&gt;
&lt;th&gt;draft acceptance&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;n-max 2&lt;/td&gt;
&lt;td&gt;77.5&lt;/td&gt;
&lt;td&gt;78.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;n-max 3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;80.2&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;70.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n-max 4&lt;/td&gt;
&lt;td&gt;70.7&lt;/td&gt;
&lt;td&gt;53.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n-max 3 + p-min 0.6&lt;/td&gt;
&lt;td&gt;54.1&lt;/td&gt;
&lt;td&gt;80.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n-max 3 + KV q8_0&lt;/td&gt;
&lt;td&gt;74.6&lt;/td&gt;
&lt;td&gt;64.5%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The counterintuitive bit: &lt;strong&gt;higher acceptance ≠ faster.&lt;/strong&gt; Pushing &lt;code&gt;p-min&lt;/code&gt; to 0.6 raised acceptance to 80% but &lt;em&gt;dropped&lt;/em&gt; throughput to 54 — the extra rejected drafts cost more than they save. Plain f16 KV beat q8 KV too. &lt;code&gt;n-max 3&lt;/code&gt; with f16 KV was the sweet spot. (These sweep rows are single runs, so read the &lt;em&gt;pattern&lt;/em&gt;, not the absolute decimals — the stable 12-run figure for n-max 3 is ~75. I also went looking for a "prefill-off" trick I'd heard about and couldn't find it as a flag in current &lt;code&gt;llama.cpp&lt;/code&gt; — draft depth was the lever that actually moved the number for me.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest caveats
&lt;/h2&gt;

&lt;p&gt;Keeping these front and center, because they're the difference between a benchmark and a benchmark you can trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~75 tok/s is this box's number&lt;/strong&gt; (RTX 3090, WSL2), as a 12-run mean. My first writeup said 80.2 from a 3-run sample — that was a lucky high draw, and re-running at n=12 corrected it to ~75. Generation under MTP has real run-to-run variance (CV ~5–7%) because draft acceptance fluctuates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill numbers are noisy&lt;/strong&gt; — my test prompt was short (~56 tokens), so I'm not headlining prefill. (A reader rightly asked about prompt processing at &amp;gt;64k context, where prefill can dominate latency; MTP only speeds generation, not prefill — that's a measurement for a follow-up.)&lt;/li&gt;
&lt;li&gt;The bartowski &lt;code&gt;Q4_K_M&lt;/code&gt; and Ollama's &lt;code&gt;Q4_K_M&lt;/code&gt; are the &lt;strong&gt;same quantization family but different conversions&lt;/strong&gt; (the rope change above), so they're not bit-identical weights. The model and quant family are matched; the conversion isn't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single GPU, single request.&lt;/strong&gt; No batching or concurrency tested — that's a different question.&lt;/li&gt;
&lt;li&gt;One benchmarking trap that cost me time: &lt;code&gt;llama-cli -n &amp;lt;N&amp;gt;&lt;/code&gt; is ignored under &lt;code&gt;-no-cnv&lt;/code&gt;, so the model just generates until timeout (mine produced a 2 GB output file and &lt;em&gt;looked&lt;/em&gt; like a 39-minute hang — it was runaway generation). Use &lt;code&gt;llama-bench&lt;/code&gt; for token-exact non-MTP runs, and &lt;code&gt;llama-server&lt;/code&gt; with &lt;code&gt;n_predict&lt;/code&gt; for MTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; RTX 3090 24 GB (Ampere, sm86), WSL2 Ubuntu 24.04, driver 591.74, nvcc 12.0.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ik_llama.cpp&lt;/strong&gt; (commit &lt;code&gt;bbe1a51&lt;/code&gt;): &lt;code&gt;cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DGGML_NATIVE=ON&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp / mainline&lt;/strong&gt; (commit &lt;code&gt;e3471b3&lt;/code&gt;): &lt;code&gt;cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=86 -DBUILD_SHARED_LIBS=OFF&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Models:&lt;/strong&gt; &lt;code&gt;bartowski/Qwen_Qwen3.6-27B-GGUF&lt;/code&gt; (&lt;code&gt;Q4_K_M&lt;/code&gt;, &lt;code&gt;IQ4_XS&lt;/code&gt;); a &lt;code&gt;nextn&lt;/code&gt;/MTP-equipped &lt;code&gt;Qwen3.6-27B-MTP-IQ4_XS&lt;/code&gt; GGUF for the speculative step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Non-MTP bench:&lt;/strong&gt; &lt;code&gt;llama-bench -m &amp;lt;gguf&amp;gt; -p 56 -n 200 -ngl 99 -fa 1 -r 3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MTP run, mainline:&lt;/strong&gt; &lt;code&gt;llama-server -m Qwen3.6-27B-MTP-IQ4_XS.gguf -ngl 99 -fa on -c 4096 --spec-type draft-mtp --spec-draft-n-max 3&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MTP run, ik_llama:&lt;/strong&gt; same model/flags, but &lt;code&gt;--spec-type mtp:n_max=3,p_min=0.0&lt;/code&gt;. Then POST &lt;code&gt;/completion&lt;/code&gt; with &lt;code&gt;n_predict: 200&lt;/code&gt;; draft acceptance ≈ 70%.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;So the reader's nudge was a good one — Ollama really was leaving a clean ~2× on the table for this model on this card, and most of it is the MTP step. Ollama stays my default for everyday use (it's simple and it's what my tooling talks to); this build is the "I want every token/sec" setup. And honestly the best part of posting it was the correction: the thread caught both my legacy-flag mistake (ik_llama does MTP) and my lucky 80.2 draw (the honest 12-run mean is ~75) — so the version you're reading is the one the community helped get right.&lt;/p&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>performance</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Building a Fully-Local Research RAG on 2 GTX 1080 Ti + an RTX 3090 — 3 Gotchas</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Fri, 05 Jun 2026 23:56:32 +0000</pubDate>
      <link>https://dev.to/sysoft/building-a-fully-local-research-rag-on-2x-gtx-1080-ti-an-rtx-3090-3-gotchas-48dl</link>
      <guid>https://dev.to/sysoft/building-a-fully-local-research-rag-on-2x-gtx-1080-ti-an-rtx-3090-3-gotchas-48dl</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I wanted to ask questions about my own papers without shipping them to a cloud API. This is the real story of building that — a private, fully-offline RAG with hybrid retrieval and reranking — across a pile of old GPUs and one newer one. Three things each cost me the better part of a day, and none of them were what I expected.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The goal: a private RAG over my own papers
&lt;/h2&gt;

&lt;p&gt;I'm a researcher with a folder of PDFs I can't (and won't) upload to a hosted API. I wanted natural-language, &lt;strong&gt;cited&lt;/strong&gt; answers over that corpus, running entirely on my own hardware. So I built a small tool — &lt;code&gt;paper-rag&lt;/code&gt;, about 200 lines of Python — with the whole stack local:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PDFs → chunk → BGE-M3 dense (Ollama) ┐
               BM25 sparse (fastembed)┴→ Qdrant (embedded, on disk)
                                            │
question → dense + sparse → RRF fusion → cross-encoder rerank → top passages
                                                                     │
                                                                     ▼
                                             local LLM (Ollama) → cited answer
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dense embeddings catch &lt;em&gt;meaning&lt;/em&gt;; BM25 sparse catches &lt;em&gt;exact terms&lt;/em&gt; (gene names, identifiers); fusing the two and then reranking with a cross-encoder gives the LLM far better context than cosine-alone. No server, no API key, nothing leaves the box.&lt;/p&gt;

&lt;p&gt;The hardware was just what I had lying around: &lt;strong&gt;2× GTX 1080 Ti&lt;/strong&gt; (Pascal, 2017, 11 GB each) on one machine, and later a single &lt;strong&gt;RTX 3090&lt;/strong&gt; (24 GB, Ampere) on another. That old-plus-new mix is where the lessons came from.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: the embedding model kept freezing the entire GPU
&lt;/h2&gt;

&lt;p&gt;On the 1080 Ti box (running under WSL2), a long ingest would make the BGE-M3 embedder hang — and not gracefully. The &lt;code&gt;llama-server&lt;/code&gt; process dropped into uninterruptible &lt;strong&gt;D-state&lt;/strong&gt;, &lt;code&gt;nvidia-smi&lt;/code&gt; itself stopped responding, and the whole GPU was wedged. No signal could kill it; only a full &lt;code&gt;wsl --shutdown&lt;/code&gt; brought it back.&lt;/p&gt;

&lt;p&gt;I chased the wrong causes first:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"It's the batch size."&lt;/strong&gt; It wasn't. Once the model degraded, even a &lt;em&gt;single&lt;/em&gt; 600-character chunk timed out at 90 seconds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"A newer Ollama will fix it."&lt;/strong&gt; I checked the changelogs — the next few patch releases only fixed an unrelated model crash.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The actual fix was to stop using the GPU for that job at all. BGE-M3 is tiny (~1 GB), so I pinned it to &lt;strong&gt;CPU&lt;/strong&gt; and kept the LLM on the GPU:&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;printf&lt;/span&gt; &lt;span class="s1"&gt;'FROM bge-m3\nPARAMETER num_gpu 0\n'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; bge-m3-cpu.Modelfile
ollama create bge-m3-cpu &lt;span class="nt"&gt;-f&lt;/span&gt; bge-m3-cpu.Modelfile
&lt;span class="nv"&gt;RAG_EMBED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bge-m3-cpu python rag.py ingest ./papers   &lt;span class="c"&gt;# embeddings on CPU, answers still on the GPU&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A ~100-chunk corpus embeds in well under a minute on CPU, and the GPU never wedges again. The lesson: on old Pascal cards under WSL the &lt;em&gt;embedding&lt;/em&gt; path is where things break — and the embedder doesn't need the GPU in the first place.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: my 27B ran at half speed until I capped the context
&lt;/h2&gt;

&lt;p&gt;On the 3090 I loaded &lt;code&gt;qwen3.6:27b&lt;/code&gt; (a 27.8B model, Q4, ~17.4 GB of weights) and saw &lt;strong&gt;~17 tok/s&lt;/strong&gt;. For a 27B that fits in 24 GB, that felt wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;ollama ps&lt;/code&gt; told the story: the model was loaded at &lt;strong&gt;24.6 GB&lt;/strong&gt;, with ~4 GB spilled to CPU. But the weights are only 17.4 GB — so what ate the other 7 GB? The &lt;strong&gt;KV cache&lt;/strong&gt;. This model ships a &lt;strong&gt;256K-token native context&lt;/strong&gt;, and Ollama sized the cache to match it, overflowing VRAM and forcing an offload that throttled everything.&lt;/p&gt;

&lt;p&gt;Cap the context to what you actually use and it all fits on the card:&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;RAG_NUM_CTX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8192 python rag.py ask &lt;span class="s2"&gt;"..."&lt;/span&gt;   &lt;span class="c"&gt;# or options.num_ctx in your API call&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Result: &lt;strong&gt;100% on-GPU, ~36 tok/s — 2× faster&lt;/strong&gt; — for a setting that loses nothing on RAG-sized prompts. The lesson: a huge &lt;em&gt;native&lt;/em&gt; context silently taxes you even when you never use it. Set &lt;code&gt;num_ctx&lt;/code&gt; to your real working size.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 3: I have an old pair AND a new card — don't merge them
&lt;/h2&gt;

&lt;p&gt;The tempting idea: pool the 2× 1080 Ti (22 GB) and the 3090 (24 GB) into one big inference cluster. You &lt;em&gt;can&lt;/em&gt; (llama.cpp's RPC backend, or &lt;code&gt;exo&lt;/code&gt;). You usually &lt;em&gt;shouldn't&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The two machines are joined by a 1 GbE LAN — orders of magnitude slower than NVLink or PCIe. Cross-machine tensor parallelism is bottlenecked by that link, and the slow Pascal cards drag the fast Ampere one down to their level. Merging only pays off if you &lt;em&gt;must&lt;/em&gt; run a model too big for any single box — and even then it's slow.&lt;/p&gt;

&lt;p&gt;What actually works is &lt;strong&gt;role specialization&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;3090&lt;/strong&gt; → the latency-critical work: LLM generation + reranking + query embedding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;1080 Ti box&lt;/strong&gt; → throughput/batch work: bulk corpus embedding (on CPU, per Gotcha 1) and ingestion.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A small env knob points embeddings at one box and the LLM at another:&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;OLLAMA_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://gpu-box:11434      &lt;span class="nv"&gt;RAG_LLM&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;qwen3.6:27b  &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nv"&gt;RAG_EMBED_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;http://127.0.0.1:11434 &lt;span class="nv"&gt;RAG_EMBED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;bge-m3-cpu &lt;span class="se"&gt;\&lt;/span&gt;
python mcp_server.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;bge-m3&lt;/code&gt; on both boxes, so the dense vectors are compatible — you can even ingest on one machine and serve from another. &lt;code&gt;ollama ps&lt;/code&gt; on each box confirms the split: &lt;code&gt;bge-m3-cpu&lt;/code&gt; on the old box's CPU, the 27B on the 3090. The two machines do &lt;strong&gt;different jobs in parallel&lt;/strong&gt; instead of fighting over one model.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: it's actually useful now
&lt;/h2&gt;

&lt;p&gt;Hybrid + rerank produces noticeably better context, which shows up as cleaner cited answers, with each claim tagged to a source page — and when the answer genuinely isn't in the retrieved context, it says so instead of inventing one.&lt;/p&gt;

&lt;p&gt;And because the tool speaks &lt;strong&gt;MCP&lt;/strong&gt;, I can call it from an agent (Claude, Cursor, …): &lt;code&gt;search_papers&lt;/code&gt; and &lt;code&gt;ask_papers&lt;/code&gt; show up as tools, so the agent searches and cites &lt;em&gt;my&lt;/em&gt; corpus — still fully local.&lt;/p&gt;

&lt;p&gt;There's a quieter reason I want this grounded and local: small quantized models are confidently wrong on niche facts. A RAG anchored to the actual papers is how I catch that, instead of trusting the model's memory.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Old Pascal cards are slow, and the embedder needs the CPU workaround above.&lt;/li&gt;
&lt;li&gt;Reranking runs on CPU (fastembed/ONNX) — fine for a personal library, not a giant corpus.&lt;/li&gt;
&lt;li&gt;It's a personal tool, not a production search system: naive fixed-size chunking, no semantic chunking yet.&lt;/li&gt;
&lt;li&gt;Answer quality is your local model's quality — verify domain-specific claims.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try it / let's compare notes
&lt;/h2&gt;

&lt;p&gt;Code is MIT-licensed: &lt;strong&gt;&lt;a href="https://github.com/shoo99/paper-rag" rel="noopener noreferrer"&gt;github.com/shoo99/paper-rag&lt;/a&gt;&lt;/strong&gt; — ~200 lines plus a small MCP server.&lt;/p&gt;

&lt;p&gt;A couple of questions for anyone running similar setups:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;For offload-heavy or multi-box rigs, has anyone gotten cross-machine pooling (llama.cpp RPC / exo) to actually &lt;strong&gt;beat&lt;/strong&gt; just running a smaller model on the fastest single card?&lt;/li&gt;
&lt;li&gt;What's your go-to reranker for local RAG — sticking with a small cross-encoder, or has something better shown up?&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ollama</category>
      <category>llm</category>
      <category>rag</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Running Brand-New Gemma 4 12B on an 8-Year-Old GTX 1080 Ti: Speed, 3 Gotchas, and Why Q8 Beat Q4 on My Own Field</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Fri, 05 Jun 2026 04:41:43 +0000</pubDate>
      <link>https://dev.to/sysoft/running-brand-new-gemma-4-12b-on-an-8-year-old-gtx-1080-ti-speed-3-gotchas-and-why-q8-beat-q4-on-39bj</link>
      <guid>https://dev.to/sysoft/running-brand-new-gemma-4-12b-on-an-8-year-old-gtx-1080-ti-speed-3-gotchas-and-why-q8-beat-q4-on-39bj</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR (Quick Answer)
&lt;/h2&gt;

&lt;p&gt;Gemma 4 12B just dropped, so I ran it on a &lt;strong&gt;GTX 1080 Ti&lt;/strong&gt; (Pascal, 2017) to see what an 8-year-old card does with a 2026 model. Real numbers, and a few honest surprises:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Speed: ~28 tok/s&lt;/strong&gt; at Q4_K_M on a &lt;strong&gt;single&lt;/strong&gt; 1080 Ti (~8 GB VRAM). The 12B fits one card, so the second GPU sits idle.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Three things broke before it worked&lt;/strong&gt;: the GGUF is &lt;em&gt;multimodal&lt;/em&gt; and its vision projector crashes Ollama; it's a &lt;em&gt;reasoning&lt;/em&gt; model that hides its answer in a &lt;code&gt;thinking&lt;/code&gt; channel; and Q4 produces visible &lt;em&gt;token glitches&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The interesting part — Q4 vs Q8.&lt;/strong&gt; I asked it real bioinformatics questions. At Q4 it answered concepts and code well but got a niche method (the HEIDI test) &lt;strong&gt;confidently backwards&lt;/strong&gt;, with garbled characters sprinkled in. Going to &lt;strong&gt;Q8_0&lt;/strong&gt; (12.7 GB, split across &lt;em&gt;both&lt;/em&gt; 1080 Tis, ~30% slower at ~19.5 tok/s) &lt;strong&gt;removed the glitches and fixed the wrong answer.&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bottom line: for chat and drafting, Q4 on one old card is genuinely usable. For work where details matter, the higher quant across two cards is worth the speed hit — and it's the one case where the second 1080 Ti finally earns its keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; 2× NVIDIA GTX 1080 Ti (11 GB each), Pascal cc 6.1, driver 581.57, via WSL2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Runtime:&lt;/strong&gt; Ollama 0.30.2. Gemma 4 isn't in Ollama's library yet, so I pulled the unsloth GGUF: &lt;code&gt;ollama pull hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 3 things that broke first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. It's multimodal — and the vision projector crashes Ollama.&lt;/strong&gt;&lt;br&gt;
First generation returned &lt;em&gt;nothing&lt;/em&gt;. The logs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: Failed to load CLIP model from .../blobs/sha256-7d10888...
llama-server process has terminated: exit status 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Gemma 4 12B-it ships with a vision (CLIP) projector, and Ollama 0.30.2 fails to load it — taking down the whole model server. If you only want text, you have to strip the projector. Pull the model, then rebuild it text-only from the same blobs (no re-download):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama show &lt;span class="nt"&gt;--modelfile&lt;/span&gt; hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; Gemma4.Modelfile
&lt;span class="c"&gt;# delete the second `FROM ...` line — the mmproj/CLIP blob — keep only the text GGUF&lt;/span&gt;
ollama create gemma4-12b-text &lt;span class="nt"&gt;-f&lt;/span&gt; Gemma4.Modelfile
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. It's a reasoning model — your answer hides in &lt;code&gt;thinking&lt;/code&gt;.&lt;/strong&gt;&lt;br&gt;
With the text model, generation worked but &lt;code&gt;content&lt;/code&gt; came back empty while &lt;code&gt;eval_count&lt;/code&gt; was 200. The output was all going into the reasoning channel and getting cut off mid-thought at the token cap. Fix: disable thinking.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"gemma4-12b-text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"think"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"messages"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With &lt;code&gt;think: false&lt;/code&gt;, clean answers in ~10 seconds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Q4 has visible token glitches.&lt;/strong&gt;&lt;br&gt;
At Q4_K_M, prose came out with occasional garbled characters — literally &lt;code&gt;self-さattention&lt;/code&gt;, &lt;code&gt;ściindicates&lt;/code&gt;, stray Korean/Japanese codepoints injected mid-word. Code blocks were clean; only prose was affected. (Spoiler: Q8 fixes this.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Speed (Q4, single 1080 Ti)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;think: false&lt;/code&gt;, &lt;code&gt;num_predict=256&lt;/code&gt;, measured via Ollama's API:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generation: ~27.6 tok/s&lt;/strong&gt; (27.5 / 27.6 / 27.7 — rock stable)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VRAM: ~8 GB on GPU0&lt;/strong&gt;; &lt;strong&gt;GPU1 completely idle (0 MiB)&lt;/strong&gt; — a 12B at Q4 fits one card, so the second GPU does nothing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quality: I asked it about my actual field
&lt;/h2&gt;

&lt;p&gt;Speed is easy; &lt;em&gt;is it useful for real work?&lt;/em&gt; I gave it four bioinformatics questions and checked the answers honestly:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Verdict&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RNA-seq normalization (raw vs TPM vs FPKM; DESeq2 input)&lt;/td&gt;
&lt;td&gt;✅ Correct and precise&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Pandas function to filter a DESeq2 results table&lt;/td&gt;
&lt;td&gt;✅ Correct, clean, usable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Troubleshoot an implausibly high DEG count&lt;/td&gt;
&lt;td&gt;✅ Good — batch effects, PCA, outliers, covariates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What a small &lt;strong&gt;HEIDI&lt;/strong&gt; p-value means (SMR/colocalization)&lt;/td&gt;
&lt;td&gt;❌ &lt;strong&gt;Confidently backwards&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That last one is the lesson. HEIDI is a niche test: a &lt;em&gt;small&lt;/em&gt; p-value means the locus &lt;strong&gt;fails&lt;/strong&gt; (heterogeneity/linkage — you filter it out). Q4 Gemma told me a small p-value means a &lt;strong&gt;single causal gene&lt;/strong&gt; — the exact opposite. It was fluent and sure of itself. If you don't already know the answer, that's the dangerous kind of wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The payoff: Q4 vs Q8
&lt;/h2&gt;

&lt;p&gt;So I pulled &lt;strong&gt;Q8_0&lt;/strong&gt; (12.7 GB) and rebuilt it text-only the same way. At 12.7 GB it no longer fits one card — Ollama splits it across &lt;strong&gt;both&lt;/strong&gt; 1080 Tis (~7 GB each). Same questions:&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;Q4_K_M&lt;/th&gt;
&lt;th&gt;Q8_0&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Size / GPUs&lt;/td&gt;
&lt;td&gt;7 GB / &lt;strong&gt;1 card&lt;/strong&gt; (GPU1 idle)&lt;/td&gt;
&lt;td&gt;12.7 GB / &lt;strong&gt;2 cards&lt;/strong&gt; (~7 GB each)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Speed&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~28 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~19.5 tok/s (&lt;strong&gt;−30%&lt;/strong&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Token glitches&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;self-さattention&lt;/code&gt; etc.&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;gone — clean&lt;/strong&gt; ✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HEIDI answer&lt;/td&gt;
&lt;td&gt;backwards ❌&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;correct&lt;/strong&gt; ✅ ("small p = fails, filter it out")&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Less quantization bought three things: the glitches disappeared, it got the niche domain detail right, and — because the bigger file overflows one card — the otherwise-idle second 1080 Ti finally did work. The cost was ~30% throughput.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Honesty note: I asked Q8 the HEIDI question with a more pointed framing than Q4, so that single comparison isn't perfectly controlled. The token-glitch difference, on identical prompts, is unambiguous.)&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  When does the second 1080 Ti actually help?
&lt;/h2&gt;

&lt;p&gt;Combining this with an &lt;a href="https://bric.pe.kr/blog/qwen3-6-35b-a3b-2x-1080-ti-benchmark-2026" rel="noopener noreferrer"&gt;earlier 35B-MoE run&lt;/a&gt;, a clear rule emerges:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model fits one card (12B Q4):&lt;/strong&gt; second GPU is idle — useless.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model overflows one card (12B Q8, or a 35B):&lt;/strong&gt; it spills to the second card, which now helps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second 1080 Ti isn't about speed; it's about &lt;em&gt;fitting a bigger or higher-precision model&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;One model, two quants, one box; your tok/s will vary with CPU, RAM, and context length.&lt;/li&gt;
&lt;li&gt;Q8 HEIDI test used a more direct prompt — suggestive, not a controlled A/B.&lt;/li&gt;
&lt;li&gt;Quality judged on a handful of prompts, not a benchmark suite.&lt;/li&gt;
&lt;li&gt;Ollama 0.30.2's Gemma 4 support is early (the CLIP crash, the reasoning-channel behavior); later versions may change this.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ollama pull hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M       &lt;span class="c"&gt;# or :Q8_0 for the 2-card run&lt;/span&gt;
ollama show &lt;span class="nt"&gt;--modelfile&lt;/span&gt; hf.co/unsloth/gemma-4-12b-it-GGUF:Q4_K_M &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; m.Modelfile
&lt;span class="c"&gt;# remove the mmproj/CLIP `FROM` line, keep the text GGUF&lt;/span&gt;
ollama create gemma4-12b-text &lt;span class="nt"&gt;-f&lt;/span&gt; m.Modelfile
&lt;span class="c"&gt;# then call /api/chat with "think": false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Can a GTX 1080 Ti run Gemma 4 12B?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — ~28 tok/s at Q4 on a single card, ~19.5 tok/s at Q8 across two. Just strip the vision projector (it crashes Ollama 0.30.2) and disable the reasoning channel with &lt;code&gt;think: false&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Q4 or Q8?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Q4 for speed and casual use (one card). Q8 when correctness matters: on my field's questions it removed the token glitches and fixed an answer Q4 got backwards — at ~30% lower speed, and it needs both cards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why did the second GPU sit idle at Q4?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A 12B at Q4 is ~7 GB and fits one 11 GB card, so Ollama uses one GPU. Only when the model overflows one card (Q8, or a larger model) does the second card get used.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Model: &lt;a href="https://huggingface.co/unsloth/gemma-4-12b-it-GGUF" rel="noopener noreferrer"&gt;unsloth/gemma-4-12b-it-GGUF&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Related: &lt;a href="https://bric.pe.kr/blog/qwen3-6-35b-a3b-2x-1080-ti-benchmark-2026" rel="noopener noreferrer"&gt;35B MoE on 2× 1080 Ti&lt;/a&gt; · &lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>ollama</category>
      <category>gpu</category>
      <category>machinelearning</category>
    </item>
    <item>
      <title>Running 35B–400B LLMs on a GPU-less Cluster to Mine 10,000 Papers — and the 4 Bugs That Almost Ruined the Data</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:55:34 +0000</pubDate>
      <link>https://dev.to/sysoft/running-35b-400b-llms-on-a-gpu-less-cluster-to-mine-10000-papers-and-the-4-bugs-that-almost-ka3</link>
      <guid>https://dev.to/sysoft/running-35b-400b-llms-on-a-gpu-less-cluster-to-mine-10000-papers-and-the-4-bugs-that-almost-ka3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;A field report from building a CPU-only, distributed LLM pipeline for large-scale scientific literature extraction. No GPUs. A lot of quantization. And four silent data-quality bugs that taught me more than the happy path ever did.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The constraint that started it all
&lt;/h2&gt;

&lt;p&gt;Our team runs an internal research cluster: a couple dozen older x86 servers, plenty of RAM, &lt;strong&gt;zero GPUs&lt;/strong&gt;. The mandate was to extract structured data — effect sizes, the entity each one describes, and the direction of effect — from ~10,000 full-text research papers, so a downstream meta-analysis could pool them.&lt;/p&gt;

&lt;p&gt;The obvious 2024-era answer is "send it to a hosted LLM API." That wasn't on the table for data-governance reasons: the corpus had to stay on-prem. So the real question became:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can you do serious LLM extraction at the 10k-document scale with CPUs only?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Spoiler: yes — but the interesting part isn't the throughput. It's that &lt;em&gt;correctness&lt;/em&gt;, not speed, turned out to be the hard problem. Let me walk through the architecture, then the four bugs that each silently corrupted the data in a different way.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack
&lt;/h2&gt;

&lt;p&gt;Everything is open source and CPU-friendly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp&lt;/strong&gt; serving quantized GGUF models over its OpenAI-ish HTTP endpoint. We ran a &lt;strong&gt;MoE model (~35B total / ~3B active, Q8)&lt;/strong&gt; as the high-throughput workhorse on 8 nodes, and a &lt;strong&gt;~400B model (Q3)&lt;/strong&gt; on a dedicated node for the heaviest pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BGE-M3&lt;/strong&gt; (1024-dim) for embeddings, also on llama.cpp, across 8 nodes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qdrant&lt;/strong&gt; as the vector DB.&lt;/li&gt;
&lt;li&gt;Plain Python (&lt;code&gt;requests&lt;/code&gt; + &lt;code&gt;ThreadPoolExecutor&lt;/code&gt;) for orchestration. No Ray, no fancy scheduler — just a queue and one worker bound per node, because each llama.cpp server runs &lt;code&gt;--parallel 1&lt;/code&gt;: on CPU, inference is memory-bandwidth bound, so one in-flight request already saturates the memory bus and batching buys little.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each node is a dual-socket Xeon, ~36 cores total (AVX-512), no accelerator. The 35B MoE generated &lt;strong&gt;~6 tokens/s per node&lt;/strong&gt;; with 8 nodes load-balanced, a sentence took ~10s end to end and the full 14k-sentence extraction finished in a few hours.&lt;/p&gt;

&lt;p&gt;MoE was the unlock for CPU: ~3B active parameters per token means it generates at a usable rate even without a GPU, while delivering quality far above what its ~3B active count alone would suggest.&lt;/p&gt;

&lt;p&gt;The ~400B Q3 model was reserved for a separate, earlier abstract-level pass — a different job at a different scale, out of scope for this post — where its stronger one-shot reading paid off. On a single CPU node it ran at low single-digit tokens/s, so routing the sentence-level corpus through it was never viable; everything below is the 35B MoE.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG is not extraction (the distinction that bites everyone)
&lt;/h2&gt;

&lt;p&gt;First, a clarification I had to make repeatedly, because it confuses people (it confused me):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAG (embed → vector search)&lt;/strong&gt; answers &lt;em&gt;questions&lt;/em&gt;. You chunk text, embed it, and at query time retrieve the top-k most semantically similar passages to ground an LLM's answer. Great for "find me passages about X."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Extraction for meta-analysis&lt;/strong&gt; needs &lt;em&gt;numbers&lt;/em&gt; — every effect size from every paper, aggregated. That is &lt;strong&gt;exhaustive structured extraction&lt;/strong&gt;, not retrieval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A vector DB stores &lt;code&gt;d = -0.45&lt;/code&gt; as a text token inside an embedding. It will happily &lt;em&gt;find&lt;/em&gt; that sentence by meaning, but it cannot &lt;em&gt;compute&lt;/em&gt; over the number. If your goal is to pool effect sizes, embeddings are the wrong tool. You want extraction.&lt;/p&gt;

&lt;p&gt;So the pipeline is a &lt;strong&gt;hybrid&lt;/strong&gt;: a cheap mechanical pass to find candidate sentences, then an LLM to interpret them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10k full-text papers
   │
   ├─ ① regex pre-filter  (mechanical, no understanding)
   │     keep sentences that have a number near a target-entity keyword
   │     → ~14k candidate sentences
   │
   └─ ② LLM mapping       (the judgment step)
         each sentence → {entity, metric, direction, value, measure_type}
         → structured JSON for the meta-analysis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Regex is the funnel; the LLM is the brain. Neither replaces the other.&lt;/p&gt;

&lt;p&gt;Now the fun part.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1 — the chunker that silently deleted 79% of the data
&lt;/h2&gt;

&lt;p&gt;The embedding side (the RAG corpus) had its own chunking pipeline. It looked fine. Counts looked fine. Then someone asked a simple question — "how many points are actually in the collection?" — and the numbers didn't add up: &lt;strong&gt;~1M chunks generated, ~217k points in the DB.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A 78% gap. Where did 800k chunks go?&lt;/p&gt;

&lt;p&gt;The culprit was the point ID. Each chunk got an ID derived from &lt;code&gt;(paper_id, chunk_index)&lt;/code&gt;. Reasonable — except &lt;code&gt;chunk_index&lt;/code&gt; was &lt;strong&gt;reset to 0 at the start of every section&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;section&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;sections&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;chunk_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;   &lt;span class="c1"&gt;# j resets per section!
&lt;/span&gt;        &lt;span class="n"&gt;point_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;make_id&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paper_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;j&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;               &lt;span class="c1"&gt;# collision: (abstract,0) == (methods,0)
&lt;/span&gt;        &lt;span class="nf"&gt;upsert&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;point_id&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 a paper's &lt;em&gt;abstract&lt;/em&gt; chunk-0 and its &lt;em&gt;methods&lt;/em&gt; chunk-0 and its &lt;em&gt;results&lt;/em&gt; chunk-0 all hashed to the &lt;strong&gt;same point ID&lt;/strong&gt;. Qdrant upserts are idempotent by ID, so each new section silently &lt;strong&gt;overwrote&lt;/strong&gt; the previous one. Every paper collapsed to roughly &lt;code&gt;max(chunks in any single section)&lt;/code&gt; points.&lt;/p&gt;

&lt;p&gt;I confirmed it by replaying the raw chunks: 27,222 chunks across a sample → only 5,672 unique &lt;code&gt;(paper_id, chunk_index)&lt;/code&gt; pairs. &lt;strong&gt;79.2% collision&lt;/strong&gt; on the sample, closely matching the 78% gap across the full DB (the small delta is just sampling — one is a replayed subset, the other the whole collection).&lt;/p&gt;

&lt;p&gt;The fix is a one-liner — make &lt;code&gt;chunk_index&lt;/code&gt; a running counter across the whole paper (and derive the ID with a deterministic hash like &lt;code&gt;hashlib&lt;/code&gt;/UUID, not Python's per-process &lt;code&gt;hash()&lt;/code&gt;, so IDs stay stable across runs) — but the lesson isn't the fix. It's that &lt;strong&gt;a silent overwrite produces a database that looks completely healthy&lt;/strong&gt;: green status, fast queries, plausible counts. Nothing errors. You only catch it if you reconcile "things I generated" against "things that landed."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Reconcile your pipeline's input count against its output count at every hop. Silent data loss doesn't throw.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bug #2 — recursive chunking that duplicated 75% of the text
&lt;/h2&gt;

&lt;p&gt;While fixing #1, I re-ran the chunker on a fresh corpus and a sample paper produced &lt;strong&gt;7,588 chunks, of which only 1,897 were unique&lt;/strong&gt; — 75% duplicates.&lt;/p&gt;

&lt;p&gt;The XML parser walked sections 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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.//sec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# ALL &amp;lt;sec&amp;gt;, including nested ones
&lt;/span&gt;    &lt;span class="n"&gt;paragraphs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;findall&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;.//p&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# ALL &amp;lt;p&amp;gt;, recursively
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In journal XML, sections nest. A parent &lt;code&gt;&amp;lt;sec&amp;gt;&lt;/code&gt; contains child &lt;code&gt;&amp;lt;sec&amp;gt;&lt;/code&gt;s. &lt;code&gt;.//p&lt;/code&gt; is recursive, so the parent emitted &lt;em&gt;all&lt;/em&gt; of its children's paragraphs — and then each child &lt;code&gt;&amp;lt;sec&amp;gt;&lt;/code&gt; was visited separately and emitted them &lt;em&gt;again&lt;/em&gt;. Deeply nested papers (a conference-proceedings document with 600 sub-sections was the worst) exploded.&lt;/p&gt;

&lt;p&gt;Fix: take &lt;strong&gt;direct-child&lt;/strong&gt; paragraphs only (&lt;code&gt;sec.findall("p")&lt;/code&gt;), plus a within-paper dedup as a safety net. Chunks dropped to the honest count, embedding time dropped with it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;code&gt;.//&lt;/code&gt; in XPath is a footgun when your tree is recursive and you also iterate the tree.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bug #3 — the reasoning model that never stops thinking
&lt;/h2&gt;

&lt;p&gt;Onto the extraction LLM — the 35B MoE workhorse. It's a reasoning model that emits a &lt;code&gt;&amp;lt;think&amp;gt;…&amp;lt;/think&amp;gt;&lt;/code&gt; block before its answer. The first run capped generation at 512 tokens with &lt;code&gt;stop=["\n\n"]&lt;/code&gt;. Result: &lt;strong&gt;0% parse rate&lt;/strong&gt;. The &lt;code&gt;\n\n&lt;/code&gt; stop fired &lt;em&gt;inside&lt;/em&gt; the thinking block, truncating mid-thought; no JSON ever appeared.&lt;/p&gt;

&lt;p&gt;OK, remove the bad stop, give it room. Bump to 1024 tokens. Now ~42% parse — better, but a third of outputs were still &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; with no &lt;code&gt;&amp;lt;/think&amp;gt;&lt;/code&gt;: the model hit the token cap &lt;em&gt;still reasoning&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So give it more room. 2048 tokens, 600-second timeout, quality-first. I ran a single hard sentence as a test. It generated &lt;strong&gt;6,144 characters in 269 seconds and still hadn't closed the think block&lt;/strong&gt; — it was literally mid-sentence, "Let's draft the JSON:", when it ran out of budget. At that rate, 14k sentences would take &lt;strong&gt;~5 days&lt;/strong&gt; and &lt;em&gt;still&lt;/em&gt; fail on the hard ones.&lt;/p&gt;

&lt;p&gt;The model wasn't slow. It was &lt;strong&gt;non-terminating&lt;/strong&gt;: on ambiguous inputs it reasoned in circles and never committed to an answer. More tokens didn't help; it just thought more.&lt;/p&gt;

&lt;p&gt;The fix is a known trick for reasoning models in raw-completion mode: &lt;strong&gt;pre-close the think block in the prompt&lt;/strong&gt; so the model skips deliberation and answers directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&amp;lt;|im_start|&amp;gt;assistant&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;#                                  ^ empty, pre-closed → no open-ended reasoning
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Latency dropped from "minutes, maybe never" to &lt;strong&gt;~10 seconds&lt;/strong&gt;, deterministically. The whole 14k run finished in hours, at 99.96% parse.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A reasoning model with no thinking budget is a liability for bulk structured output. If you don't need the chain-of-thought, close it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Bug #4 — empty outputs, and a one-character fix
&lt;/h2&gt;

&lt;p&gt;No-think mode had its own quirk: on ~14% of the harder sentences, the model returned &lt;strong&gt;completely empty output&lt;/strong&gt;. Not bad JSON — nothing. Deterministic (temperature 0), so retries reproduced the emptiness exactly.&lt;/p&gt;

&lt;p&gt;The model, forced to answer immediately, was "blanking" on sentences it found ambiguous. The fix was almost insultingly small: &lt;strong&gt;seed the assistant turn with an opening bracket&lt;/strong&gt; so the model is already inside a JSON array and &lt;em&gt;must&lt;/em&gt; continue it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&amp;lt;|im_start|&amp;gt;assistant&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;/think&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="c1"&gt;#                                                          ^ forces JSON to start
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(You then prepend the &lt;code&gt;[&lt;/code&gt; back when parsing, since the completion only returns what comes &lt;em&gt;after&lt;/em&gt; the prompt.) This recovered 298 of 301 empties → 99.86% parse on the hard subset.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;When a model can output "nothing," constrain the output space so "nothing" isn't reachable.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The bug that wasn't a bug: precision vs. recall
&lt;/h2&gt;

&lt;p&gt;The last lesson is subtler. The first extraction run mapped a number whenever a sentence had a number near a target-entity keyword. The audit found &lt;strong&gt;~50% of the mapped "effect sizes" weren't the target effect at all&lt;/strong&gt; — they were regression-predictor t-values (age, sex, medication), correlations with secondary &lt;em&gt;task&lt;/em&gt; scores, even positional coordinates (&lt;code&gt;x = -28&lt;/code&gt;) the regex had grabbed as if they were measurements.&lt;/p&gt;

&lt;p&gt;That noise produced a confident-but-spurious aggregate signal. Garbage in, &lt;em&gt;significant&lt;/em&gt; garbage out.&lt;/p&gt;

&lt;p&gt;The fix had two halves, and getting it wrong in an instructive way:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Filter at the source.&lt;/strong&gt; Only feed the LLM sentences from papers that are actually about the topic, with a target entity and a real effect statistic. (First attempt: filter at the &lt;em&gt;sentence&lt;/em&gt; level — too aggressive, it dropped real effects whose context lived in the &lt;em&gt;neighboring&lt;/em&gt; sentence. Second attempt: filter at the &lt;em&gt;paper&lt;/em&gt; level — recovered ~1,100 real effects the sentence filter had thrown away.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make the prompt relationship-aware.&lt;/strong&gt; Tell the model &lt;em&gt;what kind of number counts&lt;/em&gt; and what to reject (predictors, task-performance correlations, coordinates, cluster stats), with a worked rejection example.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;But I over-corrected: my first sharpened prompt rejected so aggressively it returned &lt;code&gt;[]&lt;/code&gt; for valid patient-vs-control effects too (1/15 on a sanity sample). The filter and the prompt were fighting — the filter guaranteed the paper was on-topic, but the prompt still demanded an explicit topic keyword &lt;em&gt;in the sentence&lt;/em&gt;. Once I told the prompt "you can trust that this sentence is from an on-topic paper; extract the entity's effect and only reject these specific noise types," recall snapped back (9/15) with zero coordinate leakage.&lt;/p&gt;

&lt;p&gt;Precision in the mapped set went from ~49% to ~66% at the &lt;em&gt;sentence&lt;/em&gt; level; at the &lt;em&gt;paper&lt;/em&gt; level — meaning every paper that contributes an effect is genuinely on-topic — it was 100%. Total entries dropped from ~4,900 to ~1,700, almost all of it noise. The residual ~34% sentence-level noise isn't pooled blind, but be precise about what catches it: the load-bearing filter downstream is &lt;strong&gt;entity normalization against a controlled vocabulary&lt;/strong&gt; — off-target entities (age, sex, medication) get dropped there — backed by a validation gate. (Stratifying by measure type and dedup are cleanup, not misclassification removal: a predictor t-value mislabeled as a target effect sails right through those.) The mapping's job is to maximize signal and flag; the controlled-vocabulary step is where the final noise is supposed to die.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The most dangerous extraction failure isn't a crash or a low parse rate. It's clean-looking data that's confidently wrong. Audit what your pipeline &lt;em&gt;includes&lt;/em&gt;, not just what it drops.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CPU + quantized MoE is genuinely viable&lt;/strong&gt; for 10k-document LLM work: at ~6 tok/s/node across 8 nodes, the full 14k-sentence extraction finished in a few hours. The bottleneck was never compute — it was correctness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reconcile counts at every hop.&lt;/strong&gt; Both data-loss bugs (#1, #2) were invisible from status dashboards; only input-vs-output reconciliation surfaced them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reasoning models need a thinking budget — or none at all — for bulk structured output.&lt;/strong&gt; Pre-closing &lt;code&gt;&amp;lt;think&amp;gt;&lt;/code&gt; and seeding the output bracket turned a 5-day non-terminating job into a few-hour deterministic one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For extraction, precision is the silent killer.&lt;/strong&gt; A permissive regex + a literal LLM will hand you a statistically significant result built on coordinates and covariates. Filter at the right granularity, and tell the model what to reject.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. They're the unglamorous correctness work that sits between "the demo runs" and "the numbers are trustworthy" — which, for anything feeding a real analysis, is the whole job.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This pipeline powered the large-scale literature extraction behind our chronic-stress scoping-review preprint (&lt;a href="https://www.researchsquare.com/article/rs-9884522/v1" rel="noopener noreferrer"&gt;Research Square&lt;/a&gt;).&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Tools used: llama.cpp, BGE-M3, Qdrant, Python. All on-prem, all CPU.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>python</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>A MOGONET-Style Multi-Omics Biomarker Pipeline: Why a Near-Random Graph Net Still Earns Its Place</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:24:09 +0000</pubDate>
      <link>https://dev.to/sysoft/a-mogonet-style-multi-omics-biomarker-pipeline-why-a-near-random-graph-net-still-earns-its-place-2a98</link>
      <guid>https://dev.to/sysoft/a-mogonet-style-multi-omics-biomarker-pipeline-why-a-near-random-graph-net-still-earns-its-place-2a98</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR (Quick Answer)
&lt;/h2&gt;

&lt;p&gt;This is an honest engineering write-up of a &lt;strong&gt;MOGONET-style multi-omics consensus biomarker pipeline&lt;/strong&gt; built as an internal R&amp;amp;D project at &lt;strong&gt;sysofti&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The headline&lt;/strong&gt; — on a small synthetic cohort (n=30), the graph network alone scores &lt;strong&gt;near-random in leak-free 5-fold cross-validation (AUC 0.53 ± 0.16)&lt;/strong&gt;. Yet as one voter in a &lt;strong&gt;5-evidence consensus&lt;/strong&gt;, the top-10 ranking is &lt;strong&gt;90% real markers&lt;/strong&gt; (9 of 10 are known periodontitis genes).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The lesson&lt;/strong&gt; — a single model that looks weak in honest evaluation can still be a &lt;em&gt;useful voter&lt;/em&gt;. That contrast is the whole point of the consensus design, and we show it with data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it is&lt;/strong&gt; — per-omics Graph Convolutional Networks (GCN) over a sample-similarity graph, attention-fused, contributing to a consensus score alongside differential-expression hubs, Random Forest, a DNN, and co-expression modules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What it is *not&lt;/strong&gt;* — the official MOGONET. We dropped the original's VCDN fusion for attention fusion. Call it "MOGONET-based." All numbers are from synthetic data with embedded ground-truth markers — code validation, &lt;strong&gt;not&lt;/strong&gt; a clinical claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're implementing multi-omics integration, the parts you can't get from the paper are below: the real results, the leakage-aware evaluation, and the bugs we hit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What MOGONET Is (the One-Line Mental Model)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;MOGONET (Multi-Omics Graph cOnvolutional NETwork)&lt;/strong&gt; learns a separate GCN per omics view on a &lt;em&gt;sample-similarity graph&lt;/em&gt; (patients as nodes, edges by feature similarity), then fuses the per-view embeddings for classification and biomarker discovery. Reference: &lt;a href="https://www.nature.com/articles/s41467-021-23774-w" rel="noopener noreferrer"&gt;Wang et al. 2021, &lt;em&gt;Nature Communications&lt;/em&gt; 12:3445&lt;/a&gt;; the GCN itself is &lt;a href="https://arxiv.org/abs/1609.02907" rel="noopener noreferrer"&gt;Kipf &amp;amp; Welling 2017&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Mental model: &lt;em&gt;"build one graph net per omics layer, let each form an opinion, then combine those opinions."&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Simplified — and Why
&lt;/h2&gt;

&lt;p&gt;The original MOGONET fuses views with a &lt;strong&gt;View Correlation Discovery Network (VCDN)&lt;/strong&gt;. We replaced it with &lt;strong&gt;attention-weighted fusion&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Why&lt;/strong&gt; — with tiny cohorts (tens of samples), VCDN's extra parameters were a liability; attention fusion gave a simpler intermediate-fusion scheme that still up-weights the more informative omics per sample.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tradeoff&lt;/strong&gt; — we lose the explicit cross-view correlation modeling that is part of MOGONET's original contribution. So this is honestly &lt;em&gt;MOGONET-based&lt;/em&gt;, not a reimplementation. The source docstring says as much: &lt;em&gt;"Simplified implementation of MOGONET."&lt;/em&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Input: X_views = [omics1 (n×p1), omics2 (n×p2), ...]   (n = common samples)
  └─ per-view StandardScaler
  └─ per-view k-NN (cosine) adjacency  (n×n)
ViewEncoder (per omics):  GraphConv(p→128) → BN → ReLU → GraphConv(128→64)
  → view embedding (n×64)
Attention fusion:  softmax(Linear(64→1)) over views → weighted sum (n×64)
Classifier:  Linear(64→32) → ReLU → Linear(32→n_classes)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;GraphConvLayer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;in_features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_features&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;linear&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;in_features&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out_features&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj&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;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mm&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;adj&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# propagate over the sample graph
&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;MOGONET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Module&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;input_dims&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hidden_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latent_dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_classes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ModuleList&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nc"&gt;ViewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hidden_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;latent_dim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;input_dims&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;attention&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latent_dim&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;classifier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Sequential&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;latent_dim&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;ReLU&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;nn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Linear&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_classes&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adjs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;embeddings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;encoders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;views&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;adjs&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
        &lt;span class="n"&gt;stacked&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stack&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;embeddings&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                       &lt;span class="c1"&gt;# n_views × n × latent
&lt;/span&gt;        &lt;span class="n"&gt;attn&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;F&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;softmax&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;attention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stacked&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;squeeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# per-view, per-sample
&lt;/span&gt;        &lt;span class="n"&gt;fused&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stacked&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;attn&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;unsqueeze&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dim&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;              &lt;span class="c1"&gt;# n × latent
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;classifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fused&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sample-similarity graph — k-NN (cosine), &lt;strong&gt;no self-loops on purpose&lt;/strong&gt; (see below):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build_adjacency&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;sim&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;cosine_similarity&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;adj&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros_like&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
        &lt;span class="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;argsort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])[&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;k&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="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# top-k neighbours, excluding self
&lt;/span&gt;        &lt;span class="n"&gt;adj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;adj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;sim&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;            &lt;span class="c1"&gt;# symmetrize
&lt;/span&gt;    &lt;span class="n"&gt;row_sum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;axis&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;keepdims&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;row_sum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;row_sum&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="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;   &lt;span class="c1"&gt;# guard zero-sum rows
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;adj&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;row_sum&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Engineering Decisions That Mattered
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sample-node graph, not feature graph.&lt;/strong&gt; Nodes are patients; edges are patient-patient similarity. Same-group patients cluster, so the GCN smooths group signal.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No self-loops — on purpose.&lt;/strong&gt; Standard GCN uses Ahat = A + I so a node keeps its own features. We deliberately omit the self-loop so each node's representation is built purely from its sample-neighborhood, pushing the model toward group structure rather than individual raw features. It is a tradeoff (you give up the node's own signal each layer), and we flag it as a choice, not an accident.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-view scaling + common-sample intersection.&lt;/strong&gt; Each omics standardized independently; only samples present in all views are used.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consensus over a single model.&lt;/strong&gt; MOGONET is one of five evidence sources by design — Hub (DE+PPI), ML (Random Forest), DL (DNN), WGCNA co-expression, and MOGONET — with a multi-evidence bonus:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;avg_score&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;composite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;avg_score&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mf"&gt;0.3&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_sources&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# reward agreement across sources
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As the results show, this design choice is what makes the pipeline useful &lt;em&gt;despite&lt;/em&gt; any single model being weak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results (Synthetic Data, with Ground Truth)
&lt;/h2&gt;

&lt;p&gt;We validate on a synthetic periodontitis case-control set (3 omics — transcriptomics 500, proteomics 200, metabolomics 100 features × 30 samples, 15 disease / 15 control, seed-fixed) with &lt;strong&gt;known biomarkers deliberately embedded&lt;/strong&gt;: up-regulated inflammatory genes (MMP8, MMP9, IL1B, IL6, TNF, RANKL, CTSK, TLR4 …) and down-regulated bone-formation genes (COL1A1, RUNX2, SP7, BGLAP, OPG …). Embedding known markers gives &lt;strong&gt;ground truth&lt;/strong&gt; — you can check whether the pipeline recovers them, which is impossible on a real cohort.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Note on sources:&lt;/strong&gt; the pipeline defines five evidence sources, but in this run WGCNA returned no co-expression hubs, so &lt;strong&gt;four sources actually contributed&lt;/strong&gt; (Hub, ML, DL, MOGONET).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  The consensus ranking surfaces real markers
&lt;/h3&gt;

&lt;p&gt;Of 793 candidate features, the top-30 consensus included 13 of the 25 embedded markers. The ranking is strikingly clean at the top:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxs0kfuk6p5beokepkm1z.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fxs0kfuk6p5beokepkm1z.png" alt="Top-20 consensus biomarkers, bar length = composite score, color = number of supporting evidence sources, star = known periodontitis marker"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rank&lt;/th&gt;
&lt;th&gt;Gene&lt;/th&gt;
&lt;th&gt;Composite&lt;/th&gt;
&lt;th&gt;Sources&lt;/th&gt;
&lt;th&gt;Known marker&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;MMP8&lt;/td&gt;
&lt;td&gt;1.888&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;td&gt;COL1A1&lt;/td&gt;
&lt;td&gt;1.212&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;MMP9&lt;/td&gt;
&lt;td&gt;1.020&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;IL6&lt;/td&gt;
&lt;td&gt;1.000&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;td&gt;IL1B&lt;/td&gt;
&lt;td&gt;0.900&lt;/td&gt;
&lt;td&gt;4&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;td&gt;METAB_0031&lt;/td&gt;
&lt;td&gt;0.866&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;7&lt;/td&gt;
&lt;td&gt;TLR4&lt;/td&gt;
&lt;td&gt;0.856&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;RANKL&lt;/td&gt;
&lt;td&gt;0.838&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;CTSK&lt;/td&gt;
&lt;td&gt;0.803&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;td&gt;SP7&lt;/td&gt;
&lt;td&gt;0.678&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;MYD88&lt;/td&gt;
&lt;td&gt;0.672&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;td&gt;★&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Precision@10 = 0.90&lt;/strong&gt; — 9 of the top 10 are known markers (only METAB_0031 is not).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recall@10 = 0.36, Recall@20 = 0.52&lt;/strong&gt; (9 then 13 of 25 known markers); it plateaus by 20 because a few embedded markers were given weak synthetic signal (e.g. TNF, fold-change ≈ 1.1).&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  More evidence = more trustworthy
&lt;/h3&gt;

&lt;p&gt;Breaking the top-30 down by which sources agreed makes the consensus logic concrete:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjfn9dz2xh2t2vulfkdgv.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fjfn9dz2xh2t2vulfkdgv.png" alt="Evidence-source combinations among the top-30 consensus genes, and how many in each group are known markers"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;4 sources → 3 genes, all 3 known&lt;/strong&gt; (100%): MMP8, MMP9, IL1B.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;3 sources → 17 genes, 9 known.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 sources (DL + MOGONET) → 8 genes, 0 known&lt;/strong&gt; — pure noise.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1 source → 2 genes, 1 known.&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The signal lives where independent methods agree. A gene flagged by four sources was always real here; genes flagged by only two were not.&lt;/p&gt;

&lt;h3&gt;
  
  
  The honest part: the graph net alone is near-random
&lt;/h3&gt;

&lt;p&gt;We cross-validated MOGONET as a &lt;em&gt;standalone&lt;/em&gt; classifier, &lt;strong&gt;rebuilding the sample graph from training folds only&lt;/strong&gt; to avoid leakage:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;MOGONET 5-fold CV AUC = 0.53 ± 0.16&lt;/strong&gt; (folds: 0.44, 0.44, 0.78, 0.33, 0.67)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is barely above chance. With n=30 (six test samples per fold) and a transductive sample-graph model, a single GCN simply cannot generalize here — and its training AUC near 1.0 is mostly the leakage and the injected signal talking. This is exactly why MOGONET is wired in as &lt;strong&gt;one voter, not the decision-maker&lt;/strong&gt;. The consensus result above is strong &lt;em&gt;because&lt;/em&gt; it doesn't trust any single model, including this one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Simplified model.&lt;/strong&gt; No VCDN fusion — attention instead. "MOGONET-based," not a reimplementation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MOGONET is a weak standalone classifier here&lt;/strong&gt; (CV AUC 0.53). Useful only in aggregate. It also scores &lt;em&gt;all&lt;/em&gt; 793 features, so its solo discriminative power is low.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synthetic, small (n=30).&lt;/strong&gt; Results validate the code's ability to recover injected signal — not clinical performance. External cohorts are required for any real claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single run (seed 42).&lt;/strong&gt; Known markers are stable at the top; the unnamed GENE_xxxx candidates shuffle on re-runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Self-loop omission is a design choice&lt;/strong&gt; with a cost — worth A/B testing against the standard A + I formulation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feature importance is an approximation&lt;/strong&gt; (first-layer weight magnitude), not a gradient-based attribution.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What Broke Along the Way (Real Notes)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero-sum adjacency → NaN.&lt;/strong&gt; If a sample's k-NN cosine similarities summed to zero, row-normalization divided by zero and propagated NaNs. Fixed with a &lt;code&gt;row_sum[row_sum == 0] = 1&lt;/code&gt; guard.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribute-name mismatches (fixed twice).&lt;/strong&gt; Pulling feature importance broke on &lt;code&gt;AttributeError&lt;/code&gt; when the sklearn-wrapper conventions clashed with the &lt;code&gt;nn.Module&lt;/code&gt; attribute names (&lt;code&gt;view_encoders&lt;/code&gt; → &lt;code&gt;encoders&lt;/code&gt;, &lt;code&gt;model&lt;/code&gt; → &lt;code&gt;model_&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Common-sample collapse.&lt;/strong&gt; When omics measured different sample sets, the intersection shrank fast. Added a "≥6 common samples" guard that skips gracefully instead of crashing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MOGONET scores everything.&lt;/strong&gt; It assigns weight to all 793 features, so it appeared in all top-30 entries — the multi-evidence bonus is what keeps it honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What We'd Improve Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Report consensus performance under the same leak-free CV, not just MOGONET's.&lt;/li&gt;
&lt;li&gt;A/B test self-loops (Ahat = A + I).&lt;/li&gt;
&lt;li&gt;Gradient-based attribution (Integrated Gradients) instead of first-layer weights.&lt;/li&gt;
&lt;li&gt;Add VCDN fusion and compare head-to-head with attention fusion.&lt;/li&gt;
&lt;li&gt;External multi-omics cohort for real-world validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Is this the official MOGONET implementation?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No — a simplified, MOGONET-based design: per-omics GCN with attention fusion, without the original's VCDN view-correlation network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: If MOGONET's CV AUC is only 0.53, why keep it?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because it is one voter in a five-source consensus, not the classifier. Single models overfit small cohorts; consensus rewards agreement across independent methods, and that ranking recovered known markers at 90% precision in the top 10. A weak voter still adds signal when combined.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why validate on synthetic data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Embedded known markers give ground truth, so you can measure recovery (recall/precision) — impossible on a real cohort where the answer is unknown. It validates the code, not clinical utility.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why omit GCN self-loops?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Intentional: without a self-loop, each node's representation comes purely from its sample-neighborhood, pushing the model toward group structure rather than individual features. It is a tradeoff worth A/B testing, not a universal recommendation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Can I use this on my own multi-omics data?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — the classifier is sklearn-compatible (&lt;code&gt;fit&lt;/code&gt;/&lt;code&gt;predict&lt;/code&gt;/&lt;code&gt;predict_proba&lt;/code&gt;). Build the sample graph from training data only to avoid leakage, and don't over-read AUC on small cohorts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reference implementation (clean, standalone, MIT): &lt;strong&gt;&lt;a href="https://github.com/shoo99/mogonet_lite" rel="noopener noreferrer"&gt;github.com/shoo99/mogonet_lite&lt;/a&gt;&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Original paper: Wang T. et al. (2021), &lt;em&gt;MOGONET integrates multi-omics data via graph convolutional networks for biomarker discovery&lt;/em&gt;, Nat Commun 12:3445.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>machinelearning</category>
      <category>bioinformatics</category>
      <category>python</category>
      <category>datascience</category>
    </item>
    <item>
      <title>Running a 35B MoE (Qwen3.6-35B-A3B) on 2x GTX 1080 Ti in 2026 — Real Benchmarks, and Does the Second GPU Actually Help?</title>
      <dc:creator>byeongsoo kang</dc:creator>
      <pubDate>Wed, 03 Jun 2026 05:18:42 +0000</pubDate>
      <link>https://dev.to/sysoft/running-a-35b-moe-qwen36-35b-a3b-on-2x-gtx-1080-ti-in-2026-real-benchmarks-and-does-the-56on</link>
      <guid>https://dev.to/sysoft/running-a-35b-moe-qwen36-35b-a3b-on-2x-gtx-1080-ti-in-2026-real-benchmarks-and-does-the-56on</guid>
      <description>&lt;h2&gt;
  
  
  TL;DR (Quick Answer)
&lt;/h2&gt;

&lt;p&gt;I actually ran &lt;strong&gt;Qwen3.6-35B-A3B&lt;/strong&gt; — a 35B-parameter mixture-of-experts model (only 3B active per token) — on a pair of &lt;strong&gt;8-year-old GTX 1080 Ti&lt;/strong&gt; cards (22 GB combined). Real, measured numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Generation speed: ~20 tokens/sec&lt;/strong&gt; on 2× 1080 Ti (IQ4_XS quant), stable across runs (19.4 / 21.4 / 20.0).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Single GPU: ~16.8 tok/s. CPU-only (i9-14900K): ~17.1 tok/s.&lt;/strong&gt; The second 1080 Ti buys only ~20% over one card — and, the kicker, &lt;strong&gt;the GPUs barely beat a modern CPU here&lt;/strong&gt; (~+18%), because the MoE experts stay mmap'd in CPU RAM regardless. See the honest update below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It only "fits" because of the MoE + CPU-mmap trick.&lt;/strong&gt; ~13 GB of the model sits on the two GPUs; ~18 GB of expert weights are mmap'd from CPU RAM, and only the active 3B runs each token.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quant matters for 22 GB:&lt;/strong&gt; the default &lt;code&gt;qwen3.6:35b-a3b&lt;/code&gt; tag is &lt;strong&gt;23.9 GB and spills to CPU&lt;/strong&gt;. You want &lt;strong&gt;≤ IQ4_XS (~17.7 GB)&lt;/strong&gt; to keep it (mostly) on the GPUs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Bottom line: a 35B MoE is genuinely usable on this box in 2026 — but the honest workhorse turned out to be the &lt;strong&gt;i9-14900K CPU&lt;/strong&gt;; the used 1080 Ti cards add only ~18%. Pick a sparse MoE and a quant that mostly fits — and know that for an offload-heavy MoE, a fast CPU + RAM bandwidth matters as much as the GPUs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The setup (and one gotcha)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPUs:&lt;/strong&gt; 2× NVIDIA GeForce GTX 1080 Ti (11 GB each, 22 GB total), Pascal, compute capability 6.1.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Driver:&lt;/strong&gt; 581.57 (Windows host, used via WSL2 passthrough). &lt;strong&gt;This matters&lt;/strong&gt; — recent Ollama bundles CUDA 13, which refuses drivers older than 570. On the older 560 driver it silently fell back to CPU (&lt;code&gt;total_vram=0&lt;/code&gt;). Updating to 581 fixed it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ollama:&lt;/strong&gt; v0.30.2. Interesting detail: its &lt;strong&gt;cuda_v13 build skips Pascal&lt;/strong&gt; ("compute capability not in compiled architectures", cc 6.1), so it &lt;strong&gt;auto-falls back to the bundled cuda_v12 build&lt;/strong&gt; to use the 1080 Ti. Good to know if you're on old hardware.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a "35B" model runs on old cards at all
&lt;/h2&gt;

&lt;p&gt;Qwen3.6-35B-A3B is a &lt;strong&gt;mixture-of-experts (MoE)&lt;/strong&gt;: 35B total parameters, but only &lt;strong&gt;~3B are active&lt;/strong&gt; for any given token. So the &lt;em&gt;compute&lt;/em&gt; per token is small (3B-class), even though all the experts must be &lt;em&gt;available&lt;/em&gt; in memory.&lt;/p&gt;

&lt;p&gt;That's the whole reason this works on Pascal: the GTX 1080 Ti has no tensor cores and modest FP16, so a dense 35B would crawl. A sparse 3B-active MoE keeps the per-token math light, and the bottleneck shifts to &lt;em&gt;where the weights live&lt;/em&gt; — which is exactly what the dual-GPU question is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Quant fit on 22 GB
&lt;/h2&gt;

&lt;p&gt;You can't just &lt;code&gt;ollama pull qwen3.6:35b-a3b&lt;/code&gt; — that default is 23.9 GB and won't sit on 22 GB of VRAM. Measured GGUF sizes:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Quant&lt;/th&gt;
&lt;th&gt;Size&lt;/th&gt;
&lt;th&gt;Fits 22 GB?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Q3_K_M&lt;/td&gt;
&lt;td&gt;~16.6–17.1 GB&lt;/td&gt;
&lt;td&gt;✅ comfortable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;IQ4_XS&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~17.7 GB&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;✅ best quality that fits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4_K_S&lt;/td&gt;
&lt;td&gt;~21 GB&lt;/td&gt;
&lt;td&gt;⚠️ too tight (spills with KV cache)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Q4_K_M / default&lt;/td&gt;
&lt;td&gt;23.9 GB+&lt;/td&gt;
&lt;td&gt;❌ offloads to CPU&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I used &lt;strong&gt;IQ4_XS&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Results: single vs dual 1080 Ti
&lt;/h2&gt;

&lt;p&gt;Same model (IQ4_XS), same prompt, &lt;code&gt;num_predict=256&lt;/code&gt;, measured via Ollama's &lt;code&gt;/api/generate&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Generation&lt;/th&gt;
&lt;th&gt;Prefill&lt;/th&gt;
&lt;th&gt;Model on GPU&lt;/th&gt;
&lt;th&gt;Model on CPU (mmap)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;CPU only (i9-14900K)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~17.1 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;td&gt;0 GB&lt;/td&gt;
&lt;td&gt;whole model in RAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1× GTX 1080 Ti&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~16.8 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~50 tok/s&lt;/td&gt;
&lt;td&gt;~3 GB&lt;/td&gt;
&lt;td&gt;~18 GB+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2× GTX 1080 Ti&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~20.3 tok/s&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~50 tok/s&lt;/td&gt;
&lt;td&gt;~13 GB (4 + 9.3)&lt;/td&gt;
&lt;td&gt;~18 GB&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;Under load, the busier card drew up to &lt;strong&gt;~101 W&lt;/strong&gt;, GPU utilization sat around &lt;strong&gt;26–33%&lt;/strong&gt; — telling: the cards are &lt;em&gt;waiting&lt;/em&gt; a lot, because the CPU-mmap'd experts are the bottleneck, not raw GPU FLOPs.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update (2026-06-03) — the honest punchline, after an r/ollama reader pushed back ("those numbers are slow for A3B").&lt;/strong&gt; I measured CPU-only on the same box — an &lt;strong&gt;Intel i9-14900K&lt;/strong&gt; (32 threads, DDR5): &lt;strong&gt;~17.1 tok/s&lt;/strong&gt;. That's &lt;em&gt;basically tied with a single 1080 Ti, and only ~18% behind both GPUs combined.&lt;/em&gt; So for this offload-heavy MoE, the old Pascal cards barely beat a modern CPU — the 14900K does most of the work and the GPUs mostly shave overhead. The honest framing isn't "a 35B runs on 2× 1080 Ti" so much as &lt;strong&gt;"a 35B MoE runs on a fast desktop CPU, and old GPUs add ~18%."&lt;/strong&gt; When the experts have to live in CPU RAM, your CPU + memory bandwidth — not the GPU — set the ceiling. (On hardware where the whole MoE is VRAM-resident, the GPU story would look very different.)&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;So, does the second 1080 Ti help?&lt;/strong&gt; A little — ~+20% over one card, ~+18% over CPU-only — by keeping ~9 GB more of the model in VRAM. But not 2×, and not the win you'd hope: an MoE that overflows your combined VRAM is gated by the CPU-side experts in every config here.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# (driver must be 570+ for current Ollama; check with: nvidia-smi)&lt;/span&gt;
ollama pull hf.co/bartowski/Qwen_Qwen3.6-35B-A3B-GGUF:IQ4_XS

&lt;span class="c"&gt;# generate + read the eval rate&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:11434/api/generate &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
  "model": "hf.co/bartowski/Qwen_Qwen3.6-35B-A3B-GGUF:IQ4_XS",
  "prompt": "Explain mixture-of-experts in 150 words.",
  "stream": false,
  "options": {"num_predict": 256}
}'&lt;/span&gt;
&lt;span class="c"&gt;# tokens/sec = eval_count / (eval_duration / 1e9)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To force a single GPU for comparison, start the server with &lt;code&gt;CUDA_VISIBLE_DEVICES=0 ollama serve&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest Limitations
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;One quant, one model, one box.&lt;/strong&gt; IQ4_XS on 2× 1080 Ti; your tokens/sec will shift with quant, context length, CPU, and RAM speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill measured on a short prompt&lt;/strong&gt; (~55 tokens) — treat ~50 tok/s as a ballpark; long-context prefill on Pascal will be slower.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;IQ4_XS is a ~4-bit quant&lt;/strong&gt; — fine for chat/drafting, but it's not full-precision quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MoE-specific.&lt;/strong&gt; These conclusions (the modest dual-GPU gain, the CPU-mmap behavior) are about &lt;em&gt;this sparse MoE&lt;/em&gt;. A dense model that fully fits VRAM would scale differently across two cards.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A few runs, not a statistical study&lt;/strong&gt; — numbers are representative, not p-valued.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Q: Can a GTX 1080 Ti really run a 35B model in 2026?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A sparse MoE one, yes — Qwen3.6-35B-A3B at IQ4_XS ran ~20 tok/s on two of them. A &lt;em&gt;dense&lt;/em&gt; 35B would not be usable. The 3B-active design is what makes it work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Will a second 1080 Ti double my speed?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. Here it added ~20%. The MoE experts stay memory-mapped in CPU RAM in both single- and dual-GPU setups, so the second card helps but doesn't scale linearly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Why did Ollama ignore my GPU until I updated the driver?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Recent Ollama bundles CUDA 13, which requires NVIDIA driver ≥ 570. On an older driver it falls back to CPU silently. Update the driver; Ollama then uses its cuda_v12 build for Pascal cards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Q: Which quant should I use on 22 GB?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;IQ4_XS (~17.7 GB) for the best quality that stays (mostly) on the GPUs; Q3_K_M if you want more headroom for context. Avoid the 23.9 GB default — it spills to CPU.&lt;/p&gt;

&lt;h2&gt;
  
  
  Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Model: &lt;a href="https://huggingface.co/bartowski/Qwen_Qwen3.6-35B-A3B-GGUF" rel="noopener noreferrer"&gt;Qwen3.6-35B-A3B GGUF (bartowski)&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://ollama.com" rel="noopener noreferrer"&gt;Ollama&lt;/a&gt; · benchmark via &lt;code&gt;/api/generate&lt;/code&gt; (&lt;code&gt;eval_count&lt;/code&gt; / &lt;code&gt;eval_duration&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>llm</category>
      <category>machinelearning</category>
      <category>gpu</category>
      <category>ollama</category>
    </item>
  </channel>
</rss>
