<?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: Arsen Apostolov</title>
    <description>The latest articles on DEV Community by Arsen Apostolov (@sikamikanikobg).</description>
    <link>https://dev.to/sikamikanikobg</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%2F1410108%2Fb7d644e9-449a-4ef9-8bcc-73a2ff63902f.jpeg</url>
      <title>DEV Community: Arsen Apostolov</title>
      <link>https://dev.to/sikamikanikobg</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sikamikanikobg"/>
    <language>en</language>
    <item>
      <title>Cloud vs Local: The Break-Even Point Where Self-Hosting Stops Losing Money</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 09 Nov 2026 08:30:16 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/cloud-vs-local-the-break-even-point-where-self-hosting-stops-losing-money-4f04</link>
      <guid>https://dev.to/sikamikanikobg/cloud-vs-local-the-break-even-point-where-self-hosting-stops-losing-money-4f04</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 14&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;"Local LLMs are cheaper than the cloud" is a claim you see constantly. "The cloud is cheaper" is a claim you see just as often. Both are right, both are wrong, and the answer depends on a number most people have never calculated: &lt;strong&gt;their own break-even point.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The break-even point is the monthly usage at which self-hosting costs the same as the API for the same work. Below it, the cloud wins. Above it, local wins. It is different for every person, because it depends on three things: what your hardware cost, what your electricity costs, and which cloud model you would otherwise be paying for.&lt;/p&gt;

&lt;p&gt;I calculated mine from a metered 30-day bill and a current price sheet. Here is the method, so you can run it on your own numbers.&lt;/p&gt;




&lt;h2&gt;
  
  
  The local side: what the box actually costs
&lt;/h2&gt;

&lt;p&gt;Two components, and only one of them scales with anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Hardware amortisation.&lt;/strong&gt; A used RTX 3090 runs €800–1,200. Midpoint €1,000, amortised over 36 months: &lt;strong&gt;€27.78/month&lt;/strong&gt;. If you already own the card for other reasons, this is €0 and the marginal cost of the inference stack is just electricity.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Electricity.&lt;/strong&gt; This is measured, not estimated. My homelab monitor attributes power per process, and over the 30 days to 2 November the resident inference stack drew:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Energy (30d)&lt;/th&gt;
&lt;th&gt;Cost (BGN)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whisper (ASR, 24/7)&lt;/td&gt;
&lt;td&gt;21.73&lt;/td&gt;
&lt;td&gt;5.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Text-to-speech (24/7)&lt;/td&gt;
&lt;td&gt;13.47&lt;/td&gt;
&lt;td&gt;3.51&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embedding model&lt;/td&gt;
&lt;td&gt;5.09&lt;/td&gt;
&lt;td&gt;1.32&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Everything else GPU-side&lt;/td&gt;
&lt;td&gt;0.36&lt;/td&gt;
&lt;td&gt;0.09&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;40.65&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;10.59&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Tariff: 0.30 BGN/kWh by day, 0.18 BGN/kWh by night (22:00–06:00). The bill above is not priced at either one on its own — it is a blend, and it works out to an effective &lt;strong&gt;0.26 BGN/kWh&lt;/strong&gt;. If you quote a single rate against a metered figure, quote the blended one and say it is blended.&lt;/p&gt;

&lt;p&gt;Converted at 1.95583 BGN/EUR, that bill is &lt;strong&gt;€5.42 for the month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the local monthly cost is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;You already own the card:&lt;/strong&gt; &lt;strong&gt;€5.42/month&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You are buying the card:&lt;/strong&gt; €27.78 + €5.42 = &lt;strong&gt;€33.20/month&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Note what that number is: the whole stack, running around the clock, including every transcription queue and embedding job it handled in those 30 days. It is close to flat. It does not move much when you ask it more questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The part that does scale, and how I know its size.&lt;/strong&gt; The card measures 40 W with the models resident and nothing queued — that is measured, and it is most of the month. What it draws while actually generating I did not meter this month, so I will not pretend otherwise: the 3090's board power limit is &lt;strong&gt;300 W at spec&lt;/strong&gt;, and that is a ceiling, not a measurement. At the day tariff, an hour pinned at that ceiling is 0.09 BGN — about &lt;strong&gt;€0.046&lt;/strong&gt;. Two hours a day of it adds €2.76/month, putting the local side at €8.18. Treat that as the worst case for a heavy user, not the typical one.&lt;/p&gt;




&lt;h2&gt;
  
  
  The cloud side: what the same work costs
&lt;/h2&gt;

&lt;p&gt;List prices, checked against the published price sheets in early November 2026. A "Q&amp;amp;A pair" is the shape of one typical assistant exchange: &lt;strong&gt;500 input tokens + 200 output tokens&lt;/strong&gt;, so 1,000 pairs is 0.5M input + 0.2M output.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cloud model&lt;/th&gt;
&lt;th&gt;$/M input&lt;/th&gt;
&lt;th&gt;$/M output&lt;/th&gt;
&lt;th&gt;Per 1,000 Q&amp;amp;A pairs&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5&lt;/td&gt;
&lt;td&gt;$2.00&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;td&gt;$3.00 (€2.69)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;$2.00&lt;/td&gt;
&lt;td&gt;$10.00&lt;/td&gt;
&lt;td&gt;$3.00 (€2.69)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;$0.75&lt;/td&gt;
&lt;td&gt;$3.75&lt;/td&gt;
&lt;td&gt;$1.13 (€1.01)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.5 Flash-Lite&lt;/td&gt;
&lt;td&gt;$0.30&lt;/td&gt;
&lt;td&gt;$2.50&lt;/td&gt;
&lt;td&gt;$0.65 (€0.58)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 5.5&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;td&gt;$0.50&lt;/td&gt;
&lt;td&gt;$0.15 (€0.13)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPT-6 Luna&lt;/td&gt;
&lt;td&gt;$0.10&lt;/td&gt;
&lt;td&gt;$0.50&lt;/td&gt;
&lt;td&gt;$0.15 (€0.13)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Two things worth knowing before you use these. First, on the premium models output costs 5x input, so a chatty model is a more expensive model independent of quality. Second, several of those rates are introductory: Gemini 3.8 Flash goes to $1.50/$7.50 on 1 January, and the Google flagship that matches the premium tier doubles from $2/$10 to $4/$20 on 1 December. The cheap end of the market is not permanently cheap.&lt;/p&gt;




&lt;h2&gt;
  
  
  The break-even point
&lt;/h2&gt;

&lt;p&gt;Set local cost equal to cloud cost and solve for pairs per month.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Case 1 — you already own the card (€5.42/month flat)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparing against&lt;/th&gt;
&lt;th&gt;Break-even&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5 / GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;~2,000 pairs&lt;/td&gt;
&lt;td&gt;~67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;~5,400 pairs&lt;/td&gt;
&lt;td&gt;~179&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 5.5 / GPT-6 Luna&lt;/td&gt;
&lt;td&gt;~40,000 pairs&lt;/td&gt;
&lt;td&gt;~1,340&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Case 2 — you are buying the card (€33.20/month flat)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparing against&lt;/th&gt;
&lt;th&gt;Break-even&lt;/th&gt;
&lt;th&gt;Per day&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Sonnet 5.5 / GPT-6.1 Sol&lt;/td&gt;
&lt;td&gt;~12,400 pairs&lt;/td&gt;
&lt;td&gt;~412&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.8 Flash&lt;/td&gt;
&lt;td&gt;~33,000 pairs&lt;/td&gt;
&lt;td&gt;~1,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Claude Haiku 5.5 / GPT-6 Luna&lt;/td&gt;
&lt;td&gt;~247,000 pairs&lt;/td&gt;
&lt;td&gt;~8,200&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Case 3 — you own the card and generate heavily (€8.18/month, two hours of generation a day)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Against Sonnet-class: ~3,000 pairs/month, ~102/day. The adder moves the line, it does not move the conclusion.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the numbers say
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The break-even is not "a few times a day." It is dozens.&lt;/strong&gt; The claim that circulates in the local-LLM crowd — that you recoup a GPU by using a local assistant a couple of times a day — does not survive contact with a meter and a price sheet. Against a premium cloud model, a card you already own pays for its electricity at around &lt;strong&gt;67 exchanges a day&lt;/strong&gt;. That is a real daily habit, not a casual one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Against the cheap end, local loses outright.&lt;/strong&gt; Haiku-class and Luna-class APIs price a Q&amp;amp;A pair at about €0.00013. Your electricity is €5.42 a month before the first token. You would need 40,000 pairs a month to break even — and 247,000 if you had to buy the hardware. If the model you would actually use is a small fast cloud model, &lt;strong&gt;do not buy a GPU to save money. You will never catch up.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hardware decision dominates everything else.&lt;/strong&gt; Owning the card already moves the break-even from ~12,400 pairs to ~2,000 — a 6x difference, and it is decided before you send a single request. If the GPU is already in the machine for gaming, for work, or for something else entirely, the inference stack is nearly free and the arithmetic flips in your favour. If you would be buying it for this, this article is not the reason to do it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The honest summary
&lt;/h2&gt;

&lt;p&gt;"Local is cheaper" holds &lt;strong&gt;if and only if&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The card is already paid for&lt;/strong&gt; (or is being bought for some other reason), AND&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You use it at the volume the table demands&lt;/strong&gt; — dozens of exchanges a day against a premium cloud model, hundreds against a cheap one, AND&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The local model is accurate enough for the task.&lt;/strong&gt; A model that is wrong half the time is not cheaper. The cost of a wrong answer does not appear on either side of this equation, and it is usually the largest number in it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The profile where local genuinely wins is narrower than the community likes to admit: &lt;strong&gt;someone who owns a 3090 anyway, runs a local assistant as a daily tool, and is otherwise paying premium-tier API rates.&lt;/strong&gt; That person is over the break-even line by a wide margin and should keep the box running.&lt;/p&gt;

&lt;p&gt;The profile where it loses is the one most often arguing for it: someone considering a first GPU purchase, comparing it against a cheap fast cloud model. That comparison is not close.&lt;/p&gt;




&lt;h2&gt;
  
  
  Run it on your own numbers
&lt;/h2&gt;

&lt;p&gt;Four inputs, one division:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hardware:&lt;/strong&gt; purchase price ÷ 36. Zero if you own it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Electricity:&lt;/strong&gt; meter it for 30 days. Do not estimate from a spec sheet's TDP — my stack's real figure is the difference between a card idling warm and a card doing work, and the idle warm part is most of the month.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud price:&lt;/strong&gt; your actual model, at the input/output token split your actual usage has. Not the model you wish you were using.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Break-even:&lt;/strong&gt; monthly local cost ÷ cost per 1,000 pairs × 1,000.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result is a number of exchanges per month. Compare it against what you really do in a month, and you have your answer — which is the only answer that was ever yours to have.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;If you own the card, you break even against a premium cloud model at roughly 2,000 Q&amp;amp;A pairs a month — about 67 a day. Against a cheap cloud model, 40,000. If you are buying the card, multiply those by six.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The card in my machine cost me €5.42 in electricity last month. Whether that was cheap or expensive depends entirely on which row of that table I would otherwise be paying for — and now I know which row I am in.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. The electricity figures here are pulled from 30 days of per-process power attribution on the box; the cloud figures are published list prices as of early November 2026, and several of them change on 1 December or 1 January.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>llm</category>
      <category>selfhosted</category>
      <category>gpu</category>
    </item>
    <item>
      <title>I Ran Whisper on a 24 GB GPU for 30 Days. It Used 8.</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 02 Nov 2026 08:28:16 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/i-ran-whisper-on-a-24-gb-gpu-for-30-days-it-used-8-195p</link>
      <guid>https://dev.to/sikamikanikobg/i-ran-whisper-on-a-24-gb-gpu-for-30-days-it-used-8-195p</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 13&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The local-AI crowd has a habit: buy the biggest GPU. 24 GB. 32 GB. 48 GB. The reasoning is always the same — "models are big, you need VRAM."&lt;/p&gt;

&lt;p&gt;I stopped believing that reasoning when I looked at my own telemetry. I've been running a Whisper transcription service on an RTX 3090 (24 GB) for a few months now. Over the last 30 days I pulled the full VRAM history: every two hours, what the card was holding, what it spiked to, and whether anything ever spilled into system RAM.&lt;/p&gt;

&lt;p&gt;The service used &lt;strong&gt;7.9 GB on average. 10.4 GB at peak. Never more.&lt;/strong&gt; In 30 days.&lt;/p&gt;

&lt;p&gt;This is the story of a GPU that is 2.3× bigger than the thing running on it — and what that means for what you should actually buy.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Card:&lt;/strong&gt; NVIDIA RTX 3090, 24 GB (24,576 MB), power limit 260 W&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service:&lt;/strong&gt; Whisper ASR webservice (WhisperX, large-v3), resident Docker container, 24/7&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telemetry:&lt;/strong&gt; 30 days of VRAM / power / utilisation at 2-hour resolution, plus per-caller attribution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Other residents:&lt;/strong&gt; a TTS service (~5.3 GB, added partway through the window), an embedding model (negligible), a photo-tagging ML container (negligible)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 3090 was never sized for Whisper. Whisper is the small, quiet service in the corner of the stack — a transcription endpoint that my own tools call when I record a voice memo, a meeting, or a podcast clip. The big chat models live on a second machine. The TTS service is the one other real tenant; it arrived partway through the measurement window, which is why the "one service" story below is about the Whisper service's own working set, not the card's total.&lt;/p&gt;

&lt;p&gt;That's the important part. Most "how much VRAM do I need" answers on the internet are about the model on paper, not the service in production. Paper VRAM is the weights. Production VRAM is weights plus context plus overhead plus whatever the framework decides to allocate. The difference is the whole article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Average VRAM: 7.9 GB. Peak: 10.4 GB. Spills to RAM: zero.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric (30 days)&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Total VRAM&lt;/td&gt;
&lt;td&gt;24,576 MB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Average VRAM&lt;/td&gt;
&lt;td&gt;7,871 MB (~7.9 GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak VRAM&lt;/td&gt;
&lt;td&gt;10,366 MB (~10.4 GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak / Total&lt;/td&gt;
&lt;td&gt;42%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Spills to system RAM&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU utilisation&lt;/td&gt;
&lt;td&gt;~0% (bursts to 1–7%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Power at rest (model resident)&lt;/td&gt;
&lt;td&gt;~40 W&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Energy attributed to the service (30d)&lt;/td&gt;
&lt;td&gt;21.73 kWh / 5.67 BGN&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The shape of the trace is boring, and that's the point. It's a flat line at ~7.9 GB with short, sharp spikes to ~10.4 GB when audio comes in, and a few longer elevated stretches when there's a queue. It never trends upward. It never approaches the ceiling. It never spills.&lt;/p&gt;

&lt;p&gt;The card sits at ~40 W with the model resident — that's the "keep it warm" cost, not the work cost. The work (the spikes) is short enough that the 30-day total attributed to the service is 21.73 kWh, which at my tariff is 5.67 BGN. The full electricity breakdown is in Part 5 of this series.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the VRAM is actually holding
&lt;/h2&gt;

&lt;p&gt;People treat VRAM like a single bucket. It isn't. In this service, the ~10.4 GB peak is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Model weights.&lt;/strong&gt; Whisper large-v3 is 1.55B parameters. In fp16 that's ~3.1 GB. This is the "paper VRAM" number everyone quotes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Audio context and feature buffers.&lt;/strong&gt; The encoder processes audio in chunks; long files and queued files hold more buffer. This is where 3.1 GB becomes 7.9 GB on average.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Framework overhead.&lt;/strong&gt; CUDA context, cuDNN workspaces, allocator fragmentation. Usually 1–2 GB, invisible in any spec sheet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The small helper models.&lt;/strong&gt; WhisperX runs a VAD (voice-activity detection) and an alignment model alongside the main model. Small, but real.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The lesson: &lt;strong&gt;a model's "VRAM requirement" is its weights. A service's VRAM requirement is 2–3× its weights.&lt;/strong&gt; When someone says "large-v3 needs 3 GB," they're telling you the floor, not the working set.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who is actually calling it
&lt;/h2&gt;

&lt;p&gt;The attribution over the last 7 days is a nice detail, because it shows this is a shared service, not a single app:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Seconds (7d)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;whisper-proxy (my own tools)&lt;/td&gt;
&lt;td&gt;3,350&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse MinIO (observability stack)&lt;/td&gt;
&lt;td&gt;1,080&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse worker&lt;/td&gt;
&lt;td&gt;720&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CI runner&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Warmup job&lt;/td&gt;
&lt;td&gt;280&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;My own tools are the main tenant; the LLM-observability stack is a secondary one — it transcribes the audio attached to the traces it ingests. Even with multiple callers hitting the endpoint, the peak stays at 10.4 GB. The second and third callers add latency, not VRAM, because they queue behind the first.&lt;/p&gt;




&lt;h2&gt;
  
  
  The counterargument: headroom is insurance
&lt;/h2&gt;

&lt;p&gt;The fair objection: VRAM headroom is insurance. You want room for longer context, bigger batches, a bigger model next quarter. A card that's exactly sized for today's workload is a card that's undersized next month.&lt;/p&gt;

&lt;p&gt;True. But the measurement says what the insurance was worth over 30 days of real use: &lt;strong&gt;the service used 42% of the card at its absolute peak, 32% on average, and never came close to the ceiling.&lt;/strong&gt; Insurance that is never claimed is still a cost — in this case, the delta between a 16 GB card and a 24 GB card, plus the power and thermals of running a card that's 2.3× the working set.&lt;/p&gt;

&lt;p&gt;The honest version of the headroom argument is: &lt;strong&gt;size for the biggest model you will actually run, plus ~50% headroom.&lt;/strong&gt; Not the biggest model that exists. Not the biggest model the card &lt;em&gt;could&lt;/em&gt; run.&lt;/p&gt;




&lt;h2&gt;
  
  
  The contrast: the other machine
&lt;/h2&gt;

&lt;p&gt;This is where the 3090's real job shows up. The big model — a 27B chat model on vLLM — runs on a second machine with two 3090s. That machine is sized for its workload: a 27B model in fp8 needs well over 24 GB, so two cards, no spills, no compromise.&lt;/p&gt;

&lt;p&gt;The hub 3090 is not sized for Whisper. It's sized for "whatever I might want to run on the hub someday." That's a different purchase decision, and it's the one most of us make without measuring. The measurement says: for the workload that actually lives on this card, a 16 GB card is enough for Whisper alone. 24 GB is only justified if a 20B+ chat model is going to share the card — and if that's the plan, you're sizing for the chat model, not for Whisper, and Whisper should be the tenant that fits around it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The card today: two tenants, still not full
&lt;/h2&gt;

&lt;p&gt;Three weeks into the window a TTS service joined the card. It holds ~5.3 GB. Right now the card's total is ~13.3 GB of 24 GB — 55% at the current moment, still zero spills, still ~2 GB of headroom under memory pressure.&lt;/p&gt;

&lt;p&gt;That's the honest current state: &lt;strong&gt;two AI services (Whisper + TTS) on one 24 GB card, and the card is not full.&lt;/strong&gt; The "you need big VRAM" advice would have me buying more. The measurement says the card has room for another tenant of similar size before it gets close to the ceiling. The 24 GB is doing its job as headroom — but it's headroom for a second or third small service, not because Whisper needs it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would buy now
&lt;/h2&gt;

&lt;p&gt;If I were building this stack today, from scratch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hub card (Whisper + TTS + embeddings): 24 GB is the right call, but only because of the second tenant.&lt;/strong&gt; Whisper alone peaks at 10.4 GB; with the TTS service it's ~13.3 GB. A 16 GB card would be tight but workable for just Whisper; 24 GB is comfortable for two.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chat card(s): sized for the chat model.&lt;/strong&gt; 27B in fp8 → two 24 GB cards, or one 48 GB card if you can find one at a sane price.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule:&lt;/strong&gt; the GPU is sized for the biggest model you will actually run, plus 50% headroom. Everything smaller is a tenant.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 3090 remains a great card — it's the best price-per-GB on the used market, and that's exactly why it's everywhere in home stacks. But "best price-per-GB" is an argument for the chat machine, and for a hub that will host a couple of small services, not for the transcription service alone.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A 24 GB GPU running a Whisper service for 30 days: 7.9 GB average, 10.4 GB peak, zero spills, 42% of the card at peak.&lt;/strong&gt; Add a 5.3 GB TTS service and the card is still only 55% full. The "you need big VRAM" advice is true for the model you're afraid of running, and false for the service you're actually running.&lt;/p&gt;

&lt;p&gt;Right-size the GPU to the workload you measured, not the workload you imagine. The meter and the VRAM counter don't care about your imagination.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. Every number in this article was pulled from 30 days of telemetry on the card, not estimated. The series continues with the cost of keeping all of this alive — the full electricity bill for the whole machine, not just the GPU.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>gpu</category>
      <category>whisper</category>
      <category>asr</category>
    </item>
    <item>
      <title>Patzer just got single sign-on — one login for your whole family</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Tue, 27 Oct 2026 09:14:48 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/patzer-just-got-single-sign-on-one-login-for-your-whole-family-3mm4</link>
      <guid>https://dev.to/sikamikanikobg/patzer-just-got-single-sign-on-one-login-for-your-whole-family-3mm4</guid>
      <description>&lt;p&gt;Last week Patzer got single sign-on. If your family already runs Authentik, Keycloak, Authelia or Pocket ID, everyone in the house now logs into the chess server with the same identity they use everywhere else — no more one extra username and password to remember, and no more "I forgot my password" at the dinner table.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;code&gt;OIDC_ISSUER&lt;/code&gt;, &lt;code&gt;OIDC_CLIENT_ID&lt;/code&gt; and &lt;code&gt;OIDC_CLIENT_SECRET&lt;/code&gt; and the login page gets a "Sign in with …" button (you choose the label with &lt;code&gt;OIDC_BUTTON_TEXT&lt;/code&gt;). Nothing changes until you set them — existing installs are untouched.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OIDC_ONLY=true&lt;/code&gt; removes password login, sign-up and password reset entirely, in the UI and on the server. A fresh install started that way skips the setup wizard, whose password nobody could use anyway: the first SSO login creates the admin.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OIDC_ADMIN_GROUP=patzer-admins&lt;/code&gt; makes members of a provider group the admins, re-checked at every login. While no admin exists, only group members can sign in — so on a fresh install the first admin is the first group member, not whoever is fastest.&lt;/li&gt;
&lt;li&gt;Logging out of Patzer logs you out of the provider too. That matters on a shared family computer.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;OIDC_AUTO_PROVISION=true&lt;/code&gt; creates an account for whoever the provider lets in; &lt;code&gt;OIDC_MATCH_BY=username|email&lt;/code&gt; links a first SSO login to an existing account instead (email only when the provider marks it verified). Linked accounts get the provider's email if they have none, so password resets and notifications still reach them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The flow is the standard authorization-code-with-PKCE, with state and nonce, built on &lt;code&gt;openid-client&lt;/code&gt;. The part I care about most is the failure handling: a failed SSO login comes back to the login page with the reason in the user's language — provider unreachable, cancelled, no account, already linked, too many attempts — and the callbacks are rate-limited per IP, so Patzer can't be used to flood your identity provider.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a chess app needs SSO
&lt;/h2&gt;

&lt;p&gt;Patzer is a family product, and the weakest link in a family app is always the account layer: nine-year-olds reset passwords, siblings share devices, and the last thing you want is a chess server that can't answer "who is this profile?". If your identity provider already knows who your family is, the chess server should just ask it.&lt;/p&gt;

&lt;p&gt;SSO is merged to main and ships in the next release (v7.20). The full guide — every environment variable, the SSO-only setup, and an honest security section on what this protects and where its limits are — is in &lt;code&gt;docs/OIDC.md&lt;/code&gt; in the repo.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/SikamikanikoBG/patzer" rel="noopener noreferrer"&gt;https://github.com/SikamikanikoBG/patzer&lt;/a&gt; — MIT licensed, one Docker container, Stockfish plus your own LLM (Ollama or vLLM).&lt;/p&gt;

</description>
      <category>selfhosted</category>
      <category>ai</category>
      <category>docker</category>
      <category>chess</category>
    </item>
    <item>
      <title>93 Services, 1 Failed, 1 Probe Down for a Week: How a Home Stack Stays Alive</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 26 Oct 2026 08:18:46 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/93-services-1-failed-1-probe-down-for-a-week-how-a-home-stack-stays-alive-3e70</link>
      <guid>https://dev.to/sikamikanikobg/93-services-1-failed-1-probe-down-for-a-week-how-a-home-stack-stays-alive-3e70</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 12&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A home server is a stack of things that can fail. 59 containers. 94 active systemd units. A GPU with models on it. A network of ports, a registry, a CI runner, a media server, a photo library, an LLM observability stack. Any one of them can die, and the question is never "will it fail" — it's "will you know, and will it come back."&lt;/p&gt;

&lt;p&gt;I instrumented the whole stack: every container has a health check, every critical service has an uptime probe, and the monitor watches both. Over the last 7 days the probes ran 10,000 checks. This is what they found — and it is not a clean 100%. One probe target has been refusing connections for the entire window, and the monitor flagged it on day one. That is the point.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machine:&lt;/strong&gt; HP Z840 Workstation, 2× Xeon E5-2680 v4, 64 GB RAM, one RTX 3090&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stack:&lt;/strong&gt; 59 Docker containers, 94 active systemd units (93 running, 1 failed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monitoring:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Container health:&lt;/strong&gt; Docker's own healthchecks on the containers that define them (the rest report state only)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service uptime:&lt;/strong&gt; two HTTP probes against the homelab monitor's own endpoints, every 60 seconds, 7-day window, fail threshold of 2 consecutive failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Event log:&lt;/strong&gt; edge-triggered alerts for OOM kills, threshold crossings, disk I/O spikes&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two things that are not up:&lt;/strong&gt; &lt;code&gt;nginx.service&lt;/code&gt; (the old reverse proxy, a leftover) and the 9802 probe target (a port that no longer listens). Both are flagged, both are explained, neither is hidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The two non-up states are the honest details. They're not mistakes I'm hiding — they're the things that are down, and they're down for known reasons: a retired reverse-proxy unit, a retired probe port. The monitor sees them, flags them, and I left them, because the roles they used to fill no longer exist behind them. The article is about the &lt;em&gt;detection&lt;/em&gt;, not the failures — the failures are the proof that the detection works.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Over 7 days, 10,000 uptime checks ran against two endpoints. One returned 100% — 5,000 checks, in the low-teens of milliseconds, zero failures. The other returned 0% — connection refused on every check — and the monitor has flagged it as a critical since day one.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Target&lt;/th&gt;
&lt;th&gt;Interval&lt;/th&gt;
&lt;th&gt;Checks (7d)&lt;/th&gt;
&lt;th&gt;Uptime&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Failures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;HomeLab 9800&lt;/td&gt;
&lt;td&gt;&lt;a href="http://ardi:9800" rel="noopener noreferrer"&gt;http://ardi:9800&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;~14 ms&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HomeLab 9802&lt;/td&gt;
&lt;td&gt;&lt;a href="http://ardi:9802" rel="noopener noreferrer"&gt;http://ardi:9802&lt;/a&gt;&lt;/td&gt;
&lt;td&gt;60 s&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;refused&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The 0% is the number that matters. Not "the stack is up" — that's a state. "One endpoint up, one endpoint refusing connections for a week, both named" is a &lt;em&gt;measurement&lt;/em&gt;. The 9800 endpoint answered 200 in about 14 ms on every check. The 9802 endpoint refused the connection on all 5,000. The fail threshold is 2 consecutive failures, so the 9802 alarm fired at the 2-minute mark on day one and has not cleared — because the port genuinely stopped listening.&lt;/p&gt;

&lt;p&gt;The event log over the same week adds the texture: &lt;strong&gt;zero OOM kills, eight disk I/O spikes&lt;/strong&gt; (the photo server writing thumbnails, the CI runner pulling images), and the single critical — 9802 down. No memory events, no GPU events, no service crashes. The only other "crit" on the box is the known, explained nginx unit.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the monitoring actually does
&lt;/h2&gt;

&lt;p&gt;The stack has three layers of liveness, and they answer three different questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Is the process alive?&lt;/strong&gt; Docker's healthchecks and systemd's unit state. This catches a crash — a process that died and didn't restart. It's the coarsest layer, and it's the one that's always on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the service answering?&lt;/strong&gt; The uptime probes. A process can be alive and not answering — hung, deadlocked, stuck in a retry loop. The probe is a 60-second HTTP GET that fails after 10 seconds. It catches the "alive but not working" state that the process check misses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the service working correctly?&lt;/strong&gt; The event log. A service can answer 200 and still be wrong — returning an error body, a 500 behind a proxy, a timeout that's just under the threshold. The edge-triggered events (OOM, threshold crossings, I/O spikes) catch the "answering but degrading" state.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The three layers are not redundant. Each one catches a failure the others miss. The process check catches the crash. The probe catches the hang. The event log catches the degradation. A 94-unit stack needs all three, because a 94-unit stack has 94 different ways to be subtly wrong.&lt;/p&gt;




&lt;h2&gt;
  
  
  The counterargument: 100% uptime is boring — so here is 100% and 0%
&lt;/h2&gt;

&lt;p&gt;The fair objection: an article about a stack that didn't fail is an article with no story. 100% uptime is the baseline, not the achievement.&lt;/p&gt;

&lt;p&gt;The point is the opposite. &lt;strong&gt;The achievement is that the stack didn't fail &lt;em&gt;and&lt;/em&gt; that I would have known if it did.&lt;/strong&gt; A 100% uptime number is only meaningful if the monitoring is good enough to have caught a failure. The 10,000 checks, the 2-consecutive-failure threshold, the three-layer liveness — that's the system that makes the 100% a fact instead of a hope. If 9800 had dropped for 30 minutes last Tuesday, the probe would have flagged it at the 2-minute mark, the event log would have recorded it, and I would have had a named, timestamped, attributed failure to look at — not a "why is the photo app slow" mystery.&lt;/p&gt;

&lt;p&gt;And this week the system didn't have to be hypothetical. The 9802 endpoint stopped answering — not for a few minutes, for the whole window — and the monitor carried it as a critical from day one. Nobody "discovered" it. It's in the dashboard, marked, explained, and left, because the role behind it no longer exists. Same with the nginx unit: down for weeks, flagged the entire time, explained, and left. That's the system working: the failures are known, named, and triaged, not hidden.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do now
&lt;/h2&gt;

&lt;p&gt;If I were building a homelab today, the monitoring would have exactly this shape:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Every container:&lt;/strong&gt; a healthcheck if the image defines one, a state check if it doesn't. The cost is nothing; the value is the "is the process alive" layer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every critical service:&lt;/strong&gt; a 60-second uptime probe with a 10-second timeout and a 2-consecutive-failure threshold. The threshold is the key — 1 failure is a blip, 2 is a problem. One failure a day, over 94 units, would be 94 false pages; the threshold kills that.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The event log:&lt;/strong&gt; edge-triggered alerts for the things that matter — OOM kills, threshold crossings, disk I/O spikes. Not a log of every metric, but a log of every &lt;em&gt;event&lt;/em&gt;. The difference is the difference between a dashboard you scroll and a feed you read.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The one rule:&lt;/strong&gt; a failure that is known, named, and explained is not a failure. It's a state. The nginx unit is down, and the 9802 probe is down, and that's fine, because both are flagged, both are explained, and neither is hiding. The system's job is not to prevent every failure — it's to make every failure &lt;em&gt;known&lt;/em&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 94-unit stack stays alive not because nothing fails, but because when something fails, the failure has a name, a timestamp, and a place in the dashboard. The 100% on 9800 is the output of that system, not the input — and the 0% on 9802 is the proof that the output is real.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;94 active systemd units, 59 containers, 10,000 uptime checks over 7 days: one endpoint at 100% (~14 ms mean), one endpoint at 0% (refused, flagged from day one), zero OOM kills, eight disk I/O spikes, two known-and-explained non-up states.&lt;/strong&gt; The stack stays alive because the monitoring catches the three layers of failure — the crash, the hang, the degradation — and names each one. The 100% is not luck, and the 0% is not a miss. It's 10,000 checks that would have told me, in two minutes, if anything had broken.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. Every number in this article was pulled from 7 days of uptime and event telemetry on the box, not estimated. The series continues with the cost of keeping all of this alive — the full electricity bill for the whole machine, not just the GPU.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>devops</category>
      <category>monitoring</category>
      <category>docker</category>
    </item>
    <item>
      <title>Patzer v7.19: the tactic trainer that shows your move and explains why the better one wins</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 08:06:43 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/patzer-v719-the-tactic-trainer-that-shows-your-move-and-explains-why-the-better-one-wins-50jn</link>
      <guid>https://dev.to/sikamikanikobg/patzer-v719-the-tactic-trainer-that-shows-your-move-and-explains-why-the-better-one-wins-50jn</guid>
      <description>&lt;p&gt;Last week I wrote about Patzer — a self-hosted Chess.com alternative that runs in one Docker container (&lt;a href="https://dev.to/sikamikanikobg/i-built-my-family-a-private-chesscom-one-docker-container-stockfish-plus-your-own-llm-184n"&gt;the original post&lt;/a&gt;). Since then, the feature I've been most proud of shipped: a tactic trainer that explains your own mistakes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gap in puzzle trainers
&lt;/h2&gt;

&lt;p&gt;The classic puzzle trainer — Lichess puzzles, Chess.com tactics — gives you a position and asks for the best move. Miss it, and you see the solution. But you never see &lt;em&gt;why&lt;/em&gt; yours was wrong: why the engine's move wins and your move loses. You memorize that Qh5 is the move, not that Qd3 leaves your queen hanging to a fork.&lt;/p&gt;

&lt;p&gt;v7.19.0 closes that gap.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's new
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Your move, on the board.&lt;/strong&gt; In Train → From your games, Patzer builds puzzles from your actual Chess.com or Lichess games. The move you played in that game is now drawn as a red arrow on the puzzle position, so you no longer have to work out from "you played Qd3" which move that was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The coach explains the difference.&lt;/strong&gt; Once the puzzle is over — solved, or the solution shown, never before — your AI coach explains in plain words why the engine's move beats the one you played. It's the same engine-checked explanation as in Game Review, in your language, pitched at your audience setting.&lt;/p&gt;

&lt;p&gt;The design decision from the original post is what makes this cheap to run: the LLM never analyses. The server works out the facts — what each move allows, hangs or wins, the opponent's best answer — and the model only turns that into coaching. A small local model (Ollama or vLLM) is enough, and a hallucinated piece is impossible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Puzzles come back.&lt;/strong&gt; A puzzle you don't solve at the first try comes back the next day, marked &lt;em&gt;Review&lt;/em&gt; — the same simple daily queue as the opening trainer. Solved at the first try on a later day, it's done; missed again, it's back tomorrow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Puzzle of the day, with a streak.&lt;/strong&gt; One puzzle from the Lichess set near your puzzle rating, the same one all day, with a plain counter of the days in a row you did it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Play from here.&lt;/strong&gt; In Game Review, every move now has a "Play from here against Patzer" button. You play your own colour, pick the bot's strength and the clock, and the game is saved like any bot game — your own games become training positions.&lt;/p&gt;

&lt;p&gt;Full list in the release notes, including a fix for the saved AI model dropdown when the model isn't on the server:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/SikamikanikoBG/patzer/releases/tag/v7.19.0" rel="noopener noreferrer"&gt;v7.19.0 release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/SikamikanikoBG/patzer" rel="noopener noreferrer"&gt;github.com/SikamikanikoBG/patzer&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Still one container:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker run -d -p 8800:8800 -v patzer-data:/app/data --name patzer ghcr.io/SikamikanikoBG/patzer:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;MIT licensed, English-first with Bulgarian, Spanish, German and Russian out of the box. If you're running Patzer, pull the latest tag and try the trainer on your own games — that's the part I'd want you to try first.&lt;/p&gt;

</description>
      <category>chess</category>
      <category>ai</category>
      <category>docker</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>54 Containers, 93 Services, 64 GB of RAM: What Actually Eats It</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 19 Oct 2026 07:11:05 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/54-containers-93-services-64-gb-of-ram-what-actually-eats-it-4fjk</link>
      <guid>https://dev.to/sikamikanikobg/54-containers-93-services-64-gb-of-ram-what-actually-eats-it-4fjk</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 11&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A home server has a question the cloud never asks: &lt;strong&gt;who is eating the RAM?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the cloud, memory is a line item on a bill. At home, it's a finite pool — 64 GB on my box — shared by every container, every service, every daemon, and the one GPU that runs the AI. When the pool runs dry, something gets killed, and the thing that gets killed is usually the thing you least expected.&lt;/p&gt;

&lt;p&gt;I ran a memory audit on my homelab: 54 Docker containers, 93 running systemd units, one RTX 3090, 64 GB of RAM. I pulled the per-service memory history over 7 days, the current per-process state, and the kernel's non-reclaimable share. This is what the 64 GB is actually holding, and what the number says about how much RAM a homelab really needs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Machine:&lt;/strong&gt; HP Z840 Workstation, 2× Intel Xeon E5-2680 v4 (28 cores, 56 threads), 64 GB RAM, one RTX 3090&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Workload:&lt;/strong&gt; 54 Docker containers, 93 running systemd units, all up&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telemetry:&lt;/strong&gt; 7 days of per-service memory at 2-hour resolution, plus the current per-process state and the kernel's non-reclaimable memory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GPU tenants (for context):&lt;/strong&gt; Whisper ASR, a TTS model, an embedding model — see the VRAM piece for what the card holds&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 64 GB is the honest number. It's not a "big" machine by data-centre standards, but it's a real workstation, and it runs a real stack: photo storage, music, a RAG pipeline, an LLM observability stack, a CI runner, a media server, a few self-hosted apps, and the AI services on the GPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The box is using 41.6 GB of its 64 GB — 65% — and the peak over the week was 15.9 GB of &lt;em&gt;application&lt;/em&gt; memory, with the rest held by the kernel and the GPU's system-side buffers.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;RAM (current)&lt;/th&gt;
&lt;th&gt;Share of 64 GB&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kernel (non-reclaimable)&lt;/td&gt;
&lt;td&gt;1,754 MB (~1.7 GB)&lt;/td&gt;
&lt;td&gt;2.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU tenants (system-side buffers + CUDA context)&lt;/td&gt;
&lt;td&gt;~2.0 GB&lt;/td&gt;
&lt;td&gt;3.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Top application (Whisper ASR webservice)&lt;/td&gt;
&lt;td&gt;7,870 MB (~7.7 GB)&lt;/td&gt;
&lt;td&gt;12.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS voice model (omnivoice)&lt;/td&gt;
&lt;td&gt;5,100 MB (~5.0 GB)&lt;/td&gt;
&lt;td&gt;7.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM observability stack (web + worker + ClickHouse)&lt;/td&gt;
&lt;td&gt;~2.3 GB&lt;/td&gt;
&lt;td&gt;3.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Photo storage server (Immich: server + postgres + ML)&lt;/td&gt;
&lt;td&gt;~1.0 GB&lt;/td&gt;
&lt;td&gt;1.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG pipeline (Qdrant + Ollama + Chroma)&lt;/td&gt;
&lt;td&gt;~0.7 GB&lt;/td&gt;
&lt;td&gt;1.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The other 49 containers + 93 services&lt;/td&gt;
&lt;td&gt;~22.7 GB&lt;/td&gt;
&lt;td&gt;35.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total in use&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;41,622 MB (~40.7 GB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;65%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The "other 49 containers + 93 services" line is the one that surprises people. It's 35% of the RAM, and it's not one thing — it's a long tail. The biggest individual entries in that tail:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;RAM (current)&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;ClickHouse (Langfuse storage)&lt;/td&gt;
&lt;td&gt;1,082 MB&lt;/td&gt;
&lt;td&gt;The tracing database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open WebUI (chat UI)&lt;/td&gt;
&lt;td&gt;777 MB&lt;/td&gt;
&lt;td&gt;The chat interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse web (LLM observability)&lt;/td&gt;
&lt;td&gt;721 MB&lt;/td&gt;
&lt;td&gt;The tracing UI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immich server (photo storage)&lt;/td&gt;
&lt;td&gt;562 MB&lt;/td&gt;
&lt;td&gt;The photo library backend&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immich postgres (photo metadata)&lt;/td&gt;
&lt;td&gt;536 MB&lt;/td&gt;
&lt;td&gt;The photo database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qdrant (vector DB)&lt;/td&gt;
&lt;td&gt;483 MB&lt;/td&gt;
&lt;td&gt;The RAG index&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse worker (trace processing)&lt;/td&gt;
&lt;td&gt;346 MB&lt;/td&gt;
&lt;td&gt;The trace worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Botpress (chatbot)&lt;/td&gt;
&lt;td&gt;292 MB&lt;/td&gt;
&lt;td&gt;The chatbot framework&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;n8n (workflow automation)&lt;/td&gt;
&lt;td&gt;279 MB&lt;/td&gt;
&lt;td&gt;The automation engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Flowise (LLM builder)&lt;/td&gt;
&lt;td&gt;255 MB&lt;/td&gt;
&lt;td&gt;The LLM workflow builder&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;homelab-monitor (this monitor)&lt;/td&gt;
&lt;td&gt;233 MB&lt;/td&gt;
&lt;td&gt;The dashboard itself&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jarvis2-core (my assistant)&lt;/td&gt;
&lt;td&gt;229 MB&lt;/td&gt;
&lt;td&gt;The assistant core&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jarvis-server&lt;/td&gt;
&lt;td&gt;213 MB&lt;/td&gt;
&lt;td&gt;The server host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jarvis2-voiceline (voice pipeline)&lt;/td&gt;
&lt;td&gt;199 MB&lt;/td&gt;
&lt;td&gt;The voice line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Immich machine-learning (face/photo ML)&lt;/td&gt;
&lt;td&gt;177 MB&lt;/td&gt;
&lt;td&gt;The photo ML worker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse minio (trace object storage)&lt;/td&gt;
&lt;td&gt;164 MB&lt;/td&gt;
&lt;td&gt;The trace storage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;searxng (meta-search)&lt;/td&gt;
&lt;td&gt;111 MB&lt;/td&gt;
&lt;td&gt;The search engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UptimeKuma (health checks)&lt;/td&gt;
&lt;td&gt;103 MB&lt;/td&gt;
&lt;td&gt;The uptime monitor&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MySQL (8.0, two instances)&lt;/td&gt;
&lt;td&gt;34 MB + 164 MB&lt;/td&gt;
&lt;td&gt;The databases&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub runner (CI)&lt;/td&gt;
&lt;td&gt;58 MB&lt;/td&gt;
&lt;td&gt;The CI agent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Postgres (three instances)&lt;/td&gt;
&lt;td&gt;15 MB + 20 MB + 536 MB&lt;/td&gt;
&lt;td&gt;The databases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The pattern: &lt;strong&gt;no single service is the hog. The RAM is eaten by a long tail of small, resident services, each holding 10–1,000 MB, and the sum is 22.7 GB.&lt;/strong&gt; The GPU tenants hold the most per service (7.7 GB and 5.0 GB), but they're two services. The long tail is forty-nine, and it's still a third of the box.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the peak says
&lt;/h2&gt;

&lt;p&gt;The 7-day peak of 15.9 GB of &lt;em&gt;application&lt;/em&gt; memory is the number that matters for sizing. It happened when the photo server was running a batch of thumbnail generation at the same time the CI runner was building a project and the observability stack was ingesting a burst of traces. Three things spiking at once, on a 64 GB box, and the peak was 15.9 GB of application memory — 25% of the pool.&lt;/p&gt;

&lt;p&gt;The headroom at peak was 48 GB. That's the insurance. The box has never come close to the ceiling, and the events log over the week shows &lt;strong&gt;zero OOM kills, zero memory threshold crossings&lt;/strong&gt; — the only alerts were eight disk I/O spikes, which are storage, not memory.&lt;/p&gt;

&lt;p&gt;The honest reading: &lt;strong&gt;64 GB is comfortable for this stack. 32 GB would be tight but workable. 16 GB would not.&lt;/strong&gt; The peak of 15.9 GB is the worst case that actually happened, and it left 48 GB on the table. If I were buying RAM for this box today, I'd buy 64 GB and stop — not because it's the minimum, but because it's the size where the peak is a non-event.&lt;/p&gt;




&lt;h2&gt;
  
  
  The counterargument: RAM is cheap, don't sweat it
&lt;/h2&gt;

&lt;p&gt;The fair objection: RAM is the cheapest part of a server. 64 GB is a few hundred euros. Why spend an article on it?&lt;/p&gt;

&lt;p&gt;Because the RAM question is where homelab sizing goes wrong. People size the GPU (the expensive, scarce part) and under-size the RAM (the cheap, abundant part), and then the box OOMs the one service that matters — usually the database or the photo server — at the worst moment. The GPU is the part everyone benchmarks. The RAM is the part nobody measures, and it's the part that fails.&lt;/p&gt;

&lt;p&gt;The measurement says the failure mode is not "one big service." It's &lt;strong&gt;the long tail.&lt;/strong&gt; Fifty small services, each holding 10–100 MB, add up to a third of the box. The fix is not "buy more RAM" (though that helps). The fix is knowing the tail is real, so when the box gets tight you know which of the fifty to trim, not which of the two to kill.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do now
&lt;/h2&gt;

&lt;p&gt;If I were building this box today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;RAM: 64 GB.&lt;/strong&gt; The measured peak is 15.9 GB of application memory; 64 GB is 4× peak. 32 GB would be 2× peak — workable, but the photo server and the CI runner would start fighting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule:&lt;/strong&gt; size RAM for the &lt;em&gt;peak of the long tail&lt;/em&gt;, not for the biggest single service. The biggest service is 7.7 GB. The tail is 22.7 GB. The tail is the size.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audit:&lt;/strong&gt; a per-service memory panel, current and 7-day peak, with the kernel share separated out. The number that matters is not "the box is at 65%" — it's "the peak was 15.9 GB, and here are the ten services that were up at the peak."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The homelab monitor I use has exactly that panel. The 7-day peak per service, the current per-process state, and the kernel's non-reclaimable share, in one view. It's the difference between "the box is full" and "here's what's full, and here's what to trim."&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;54 containers, 93 services, 64 GB of RAM: 41.6 GB in use (65%), 15.9 GB peak over the week, zero OOM kills.&lt;/strong&gt; The RAM is not eaten by one big service — it's eaten by a long tail of fifty small, resident services that add up to 22.7 GB. The GPU tenants hold the most per service, but the tail holds a third of the box by itself. Size the RAM for the peak of the tail, not for the biggest single service, and the box stops being a guessing game.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. Every number in this article was pulled from 7 days of per-service memory telemetry on the box, not estimated. Next: the one failed service on a 93-service stack — and how the uptime checks keep the rest alive.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>homelab</category>
      <category>docker</category>
      <category>linux</category>
      <category>sysadmin</category>
    </item>
    <item>
      <title>Your homelab, legible to AI agents — a read-only MCP server that rides along with your monitor</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Sun, 18 Oct 2026 08:43:30 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/your-homelab-legible-to-ai-agents-a-read-only-mcp-server-that-rides-along-with-your-monitor-40io</link>
      <guid>https://dev.to/sikamikanikobg/your-homelab-legible-to-ai-agents-a-read-only-mcp-server-that-rides-along-with-your-monitor-40io</guid>
      <description>&lt;p&gt;i keep a monitoring container on my homelab — it reads nvidia-smi, the docker api, systemd and /proc, and turns it into one page. the boring kind of dashboard: what's using the gpu, what it costs, what's filling the disk.&lt;/p&gt;

&lt;p&gt;last month i started running agents on the box, and i hit a wall that i think most people will hit: the agent couldn't see my homelab. it could run commands, but it couldn't &lt;em&gt;see&lt;/em&gt; — which model is holding the 3090, why the temperature is climbing, what the month cost in energy. i'd have to paste screen output into the chat by hand, and the agent was guessing about the rest.&lt;/p&gt;

&lt;p&gt;the data was already there. the monitor was reading all of it, every few seconds, into a sqlite database. what was missing was just a standard way to hand it over.&lt;/p&gt;

&lt;p&gt;so i added a read-only MCP server to the same container. it comes up on port 9810, and the whole setup is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude mcp add &lt;span class="nt"&gt;--transport&lt;/span&gt; http homelab http://YOUR-HUB:9810/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nineteen named tools, all read-only: hosts, containers, services, per-card gpu state and which process is on which card, per-process ram, the model servers, installed models, energy cost, benchmark runs, disk treemaps, history, alerts. that's the same coverage the dashboard has — the server is just the same database with an MCP face on it.&lt;/p&gt;

&lt;p&gt;what this actually gets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"which model is eating vram right now, and on which card?" — it reads the per-card split, not one pooled number. on my 3x3090 box a 63gb model shows as 22.5 / 22.1 / 18.8.&lt;/li&gt;
&lt;li&gt;"what did the last week of inference cost me?" — it has the power integration, so it's a measured number, not a guess.&lt;/li&gt;
&lt;li&gt;"is anything down?" — the uptime checks and alerts are tools too.&lt;/li&gt;
&lt;li&gt;"what's filling the disk on the pi?" — the treemap, on demand, over the same ssh connection everything else uses.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;a few things i'm deliberately not doing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;no writes. both directions are question and answer. the agent can look around the lab, it can't restart anything. (the dashboard itself has controls, but the mcp surface is read-only on purpose — an agent that can see your lab is already trusted, and i didn't want to make it &lt;em&gt;responsible&lt;/em&gt;.)&lt;/li&gt;
&lt;li&gt;no extra install on the remote boxes. the monitor already polls them over ssh with nothing persisted, and the mcp server just reads what the monitor has.&lt;/li&gt;
&lt;li&gt;no cloud. the server is on your lan, same as the dashboard. point a local ollama agent at it and nothing leaves the house.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;the monitor is open source, mit, one container: &lt;a href="https://github.com/SikamikanikoBG/homelab-monitor" rel="noopener noreferrer"&gt;github.com/SikamikanikoBG/homelab-monitor&lt;/a&gt; — and the mcp part is on by default (enable_mcp=0 turns it off). if you run a homelab and you're pointing agents at it, i'd be curious what you'd ask it first.&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>selfhosted</category>
      <category>homelab</category>
    </item>
    <item>
      <title>My GPU Was Busy. I Finally Found Out Who Was Using It.</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 07:10:05 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/my-gpu-was-busy-i-finally-found-out-who-was-using-it-1pkg</link>
      <guid>https://dev.to/sikamikanikobg/my-gpu-was-busy-i-finally-found-out-who-was-using-it-1pkg</guid>
      <description>&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 10&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every homelab has a moment like this: the GPU is at 100%, the fan is screaming, and you have no idea which of the twenty services on the box is doing it. You open &lt;code&gt;nvidia-smi&lt;/code&gt;, it shows one process, you close it, and the mystery continues.&lt;/p&gt;

&lt;p&gt;The problem is that &lt;code&gt;nvidia-smi&lt;/code&gt; answers "which &lt;em&gt;process&lt;/em&gt; is using the GPU" — and in a Docker stack, the process is the container runtime, not the service. The answer you actually want is "which &lt;em&gt;service&lt;/em&gt; is using the GPU, and how much time did it take, and who &lt;em&gt;called&lt;/em&gt; it."&lt;/p&gt;

&lt;p&gt;I built the answer into my homelab monitor: caller→server attribution. Every sample of GPU utilisation is tagged with the service that owns the process, and for shared services (a transcription endpoint, an embedding endpoint) it's tagged with the &lt;em&gt;caller&lt;/em&gt; — the thing that made the request. Over the last 7 days the attribution logged 1,055 samples. This is what it says about who eats a home GPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Card:&lt;/strong&gt; NVIDIA RTX 3090, 24 GB, power limit 390 W&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared services on the card:&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whisper ASR webservice&lt;/strong&gt; — a transcription endpoint with an nginx proxy in front (my tools, the observability stack, CI, and a warmup job all hit the proxy)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nomic-embed-text (Ollama)&lt;/strong&gt; — an embedding endpoint used by the RAG pipeline&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribution:&lt;/strong&gt; every GPU sample over 7 days is tagged with (a) the service owning the process and (b) the caller for shared endpoints. Total: 1,055 samples, ~1.1 million seconds of GPU time attributed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key design decision: &lt;strong&gt;the caller is recorded from the request, not from the process.&lt;/strong&gt; When my assistant core sends an audio file to the whisper proxy, the sample is tagged &lt;code&gt;jarvis2-core → whisper-proxy&lt;/code&gt;, not &lt;code&gt;nginx → whisper&lt;/code&gt;. That's the difference between "the GPU is busy" and "my assistant is the one using it."&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Over 7 days, 47% of the GPU's logged time went to one caller: my own assistant core (4,970 seconds of 10,555 total attributed seconds).&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Seconds (7d)&lt;/th&gt;
&lt;th&gt;Server&lt;/th&gt;
&lt;th&gt;% of total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;jarvis2-core (my assistant)&lt;/td&gt;
&lt;td&gt;4,970&lt;/td&gt;
&lt;td&gt;whisper-proxy&lt;/td&gt;
&lt;td&gt;47.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whisper-proxy (shared endpoint, direct)&lt;/td&gt;
&lt;td&gt;920&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;8.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;langfuse worker (observability)&lt;/td&gt;
&lt;td&gt;910&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;8.6%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;langfuse minio (object storage)&lt;/td&gt;
&lt;td&gt;860&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;8.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;whisper-warmup (keep-alive job)&lt;/td&gt;
&lt;td&gt;360&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;3.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;gh-runner (CI)&lt;/td&gt;
&lt;td&gt;360&lt;/td&gt;
&lt;td&gt;whisper-proxy&lt;/td&gt;
&lt;td&gt;3.4%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;jarvis-server + watchtower&lt;/td&gt;
&lt;td&gt;410&lt;/td&gt;
&lt;td&gt;whisper-proxy / service&lt;/td&gt;
&lt;td&gt;3.9%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;firefly importer&lt;/td&gt;
&lt;td&gt;80&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;0.8%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ollama-embed callers (CI, assistant, watchtower)&lt;/td&gt;
&lt;td&gt;210&lt;/td&gt;
&lt;td&gt;ollama-embed&lt;/td&gt;
&lt;td&gt;2.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UptimeKuma (health checks)&lt;/td&gt;
&lt;td&gt;20&lt;/td&gt;
&lt;td&gt;whisper service&lt;/td&gt;
&lt;td&gt;0.2%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The single biggest consumer of the GPU is not a model, not a benchmark, not a training run. It's &lt;strong&gt;my assistant transcribing the audio it records.&lt;/strong&gt; Voice memos, meeting recordings, the audio attached to the traces the observability stack ingests — all of it flows through the Whisper endpoint, and all of it is attributed to the caller that sent it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What the attribution changes
&lt;/h2&gt;

&lt;p&gt;Without caller attribution, the 7-day story of this GPU is: "the Whisper service used 10,555 seconds of GPU time." That's a number. It doesn't tell you anything you can act on.&lt;/p&gt;

&lt;p&gt;With caller attribution, the story is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;My assistant is the tenant.&lt;/strong&gt; 47% of the GPU's time is my own tooling. The service exists &lt;em&gt;for&lt;/em&gt; it, and it uses it more than everything else combined. That's the design working as intended — and it's also the reason the service is resident 24/7 instead of on-demand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The observability stack is the second tenant.&lt;/strong&gt; Langfuse's worker and MinIO together take 17% of the GPU's time. They transcribe the audio attached to the LLM traces they store. That's a cost nobody budgets for: the tracing stack is &lt;em&gt;using&lt;/em&gt; the GPU to do its job, on top of the LLM it's tracing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CI is a real, small tenant.&lt;/strong&gt; The GitHub runner takes 3.4% — test fixtures that get transcribed during CI runs. It's small, but it's &lt;em&gt;unexpected&lt;/em&gt; small. Nobody thinks about CI touching the GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The warmup job is 3.4%.&lt;/strong&gt; A keep-alive job that pings the endpoint every few minutes so the model stays warm. That's the "keep it warm" cost made visible — 3.4% of the GPU's week, doing nothing but staying ready.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The verdict from the numbers: &lt;strong&gt;the GPU is not "busy" in the way the alarm implies. It is busy &lt;em&gt;for specific, named reasons&lt;/em&gt;, and the reasons add up to a stack that is working, not failing.&lt;/strong&gt; The alarm that says "GPU at 100%" is a symptom. The attribution is the diagnosis.&lt;/p&gt;




&lt;h2&gt;
  
  
  The counterargument: attribution is overhead
&lt;/h2&gt;

&lt;p&gt;The fair objection: tagging every GPU sample with a caller is extra bookkeeping, and in a hot loop it's overhead. The attribution has to be cheap enough that it doesn't slow the inference it's measuring.&lt;/p&gt;

&lt;p&gt;The implementation I use is cheap by design: the tag is set at the &lt;em&gt;request&lt;/em&gt; boundary, not at the sample boundary. When a request comes in, the caller is recorded once and attached to every sample the request produces. The per-sample cost is a field read, not a lookup. Over 1,055 samples the overhead is below the resolution of the power meter — you cannot measure it against a 2-hour bucket.&lt;/p&gt;

&lt;p&gt;The other objection: attribution only works for services that go through a request boundary. A model that's loaded and used in-process (a training run, a direct script) has no caller to tag. For those, the service-level tag is the best you can do, and that's still better than nothing. The attribution is a spectrum: caller-level for request-driven services, service-level for the rest.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would do now
&lt;/h2&gt;

&lt;p&gt;If I were building a homelab monitor today, the GPU panel would have three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The card level:&lt;/strong&gt; utilisation, VRAM, power, temperature. The thing &lt;code&gt;nvidia-smi&lt;/code&gt; shows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The service level:&lt;/strong&gt; which container owns the process, and how much VRAM and time it holds. The thing Docker's stats show, but per-GPU.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The caller level:&lt;/strong&gt; for shared endpoints, which client made the request that produced each sample. The thing nothing shows, and the thing that answers "who is using my GPU."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third layer is the one that turns a dashboard into a tool. When the GPU spikes, you don't open &lt;code&gt;nvidia-smi&lt;/code&gt; and guess. You look at the attribution and see the name. When a service misbehaves (a runaway job, a retry storm), the caller list shows it in the same view. The number that matters is not "the GPU is at 100%" — it's "jarvis2-core took 4,970 seconds this week, and here's the request that did it."&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;One week of caller-level GPU attribution: 47% of the card's time went to my own assistant, 17% to the observability stack, 3.4% to CI, 3.4% to a warmup job that does nothing but stay ready.&lt;/strong&gt; The GPU was never "mysteriously busy." It was busy for named reasons, and the attribution is the only view that shows them. If your homelab GPU spikes and you don't know why, the fix is not a faster card — it's a caller tag on every sample.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. Every number in this article was pulled from 7 days of caller-level attribution on the card, not estimated. Next: the 55 containers and 92 services that share this box with the GPU — what actually eats RAM when the machine has 64 GB of it.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>homelab</category>
      <category>ai</category>
      <category>gpu</category>
    </item>
    <item>
      <title>I built my family a private Chess.com — one Docker container, Stockfish plus your own LLM</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:55:29 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/i-built-my-family-a-private-chesscom-one-docker-container-stockfish-plus-your-own-llm-184n</link>
      <guid>https://dev.to/sikamikanikobg/i-built-my-family-a-private-chesscom-one-docker-container-stockfish-plus-your-own-llm-184n</guid>
      <description>&lt;p&gt;My kids play chess on Chess.com. It is a great product, but the account, the cloud and the ads are not something I want to hand to a nine-year-old. So I built Patzer: a self-hosted, open-source alternative that runs on a Pi, a NAS or an old laptop.&lt;/p&gt;

&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Game Review&lt;/strong&gt; — pull your public Chess.com or Lichess games, or paste a PGN. Bundled Stockfish classifies every move (Brilliant / Great / Inaccuracy / Mistake / Blunder), gives accuracy, estimated Elo and an eval graph, plus a "what's the threat?" probe that answers in plain words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play vs Bot&lt;/strong&gt; — Stockfish at seven named tiers from Kid to Stockfish max, all standard time controls, with kid-mode blunder warnings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Play vs Friend&lt;/strong&gt; — real-time PvP between profiles on the same server over WebSocket: draw offers, takebacks, one-click rematch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI Coach&lt;/strong&gt; — point it at your own Ollama or vLLM host. The coach explains &lt;em&gt;why&lt;/em&gt; a move works or fails, shows the better move, and connects the mistake to your own recent games ("4 of your last 10 games: leaving a piece unprotected"). It never contradicts the engine: an answer that praises a blunder is caught before you see it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Family-ready&lt;/strong&gt; — multi-user with an admin console, per-profile language, kid mode, "horsey" piece names for the youngest profiles.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The design choice I keep coming back to: the LLM never analyses. The server works out the facts — what a move allows, hangs or wins, the opponent's best answer, your recurring mistakes — and the model only turns that into coaching. So a small local model is enough, and a hallucinated piece is impossible.&lt;/p&gt;

&lt;p&gt;One container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; 8800:8800 &lt;span class="nt"&gt;-v&lt;/span&gt; patzer-data:/app/data &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--name&lt;/span&gt; patzer ghcr.io/SikamikanikoBG/patzer:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything runs on your LAN. Games, profiles and settings never leave home. MIT licensed, English-first with Bulgarian, Spanish, German and Russian out of the box.&lt;/p&gt;

&lt;p&gt;Repo: &lt;a href="https://github.com/SikamikanikoBG/patzer" rel="noopener noreferrer"&gt;https://github.com/SikamikanikoBG/patzer&lt;/a&gt;&lt;/p&gt;

</description>
      <category>chess</category>
      <category>ai</category>
      <category>docker</category>
      <category>selfhosted</category>
    </item>
    <item>
      <title>I Put Three AI Services on One 24 GB GPU. It Used 17.8. Here's What Fits.</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Mon, 12 Oct 2026 03:49:09 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/i-put-three-ai-services-on-one-24-gb-gpu-it-used-178-heres-what-fits-6ne</link>
      <guid>https://dev.to/sikamikanikobg/i-put-three-ai-services-on-one-24-gb-gpu-it-used-178-heres-what-fits-6ne</guid>
      <description>&lt;h1&gt;
  
  
  I Put Three AI Services on One 24 GB GPU. It Used 17.8. Here's What Fits.
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;The Local LLM Lab — Part 9&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;One controlled experiment. One number. One verdict.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The "how much VRAM do I need" advice on the internet comes in one flavour: a spec sheet. "27B in FP8 needs 28 GB. 70B in Q4 needs 42 GB. Buy a 48 GB card."&lt;/p&gt;

&lt;p&gt;That's the answer for one model. Nobody tells you what happens when you try to run your actual stack — a transcription service, an embedding model, a small chat model — on one card. The answers you find are guesses, because nobody has measured a &lt;em&gt;multi-tenant&lt;/em&gt; GPU.&lt;/p&gt;

&lt;p&gt;I have. For the last week my RTX 3090 (24 GB) has been running three AI services at once: a Whisper ASR webservice, a small TTS voice model, and an embedding model for a RAG pipeline. I pulled the full VRAM history and the per-tenant attribution. This is what the card is actually holding, and what the number says about sizing a home GPU.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Card:&lt;/strong&gt; NVIDIA RTX 3090, 24 GB (24,576 MB), power limit 390 W&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tenants (all resident, all Docker):&lt;/strong&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Whisper ASR webservice&lt;/strong&gt; (WhisperX, large-v3) — a transcription endpoint my own tools call for voice memos, meetings, podcast clips&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A small TTS voice model&lt;/strong&gt; — text-to-speech for the same assistant stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;nomic-embed-text (Ollama)&lt;/strong&gt; — embeddings for the RAG pipeline&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telemetry:&lt;/strong&gt; 7 days of VRAM / power / utilisation at 2-hour resolution, plus per-caller attribution&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else on the box:&lt;/strong&gt; 55 containers, 92 systemd units, a 56-thread Xeon, 64 GB of RAM. The GPU sees only the three tenants above.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three services were chosen because that is the actual shape of a home AI stack: one big-ish resident model (ASR), one medium resident model (TTS), one tiny resident model (embeddings). No chat model. The 27B chat model lives on a second machine with two 3090s — that's a different article.&lt;/p&gt;




&lt;h2&gt;
  
  
  The number
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The card is holding 17.8 GB of its 24 GB — 72% — with three models resident, and the peak over the week was 23.4 GB. It never spilled a byte to system RAM.&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tenant&lt;/th&gt;
&lt;th&gt;Avg VRAM (7d)&lt;/th&gt;
&lt;th&gt;Peak VRAM (7d)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Whisper ASR (large-v3)&lt;/td&gt;
&lt;td&gt;7,871 MB (~7.7 GB)&lt;/td&gt;
&lt;td&gt;10,558 MB (~10.3 GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTS voice model&lt;/td&gt;
&lt;td&gt;4,876 MB (~4.7 GB)&lt;/td&gt;
&lt;td&gt;4,964 MB (~4.8 GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;nomic-embed-text&lt;/td&gt;
&lt;td&gt;4,633 MB (~4.5 GB)&lt;/td&gt;
&lt;td&gt;4,668 MB (~4.6 GB)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total (measured, 7d)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~17,380 MB (~17.0 GB)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;23,379 MB (~22.8 GB)&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The measured current state at the time of writing: 17,775 MB used of 24,576 MB — 72% of the card, with the three tenants sitting at 7.87 GB + 4.96 GB + 4.67 GB = 17.5 GB, plus the CUDA context overhead of the card itself.&lt;/p&gt;

&lt;p&gt;The peak of 23.4 GB is the interesting number. It happened when the Whisper service received a batch of long audio files at the same time the TTS model was rendering a long response. The two big tenants spiked together, the embedding model held its 4.6 GB, and the card came within 1.2 GB of the ceiling. It held. No spill, no OOM, no crash — because the peak was a few seconds, not a sustained load.&lt;/p&gt;




&lt;h2&gt;
  
  
  What each tenant is actually holding
&lt;/h2&gt;

&lt;p&gt;The "paper VRAM" of a model is its weights. The measured VRAM is weights plus working set plus framework overhead. Here's the gap, per tenant:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Whisper large-v3.&lt;/strong&gt; The weights in FP16 are ~3.1 GB. The measured average is 7.7 GB — 2.5× the weights. The difference is the encoder's audio feature buffers (long files and queued files hold more), the VAD and alignment models WhisperX runs alongside, and the CUDA context. The peak of 10.3 GB is a batch of long files in flight.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The TTS voice model.&lt;/strong&gt; Weights ~4.5 GB, measured 4.7–4.8 GB. This one is close to its paper number because TTS is stateless between requests: no long context, no queue buffers. It's the most "honest" tenant on the card.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;nomic-embed-text.&lt;/strong&gt; A 137M-parameter embedding model. Paper weights ~0.3 GB. Measured 4.5–4.7 GB. That's the Ollama runtime, its CUDA context, and the KV space it pre-allocates for embedding batches. A model that "needs 300 MB" needs 4.6 GB when it's a resident service. That's the number people never quote.&lt;/p&gt;

&lt;p&gt;The pattern: &lt;strong&gt;the measured VRAM of a resident service is 1.2–2.5× the weight size, and the multiplier is biggest for the models with the most state.&lt;/strong&gt; If you're sizing a card, budget for the measured number, not the weights.&lt;/p&gt;




&lt;h2&gt;
  
  
  Who is calling it
&lt;/h2&gt;

&lt;p&gt;The 7-day attribution shows why the Whisper tenant is the biggest consumer of &lt;em&gt;time&lt;/em&gt; on the card, even though the TTS model is the bigger &lt;em&gt;spike&lt;/em&gt; contributor:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Caller&lt;/th&gt;
&lt;th&gt;Seconds on the card (7d)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;My own assistant core (via the whisper proxy)&lt;/td&gt;
&lt;td&gt;4,970&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper proxy (shared endpoint)&lt;/td&gt;
&lt;td&gt;920&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse worker (observability stack, transcribes attached audio)&lt;/td&gt;
&lt;td&gt;910&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Langfuse MinIO (object storage, transcribes on ingest)&lt;/td&gt;
&lt;td&gt;860&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Whisper warmup (keep-alive job)&lt;/td&gt;
&lt;td&gt;360&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GitHub runner (CI jobs that transcribe test fixtures)&lt;/td&gt;
&lt;td&gt;360&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The card is a shared service, not a single app. Six different callers hit the Whisper endpoint over the week. The TTS and embedding tenants have one caller each (the assistant stack). The attribution is what turns "the GPU is busy" into "here is &lt;em&gt;who&lt;/em&gt; is busy" — and it's the difference between a dashboard you glance at and one you can act on.&lt;/p&gt;




&lt;h2&gt;
  
  
  The counterargument: 72% is a lot
&lt;/h2&gt;

&lt;p&gt;The fair objection: a card at 72% of its VRAM with a 1.2 GB headroom at peak is a card that will fail the day a bigger file comes in. Headroom is insurance, and 12% is thin insurance.&lt;/p&gt;

&lt;p&gt;True. But the measurement says what the insurance was worth over a week of real use: &lt;strong&gt;the peak was a 3-second event, and the card absorbed it.&lt;/strong&gt; The sustained state is ~17 GB, which leaves 7.5 GB of headroom — 31% of the card. The 23.4 GB peak is the worst case that actually happened, not the worst case that is possible.&lt;/p&gt;

&lt;p&gt;The honest version of the headroom argument: &lt;strong&gt;size for the biggest &lt;em&gt;sustained&lt;/em&gt; working set you will actually run, plus 50% headroom.&lt;/strong&gt; Not for the biggest spike you can imagine. If your stack's sustained working set is 17 GB, a 24 GB card is 1.4× sustained — enough. If you add a 7B chat model to the same card (sustained ~8 GB), the sustained working set becomes 25 GB and the 24 GB card is done. That's the actual failure mode: not a spike, but a new tenant.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I would buy now
&lt;/h2&gt;

&lt;p&gt;If I were building this stack today:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The hub card (ASR + TTS + embeddings): 24 GB.&lt;/strong&gt; Sustained working set 17 GB, peak 23.4 GB. A 16 GB card is not enough — the TTS model alone would crowd the Whisper service. A 32 GB card would be comfortable but costs more and runs hotter for a workload that fits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The chat card(s): sized for the chat model.&lt;/strong&gt; The 27B in FP8 needs two 24 GB cards (or one 48 GB card). It does not share the hub card.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The rule:&lt;/strong&gt; one card per class of workload — resident services on one, the big chat model on another. The measured working set of each class, plus 50% headroom, is the size.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The 24 GB 3090 is still the best price-per-GB on the used market, and that's why it's everywhere in home stacks. But "best price-per-GB" is an argument for the chat machine. For the hub, the argument is "fits the measured working set of my resident services with room for one more tenant" — and for my stack, that's 24 GB, not 16 and not 48.&lt;/p&gt;




&lt;h2&gt;
  
  
  Verdict
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three AI services on one 24 GB GPU: 17.8 GB sustained, 23.4 GB peak, zero spills over 7 days.&lt;/strong&gt; The measured VRAM of a resident service is 1.2–2.5× its weights, and the peak is a few seconds, not a sustained load. Size the card for the sustained working set plus 50% headroom, and keep the big chat model on its own card. The spec sheet tells you what one model needs. The meter tells you what your stack needs.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The Local LLM Lab is a series of measured experiments on a home GPU stack. Every number in this article was pulled from 7 days of VRAM telemetry on the card, not estimated. Next: who is actually eating the GPU — the caller-level attribution that turns "the GPU is busy" into a list of names.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>homelab</category>
    </item>
    <item>
      <title>Qwen3.8-Flash-Next (125B) on Three RTX 3090s at 80 Tokens/s: Teaching llama.cpp Which Experts Matter</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Sat, 26 Sep 2026 16:02:31 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/i-ran-a-125b-model-on-three-rtx-3090s-at-80-tokenss-by-teaching-llamacpp-which-experts-matter-4ioi</link>
      <guid>https://dev.to/sikamikanikobg/i-ran-a-125b-model-on-three-rtx-3090s-at-80-tokenss-by-teaching-llamacpp-which-experts-matter-4ioi</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Qwen3.8-Flash-Next is a 125B mixture-of-experts model: on paper it beats the Qwen3.8-27B I run in production everywhere, by +16.5 points on agentic coding. It does not fit in the 72 GB of VRAM my three RTX 3090s have, and stock llama.cpp, spilling a quarter of the experts into system RAM, runs it at &lt;strong&gt;23 tokens/second&lt;/strong&gt;. Three changes get it to &lt;strong&gt;80 tokens/second, entirely in VRAM&lt;/strong&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Sort the experts by how often they're used.&lt;/strong&gt; They are wildly unequal: the busiest 25% handle 52% of the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Store them at three precisions.&lt;/strong&gt; Popular experts keep more bits and rare ones get squeezed harder, so the whole model fits on the GPUs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patch llama.cpp&lt;/strong&gt; (~400 lines) so one routing decision drives three expert tables at once, then add speculative decoding on top.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Quality stays close to the 8-bit reference: &lt;strong&gt;+1.9% perplexity, 91% same next token, GSM8K 95.5%&lt;/strong&gt; (the 27B scores 95–96.5% on the same harness). There's also a 256k-context profile that finds a random code hidden 173,000 tokens deep. The trade-offs are real, and I've listed them. Everything is open: &lt;a href="https://github.com/SikamikanikoBG/qwen38-flash-next-3x3090" rel="noopener noreferrer"&gt;patches, tools, raw measurements&lt;/a&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Update, 26 Sep 2026, after the first day behind my assistant:&lt;/strong&gt; the 80 tok/s holds for short prompts. In real agent use, with ~25k-token prompts, sampling and thinking on, the model decodes at &lt;strong&gt;30–45 tok/s&lt;/strong&gt;. My first deployment also had a caching mistake that cost ~45 s before every answer; a server config change fixed it (now &lt;strong&gt;0.7 s&lt;/strong&gt;). Details in the "real-world update" section below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  the use case
&lt;/h2&gt;

&lt;p&gt;My box, &lt;strong&gt;vader&lt;/strong&gt;, runs &lt;a href="https://dev.to/sikamikanikobg/qwen38-27b-on-2x-rtx-3090-my-first-local-model-i-actually-trust-fpi"&gt;Qwen3.8-27B&lt;/a&gt; around the clock: two RTX 3090s, vLLM, about 135 tokens/second. It's the first local model I actually trust with agent work, and it does most of the work my assistant does.&lt;/p&gt;

&lt;p&gt;Then Qwen shipped &lt;strong&gt;Qwen3.8-Flash-Next&lt;/strong&gt;, the preview of their Qwen4 architecture. Their own model card puts it ahead of my 27B on almost everything I care about:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;benchmark (Qwen's card)&lt;/th&gt;
&lt;th&gt;Flash-Next&lt;/th&gt;
&lt;th&gt;3.8-27B&lt;/th&gt;
&lt;th&gt;gap&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DeepSWE (agentic coding)&lt;/td&gt;
&lt;td&gt;58.7&lt;/td&gt;
&lt;td&gt;42.2&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+16.5&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JobBench (professional tasks)&lt;/td&gt;
&lt;td&gt;55.7&lt;/td&gt;
&lt;td&gt;33.4&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+22.3&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SWE-bench Multilingual&lt;/td&gt;
&lt;td&gt;81.0&lt;/td&gt;
&lt;td&gt;73.8&lt;/td&gt;
&lt;td&gt;+7.2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Toolathlon (tool use)&lt;/td&gt;
&lt;td&gt;73.5&lt;/td&gt;
&lt;td&gt;67.1&lt;/td&gt;
&lt;td&gt;+6.4&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HLE&lt;/td&gt;
&lt;td&gt;35.9&lt;/td&gt;
&lt;td&gt;30.8&lt;/td&gt;
&lt;td&gt;+5.1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPQA Diamond&lt;/td&gt;
&lt;td&gt;91.7&lt;/td&gt;
&lt;td&gt;89.2&lt;/td&gt;
&lt;td&gt;+2.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IFBench&lt;/td&gt;
&lt;td&gt;81.3&lt;/td&gt;
&lt;td&gt;79.5&lt;/td&gt;
&lt;td&gt;+1.8&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the goal was simple to state: &lt;strong&gt;the smarter model, on the same three cards, with decode and time-to-first-token that still feel interactive.&lt;/strong&gt; No new hardware. I told my AI engineer (Claude Code, more on that at the end) that "no" was not an acceptable answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  the box
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GPUs:&lt;/strong&gt; 3x RTX 3090, 24 GB each: 72 GB of VRAM. No NVLink, and no PCIe peer-to-peer (the board has no Resizable BAR), so the cards can't talk to each other directly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CPU:&lt;/strong&gt; dual Xeon E5-2660 v4, 56 threads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;RAM:&lt;/strong&gt; 60 GB. Only &lt;strong&gt;one&lt;/strong&gt; 32 GB stick per CPU socket, so it's single-channel, and that matters a lot below.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Disk:&lt;/strong&gt; one 1 TB NVMe.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  a small dictionary before we start
&lt;/h2&gt;

&lt;p&gt;If you know what a KV cache is, skip this. If you don't, it's all you need for the rest of the article.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Parameters / weights.&lt;/strong&gt; The numbers a model learned. "125B" means 125 billion of them. Each one normally takes 2 bytes, so 125B parameters is ~250 GB, far more than 72 GB of VRAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VRAM.&lt;/strong&gt; The GPU's own memory. It's about 20–50x faster than system RAM &lt;em&gt;on this box&lt;/em&gt;. A model runs fast only if what it needs for each word lives in VRAM.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quantization.&lt;/strong&gt; Storing each weight with fewer bits (8, 4, 3…) instead of 16. Like saving a photo as a smaller JPEG: smaller file, a little less detail. "Q8_0", "Q4_K", "IQ3_S", "MXFP4" are llama.cpp's names for different bit-budgets and recipes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mixture of experts (MoE).&lt;/strong&gt; Instead of one giant network, each layer has many small "expert" networks, 512 of them here, and a &lt;strong&gt;router&lt;/strong&gt; picks the best 10 for each word. So the model &lt;em&gt;knows&lt;/em&gt; 125B parameters' worth of things but only &lt;em&gt;computes&lt;/em&gt; with about 6B per word. That's why it can be fast, and also why it's huge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token.&lt;/strong&gt; A word or word-piece. &lt;strong&gt;Decode speed&lt;/strong&gt; (tokens/second) is how fast the answer streams out.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill / time to first token (TTFT).&lt;/strong&gt; Before answering, the model reads your whole prompt. That's prefill, and TTFT is how long you wait before the first word appears.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context / KV cache.&lt;/strong&gt; How much text the model can hold in mind at once ("256k context" is about 500 pages), and the memory it uses to remember it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;n-gram embedding.&lt;/strong&gt; New in this model: a 51 GB lookup table indexed by 2–3 word phrases. It's only ever &lt;em&gt;looked up&lt;/em&gt;, a few rows per word, so it can live on the NVMe drive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Speculative decoding / MTP.&lt;/strong&gt; A small built-in "draft head" guesses the next few words, and the big model checks all the guesses in one pass. Correct guesses are free words.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;KL divergence (KLD).&lt;/strong&gt; How far the compressed model's word probabilities drift from the original's. 0 means identical; lower is better. It's the most sensitive quality meter there is.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp / GGUF.&lt;/strong&gt; The inference engine I patched, and its model-file format.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  the problem, in one picture
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobry7yo68to60yzsyojk.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fobry7yo68to60yzsyojk.png" alt="Where Qwen3.8-Flash-Next (125B) lives: before and after" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In 16-bit the model is 360 GB. Even the popular 4-bit build (&lt;a href="https://huggingface.co/unsloth/Qwen3.8-Flash-Next-GGUF" rel="noopener noreferrer"&gt;unsloth's UD-Q4_K_XL&lt;/a&gt;) is 111 GB: 72 GB of experts, 27 GB of n-gram table (which can stay on disk) and a few GB of everything else. The experts alone don't fit in 72 GB of VRAM once the rest of the model and the context need room too. So llama.cpp does the sensible thing: it puts the overflow experts in system RAM and computes them on the CPU.&lt;/p&gt;

&lt;p&gt;On this machine that means &lt;strong&gt;23 tokens/second&lt;/strong&gt;. With one memory stick per socket, the CPU reads those experts about ten times slower than a GPU would. And for every word, the work bounces between the GPUs and the CPU dozens of times, so each GPU waits.&lt;/p&gt;

&lt;h2&gt;
  
  
  step 1: the obvious setup, and the first trap
&lt;/h2&gt;

&lt;p&gt;The first run with stock settings also showed prefill all over the place (47 to 306 tokens/second on the same 4k prompt) and decode sagging to 10 tok/s. Thread states told the story: the main thread sat in &lt;code&gt;D&lt;/code&gt; (waiting on disk). llama.cpp memory-maps the model file, loading the GPU part streams 70+ GB through the page cache, and the CPU-side experts kept getting evicted and re-read from NVMe.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--load-mode none&lt;/code&gt; loads the CPU-side weights into RAM and keeps them there. Also, &lt;strong&gt;don't download a 188 GB file while you benchmark&lt;/strong&gt;: my download filled the page cache and pushed the server into swap. With both fixed, prefill doubled (&lt;strong&gt;658–792 tok/s&lt;/strong&gt;) and decode steadied at &lt;strong&gt;23–26 tok/s&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Speculative decoding (MTP) barely helped: 24–29 tok/s. The reason matters for everything that follows. A draft of 5 words means the big model checks 6 words at once. Each word picks 10 experts, so one check can touch up to 60 &lt;em&gt;different&lt;/em&gt; experts per layer instead of 10, and the slow ones in CPU RAM get hit harder. &lt;strong&gt;As long as experts live in CPU RAM, nothing else will make this fast.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  step 2: the experts are not equal
&lt;/h2&gt;

&lt;p&gt;Here's the observation the whole project rests on. llama.cpp's calibration data (the &lt;em&gt;importance matrix&lt;/em&gt; unsloth publishes with their quants) records how often each of the 24,576 experts was picked on real text:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38hgpedoun8y1is3uj7x.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F38hgpedoun8y1is3uj7x.png" alt="Routing skew: a few experts do most of the work" width="800" height="473"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the average layer, &lt;strong&gt;the busiest 25% of experts handle 52% of the tokens, and the busiest 80% handle 95%.&lt;/strong&gt; Stock llama.cpp can only place &lt;em&gt;whole layers&lt;/em&gt; of experts: a layer's 512 experts are one tensor, all on the GPU or all on the CPU. It offloads a quarter of the experts, including popular ones, so a quarter of the expert work lands on the slow path.&lt;/p&gt;

&lt;h2&gt;
  
  
  step 3: split every layer into hot and cold
&lt;/h2&gt;

&lt;p&gt;The first patch splits every layer's expert tensor in two at load time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;hot experts&lt;/strong&gt; go to the GPUs;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cold experts&lt;/strong&gt; go to CPU RAM;&lt;/li&gt;
&lt;li&gt;the &lt;strong&gt;router&lt;/strong&gt; is permuted so its choices land in the right half.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With the same 19% of expert bytes on the CPU, the cold experts now serve only &lt;strong&gt;4.5% of routed tokens&lt;/strong&gt; instead of ~19%, about 4x less slow-path traffic.&lt;/p&gt;

&lt;p&gt;Getting it right took four bugs, each instructive enough to list in its own section below. The result is &lt;strong&gt;30–32 tok/s&lt;/strong&gt; with correct answers. That's +30%, but it's not the leap it should be, so I profiled it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;where one decoded word spends its time&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;kernel launches&lt;/td&gt;
&lt;td&gt;~4,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;dense weights (attention, DeltaNet, shared expert), 8-bit&lt;/td&gt;
&lt;td&gt;~4.9 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;routed experts&lt;/td&gt;
&lt;td&gt;~2.5 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;~3,500 tiny element-wise kernels&lt;/td&gt;
&lt;td&gt;~6–7 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GPU↔CPU synchronisations&lt;/td&gt;
&lt;td&gt;~390&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The CPU was barely reading any experts anymore; it was the &lt;em&gt;round trips&lt;/em&gt; that cost. Every layer handed work to the CPU and waited for the answer. Then a cheap experiment: drop the cold experts entirely (quality garbage, speed only) and see how fast it goes fully on GPU: &lt;strong&gt;52 tok/s&lt;/strong&gt;, and &lt;strong&gt;86–88 tok/s&lt;/strong&gt; with speculative decoding. That was the target. The question became: &lt;strong&gt;how do you fit every expert in VRAM without wrecking quality?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  step 4: three shelves, all on the GPU
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbzg98y08zf6u37wtx0l.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fnbzg98y08zf6u37wtx0l.png" alt="How one word gets written" width="800" height="320"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The idea: if some experts do most of the work, &lt;strong&gt;give them more bits and squeeze the rarely-used ones harder&lt;/strong&gt;. Everything still fits in VRAM, and most tokens still see a well-preserved expert. Think of a library that keeps its bestsellers in hardcover and prints the rarely-borrowed titles as compact paperbacks, so the whole collection fits in the building.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2apbxoc17r1j93ih4vb.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw2apbxoc17r1j93ih4vb.png" alt="What I built" width="800" height="235"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two pieces make it work.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An offline re-packer&lt;/strong&gt; (&lt;a href="https://github.com/SikamikanikoBG/qwen38-flash-next-3x3090/blob/main/tools/expert_tiers.py" rel="noopener noreferrer"&gt;&lt;code&gt;tools/expert_tiers.py&lt;/code&gt;&lt;/a&gt;). It starts from the 8-bit model (188 GB) and uses the importance matrix for both &lt;em&gt;how often&lt;/em&gt; each expert is used and &lt;em&gt;which inputs matter&lt;/em&gt; inside it. A greedy planner fills a VRAM budget: each byte goes to the expert where it removes the most expected error. Every expert is then re-quantized with llama.cpp's own quantizers, and the file is written with the experts reordered hottest-first as three tensors per layer. One wrinkle: the down-projection matrices have rows 640 wide, which the fancy 2–3-bit formats can't handle (they need multiples of 256). So they get their own ladder: IQ4_NL / MXFP4 instead of IQ4_XS / IQ3.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A patched llama.cpp&lt;/strong&gt; (&lt;a href="https://github.com/SikamikanikoBG/qwen38-flash-next-3x3090/tree/main/patches" rel="noopener noreferrer"&gt;&lt;code&gt;patches/&lt;/code&gt;&lt;/a&gt;). &lt;code&gt;mul_mat_id&lt;/code&gt;, the operation that runs "each word through its chosen experts", learns to take an &lt;strong&gt;id range&lt;/strong&gt;. Each shelf's matrix multiply sees the router's full choice list, computes only the ids that fall on its shelf, and writes zeros for the rest. The three results simply add up. That meant touching the CPU kernels, three CUDA paths (decode, prefill, and the fused gate+up+activation kernel) and the loader. On top sits Qwen's multi-token-prediction draft head, from a &lt;a href="https://github.com/ggml-org/llama.cpp/pull/28243" rel="noopener noreferrer"&gt;not-yet-merged llama.cpp PR&lt;/a&gt;, re-quantized to 4 bits so it fits too.&lt;/p&gt;

&lt;p&gt;Result, in one chart:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjgtle8cia7p3qxlcm81.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsjgtle8cia7p3qxlcm81.png" alt="Decode journey" width="800" height="370"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  the four bugs, briefly
&lt;/h2&gt;

&lt;p&gt;For people who'll try this, and because two of them are delightful.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate experts crash the CUDA MoE kernel.&lt;/strong&gt; My first version pointed "not on this shelf" at expert 0. A word with two cold experts then had expert 0 twice, and CUDA's expert-grouping helper assumes each expert appears at most once per word. It silently dropped a slot, and a later kernel read an unwritten index. Fixed by teaching the kernels a real "skip".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A flag stored where a precision setting lives.&lt;/strong&gt; I marked the skip-capable nodes in &lt;code&gt;op_params[0]&lt;/code&gt;, which llama.cpp already uses for the accumulator precision. The flag was quietly overwritten. It moved to slot 7, with a magic value.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The unsigned ternary.&lt;/strong&gt; &lt;code&gt;ids ? ids[i] : blockIdx.x&lt;/code&gt;, where &lt;code&gt;blockIdx.x&lt;/code&gt; is unsigned. C++ promotes the whole expression to unsigned, so my &lt;code&gt;-1&lt;/code&gt; ("skip") became 4,294,967,295 and the kernel read 4 billion rows past the buffer. &lt;code&gt;compute-sanitizer&lt;/code&gt; found it in one run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fused kernels hide the tag.&lt;/strong&gt; For decode, CUDA fuses gate, up and activation into one kernel whose "destination" is the activation node, not the matrix multiply that carries my id-range tag. The first id-range build printed &lt;code&gt;//////////&lt;/code&gt;. The kernel now looks up the tag on its source node.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  how much quality did it cost?
&lt;/h2&gt;

&lt;p&gt;The honest meter is KL divergence against the 8-bit model over 24,576 tokens of Wikipedia text:&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscypq9ckgugggwtb9keq.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fscypq9ckgugggwtb9keq.png" alt="Quality vs size" width="800" height="453"&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;model&lt;/th&gt;
&lt;th&gt;expert size&lt;/th&gt;
&lt;th&gt;fits in VRAM&lt;/th&gt;
&lt;th&gt;perplexity vs 8-bit&lt;/th&gt;
&lt;th&gt;KLD&lt;/th&gt;
&lt;th&gt;same top token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;unsloth UD-Q4_K_XL (stock)&lt;/td&gt;
&lt;td&gt;71.7 GiB&lt;/td&gt;
&lt;td&gt;no, 25% in RAM&lt;/td&gt;
&lt;td&gt;+0.9%&lt;/td&gt;
&lt;td&gt;0.045&lt;/td&gt;
&lt;td&gt;93.7%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;tiered, 128k profile&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;53.0 GiB&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;+1.9%&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.091&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;91.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;uniform IQ3_S / MXFP4, same size&lt;/td&gt;
&lt;td&gt;52.2 GiB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;+3.0%&lt;/td&gt;
&lt;td&gt;0.095&lt;/td&gt;
&lt;td&gt;91.0%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tiered, 256k profile&lt;/td&gt;
&lt;td&gt;49.5 GiB&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;+3.2%&lt;/td&gt;
&lt;td&gt;0.113&lt;/td&gt;
&lt;td&gt;90.1%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;What I take from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fitting in VRAM costs quality, and there's no way around that.&lt;/strong&gt; 20 GB less expert weight roughly doubles the KL divergence of the stock 4-bit build. That's physics, not a bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;At equal size, tiering beats uniform&lt;/strong&gt;: perplexity drift 1.9% vs 3.0%. But the gain is smaller than my planner predicted: its error model is crude, and at this size the total byte count dominates. There's more to win with a better tier recipe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a task, it holds up.&lt;/strong&gt; GSM8K (200 grade-school maths questions, greedy, no thinking) scores &lt;strong&gt;95.5%&lt;/strong&gt;. The 27B's published number on the same harness is 95.0–96.5%. GSM8K is saturated, so it mainly proves nothing broke. The model card's gains on agentic work are where the upgrade should show.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  speed vs context, and the 256k profile
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftaeq91ejcnvjjlalewln.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftaeq91ejcnvjjlalewln.png" alt="Speed vs context" width="800" height="383"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Both profiles start at &lt;strong&gt;~80 tokens/second&lt;/strong&gt; on a short prompt and slow down as the prompt grows. The per-word attention and indexer work grows with context, and the draft head's guesses get a little worse too. Typical numbers, averaging two runs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;prompt&lt;/th&gt;
&lt;th&gt;128k profile: decode / time to first token&lt;/th&gt;
&lt;th&gt;256k profile: decode / time to first token&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;4k tokens&lt;/td&gt;
&lt;td&gt;82 tok/s / 7–9 s&lt;/td&gt;
&lt;td&gt;67 tok/s / 7–8 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;15k tokens&lt;/td&gt;
&lt;td&gt;66 tok/s / 18–22 s&lt;/td&gt;
&lt;td&gt;58 tok/s / 19–22 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;30k tokens&lt;/td&gt;
&lt;td&gt;58 tok/s / 35 s&lt;/td&gt;
&lt;td&gt;54 tok/s / 38 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;61k tokens&lt;/td&gt;
&lt;td&gt;53 tok/s / 76–79 s&lt;/td&gt;
&lt;td&gt;51 tok/s / 82 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95k / 122k tokens&lt;/td&gt;
&lt;td&gt;39 tok/s / 140 s&lt;/td&gt;
&lt;td&gt;35 tok/s / 190 s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prefill runs at &lt;strong&gt;540–860 tokens/second&lt;/strong&gt;. That's the weak spot next to the 27B's ~1,300, and it's where I'd look next: it's also the part that makes a 120k-token prompt a three-minute wait.&lt;/p&gt;

&lt;p&gt;The 256k profile needed two more compromises. The KV cache drops to 8-bit, and experts shrink to 49.5 GiB, because the sparse-attention indexer's scratch memory grows with context and pushed a card out of memory at ~200k on the first try. After rebalancing layers across the cards, it read &lt;strong&gt;173,692 tokens of Wikipedia with a random 10-character code hidden at 45% depth, and returned the code: exactly right (&lt;code&gt;6BC5XYE8FS&lt;/code&gt;). A second run with the code at 90% depth of 112,711 tokens was also exact.&lt;/strong&gt; It took 6.1 minutes (472 tokens/second) to read that much. Long-context prefill on this architecture is still the slow part.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it costs to run
&lt;/h2&gt;

&lt;p&gt;Measured at the cards (&lt;code&gt;nvidia-smi&lt;/code&gt;, 5 Hz, three 1,024-token generations per profile): the three 3090s draw &lt;strong&gt;~540 W together while decoding&lt;/strong&gt; and ~124 W at rest with the model loaded. That's &lt;strong&gt;7.5–8.4 joules per token&lt;/strong&gt;, 2.1–2.3 kWh per million tokens. On my tariff (0.30 BGN/kWh day, 0.18 night) &lt;strong&gt;a million generated tokens cost 0.63–0.70 BGN in daytime, 0.38–0.42 BGN at night&lt;/strong&gt;, about $0.35–0.40. &lt;a href="https://dev.to/sikamikanikobg/qwen38-27b-on-one-rtx-3090-vs-two-20-decode-14-cold-prefill-and-3x-on-cached-prompts-55kc"&gt;The 27B costs 0.21–0.34 BGN&lt;/a&gt; for the same million, so the bigger brain costs roughly twice as much per word, all three cards included. It's still coffee money.&lt;/p&gt;

&lt;h2&gt;
  
  
  real-world update: day one behind my assistant
&lt;/h2&gt;

&lt;p&gt;Benchmarks are one request with a short prompt and greedy decoding. My assistant is none of those things, so after a day of it running Jarvis, here is what the server logs say about real requests:&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;a real assistant turn&lt;/th&gt;
&lt;th&gt;the benchmark&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;prompt&lt;/td&gt;
&lt;td&gt;~25,000 tokens (system prompt, tools, memory)&lt;/td&gt;
&lt;td&gt;41 tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sampling&lt;/td&gt;
&lt;td&gt;temperature 0.7, thinking on&lt;/td&gt;
&lt;td&gt;greedy&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens per verify step (draft acceptance)&lt;/td&gt;
&lt;td&gt;2.6 (39%)&lt;/td&gt;
&lt;td&gt;3.4 (62%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;decode&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;41–45 tok/s&lt;/strong&gt; (30–45 is what it feels like)&lt;/td&gt;
&lt;td&gt;80 tok/s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Decode: 30–45 tok/s, not 80.&lt;/strong&gt; Two things compound. The prompt is deep, and every decoded word pays attention over it; the context chart above already shows ~50 tok/s at 60k. And the draft head guesses sampled text, tool-call JSON and reasoning worse than greedy prose, so speculative decoding saves less. That's the honest number for agent work on this box. For comparison, the 27B benchmarks at ~135 tok/s on this box, so the upgrade is intelligence, not speed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Time to first token: the mistake was mine.&lt;/strong&gt; My assistant runs several &lt;em&gt;roles&lt;/em&gt;: chat, planner, classifier, triage, background jobs. Each has its own ~25k-token prompt prefix. I deployed the server with one slot (&lt;code&gt;-np 1&lt;/code&gt;), so every time the role changed, the cached prefix was thrown away and &lt;strong&gt;25,000 tokens were re-read from scratch: ~45 seconds before every answer&lt;/strong&gt;. The fix is four slots sharing one KV pool, so every role keeps its prefix warm:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;returning to a 14k-token prompt after another role ran&lt;/th&gt;
&lt;th&gt;before (&lt;code&gt;-np 1&lt;/code&gt;)&lt;/th&gt;
&lt;th&gt;after (&lt;code&gt;-np 4 -kvu&lt;/code&gt;)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;time to first token&lt;/td&gt;
&lt;td&gt;20–45 s&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.6–0.7 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It isn't free: four slots need a little more memory, so the shared pool dropped from 128k to 96k tokens (128k with four slots ran out of VRAM). A single request can still use all 96k.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Schedule the background jobs apart.&lt;/strong&gt; A model at 30–45 tok/s with 25k-token prompts spends minutes per agent task. My assistant had 13 scheduled jobs between 06:30 and 09:00 on Mondays, four of them at the same minute. I've spread them 30 minutes apart (05:00 to 11:30) so they stop queueing behind each other. I'll report after a week whether that's enough.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images and video work too,&lt;/strong&gt; once the vision projector is loaded (&lt;code&gt;--mmproj&lt;/code&gt;, on the GPU with the most free memory: 4 s per photo, against 61 s on the CPU) and the container has &lt;code&gt;ffmpeg&lt;/code&gt; for video (a 5-second clip in 6.4 s). The repo has the exact launcher.&lt;/p&gt;

&lt;h2&gt;
  
  
  what I'd not claim
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's single-user.&lt;/strong&gt; Every speed number here is one request at a time, and real agent turns decode at 30–45 tok/s, not 80. The 27B on vLLM batches many users far better.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 27B is still faster.&lt;/strong&gt; About 135 vs 80 tok/s decode, and roughly 1,300 vs 540–860 tok/s prefill. The 125B is smarter, not quicker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It uses all three cards.&lt;/strong&gt; You can't also keep the 27B running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The quality numbers are wikitext + GSM8K.&lt;/strong&gt; They say the compression is gentle. They don't prove the agentic gains survive intact; that needs agent benchmarks I haven't run yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The tier planner is a heuristic.&lt;/strong&gt; The measurements say it helps; a calibrated one would help more.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything is in &lt;strong&gt;&lt;a href="https://github.com/SikamikanikoBG/qwen38-flash-next-3x3090" rel="noopener noreferrer"&gt;SikamikanikoBG/qwen38-flash-next-3x3090&lt;/a&gt;&lt;/strong&gt;: both llama.cpp patches, the re-packer, the benchmark scripts, and every raw number behind the charts. The short version:&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;# llama.cpp at 81bc6b8 + MTP PR #28243 + the tiering patch&lt;/span&gt;
git apply patches/0001-qwen4exp-mtp-pr28243.patch patches/0002-tiered-experts-hot-cold-split.patch
cmake &lt;span class="nt"&gt;-B&lt;/span&gt; build &lt;span class="nt"&gt;-DGGML_CUDA&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ON &lt;span class="nt"&gt;-DCMAKE_CUDA_ARCHITECTURES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;86 &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; cmake &lt;span class="nt"&gt;--build&lt;/span&gt; build &lt;span class="nt"&gt;-j&lt;/span&gt;

&lt;span class="c"&gt;# re-pack the 8-bit model into three precision shelves (53 GiB of experts)&lt;/span&gt;
python tools/expert_tiers.py write &lt;span class="nt"&gt;--src&lt;/span&gt; Qwen3.8-Flash-Next-Q8_0-00001-of-00006.gguf &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--imatrix&lt;/span&gt; imatrix_unsloth.gguf &lt;span class="nt"&gt;--budget-gib&lt;/span&gt; 53 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--gu&lt;/span&gt; Q6_K,IQ4_XS,IQ3_XXS &lt;span class="nt"&gt;--dn&lt;/span&gt; Q8_0,IQ4_NL,MXFP4 &lt;span class="nt"&gt;--out&lt;/span&gt; fn-tier53.gguf

&lt;span class="c"&gt;# serve: all layers on GPU, 4 slots sharing a 96k KV pool, vision, MTP drafting 4 tokens&lt;/span&gt;
llama-server &lt;span class="nt"&gt;-m&lt;/span&gt; fn-tier53-00001-of-00002.gguf &lt;span class="nt"&gt;-ngl&lt;/span&gt; 99 &lt;span class="nt"&gt;-ts&lt;/span&gt; 16,16,16 &lt;span class="nt"&gt;-c&lt;/span&gt; 98304 &lt;span class="nt"&gt;-np&lt;/span&gt; 4 &lt;span class="nt"&gt;-kvu&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-b&lt;/span&gt; 1024 &lt;span class="nt"&gt;-ub&lt;/span&gt; 256 &lt;span class="nt"&gt;-fa&lt;/span&gt; on &lt;span class="nt"&gt;--jinja&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--mmproj&lt;/span&gt; mmproj-F16.gguf &lt;span class="nt"&gt;-mmdev&lt;/span&gt; CUDA1 &lt;span class="nt"&gt;--image-max-tokens&lt;/span&gt; 1024 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-md&lt;/span&gt; mtp-shared-iq4.gguf &lt;span class="nt"&gt;--spec-type&lt;/span&gt; draft-mtp &lt;span class="nt"&gt;--spec-draft-n-max&lt;/span&gt; 4
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  credits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Qwen&lt;/strong&gt;, for the model and an architecture that rewards this kind of work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;unsloth&lt;/strong&gt;, for the GGUFs, the MTP draft files and the importance matrix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;llama.cpp and ggml&lt;/strong&gt;, plus the authors of the &lt;a href="https://github.com/ggml-org/llama.cpp/pull/28243" rel="noopener noreferrer"&gt;Qwen4Exp MTP PR&lt;/a&gt; and the &lt;a href="https://github.com/ggml-org/llama.cpp/issues/28734" rel="noopener noreferrer"&gt;sparse-attention decay issue&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://github.com/syv-ai/qwen38-27b-rtx3090" rel="noopener noreferrer"&gt;syv-ai/qwen38-27b-rtx3090&lt;/a&gt;&lt;/strong&gt;, whose rigor on the 27B set the bar for measuring this.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;This project, from the patches and the tools to the measurements and this write-up, was done with **Claude Code&lt;/em&gt;* acting as my AI engineer, working on my hardware under my direction. The numbers are from vader, the raw data is in the repo, and the mistakes are both of ours.*&lt;/p&gt;

</description>
      <category>llm</category>
      <category>qwen</category>
      <category>homelab</category>
      <category>ai</category>
    </item>
    <item>
      <title>Qwen3.8-27B on One RTX 3090 vs Two: +20% Decode, +14% Cold Prefill, and 3x on Cached Prompts</title>
      <dc:creator>Arsen Apostolov</dc:creator>
      <pubDate>Wed, 23 Sep 2026 03:27:37 +0000</pubDate>
      <link>https://dev.to/sikamikanikobg/qwen38-27b-on-one-rtx-3090-vs-two-20-decode-14-cold-prefill-and-3x-on-cached-prompts-55kc</link>
      <guid>https://dev.to/sikamikanikobg/qwen38-27b-on-one-rtx-3090-vs-two-20-decode-14-cold-prefill-and-3x-on-cached-prompts-55kc</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Qwen3.8-27B — the dense 27.8B that dropped on August 14 — fits on a single RTX 3090 at W4A16 and decodes at &lt;strong&gt;~125–155 tokens/sec&lt;/strong&gt;. Splitting it across two cards with tensor parallelism buys &lt;strong&gt;roughly 20% more decode&lt;/strong&gt; (noisy: +13% in one run, +22% in the next) and &lt;strong&gt;14% faster prefill on average&lt;/strong&gt; on a cold prompt (23% at the top of the ladder: an 8,600-token prompt takes &lt;strong&gt;8.5 s&lt;/strong&gt; to first token on one card and &lt;strong&gt;6.9 s&lt;/strong&gt; on two). On a prompt the server has already seen, the pair answers &lt;strong&gt;2–3x faster&lt;/strong&gt; — but that is the prefix cache, not the cards, and the script below measures the two separately. At the API list price of $3.00 per million output tokens, one million tokens cost &lt;strong&gt;0.21–0.34 BGN of electricity, about $0.12–0.20&lt;/strong&gt;, on the box that serves them.&lt;/p&gt;

&lt;h2&gt;
  
  
  the box
&lt;/h2&gt;

&lt;p&gt;vader is the machine I run this on: three RTX 3090s in an ASUS Z10PE-D8 WS, dual Xeon E5-2660 v4 (56 threads), 60 GB of RAM, Ubuntu 26.04. It has been my inference box since the Whisper experiments, and since July it has also carried the power-limiting work (&lt;a href="https://dev.to/sikamikanikobg/what-does-a-local-llm-actually-cost-per-month-i-read-the-meters-1274"&gt;the meters post&lt;/a&gt; is where those numbers live). Two of the three cards are in this test; the third sits idle.&lt;/p&gt;

&lt;p&gt;The model under test is the one that is currently loaded on it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;single-card&lt;/th&gt;
&lt;th&gt;TP2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;container&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen38-27b-single-1&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen38-27b-tp2-single-1&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;image&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ghcr.io/syv-ai/qwen38-27b-rtx3090&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;same, &lt;code&gt;:latest&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;weights&lt;/td&gt;
&lt;td&gt;Qwen3.8-27B-W4A16-AutoRound-fast&lt;/td&gt;
&lt;td&gt;same&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;cards&lt;/td&gt;
&lt;td&gt;1x 3090 (22.6 GB VRAM)&lt;/td&gt;
&lt;td&gt;2x 3090 (21.7 GB each)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;max context&lt;/td&gt;
&lt;td&gt;131,072&lt;/td&gt;
&lt;td&gt;262,144&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;port&lt;/td&gt;
&lt;td&gt;18021&lt;/td&gt;
&lt;td&gt;18022&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Both are vLLM, both have been up two days, both report healthy, and both run with &lt;strong&gt;prefix caching on&lt;/strong&gt; — which is why every number below comes in two flavours, cold and warm. The W4A16 quantization is what puts a 27.8B dense model inside one 24 GB card — 22.6 GB of it, with the KV cache budget for the rest of the 128k window coming out of what is left.&lt;/p&gt;

&lt;h2&gt;
  
  
  the method
&lt;/h2&gt;

&lt;p&gt;No benchmark harness, no &lt;code&gt;vllm bench&lt;/code&gt; — a 150-line script (&lt;a href="https://github.com/SikamikanikoBG/qwen38-bench" rel="noopener noreferrer"&gt;&lt;code&gt;bench_qwen38_v2.py&lt;/code&gt;&lt;/a&gt;) that sends a real chat-completion request with a prompt of a given size, streams the answer, and times it. For each prompt size it runs three times and takes the median:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;prompt sizes:&lt;/strong&gt; calibrated so the server reports ~600, ~2,400, ~4,700, ~9,300 prompt tokens (the script probes the tokenizer first, then scales the prompt to hit the target)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;output:&lt;/strong&gt; &lt;code&gt;max_tokens=256&lt;/code&gt;, &lt;code&gt;temperature=0&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;measured:&lt;/strong&gt; time to first SSE event, total time, and the server-reported token counts from &lt;code&gt;stream_options.include_usage&lt;/code&gt; — not my own chunk counting&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;cold vs warm:&lt;/strong&gt; every cold request carries a unique first line, so no cached prefix can match it; the warm pass repeats one identical request after a warm-up call&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last bullet is the one that decides whether a benchmark means anything. With prefix caching on — and it is on by default in vLLM — timing the same prompt twice does not measure prefill the second time, it measures a cache lookup. The tell is arithmetic: 8,600 tokens "prefilled" in 0.56 s is 15,000 tokens/sec, roughly ten times what two 3090s can actually push through a 27B model. If your TTFT numbers look like that, you are timing the cache. So every cold request here is salted with a unique first line, and the warm pass is run deliberately and reported in its own column.&lt;/p&gt;

&lt;p&gt;One caveat: this is a single-request benchmark. vLLM's continuous batching is where production throughput lives, and I did not hammer it with concurrent requests. What I measured is the latency you feel as the one person using the box — which, for a homelab, is most of the time.&lt;/p&gt;

&lt;h2&gt;
  
  
  decode: the second card buys 15–30%, noisily
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn1hypir2dc3rti8678h.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyn1hypir2dc3rti8678h.png" alt="Decode speed by prompt size, cold runs" width="799" height="395"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The single card holds &lt;strong&gt;123–154 tokens/sec&lt;/strong&gt; across the ladder; the TP2 pair runs &lt;strong&gt;146–193&lt;/strong&gt;. A 27B dense model at W4A16 on one 3090 is memory-bandwidth-bound in a way that does not care much about prompt size, because the KV cache for these sizes is small next to the weights. The pair is faster at every size but one (at 4,700 tokens the two came out level), and the average gain was +22% in this run against +13% in a run an hour earlier. Single-stream decode on these cards moves by about 10% between runs an hour apart, so I am not going to print a decimal: &lt;strong&gt;call it 15–30%, and expect the lower end.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a real number and a small one. For a 27B model, tensor parallelism across two cards is not the 2x you get from adding compute — the all-reduce between the cards eats most of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  prefill: cold is the cards, warm is the cache
&lt;/h2&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkheov6y1214phf5k1xx.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdkheov6y1214phf5k1xx.png" alt="Time-to-first-token by prompt size, cold and warm" width="800" height="398"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two columns, two different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cold&lt;/strong&gt; — a prompt the server has never seen — is prefill, and prefill is compute-bound: the single card does &lt;strong&gt;1,100–1,220 tokens/sec&lt;/strong&gt; of it, the pair &lt;strong&gt;1,230–1,360&lt;/strong&gt;. Time to first token climbs from 0.53 s to &lt;strong&gt;8.5 s&lt;/strong&gt; on one card as the prompt goes from 600 to 9,300 tokens, and from 0.50 s to &lt;strong&gt;6.9 s&lt;/strong&gt; on two. The gap grows with the prompt: 6% at 600 tokens, &lt;strong&gt;23% at 9,300&lt;/strong&gt;, 14% on average. That is what the second card buys you on a fresh long prompt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Warm&lt;/strong&gt; — the same prompt again — is the prefix cache doing its job, and here the pair really is 2–3x faster: &lt;strong&gt;0.5–0.75 s&lt;/strong&gt; flat across the ladder against &lt;strong&gt;0.5–1.7 s&lt;/strong&gt; on the single card, whose smaller KV pool and slower re-read of the uncached tail show at every size above 600 tokens. It is a cache number rather than a prefill number, and it is also the one you feel most often, because every follow-up turn in a chat is a warm request.&lt;/p&gt;

&lt;p&gt;The routing rule that falls out: &lt;strong&gt;a long fresh prompt goes to the pair and saves about a fifth of its wait; a conversation stays on whichever lane it started on, so its prefix stays cached.&lt;/strong&gt; For prompts under about 2,000 tokens the single card is within noise of the pair on both counts and draws one card's power.&lt;/p&gt;

&lt;h2&gt;
  
  
  what it costs
&lt;/h2&gt;

&lt;p&gt;The box draws &lt;strong&gt;620 W&lt;/strong&gt; at the wall with both containers loaded and serving — 247 W on one TP2 card, 328 W on the other, 45 W on the single-card 3090 sitting at P8 with its weights resident. Those are the monitor's readings from the day the containers went up, not a measurement taken during these runs; the governor from the meters post is power-capping it, holding 79°C.&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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqunez6xef0ov2ri6kezf.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.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fqunez6xef0ov2ri6kezf.png" alt="API price vs local electricity cost" width="800" height="356"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;At the API's list price — $3.00 per million output tokens, DeepInfra's rate for Qwen3.8-27B as listed on llm-stats — one million generated tokens takes about &lt;strong&gt;6,667 seconds&lt;/strong&gt; at 150 tokens/sec. At 620 W that is &lt;strong&gt;1.15 kWh&lt;/strong&gt;. On the Bulgarian dual-rate tariff that is &lt;strong&gt;0.34 BGN by day (0.30 BGN/kWh) or 0.21 BGN by night (0.18 BGN/kWh)&lt;/strong&gt; — roughly &lt;strong&gt;$0.20 or $0.12&lt;/strong&gt;. The API price is &lt;strong&gt;15x to 25x&lt;/strong&gt; the electricity, depending on when the box runs.&lt;/p&gt;

&lt;p&gt;I am not going to pretend that makes the API a bad deal. The API does not cost you 620 W of heat in your room, it does not need a 60 GB machine, it does not need you to keep a W4A16 quant on a 24 GB card, and its $3.00 buys you their datacenter's power, cooling and idle capacity, not yours. But for a box that is already on and already paying its standby tax, every token it serves is almost pure margin. The electricity argument for local inference is not "it is cheaper than the API" in any total-cost sense — it is "the marginal cost of a token on hardware you already run is an order of magnitude below the list price."&lt;/p&gt;

&lt;h2&gt;
  
  
  the verdict
&lt;/h2&gt;

&lt;p&gt;Qwen3.8-27B is the first 27B-class dense model I have run that I would put in front of real work on this box, and the numbers say why:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It fits.&lt;/strong&gt; W4A16 on one 3090, 22.6 GB, with room for a 128k context window. The TP2 variant doubles the context to 262k by splitting the weights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It is fast enough to feel like an API.&lt;/strong&gt; 125–190 tokens/sec decode is faster than most hosted endpoints feel in a chat UI. The part you notice is a cold long prompt: several seconds on either lane.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The two-card setup is not 2x.&lt;/strong&gt; It is roughly +20% decode and +14% cold prefill, and 2–3x on warm follow-ups because the pair keeps a bigger cache. Buy the second card for the context window and the cache, not for raw speed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The marginal cost is 0.21–0.34 BGN, about $0.12–0.20, per million tokens&lt;/strong&gt; against a $3.00 list price, on hardware that is already drawing its standby power.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmark with the cache in mind.&lt;/strong&gt; If a prefill number looks like 15,000 tokens/sec on consumer cards, it is the cache. Salt every request, or turn prefix caching off, and report which one you did.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The script, the raw JSON and the charts are in the repo: &lt;a href="https://github.com/SikamikanikoBG/qwen38-bench" rel="noopener noreferrer"&gt;qwen38-bench&lt;/a&gt;. If you run a different quant or a different card, I want your numbers — the interesting part of this post is the shape of the curve, and the shape changes with the hardware.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;vader: 3x RTX 3090 (two used here), dual Xeon E5-2660 v4, 60 GB RAM, Ubuntu 26.04, vLLM, W4A16-AutoRound-fast quantization.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>qwen</category>
    </item>
  </channel>
</rss>
