@t1merickson @joeltelling @CorridorDigital Will test CorridorKey2.0#1 at some point
36 views
@CorridorDigital Update: I let Claude take a crack at it. Have not had a chance to test it, but looks like the complication is the plugin needs to operate on more than 1 frame at a time, so processing would need to go through a background server app: CorridorKey2.0#1
9 likes · 942 views
What 117 real local-LLM rigs tell us about tok/s per euro in 2026
---
title: "What 117 real local-LLM rigs tell us about tok/s per euro in 2026"
published: true
description: "We divided measured tokens/sec from 117 sourced home AI builds by current German GPU prices. A used RTX 3090 gives ~3x more speed per euro than an RTX 5090, and speculative decoding is the cheapest upgrade of all."
tags: llm, ai, hardware, opensource
canonical_url: getrigbase.com
---
"Which GPU should I buy for local LLMs?" usually gets answered with spec sheets. I wanted an answer from measured numbers, so I took the open RigBase dataset (117 home AI builds, 298 measured results, every row linked to its public source) and divided tokens/sec by what the card costs today.
Short version:
- A used RTX 3090 gives about 3× more generation speed per euro than an RTX 5090 on every current model measured on both. The 5090 is 1.76–1.86× faster, but costs ~5.7× as much.
- For gpt-oss 20B, 16 GB cards win on value. RTX 5070 Ti and 5060 Ti both deliver ~150 tok/s per €1,000.
- Speculative decoding (MTP and friends) gave a median 1.76× speedup in ten same-hardware before/after pairs. That's a free upgrade.
The dataset in one paragraph
- 117 builds, 298 results, 48 models. 26 of those models are what people currently run (Qwen3.8, Qwen3.6, Gemma 4, gpt-oss, Nemotron 3.5…), and they account for 211 of the results.
- Sources: 33 different sites. GitHub repos/issues are the biggest (47 builds), then Reddit (12), llm-speed.com (10) and llamabench.ai (7).
- Engines: llama.cpp or Ollama (198 results), vLLM (37), MLX (29), other engines (34).
- 53 results use speculative decoding and are flagged as such, because they are not comparable 1:1 with plain decode.
- Hardware: 72 NVIDIA builds, 24 Apple, 16 AMD, 3 Intel, 2 CPU-only.
It's free under CC BY 4.0: builds.csv and builds.json.
Method (so you can disagree with it)
- Speed = decode (generation) tokens/sec as reported by the source. Plain decode only unless stated, single card, no CPU offload. Where a source gives a range, I use the low end.
- Price = lowest new listing on billiger.de (Germany, incl. VAT), checked 16–24 Oct 2026. The exception is the RTX 3090, which is out of production. For it I use the used eBay sold median of €1,248 (237 sales, pcprice.watch, 24 Oct 2026).
- Value = tok/s ÷ GPU price × 1000, i.e. tok/s per €1,000 of GPU. GPU only: no CPU, RAM, PSU or case.
- Different sources use different quants and builds. I group comparisons by source wherever possible, and you should treat gaps under ~10% as noise.
1. gpt-oss 20B: one source, six cards
All of these are llama-bench runs in native MXFP4 from the same llama.cpp thread (discussion #15396), so this is the cleanest comparison in the set.
| GPU | tok/s | Price | tok/s per €1k | |
|---|---|---|---|---|
| RTX 5070 Ti | 189.45 | €1,245 new (2026-10-24) | 152.2 | build |
| RTX 5060 Ti 16GB | 111.51 | €739 new (2026-10-16) | 150.9 | build |
| RTX 3090 (used) | 161.77 | €1,248 used (eBay sold median) | 129.6 | build |
| RTX 5080 | 204.85 | €1,770 new (2026-10-24) | 115.7 | build |
| RTX 5090 | 282.51 | €7,140 new (2026-10-24) | 39.6 | build |
| RTX Pro 6000 Blackwell | 286.91 | €16,885 new (2026-10-24) | 17.0 | build |
The 5090 is only 1.49× faster than a 5070 Ti here (282.51 vs 189.45), yet it costs 5.7× as much. A 20B MoE in MXFP4 fits comfortably in 16 GB, so the extra VRAM buys you nothing on this model.
Same test rig, with power
Jeff Geerling's ai-benchmarks swaps cards in one Core Ultra 265K system and records peak whole-system power:
| GPU | gpt-oss 20B tok/s | Price | tok/s per €1k | Peak system power | tok/s per W |
|---|---|---|---|---|---|
| RTX 3060 12GB | 83.96 | €429 (2026-10-16) | 195.7 | 229.3 W | 0.37 |
| RTX A4000 | 93.55 | €1,050 (2026-10-24) | 89.1 | 199.4 W | 0.47 |
| Radeon AI Pro R9700 | 163.26 | €1,848 (2026-10-24) | 88.4 | 332.9 W | 0.49 |
| RTX 4090 | 279.65 | €3,955 (2026-10-16) | 70.7 | 509.1 W | 0.55 |
Sources: #40, #36, #35, #43. The quant isn't stated in his summary table.
The cheap card wins on value and the fast card wins on efficiency. The €429 RTX 3060 has the best tok/s per euro in the dataset for this model. One caveat: gpt-oss 20B's weights are about 13 GB, more than the card's 12 GB, so part of the model may have run from system RAM. The 4090 draws more than twice the power, but gets the most tokens per watt.
2. Current 24–32 GB models: used 3090 vs 4090 vs 5090
Here are single-card results on current models, without speculative decoding. Each cell shows measured tok/s → tok/s per €1k.
| Model | RTX 3090 (used, €1,248) | RTX 4090 (€3,955) | RTX 5090 (€7,140) | 5090 ÷ 3090 speed |
|---|---|---|---|---|
| Qwen3.6 35B-A3B | 146 → 117.0 | 160.6 → 40.6 | 258 → 36.1 | 1.77× |
| Gemma 4 31B | 38.8 → 31.1 | 43.8 → 11.1 | 71.3 → 10.0 | 1.84× |
| Nemotron 3.5 Bolt 74B-A9B (2-bit) | 87.75 → 70.3 | 108 → 27.3 | 154.7 → 21.7 | 1.76× |
| Qwen3.8 27B | 40.4 → 32.4 | — | 75.2 → 10.5 | 1.86× |
Sources per row:
- Qwen3.6: 3090 llamabench (low end of 146–153), 4090 llm-speed, 5090 llamabench.
- Gemma 4 31B: 3090, 4090, 5090.
- Nemotron Bolt: llm-speed post for the 3090 and 5090, llamabench for the 4090.
- Qwen3.8 27B: 3090 OpenJet README (llama.cpp, no speculation), 5090 llm-speed.
On all four models the used 3090 gives 3.1–3.2× more tok/s per euro than the 5090. The 5090 is consistently ~1.8× faster, which tracks its memory bandwidth (1,792 vs 936 GB/s). It also has 8 GB more VRAM, which matters for long context. If raw speed is the goal, buy the 5090. If value is the goal, the used 3090 remains the card to beat in late 2026.
3. The cheapest upgrade: speculative decoding
Here are ten before/after pairs where the same build ran the same model with the same engine and quant, and the only change was turning on a draft method (MTP, DSpark):
| Setup | Model | Plain tok/s | With drafting | Speedup |
|---|---|---|---|---|
| RTX 3090, llama.cpp Q4_K_M, MTP 2 drafts (code prompt) | Qwen3.8 27B | 40.4 | 72.5 | 1.79× |
| RTX 3090, llama.cpp Q4_K_M, MTP 2 drafts (reasoning prompt) | Qwen3.8 27B | 40.4 | 50.9 | 1.26× |
| Strix Halo, llama.cpp Vulkan Q6_K, MTP n=2 | Qwen3.8 27B | 9.59 | 18.01 | 1.88× |
| Strix Halo, llama.cpp ROCm Q8_0, MTP n=3 | Qwen3.8 27B | 7.76 | 15.67 | 2.02× |
| Strix Halo, llama.cpp Vulkan Q8_0, MTP n=3 | Qwen3.8 27B | 7.77 | 13.19 | 1.70× |
| DGX Spark, vLLM, MTP k=2 | Qwen3.8 27B | 11 | 19 | 1.73× |
| DGX Spark, vLLM, MTP drafter k=3 | Gemma 4 26B-A4B | 30.8 | 56.5 | 1.83× |
| DGX Spark, vLLM NVFP4, MTP k=2 | Qwen3.6 35B-A3B | 78.1 | 102.3 | 1.31× |
| DGX Spark, vLLM, DSpark k=3 | Nemotron 3.5 Lightning 30B-A3B | 70.3 | 94.4 | 1.34× |
| 2× Tesla V100, bnk Q8_0, MTP | Qwen3.8 27B | 42.4 | 104 | 2.45× |
Sources: OpenJet, Sleeping Robots, howquant.dev, Cristian Tala, bnk.
The speedups range from 1.26× to 2.45×, with a median of 1.76×. That's roughly the gap between a 3090 and a 5090, and it costs nothing. Two patterns show up:
- Small-active-parameter MoEs gain least. The dense Qwen3.8 27B gained 1.70–2.45× in every pair except one reasoning-prompt run, and Gemma 4 26B-A4B gained 1.83×. The ~3B-active MoE models (Qwen3.6 35B-A3B, Nemotron Lightning) gained only 1.31–1.34×.
- The prompt matters. On the same 3090, MTP gave 1.79× on a code prompt but 1.26× on a reasoning prompt. Predictable text gets more drafts accepted.
The catch is that drafting numbers are not comparable with plain decode across sources. That's why RigBase flags them ⚡ and lets you hide them.
4. Big models: where there's no good per-euro answer yet
For ~100B+ models, the hardware is mostly unified-memory boxes (Mac Studio, Strix Halo, DGX Spark), and I don't have comparable price data for them yet. So here are the raw measured speeds for gpt-oss 120B:
| Hardware | tok/s | Engine | |
|---|---|---|---|
| RTX Pro 6000 Blackwell (96 GB) | 196.31 | llama.cpp MXFP4 | source |
| Apple M2 Ultra 76-core (192 GB) | 79.68 | llama.cpp MXFP4 | source |
| Ryzen AI Max+ PRO 495 (Radeon 8065S) | 55.8 | llama.cpp Vulkan MXFP4 | source |
| Ryzen AI Max+ PRO 395 (Radeon 8060S) | 44.7 | llama.cpp Vulkan MXFP4 | source |
| DGX Spark (GB10) | 44.4 | vLLM, 128K ctx | source |
A €16,885 card is 2.5× faster than a 2023 Mac Studio, and the Strix Halo boxes match the DGX Spark. If you know reliable current prices for these systems, I'd like to add them.
Takeaways
1. Up to ~20B MoE (gpt-oss 20B, Qwen3.5 9B, Gemma 4 12B): buy a 16 GB card. The RTX 5060 Ti and 5070 Ti tie on value at ~150 tok/s per €1k.
2. 27–35B (Qwen3.8 27B, Qwen3.6 35B-A3B, Gemma 4 31B): a used RTX 3090 gives ~3× the value of a 5090. The 5090 is ~1.8× faster.
3. Before buying anything, turn on speculative decoding. In this data it was worth a median 1.76×.
4. Don't buy a halo card for value. The RTX Pro 6000 is at 17 tok/s per €1k on gpt-oss 20B, versus 152 for a 5070 Ti.
Get the data, add yours
- Browse by GPU, by VRAM size, or check if a model fits your card.
- Download everything: getrigbase.com (CSV + JSON, CC BY 4.0, attribution "RigBase").
- Ran llama-bench on a current model? Submit it. It needs a public proof link, and that's the only way a number gets in.
Data snapshot: RigBase builds.json, 24 Oct 2026. Prices: billiger.de 16–24 Oct 2026; used RTX 3090 from PCPrice.Watch, 24 Oct 2026. All ratios are arithmetic on the sourced numbers. Corrections welcome.
Raspberry Pi と Claude で忙しくても個人開発を続ける
現在、プライベートの事情で個人開発に時間が取れていません。個人開発は楽しいので、そんな中でもなんとか続けようと取り組んでいます。AIのおかげで今までできないことができるようになっていると思います。同じく忙しい方の参考になれば幸いです。
開発環境
Raspberry Piを常駐起動し、Claude CodeのRemote Controlでスマホから開発指示を出しています。
Raspberry Pi5 8GB
筐体:Geekworm
SSD:NVMe SSD 256GB
Claude Code
スマホ(メイン):Remote Control で Claudeアプリから
PC(時間が取れた時):Zed + tmux で
Claude Artifacts
スマホからMarkdownやソースコードが見られないのでありとあらゆるもののレビューに
wsl-relay
もともとはDocker Containerとの通信用ですがリモートホストからのClaude Code完了通知も受け取れる
Raspberry Pi セットアップ
microSDにOSをかき込む
Raspberry Pi Imager を PC にインストールし、以下を選択。
デバイス: Raspberry Pi 5
OS: Raspberry Pi OS Lite (64-bit)
ストレージ: microSD
歯車アイコン(詳細設定)を必ず開く
Lite はモニタをつないでも CLI ログインしかないため、ここを入れ忘れると詰む。
ホスト名: pi5-claude など
ユーザー名 / パスワード
SSH を有効化 → 公開鍵認証を選び、鍵を貼り付け
Wi-Fi(有線なら不要)
ロケール / タイムゾーン: Asia/Tokyo
書き込み後、microSD を Pi に挿して起動。
初回ログインとファームウェア更新
ssh ユーザー名@pi5-claude.local
sudo apt update && sudo apt full-upgrade -y
sudo rpi-eeprom-update -a
sudo reboot
ブート順を NVMe 優先に変更
sudo raspi-config
6 Advanced Options → Boot Order → NVMe/USB Boot
SSD に OS を入れる
git clone geerlingguy/rpi-clone
cd rpi-clone
sudo cp rpi-clone rpi-clone-setup /usr/local/sbin/
sudo poweroff
microSD を抜いてから電源投入
lsblk
/ が nvme0n1p2 になっていれば成功
ネットワーク設定
Wi-FiルーターでIPを固定化。設定方法はルーターのマニュアルを確認。
ip link show eth0 # link/ether の後ろ 12桁
ip link show wlan0 # 無線も使うなら両方
sudo reboot
起動後
ip a show eth0
SSH接続設定
~/.ssh/config に追記:
Host pi5
HostName 192.168.xxx.xxx(設定した固定IP)
User あなたのユーザー名
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 60
常駐化対応
セキュリティ更新の自動適用
sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
ログでディスクを埋めない
sudo journalctl --vacuum-size=200M
/etc/systemd/journald.conf に SystemMaxUse=200M を設定すると恒久化できる。
長時間タスクは tmux 内で実行し、セッションが切れても継続させる。
tmux new -s claude
デタッチ: Ctrl-b → d
再接続: tmux attach -t claude
仕組み
スマホだと一覧できる情報量が少なく、入力速度が遅いといった課題があります。毎回のプロンプトに頼っていては回らないです。Claude CodeのSKILLを使いPluginとして配布することで、AI開発に規律をもたらせるようにしています。忙しくなる前にベースを作っていたもので、かなり助けられています。代表的なものをいくつか紹介します。
docs-authoring
読者を定めて一視点で書く、ピラミッド構造で書く、一文一義などテクニカルライティングの手法をまとめたもので、スマホで読んでも判断しやすく読みやすいドキュメントができるようになります。
github.com/khaym/Claude-Code-Plugins/blob/main/pl…
dev-cycle
開発プロセスを定義したものです。human-in-the-loopで都度都度、人間の判断を入れていくと一向に開発が進みません。シフトレフトで事前に計画を承認し、承認出来たらClaudeが仕上げて検証するといったプロセスを定義しています。
github.com/khaym/Claude-Code-Plugins/blob/main/pl…
リンク
開発の進め方はそれぞれの人に流儀があると思うので、自分でClaudeにスキルを作らせた方がいいかもしれませんが参考用です。上記SKILL以外も配布しています。
khaym/Claude-Code-Plugins
What GPU do you need for Qwen3.8, Qwen3.6, Gemma 4 and gpt-oss locally? (measured, Oct 2026)
---
title: "What GPU do you need for Qwen3.8, Qwen3.6, Gemma 4 and gpt-oss locally? (measured, Oct 2026)"
published: true
description: "Measured tokens/sec for Qwen3.8 27B, Qwen3.6, Gemma 4 and gpt-oss on 117 real home rigs: RTX 3060 to RTX Pro 6000, Macs, DGX Spark, Strix Halo, Arc B70."
tags: llm, ai, hardware, machinelearning
canonical_url: getrigbase.com
---
Every time a new open model comes out, people ask the same thing: will it run on my card, and how fast? Spec-sheet calculators can tell you whether the weights fit. They can't tell you what speed you'll actually get. So here are measured generation speeds (decode tokens/sec) for the models people are running in October 2026, collected from public benchmarks. Every number is linked to the place it came from.
How to read the numbers
- Speeds are generation (decode) tokens/sec at short context unless noted. Prompt processing is a different number and is usually much faster.
- Sources use different engines, quants and llama.cpp builds. Treat gaps under ~10% as noise.
- "llamabench community" rows are aggregated llama-bench tg128 runs from llamabench.ai. Open the source for the exact quant and build.
TL;DR
| If you have… | What runs well (measured) |
|---|---|
| 12 GB (RTX 3060) | Gemma 4 12B at 38.5 tok/s, gpt-oss 20B at 84 tok/s |
| 16 GB (RTX 5060 Ti / 5070 Ti / 5080) | gpt-oss 20B at 112 / 189 / 205 tok/s |
| 24 GB (RTX 3090 / 4090) | Qwen3.6 35B-A3B at 140–153 tok/s on a 3090; Qwen3.8 27B at 40.4 tok/s (72.5 with MTP) on a 3090; Gemma 4 31B at 43.8 tok/s on a 4090 |
| 32 GB (RTX 5090) | Qwen3.6 35B-A3B at 258, Qwen3.8 27B at 75.2, Gemma 4 31B at 70 tok/s |
| 96 GB+ (RTX Pro 6000, Macs) | gpt-oss 120B at 196 tok/s (Pro 6000) / 79.7 tok/s (M2 Ultra) |
Details and sources below.
1. gpt-oss 20B: the one model everyone has benchmarked
gpt-oss 20B runs in its native MXFP4 format, so these numbers compare well across cards. These are llama-bench tg128 results from the official llama.cpp gpt-oss guide (discussion #15396), plus Apple results:
| Hardware | Memory | gpt-oss 20B tok/s | Source |
|---|---|---|---|
| RTX Pro 6000 (Blackwell) | 96 GB | 286.91 | llama.cpp #15396 |
| RTX 5090 | 32 GB | 282.51 | llama.cpp #15396 |
| RTX 5080 | 16 GB | 204.85 | llama.cpp #15396 |
| RTX 5070 Ti | 16 GB | 189.45 | llama.cpp #15396 |
| RTX 3090 | 24 GB | 161.77 | llama.cpp #15396 |
| Apple M2 Ultra (76-core GPU) | 192 GB | 116.08 | llama.cpp #4167 |
| Apple M3 Ultra | 512 GB | 115.52 | llama.cpp #15396 |
| RTX 5060 Ti 16GB | 16 GB | 111.51 | llama.cpp #15396 |
| Apple M4 Max (MacBook) | 36 GB | 92.36 | llama.cpp #15396 |
| Apple M1 Max | 64 GB | 75.15 | llama.cpp #15396 |
Takeaway: a 16 GB card is enough for gpt-oss 20B. Even the 5060 Ti does over 100 tok/s, which is far faster than you can read.
Speed and power: whole-system measurements
Jeff Geerling's ai-benchmarks records peak whole-system power during each run. That data is rare. (His summary table doesn't state the quant.)
| System | gpt-oss 20B tok/s | Peak system power | tok/s per W (arithmetic) | Source |
|---|---|---|---|---|
| Core Ultra 265K + RTX 4090 | 279.65 | 509.1 W | 0.55 | #43 |
| Core Ultra 265K + Radeon AI Pro R9700 (32 GB) | 163.26 | 332.9 W | 0.49 | #35 |
| Core Ultra 265K + RTX 4070 Ti | 163.14 | 298.7 W | 0.55 | #45 |
| Mac Studio M3 Ultra 512 GB | 115.29 | 227 W | 0.51 | #42 |
| Core Ultra 265K + RTX A4000 | 93.55 | 199.4 W | 0.47 | #36 |
| Dell Pro Max GB10 (DGX Spark) | 87.97 | 122.8 W | 0.72 | #34 |
| Core Ultra 265K + RTX 3060 | 83.96 | 229.3 W | 0.37 | #40 |
The DGX Spark is the slowest box here on raw speed, but it gets the most tokens per watt. The 4090 is the fastest, at about 4× the Spark's peak draw.
2. gpt-oss 120B: you need ~96 GB of fast memory
| Hardware | gpt-oss 120B tok/s | Source |
|---|---|---|
| RTX Pro 6000 (Blackwell, 96 GB) | 196.31 | llama.cpp #15396 |
| Apple M2 Ultra 192 GB | 79.68 | llama.cpp #4167 |
3. Qwen3.6 35B-A3B: the 24 GB sweet spot
It's a mixture-of-experts model with only ~3B active parameters, so it runs very fast once it fits.
| Hardware | Setup | tok/s | Source |
|---|---|---|---|
| RTX 5090 (32 GB) | llama-bench tg128 | 258 | llamabench.ai |
| 4× RTX 3090 (using 2) | vLLM, AWQ-INT4, TP=2 + MTP-3 speculative decoding | 225–231 | tfriedel/qwen3.6-rtx3090-lab |
| 4× RTX 3090 (using 2) | vLLM, AWQ-INT4, TP=2 | 149 | same |
| RTX 3090 (24 GB) | llama-bench tg128 | 146–153 | llamabench.ai |
| RTX 3090 (24 GB) | Q4 GGUF, all layers on GPU, ~90K context | 139.6 | Giles Thomas |
| RTX 3090 (24 GB) | llama.cpp, IQ4_XS, 1 GPU | 115–133 | tfriedel lab |
| RTX 4090 (24 GB) | llama.cpp, Q4_K_M | 160.6 | llm-speed |
| Apple M5 Max (36 GB) | oMLX, 4-bit MLX | 145.8 | Rapid-MLX compare |
| Intel Arc Pro B70 (32 GB) | vLLM XPU, GPTQ Int4, no MTP | 120 | riceharvest cookbook |
| DGX Spark (GB10) | vLLM, NVFP4, 128K ctx, no spec. decoding | 78.1 | Cristian Tala |
| Strix Halo (Radeon 8060S, 96 GB) | llama-bench tg128 | 50.2–62.4 | llamabench.ai |
Two things stand out:
- One used RTX 3090 is enough. Giles Thomas got 139.6 tok/s while keeping ~90K tokens of context on the GPU. At the full 262K context, with 12 layers of FFNs moved to the CPU, he still got about 66 tok/s (source).
- A second 3090 helps through vLLM, not llama.cpp. Tensor parallel with AWQ went from 115–133 to 149 tok/s, and adding MTP speculative decoding pushed it to 225–231.
4. Qwen3.8 27B: the new default dense model
Qwen3.8 27B replaced Qwen3.6 27B and is now the most-benchmarked model in RigBase (50 results). It's dense, so every token reads all ~27B weights, and speed follows memory bandwidth. These are plain decode numbers, without speculative decoding:
| Hardware | Setup | tok/s | Source |
|---|---|---|---|
| RTX 5090 (32 GB) | llama.cpp, Q4_K_M | 75.2 | llm-speed |
| RTX 3090 (24 GB) | OpenJet megakernel, Q4_K_M | 42.9 | OpenJet |
| RTX 3090 (24 GB) | llama.cpp, Q4_K_M | 40.4 | OpenJet |
| Apple M5 Max (128 GB) | MLX 4-bit | 31 | Baobab Tech |
| Radeon AI Pro R9700 (32 GB) | Ollama, Q4_K_M (includes hidden reasoning tokens) | 30 | vaditaslim |
| RTX 5060 Ti 16GB | llama.cpp, UD-IQ4_XS, 8K ctx | 28.0 | JESVS |
| Intel Arc Pro B70 (32 GB) | llama.cpp SYCL, Q4_K_M | 27.8 | b70-optimization-lab |
| Strix Halo (Radeon 8060S) | llama.cpp ROCm, Q4_K_M | 12.6 | AkitaOnRails |
| DGX Spark (GB10) | vLLM, 128K ctx | 11.0 | Cristian Tala |
| RTX 3060 12GB | llama-bench (doesn't fit, offloads) | 5.2–6.7 | llamabench.ai |
Turn on MTP. Qwen3.8 supports multi-token-prediction (MTP) drafting, and in same-hardware tests it helps a lot. On the RTX 3090 it took llama.cpp from 40.4 to 72.5 tok/s on a code prompt (50.9 on a reasoning prompt) (OpenJet). On Strix Halo it took Vulkan Q6_K from 9.59 to 18.01 (Sleeping Robots), and on the DGX Spark it took vLLM from 11.0 to 19.0 (Cristian Tala).
5. Gemma 4 and Qwen3.6 27B: dense models, where bandwidth wins
Dense models read every weight for every token, so speed follows memory bandwidth. Here are llamabench community tg128 results:
| Model | RTX 5090 | RTX 4090 | 5090 ÷ 4090 (arithmetic) |
|---|---|---|---|
| Gemma 4 31B | 70.0 | 43.8 | 1.60× |
| Qwen3.6 27B (legacy, now Qwen3.8 27B) | 75.7 | 47.0 | 1.61× |
| Gemma 4 26B-A4B (MoE) | 235 | 164 | 1.43× |
| Gemma 4 12B | 142 | 98.1 | 1.45× |
| gpt-oss 20B | 313 | 203 | 1.54× |
Sources: RTX 5090, RTX 4090.
On dense ~30B models, the 5090 is about 1.6× faster than the 4090. On the MoE models the gap is smaller.
For the RTX 3090, llamabench lists 40.2 tok/s on Qwen3.6 27B (source). That row includes some runs with speculative decoding, so don't compare it directly with the table above.
Gemma 4 12B on smaller cards (llamabench community):
| Card | Gemma 4 12B tok/s | Source |
|---|---|---|
| RTX 5070 Ti (16 GB) | 87.6 | llamabench.ai |
| RTX 3060 (12 GB) | 38.5 | llamabench.ai |
So what should you buy?
Based only on the numbers above:
- gpt-oss 20B / Gemma 4 12B: any 16 GB card works. A 12 GB RTX 3060 runs both at usable speeds.
- Qwen3.6 35B-A3B: a single 24 GB card. A used RTX 3090 does 140–153 tok/s.
- Dense ~30B (Qwen3.8 27B, Gemma 4 31B): 24 GB fits them at Q4. A used 3090 does ~40 tok/s on Qwen3.8 27B (≈72 with MTP); the RTX 5090 does 75.2 and is ~1.6× faster than a 4090 on Gemma 4 31B.
- gpt-oss 120B: a 96 GB GPU or a big-memory Mac.
- Lowest power: the DGX Spark had the best tokens-per-watt on gpt-oss 20B in Geerling's tests, though it was the slowest box.
Missing your card?
The full dataset has 117 builds and 298 measured results, with parts lists, power draw and a link to every source: RigBase. Browse by GPU or by VRAM size, check fit on the Can it run? calculator, or download it all free as CSV/JSON (CC BY 4.0).
If you've run llama-bench on a current model, submit it. It needs a proof link, and that's the only way a number gets in.
Data snapshot: RigBase builds.json, first published 19 Oct 2026, updated 24 Oct 2026. Corrections welcome. Every row links to its source, so check there first.
1 reaction · 5 comments
Yo @geerlingguy I made a PR to internet-pi to add the Dual-WAN support that I was working on :) Added the tests that I've made in the PR description as well
internet-pi#666 t.co
2 likes · 77 views
How I Keep My Mac Up to Date With Dotfiles and Ansible
How I Keep My Mac Up to Date With Dotfiles and Ansible
The commit log of my dotfiles repo reads like a diary of a bad week. "Fix git under Rosetta." "Target native Apple Silicon Homebrew." "Drop Rosetta leftovers." "Ansible: stop forcing cask installs over hand-installed apps."
None of those commits are about adding a feature. They are all about the same thing: my Mac had drifted away from what I thought it was. Intel Homebrew in one place, native Homebrew in another, an app I installed by hand that Homebrew did not know about, a font the old install left behind that made the new one fail.
That is the real job of a dotfiles repo. Not "back up my .zshrc." The job is keeping the machine and the repo telling the same story, so that when something drifts, you notice and fix it in one place.
This article is how I do that on macOS with an Ansible-driven dotfiles setup. What the repo looks like, how a bare Mac becomes my Mac with one command, how I update it afterward, and the specific things that broke along the way. If you want to skip to the code, the whole thing is public at alexcloudstar/.dotfiles.
---
What "Up to Date" Actually Means for a Mac
When people say they keep their Mac up to date, they usually mean one of three different things. Mixing them up is why most dotfiles setups quietly rot.
Config drift. Your .zshrc, .tmux.conf, and Neovim config on the machine no longer match the repo. You tweaked something in a hurry and never committed it.
Software drift. You ran brew install something to follow a tutorial. Now the machine has a tool the repo has never heard of. Rebuild the Mac from the repo and that tool is gone.
Toolchain drift. Node, Rust, Go, and Python versions move. Global packages pile up. The thing that works on this machine fails on the next one because it was set up in a different order.
A symlink manager solves the first one. A Brewfile solves most of the second. Neither touches the third.
My rule is simple. The repo is the only place installs get recorded. If I want a tool, it goes into a file first, then the machine catches up. If it is on the machine and not in the file, it is drift, and I either add it or remove it.
---
Where Stow Stopped Being Enough
My dotfiles repo started in January 2024 with a Neovim config and a stow-compatible layout. I wrote about that approach in [Dotfiles: The Secret Weapon for Effortless Configuration Management](/blog/dotfiles-the-secret-weapon-for-effortless-configuration-management/), and the advice there still holds for the first step. Put configs in a repo. Link them into place.
GNU Stow is good at exactly that. It is small, it has no config of its own, and it does one thing. The trouble is that "one thing" is only the first of the three drifts.
Stow manages files. It does not know about Homebrew. It does not install Node. It does not set your login shell. On a fresh Mac you still run a pile of commands from memory, or from a setup.sh that you stopped updating a year ago.
Other people hit the same wall. In a write-up on migrating from Stow to chezmoi, the author describes Stow writing every edit straight into each machine's repo clone, refusing to link over existing files like ~/.zprofile on fresh machines, and leaving packages and macOS settings in separate scripts. Different fix than mine, same diagnosis: the symlinks were the easy part.
So in April 2026 I added an Ansible playbook to the repo. Stow is still installed on my machine and nothing uses it. That is how I know the migration is done.
---
Why Ansible for macOS Dotfiles
Ansible is not the obvious choice for a personal laptop. It is built for fleets of servers, it is written in YAML, and it feels like overkill for one machine.
I picked it for three reasons that matter more on a laptop than people expect.
It is idempotent by design. Every task describes a state, not a command. "This symlink exists." "This package is installed." Run the playbook ten times and the tenth run changes nothing. That is the property that turns "setup script I run once" into "command I can run any time."
It does everything in one language. Symlinks, Homebrew taps, formulae, casks, /etc/shells, rustup, nvm, global npm packages. One tool, one command, one place to read what the machine looks like.
It has a dry-run mode. ansible-playbook site.yml --check tells you what would change without changing it. For a repo whose job is to reveal drift, that is the feature.
The cost is verbosity. Ansible wants twelve lines for what a shell one-liner does in one. I accept that because the twelve lines are still readable six months later.
For a well-known example of the same idea, look at Jeff Geerling's mac-dev-playbook. It covers Homebrew, Mac App Store apps, dotfiles, and macOS settings, with tags like dotfiles and homebrew for running a subset. As far as I can tell from its README, it is about installing. What to do a month later is the problem I actually have.
---
The Layout: One Command, Six Roles
The repo has one entry point. On a fresh Mac, the whole install is:
git clone --recurse-submodules https://github.com/alexcloudstar/.dotfiles ~/.dotfiles
cd ~/.dotfiles
./bootstrap.sh
bootstrap.sh is a plain bash script. It checks that you are on macOS and not running under Rosetta, installs Xcode Command Line Tools if they are missing, installs Homebrew, installs Ansible with Homebrew, installs the one Ansible collection the playbook needs, and runs the playbook:
ansible-galaxy collection install -r requirements.yml
ansible-playbook site.yml --ask-become-pass "$@"
That "$@" matters. Any argument you pass to bootstrap.sh goes straight to ansible-playbook, which is how ./bootstrap.sh --skip-tags claude works later.
The playbook applies six roles in order, and each role has a tag with the same name:
roles:
- { role: homebrew, tags: [homebrew] }
- { role: packages, tags: [packages] }
- { role: shell, tags: [shell] }
- { role: languages, tags: [languages] }
- { role: symlinks, tags: [symlinks] }
- { role: claude, tags: [claude] }
The order is not arbitrary. Homebrew has to exist before packages. Packages have to exist before the shell role, because the shell role wants Homebrew's zsh. The languages role wants go from Homebrew. Symlinks come last so a half-finished install never leaves you with broken config pointing at tools that are not there.
The repo itself is boring on purpose. One folder per tool (nvim/, zsh/, tmux/, yabai/, skhd/, gitconfig/), a local/ folder for scripts, and ansible/ for the playbook. No templating, no per-machine variables. One Mac, one config.
---
Config Drift: Plain Symlinks Beat Clever Tools
The symlinks role is the part Stow used to do. It is about thirty lines:
- name: Symlink home-level dotfiles
ansible.builtin.file:
src: "{{ dotfiles_root }}/{{ item.src }}"
dest: "{{ ansible_facts['env']['HOME'] }}/{{ item.dest }}"
state: link
force: true
loop:
- { src: "gitconfig/.gitconfig", dest: ".gitconfig" }
- { src: "tmux/.tmux.conf", dest: ".tmux.conf" }
- { src: "zsh/.zshrc", dest: ".zshrc" }
state: link with force: true means the link is created if missing, replaced if wrong, and left alone if right. That also fixes the Stow complaint above: a stale file at the destination does not block the run.
The reason I like symlinks over copies is the update path. When I edit ~/.zshrc, I am editing the file in the repo. git status tells me immediately that the machine and the repo disagree. There is no separate "sync" step to forget.
You do not need a dedicated tool for this. Ansible's file module is enough, and the link lives next to the rest of your provisioning.
---
Software Drift: Package Lists in One File
Every package, tap, and cask lives in a single variables file, ansible/roles/packages/vars/main.yml. Today that is about 35 formulae and 15 casks across four third-party taps.
homebrew_casks:
- claude
- docker-desktop
- ghostty
- google-chrome
- iterm2
- raycast
- visual-studio-code
The task that uses it is short:
- name: Install Homebrew casks
community.general.homebrew_cask:
name: "{{ homebrew_casks }}"
path: "{{ brew_bin | dirname }}"
state: present
accept_external_apps: true
If you are new to Homebrew, the split is simple. Formulae are command-line tools and libraries, built or poured into Homebrew's own prefix. Casks are macOS apps and fonts, installed with the vendor's installer into /Applications. Ansible has a separate module for each, so my vars file has two lists.
Adding a tool is a one-line pull request to myself. Removing one is the same. And because the file is the source of truth, "what is on my machine" is a question I can answer by reading it.
There is one wrinkle with third-party taps. Homebrew ignores formulae from taps it has not been told to trust, so yabai and skhd from asmvik/formulae need a brew trust first. The playbook runs it for those two formulae before the install step. Worth knowing if you pull from taps.
---
Toolchain Drift: Check First, Then Install
Languages are where "idempotent" gets tested, because their installers are not.
nvm, bun, and rustup all install by piping a script from the internet into a shell. Run that twice and you get either a duplicate, an error, or a surprise. So the languages role asks before it acts:
- name: Check installed go tools
ansible.builtin.stat:
path: "{{ ansible_facts['env']['HOME'] }}/go/bin/{{ item | basename }}"
loop: "{{ go_tools }}"
register: go_tool_stats
- name: Install missing go tools
ansible.builtin.command: "{{ brew_bin | dirname }}/go install {{ item.item }}@latest"
loop: "{{ go_tool_stats.results }}"
when: not item.stat.exists
First task checks whether the binary exists. Second task installs only the ones that do not. Same pattern for nvm, bun, rustup, Node, and the global npm packages. The playbook installs nvm at a pinned version, Node at the latest LTS, vercel as the one global npm package, a stable Rust toolchain, and five Go tools.
There is a tradeoff in that go-tools task, and I wrote it into a comment in the file: it only installs when the binary is missing. It never updates them. If I want newer gopls, I run go install by hand. I would rather have a predictable playbook than one that quietly changes versions on every run.
That tradeoff is the whole design question for toolchains. A re-runnable playbook and an auto-updater are two different tools, and I keep them separate.
---
How I Actually Update the Mac: The Upkeep Tag
This is the part most dotfiles guides skip, and the reason I wrote this article.
A normal run of my playbook installs what is missing and fixes what has drifted. It does not upgrade anything. Upgrading is slow, occasionally breaks things, and I do not want it happening every time I re-link a config file.
So upgrades live behind their own tag:
- name: Upgrade Homebrew formulae and casks
community.general.homebrew:
upgrade_all: true
path: "{{ brew_bin | dirname }}"
tags: [never, upkeep]
- name: Remove old Homebrew downloads and versions
ansible.builtin.command: "{{ brew_bin }} cleanup"
changed_when: true
tags: [never, upkeep]
never is a special Ansible tag. A task tagged never is skipped unless you ask for another tag it carries. Add upkeep to the same list and the task runs only when I run:
cd ~/.dotfiles/ansible
ansible-playbook site.yml --tags upkeep
That one command runs brew upgrade for formulae and casks, then brew cleanup to clear old versions. A plain ./bootstrap.sh never touches either.
Two things to know. First, the homebrew role updates Homebrew's own index on every run, so you always install against fresh package data even when you do not upgrade. Second, never is not as safe as it looks. Ansible applies a role's tag to every task inside it, and that includes tasks tagged never. Run --tags packages and the upgrade tasks come along for the ride. I caught this in my own repo with --list-tasks, and I fixed it by adding a guard to both tasks:
when: "'upkeep' in ansible_run_tags"
Now the upgrade only runs when upkeep is one of the tags you asked for. ansible-playbook site.yml --tags <tag> --list-tasks is a good habit before you trust any tag: it shows exactly what will run.
The mental model I settled on has two commands:
- Converge: ./bootstrap.sh. Make the machine match the repo. Safe, fast, run it whenever.
- Upkeep: --tags upkeep. Move versions forward. Run it on purpose.
Separating them is the single best decision in the repo.
---
The Gotchas That Cost Me an Afternoon Each
Most of the value in an Ansible setup is in the problems you only find by running it on a real, messy machine. Here are the ones that left a mark in the commit history.
Rosetta Pretending to Be Apple Silicon
For a while my Apple Silicon Mac was running an Intel-era setup. Leftovers from the old Intel Homebrew, x86_64 toolchains, git needing a workaround under Rosetta. Mostly it worked, which is exactly the problem.
The fix was to stop assuming and start checking. The playbook now fails fast if it detects an x86_64 shell on arm64 hardware:
- name: Fail if running under Rosetta on Apple Silicon
ansible.builtin.fail:
msg: "Running under Rosetta (x86_64). Open a native arm64 terminal and rerun."
when:
- ansible_facts['os_family'] == 'Darwin'
- ansible_facts['architecture'] != 'arm64'
bootstrap.sh has the same guard in shell, because Ansible itself is not installed yet when the first check needs to run. The languages role goes further and reinstalls rustup natively if it finds an x86_64 binary.
The lesson is general. If your setup has an assumption, encode it as a check that fails loudly. A guard that costs two lines is cheaper than a month of slow, subtly wrong builds.
Docker Desktop Needs sudo, and Ansible Has No Terminal
The docker-desktop cask links docker-compose into /usr/local/cli-plugins and runs sudo mkdir to create the folder. Inside a playbook there is no terminal to type a password into, so Homebrew died with sudo: a password is required and rolled the install back.
The fix is to create the folder first, as a separate task that Ansible can escalate properly:
- name: Create Docker CLI plugins directory
ansible.builtin.file:
path: /usr/local/cli-plugins
state: directory
mode: "0755"
become: true
The pattern applies to any cask that wants sudo. Find what it is trying to create and create it yourself, ahead of time, with become.
A Real Folder Where a Symlink Should Be
My Claude Code config lives in a private submodule and gets linked into ~/.claude. On one run a real ~/.claude/skills directory was already sitting where the link should go. A symlink cannot replace a directory with files in it, so the task would fail.
The role now looks for real directories at link destinations and moves them aside instead of deleting them:
- name: Move aside real directories blocking Claude Code symlinks
ansible.builtin.command:
argv: [mv, "{{ item.item }}", "{{ item.item }}.pre-dotfiles-bak"]
creates: "{{ item.item }}.pre-dotfiles-bak"
when: item.stat.exists and item.stat.isdir and not item.stat.islnk
I found out later that one of those backed-up folders held a skill I still needed. Moving aside instead of deleting is the only reason I got it back. Never let automation destroy something it did not create.
Dry Runs Cannot Always Dry-Run
--check is the best feature in Ansible for this use case, with one caveat. Tasks that need sudo, like adding Homebrew's zsh to /etc/shells, fail in check mode when there is no password available. My dry run reports one failure on that task when I run it without a terminal, and that is expected. If you want a fully clean dry run, run it from a real terminal with --ask-become-pass.
Hand-Installed Apps
The last one is about apps you installed by dragging a .dmg into /Applications. Homebrew refuses to install a cask over an app that is already there.
You have three options:
- Leave them alone. accept_external_apps: true, which my casks task uses, tells Ansible to skip apps that already exist.
- Adopt them. brew install --cask --adopt <name> tells Homebrew to take over an existing app. Its help text says it adopts existing artifacts that are identical to the ones being installed, so it only works when the app on disk matches the version Homebrew would install. The Ansible homebrew_cask module has no option for it, so it is a manual step outside the playbook.
- Trash and reinstall. Move the app to the Trash, run brew install --cask <name>, and Homebrew owns it from then on. Your settings live in ~/Library, so they survive.
I looked at adopting first, decided it did not fit the playbook, and ended up trashing and reinstalling nine apps. Docker Desktop and OBS needed a second attempt. If you are going to do it, do it once, early, and then let Ansible own them.
---
Stow vs Chezmoi vs Ansible vs Nix: Which Should You Pick?
There is no single right answer, and anyone who tells you otherwise is selling something. Here is how I think about it after living with a few of them.
| Tool | What it manages | Good at | Where it hurts |
| :--- | :--- | :--- | :--- |
| GNU Stow | Symlinks | Tiny, no config, easy to understand | Files only. Nothing for packages or settings |
| chezmoi | Dotfiles, with templates | Per-machine differences, encryption, one-command bootstrap | Copies files instead of linking, so a separate apply step |
| Brewfile | Homebrew packages | The lightest way to pin what is installed | Packages only |
| Ansible | Everything | One tool and one command for the whole machine | Verbose YAML, needs sudo, overkill for one config file |
| nix-darwin | Everything, declaratively | The most reproducible option | Steep learning curve, and GUI apps often still come from Homebrew |
My own rough rules:
- One machine, simple config, no packages to track: Stow or plain symlinks. Do not over-build.
- Several machines that differ a little: chezmoi. Its templating handles per-machine differences better than anything else on this list.
- One or two Macs and you want the whole machine described: Ansible, if you are comfortable with YAML.
- You like going deep and want reproducibility above all: look at nix-darwin. People who use it commonly pair it with Homebrew for GUI apps, as in this write-up on using Nix as a Homebrew user.
The split I see most often in real setups is the same one: a tool for the user's files, and a tool for the system. Mine just happens to be one tool for both.
If you need a refresher on keeping your own machine's tool sprawl in check before you even pick one, I wrote about [auditing a dev machine with SnapPoint](/blog/snappoint-a-hard-reset-for-your-dev-machine/).
---
Should You Copy This Setup?
Probably not line for line. A dotfiles repo is personal, and mine has opinions: yabai and skhd for tiling, Neovim with lazy.nvim, iTerm2 and Ghostty side by side, Homebrew Python and no version manager.
What I would copy is the shape: one entry point, one role per concern with tags, check before you install, converge and upkeep as separate commands, guards that fail loudly, and move things aside instead of deleting them.
If you want a working starting point, fork the repo. The README lists every language, formula, cask, and Neovim plugin it installs, explains each role, and documents the flags for skipping parts you do not want:
./bootstrap.sh --skip-tags claude # no access to the private submodule
./bootstrap.sh --skip-tags packages # keep your own brew setup
The claude/ folder is a private submodule, so skip that role or point .gitmodules at your own repo. Everything else is public. The first tagged release is v1.0.0. If it saves you an afternoon, a star on alexcloudstar/.dotfiles helps other people find it.
Once the base is in place, the same repo is a good home for your AI tooling config too. I covered how to organize that in [writing a Claude Code skill](/blog/writing-claude-code-skill-developer-guide-2026/), and a dotfiles repo is exactly where those folders belong.
---
Frequently Asked
Is Ansible good for managing dotfiles? It is good for provisioning a whole machine and fine for linking files. It is not the best tool if all you need is symlinks. Use it when you also want packages, shell setup, and language toolchains handled by the same command.
How do I set up a new Mac with Ansible? Install Apple's command line tools, Homebrew, and Ansible, then run a playbook from your dotfiles repo with --ask-become-pass. A small bootstrap script that does all of that, like the one in my repo, turns it into a single command.
How do I keep Homebrew packages up to date from a dotfiles repo? Keep the package list in a file in the repo, install from it, and run brew upgrade as a separate step. In my setup that step is an Ansible tag, upkeep, that never runs during a normal install.
Can I use brew install --cask --adopt in Ansible? Not through the homebrew_cask module, which has no adopt option. Run it manually once per app, or use accept_external_apps: true to leave hand-installed apps alone.
What about secrets in a public dotfiles repo? Keep them out. Use environment variables, ignored files, or a private submodule for anything sensitive. My Claude Code config is a private submodule for that reason.
---
My Take
I used to think of dotfiles as a backup. A place to put files so I would not lose them. That framing is why my first version was a pile of configs and a script I stopped updating.
The better framing is that a dotfiles repo is a contract. It says what my machine is supposed to be. The playbook enforces that contract, and the commit history records every time reality and the contract disagreed.
That is also why the boring parts matter most. A guard that rejects the wrong architecture. A task that creates a folder before a cask needs it. A flag that moves a stale directory aside instead of deleting it. None of it is clever. All of it is the difference between a repo that works on the day you write it and one that works the day you actually need it.
I do not think everyone needs Ansible for this. If Stow plus a Brewfile covers you, stay there, and be happy. But if you have ever rebuilt a Mac and spent the evening remembering what you had installed, write the contract down. Pick the smallest tool that can enforce it. Then run it often enough that the repo and the machine never get a chance to drift far apart.
If you want to see what that looks like in practice, the whole thing is at alexcloudstar/.dotfiles. Clone it, break it, and tell me what I missed.
1 reaction
mini-rack ⭐ 4k
A collection of small server rack builds for homelab and portable setups.
🔗 geerlingguy/mini-rack
#opensource t.co
5 views
Mentions
@t1merickson @joeltelling @CorridorDigital Will test CorridorKey2.0#1 at some point
@CorridorDigital Update: I let Claude take a crack at it. Have not had a chance to test it, but looks like the complication is the plugin needs to operate on more than 1 frame at a time, so processing would need to go through a background server app: CorridorKey2.0#1
What 117 real local-LLM rigs tell us about tok/s per euro in 2026 --- title: "What 117 real local-LLM rigs tell us about tok/s per euro in 2026" published: true description: "We divided measured tokens/sec from 117 sourced home AI builds by current German GPU prices. A used RTX 3090 gives ~3x more speed per euro than an RTX 5090, and speculative decoding is the cheapest upgrade of all." tags: llm, ai, hardware, opensource canonical_url: getrigbase.com --- "Which GPU should I buy for local LLMs?" usually gets answered with spec sheets. I wanted an answer from measured numbers, so I took the open RigBase dataset (117 home AI builds, 298 measured results, every row linked to its public source) and divided tokens/sec by what the card costs today. Short version: - A used RTX 3090 gives about 3× more generation speed per euro than an RTX 5090 on every current model measured on both. The 5090 is 1.76–1.86× faster, but costs ~5.7× as much. - For gpt-oss 20B, 16 GB cards win on value. RTX 5070 Ti and 5060 Ti both deliver ~150 tok/s per €1,000. - Speculative decoding (MTP and friends) gave a median 1.76× speedup in ten same-hardware before/after pairs. That's a free upgrade. The dataset in one paragraph - 117 builds, 298 results, 48 models. 26 of those models are what people currently run (Qwen3.8, Qwen3.6, Gemma 4, gpt-oss, Nemotron 3.5…), and they account for 211 of the results. - Sources: 33 different sites. GitHub repos/issues are the biggest (47 builds), then Reddit (12), llm-speed.com (10) and llamabench.ai (7). - Engines: llama.cpp or Ollama (198 results), vLLM (37), MLX (29), other engines (34). - 53 results use speculative decoding and are flagged as such, because they are not comparable 1:1 with plain decode. - Hardware: 72 NVIDIA builds, 24 Apple, 16 AMD, 3 Intel, 2 CPU-only. It's free under CC BY 4.0: builds.csv and builds.json. Method (so you can disagree with it) - Speed = decode (generation) tokens/sec as reported by the source. Plain decode only unless stated, single card, no CPU offload. Where a source gives a range, I use the low end. - Price = lowest new listing on billiger.de (Germany, incl. VAT), checked 16–24 Oct 2026. The exception is the RTX 3090, which is out of production. For it I use the used eBay sold median of €1,248 (237 sales, pcprice.watch, 24 Oct 2026). - Value = tok/s ÷ GPU price × 1000, i.e. tok/s per €1,000 of GPU. GPU only: no CPU, RAM, PSU or case. - Different sources use different quants and builds. I group comparisons by source wherever possible, and you should treat gaps under ~10% as noise. 1. gpt-oss 20B: one source, six cards All of these are llama-bench runs in native MXFP4 from the same llama.cpp thread (discussion #15396), so this is the cleanest comparison in the set. | GPU | tok/s | Price | tok/s per €1k | | |---|---|---|---|---| | RTX 5070 Ti | 189.45 | €1,245 new (2026-10-24) | 152.2 | build | | RTX 5060 Ti 16GB | 111.51 | €739 new (2026-10-16) | 150.9 | build | | RTX 3090 (used) | 161.77 | €1,248 used (eBay sold median) | 129.6 | build | | RTX 5080 | 204.85 | €1,770 new (2026-10-24) | 115.7 | build | | RTX 5090 | 282.51 | €7,140 new (2026-10-24) | 39.6 | build | | RTX Pro 6000 Blackwell | 286.91 | €16,885 new (2026-10-24) | 17.0 | build | The 5090 is only 1.49× faster than a 5070 Ti here (282.51 vs 189.45), yet it costs 5.7× as much. A 20B MoE in MXFP4 fits comfortably in 16 GB, so the extra VRAM buys you nothing on this model. Same test rig, with power Jeff Geerling's ai-benchmarks swaps cards in one Core Ultra 265K system and records peak whole-system power: | GPU | gpt-oss 20B tok/s | Price | tok/s per €1k | Peak system power | tok/s per W | |---|---|---|---|---|---| | RTX 3060 12GB | 83.96 | €429 (2026-10-16) | 195.7 | 229.3 W | 0.37 | | RTX A4000 | 93.55 | €1,050 (2026-10-24) | 89.1 | 199.4 W | 0.47 | | Radeon AI Pro R9700 | 163.26 | €1,848 (2026-10-24) | 88.4 | 332.9 W | 0.49 | | RTX 4090 | 279.65 | €3,955 (2026-10-16) | 70.7 | 509.1 W | 0.55 | Sources: #40, #36, #35, #43. The quant isn't stated in his summary table. The cheap card wins on value and the fast card wins on efficiency. The €429 RTX 3060 has the best tok/s per euro in the dataset for this model. One caveat: gpt-oss 20B's weights are about 13 GB, more than the card's 12 GB, so part of the model may have run from system RAM. The 4090 draws more than twice the power, but gets the most tokens per watt. 2. Current 24–32 GB models: used 3090 vs 4090 vs 5090 Here are single-card results on current models, without speculative decoding. Each cell shows measured tok/s → tok/s per €1k. | Model | RTX 3090 (used, €1,248) | RTX 4090 (€3,955) | RTX 5090 (€7,140) | 5090 ÷ 3090 speed | |---|---|---|---|---| | Qwen3.6 35B-A3B | 146 → 117.0 | 160.6 → 40.6 | 258 → 36.1 | 1.77× | | Gemma 4 31B | 38.8 → 31.1 | 43.8 → 11.1 | 71.3 → 10.0 | 1.84× | | Nemotron 3.5 Bolt 74B-A9B (2-bit) | 87.75 → 70.3 | 108 → 27.3 | 154.7 → 21.7 | 1.76× | | Qwen3.8 27B | 40.4 → 32.4 | — | 75.2 → 10.5 | 1.86× | Sources per row: - Qwen3.6: 3090 llamabench (low end of 146–153), 4090 llm-speed, 5090 llamabench. - Gemma 4 31B: 3090, 4090, 5090. - Nemotron Bolt: llm-speed post for the 3090 and 5090, llamabench for the 4090. - Qwen3.8 27B: 3090 OpenJet README (llama.cpp, no speculation), 5090 llm-speed. On all four models the used 3090 gives 3.1–3.2× more tok/s per euro than the 5090. The 5090 is consistently ~1.8× faster, which tracks its memory bandwidth (1,792 vs 936 GB/s). It also has 8 GB more VRAM, which matters for long context. If raw speed is the goal, buy the 5090. If value is the goal, the used 3090 remains the card to beat in late 2026. 3. The cheapest upgrade: speculative decoding Here are ten before/after pairs where the same build ran the same model with the same engine and quant, and the only change was turning on a draft method (MTP, DSpark): | Setup | Model | Plain tok/s | With drafting | Speedup | |---|---|---|---|---| | RTX 3090, llama.cpp Q4_K_M, MTP 2 drafts (code prompt) | Qwen3.8 27B | 40.4 | 72.5 | 1.79× | | RTX 3090, llama.cpp Q4_K_M, MTP 2 drafts (reasoning prompt) | Qwen3.8 27B | 40.4 | 50.9 | 1.26× | | Strix Halo, llama.cpp Vulkan Q6_K, MTP n=2 | Qwen3.8 27B | 9.59 | 18.01 | 1.88× | | Strix Halo, llama.cpp ROCm Q8_0, MTP n=3 | Qwen3.8 27B | 7.76 | 15.67 | 2.02× | | Strix Halo, llama.cpp Vulkan Q8_0, MTP n=3 | Qwen3.8 27B | 7.77 | 13.19 | 1.70× | | DGX Spark, vLLM, MTP k=2 | Qwen3.8 27B | 11 | 19 | 1.73× | | DGX Spark, vLLM, MTP drafter k=3 | Gemma 4 26B-A4B | 30.8 | 56.5 | 1.83× | | DGX Spark, vLLM NVFP4, MTP k=2 | Qwen3.6 35B-A3B | 78.1 | 102.3 | 1.31× | | DGX Spark, vLLM, DSpark k=3 | Nemotron 3.5 Lightning 30B-A3B | 70.3 | 94.4 | 1.34× | | 2× Tesla V100, bnk Q8_0, MTP | Qwen3.8 27B | 42.4 | 104 | 2.45× | Sources: OpenJet, Sleeping Robots, howquant.dev, Cristian Tala, bnk. The speedups range from 1.26× to 2.45×, with a median of 1.76×. That's roughly the gap between a 3090 and a 5090, and it costs nothing. Two patterns show up: - Small-active-parameter MoEs gain least. The dense Qwen3.8 27B gained 1.70–2.45× in every pair except one reasoning-prompt run, and Gemma 4 26B-A4B gained 1.83×. The ~3B-active MoE models (Qwen3.6 35B-A3B, Nemotron Lightning) gained only 1.31–1.34×. - The prompt matters. On the same 3090, MTP gave 1.79× on a code prompt but 1.26× on a reasoning prompt. Predictable text gets more drafts accepted. The catch is that drafting numbers are not comparable with plain decode across sources. That's why RigBase flags them ⚡ and lets you hide them. 4. Big models: where there's no good per-euro answer yet For ~100B+ models, the hardware is mostly unified-memory boxes (Mac Studio, Strix Halo, DGX Spark), and I don't have comparable price data for them yet. So here are the raw measured speeds for gpt-oss 120B: | Hardware | tok/s | Engine | | |---|---|---|---| | RTX Pro 6000 Blackwell (96 GB) | 196.31 | llama.cpp MXFP4 | source | | Apple M2 Ultra 76-core (192 GB) | 79.68 | llama.cpp MXFP4 | source | | Ryzen AI Max+ PRO 495 (Radeon 8065S) | 55.8 | llama.cpp Vulkan MXFP4 | source | | Ryzen AI Max+ PRO 395 (Radeon 8060S) | 44.7 | llama.cpp Vulkan MXFP4 | source | | DGX Spark (GB10) | 44.4 | vLLM, 128K ctx | source | A €16,885 card is 2.5× faster than a 2023 Mac Studio, and the Strix Halo boxes match the DGX Spark. If you know reliable current prices for these systems, I'd like to add them. Takeaways 1. Up to ~20B MoE (gpt-oss 20B, Qwen3.5 9B, Gemma 4 12B): buy a 16 GB card. The RTX 5060 Ti and 5070 Ti tie on value at ~150 tok/s per €1k. 2. 27–35B (Qwen3.8 27B, Qwen3.6 35B-A3B, Gemma 4 31B): a used RTX 3090 gives ~3× the value of a 5090. The 5090 is ~1.8× faster. 3. Before buying anything, turn on speculative decoding. In this data it was worth a median 1.76×. 4. Don't buy a halo card for value. The RTX Pro 6000 is at 17 tok/s per €1k on gpt-oss 20B, versus 152 for a 5070 Ti. Get the data, add yours - Browse by GPU, by VRAM size, or check if a model fits your card. - Download everything: getrigbase.com (CSV + JSON, CC BY 4.0, attribution "RigBase"). - Ran llama-bench on a current model? Submit it. It needs a public proof link, and that's the only way a number gets in. Data snapshot: RigBase builds.json, 24 Oct 2026. Prices: billiger.de 16–24 Oct 2026; used RTX 3090 from PCPrice.Watch, 24 Oct 2026. All ratios are arithmetic on the sourced numbers. Corrections welcome.
Raspberry Pi と Claude で忙しくても個人開発を続ける 現在、プライベートの事情で個人開発に時間が取れていません。個人開発は楽しいので、そんな中でもなんとか続けようと取り組んでいます。AIのおかげで今までできないことができるようになっていると思います。同じく忙しい方の参考になれば幸いです。 開発環境 Raspberry Piを常駐起動し、Claude CodeのRemote Controlでスマホから開発指示を出しています。 Raspberry Pi5 8GB 筐体:Geekworm SSD:NVMe SSD 256GB Claude Code スマホ(メイン):Remote Control で Claudeアプリから PC(時間が取れた時):Zed + tmux で Claude Artifacts スマホからMarkdownやソースコードが見られないのでありとあらゆるもののレビューに wsl-relay もともとはDocker Containerとの通信用ですがリモートホストからのClaude Code完了通知も受け取れる Raspberry Pi セットアップ microSDにOSをかき込む Raspberry Pi Imager を PC にインストールし、以下を選択。 デバイス: Raspberry Pi 5 OS: Raspberry Pi OS Lite (64-bit) ストレージ: microSD 歯車アイコン(詳細設定)を必ず開く Lite はモニタをつないでも CLI ログインしかないため、ここを入れ忘れると詰む。 ホスト名: pi5-claude など ユーザー名 / パスワード SSH を有効化 → 公開鍵認証を選び、鍵を貼り付け Wi-Fi(有線なら不要) ロケール / タイムゾーン: Asia/Tokyo 書き込み後、microSD を Pi に挿して起動。 初回ログインとファームウェア更新 ssh ユーザー名@pi5-claude.local sudo apt update && sudo apt full-upgrade -y sudo rpi-eeprom-update -a sudo reboot ブート順を NVMe 優先に変更 sudo raspi-config 6 Advanced Options → Boot Order → NVMe/USB Boot SSD に OS を入れる git clone geerlingguy/rpi-clone cd rpi-clone sudo cp rpi-clone rpi-clone-setup /usr/local/sbin/ sudo poweroff microSD を抜いてから電源投入 lsblk / が nvme0n1p2 になっていれば成功 ネットワーク設定 Wi-FiルーターでIPを固定化。設定方法はルーターのマニュアルを確認。 ip link show eth0 # link/ether の後ろ 12桁 ip link show wlan0 # 無線も使うなら両方 sudo reboot 起動後 ip a show eth0 SSH接続設定 ~/.ssh/config に追記: Host pi5 HostName 192.168.xxx.xxx(設定した固定IP) User あなたのユーザー名 IdentityFile ~/.ssh/id_ed25519 ServerAliveInterval 60 常駐化対応 セキュリティ更新の自動適用 sudo apt install -y unattended-upgrades sudo dpkg-reconfigure -plow unattended-upgrades ログでディスクを埋めない sudo journalctl --vacuum-size=200M /etc/systemd/journald.conf に SystemMaxUse=200M を設定すると恒久化できる。 長時間タスクは tmux 内で実行し、セッションが切れても継続させる。 tmux new -s claude デタッチ: Ctrl-b → d 再接続: tmux attach -t claude 仕組み スマホだと一覧できる情報量が少なく、入力速度が遅いといった課題があります。毎回のプロンプトに頼っていては回らないです。Claude CodeのSKILLを使いPluginとして配布することで、AI開発に規律をもたらせるようにしています。忙しくなる前にベースを作っていたもので、かなり助けられています。代表的なものをいくつか紹介します。 docs-authoring 読者を定めて一視点で書く、ピラミッド構造で書く、一文一義などテクニカルライティングの手法をまとめたもので、スマホで読んでも判断しやすく読みやすいドキュメントができるようになります。 github.com/khaym/Claude-Code-Plugins/blob/main/pl… dev-cycle 開発プロセスを定義したものです。human-in-the-loopで都度都度、人間の判断を入れていくと一向に開発が進みません。シフトレフトで事前に計画を承認し、承認出来たらClaudeが仕上げて検証するといったプロセスを定義しています。 github.com/khaym/Claude-Code-Plugins/blob/main/pl… リンク 開発の進め方はそれぞれの人に流儀があると思うので、自分でClaudeにスキルを作らせた方がいいかもしれませんが参考用です。上記SKILL以外も配布しています。 khaym/Claude-Code-Plugins
What GPU do you need for Qwen3.8, Qwen3.6, Gemma 4 and gpt-oss locally? (measured, Oct 2026) --- title: "What GPU do you need for Qwen3.8, Qwen3.6, Gemma 4 and gpt-oss locally? (measured, Oct 2026)" published: true description: "Measured tokens/sec for Qwen3.8 27B, Qwen3.6, Gemma 4 and gpt-oss on 117 real home rigs: RTX 3060 to RTX Pro 6000, Macs, DGX Spark, Strix Halo, Arc B70." tags: llm, ai, hardware, machinelearning canonical_url: getrigbase.com --- Every time a new open model comes out, people ask the same thing: will it run on my card, and how fast? Spec-sheet calculators can tell you whether the weights fit. They can't tell you what speed you'll actually get. So here are measured generation speeds (decode tokens/sec) for the models people are running in October 2026, collected from public benchmarks. Every number is linked to the place it came from. How to read the numbers - Speeds are generation (decode) tokens/sec at short context unless noted. Prompt processing is a different number and is usually much faster. - Sources use different engines, quants and llama.cpp builds. Treat gaps under ~10% as noise. - "llamabench community" rows are aggregated llama-bench tg128 runs from llamabench.ai. Open the source for the exact quant and build. TL;DR | If you have… | What runs well (measured) | |---|---| | 12 GB (RTX 3060) | Gemma 4 12B at 38.5 tok/s, gpt-oss 20B at 84 tok/s | | 16 GB (RTX 5060 Ti / 5070 Ti / 5080) | gpt-oss 20B at 112 / 189 / 205 tok/s | | 24 GB (RTX 3090 / 4090) | Qwen3.6 35B-A3B at 140–153 tok/s on a 3090; Qwen3.8 27B at 40.4 tok/s (72.5 with MTP) on a 3090; Gemma 4 31B at 43.8 tok/s on a 4090 | | 32 GB (RTX 5090) | Qwen3.6 35B-A3B at 258, Qwen3.8 27B at 75.2, Gemma 4 31B at 70 tok/s | | 96 GB+ (RTX Pro 6000, Macs) | gpt-oss 120B at 196 tok/s (Pro 6000) / 79.7 tok/s (M2 Ultra) | Details and sources below. 1. gpt-oss 20B: the one model everyone has benchmarked gpt-oss 20B runs in its native MXFP4 format, so these numbers compare well across cards. These are llama-bench tg128 results from the official llama.cpp gpt-oss guide (discussion #15396), plus Apple results: | Hardware | Memory | gpt-oss 20B tok/s | Source | |---|---|---|---| | RTX Pro 6000 (Blackwell) | 96 GB | 286.91 | llama.cpp #15396 | | RTX 5090 | 32 GB | 282.51 | llama.cpp #15396 | | RTX 5080 | 16 GB | 204.85 | llama.cpp #15396 | | RTX 5070 Ti | 16 GB | 189.45 | llama.cpp #15396 | | RTX 3090 | 24 GB | 161.77 | llama.cpp #15396 | | Apple M2 Ultra (76-core GPU) | 192 GB | 116.08 | llama.cpp #4167 | | Apple M3 Ultra | 512 GB | 115.52 | llama.cpp #15396 | | RTX 5060 Ti 16GB | 16 GB | 111.51 | llama.cpp #15396 | | Apple M4 Max (MacBook) | 36 GB | 92.36 | llama.cpp #15396 | | Apple M1 Max | 64 GB | 75.15 | llama.cpp #15396 | Takeaway: a 16 GB card is enough for gpt-oss 20B. Even the 5060 Ti does over 100 tok/s, which is far faster than you can read. Speed and power: whole-system measurements Jeff Geerling's ai-benchmarks records peak whole-system power during each run. That data is rare. (His summary table doesn't state the quant.) | System | gpt-oss 20B tok/s | Peak system power | tok/s per W (arithmetic) | Source | |---|---|---|---|---| | Core Ultra 265K + RTX 4090 | 279.65 | 509.1 W | 0.55 | #43 | | Core Ultra 265K + Radeon AI Pro R9700 (32 GB) | 163.26 | 332.9 W | 0.49 | #35 | | Core Ultra 265K + RTX 4070 Ti | 163.14 | 298.7 W | 0.55 | #45 | | Mac Studio M3 Ultra 512 GB | 115.29 | 227 W | 0.51 | #42 | | Core Ultra 265K + RTX A4000 | 93.55 | 199.4 W | 0.47 | #36 | | Dell Pro Max GB10 (DGX Spark) | 87.97 | 122.8 W | 0.72 | #34 | | Core Ultra 265K + RTX 3060 | 83.96 | 229.3 W | 0.37 | #40 | The DGX Spark is the slowest box here on raw speed, but it gets the most tokens per watt. The 4090 is the fastest, at about 4× the Spark's peak draw. 2. gpt-oss 120B: you need ~96 GB of fast memory | Hardware | gpt-oss 120B tok/s | Source | |---|---|---| | RTX Pro 6000 (Blackwell, 96 GB) | 196.31 | llama.cpp #15396 | | Apple M2 Ultra 192 GB | 79.68 | llama.cpp #4167 | 3. Qwen3.6 35B-A3B: the 24 GB sweet spot It's a mixture-of-experts model with only ~3B active parameters, so it runs very fast once it fits. | Hardware | Setup | tok/s | Source | |---|---|---|---| | RTX 5090 (32 GB) | llama-bench tg128 | 258 | llamabench.ai | | 4× RTX 3090 (using 2) | vLLM, AWQ-INT4, TP=2 + MTP-3 speculative decoding | 225–231 | tfriedel/qwen3.6-rtx3090-lab | | 4× RTX 3090 (using 2) | vLLM, AWQ-INT4, TP=2 | 149 | same | | RTX 3090 (24 GB) | llama-bench tg128 | 146–153 | llamabench.ai | | RTX 3090 (24 GB) | Q4 GGUF, all layers on GPU, ~90K context | 139.6 | Giles Thomas | | RTX 3090 (24 GB) | llama.cpp, IQ4_XS, 1 GPU | 115–133 | tfriedel lab | | RTX 4090 (24 GB) | llama.cpp, Q4_K_M | 160.6 | llm-speed | | Apple M5 Max (36 GB) | oMLX, 4-bit MLX | 145.8 | Rapid-MLX compare | | Intel Arc Pro B70 (32 GB) | vLLM XPU, GPTQ Int4, no MTP | 120 | riceharvest cookbook | | DGX Spark (GB10) | vLLM, NVFP4, 128K ctx, no spec. decoding | 78.1 | Cristian Tala | | Strix Halo (Radeon 8060S, 96 GB) | llama-bench tg128 | 50.2–62.4 | llamabench.ai | Two things stand out: - One used RTX 3090 is enough. Giles Thomas got 139.6 tok/s while keeping ~90K tokens of context on the GPU. At the full 262K context, with 12 layers of FFNs moved to the CPU, he still got about 66 tok/s (source). - A second 3090 helps through vLLM, not llama.cpp. Tensor parallel with AWQ went from 115–133 to 149 tok/s, and adding MTP speculative decoding pushed it to 225–231. 4. Qwen3.8 27B: the new default dense model Qwen3.8 27B replaced Qwen3.6 27B and is now the most-benchmarked model in RigBase (50 results). It's dense, so every token reads all ~27B weights, and speed follows memory bandwidth. These are plain decode numbers, without speculative decoding: | Hardware | Setup | tok/s | Source | |---|---|---|---| | RTX 5090 (32 GB) | llama.cpp, Q4_K_M | 75.2 | llm-speed | | RTX 3090 (24 GB) | OpenJet megakernel, Q4_K_M | 42.9 | OpenJet | | RTX 3090 (24 GB) | llama.cpp, Q4_K_M | 40.4 | OpenJet | | Apple M5 Max (128 GB) | MLX 4-bit | 31 | Baobab Tech | | Radeon AI Pro R9700 (32 GB) | Ollama, Q4_K_M (includes hidden reasoning tokens) | 30 | vaditaslim | | RTX 5060 Ti 16GB | llama.cpp, UD-IQ4_XS, 8K ctx | 28.0 | JESVS | | Intel Arc Pro B70 (32 GB) | llama.cpp SYCL, Q4_K_M | 27.8 | b70-optimization-lab | | Strix Halo (Radeon 8060S) | llama.cpp ROCm, Q4_K_M | 12.6 | AkitaOnRails | | DGX Spark (GB10) | vLLM, 128K ctx | 11.0 | Cristian Tala | | RTX 3060 12GB | llama-bench (doesn't fit, offloads) | 5.2–6.7 | llamabench.ai | Turn on MTP. Qwen3.8 supports multi-token-prediction (MTP) drafting, and in same-hardware tests it helps a lot. On the RTX 3090 it took llama.cpp from 40.4 to 72.5 tok/s on a code prompt (50.9 on a reasoning prompt) (OpenJet). On Strix Halo it took Vulkan Q6_K from 9.59 to 18.01 (Sleeping Robots), and on the DGX Spark it took vLLM from 11.0 to 19.0 (Cristian Tala). 5. Gemma 4 and Qwen3.6 27B: dense models, where bandwidth wins Dense models read every weight for every token, so speed follows memory bandwidth. Here are llamabench community tg128 results: | Model | RTX 5090 | RTX 4090 | 5090 ÷ 4090 (arithmetic) | |---|---|---|---| | Gemma 4 31B | 70.0 | 43.8 | 1.60× | | Qwen3.6 27B (legacy, now Qwen3.8 27B) | 75.7 | 47.0 | 1.61× | | Gemma 4 26B-A4B (MoE) | 235 | 164 | 1.43× | | Gemma 4 12B | 142 | 98.1 | 1.45× | | gpt-oss 20B | 313 | 203 | 1.54× | Sources: RTX 5090, RTX 4090. On dense ~30B models, the 5090 is about 1.6× faster than the 4090. On the MoE models the gap is smaller. For the RTX 3090, llamabench lists 40.2 tok/s on Qwen3.6 27B (source). That row includes some runs with speculative decoding, so don't compare it directly with the table above. Gemma 4 12B on smaller cards (llamabench community): | Card | Gemma 4 12B tok/s | Source | |---|---|---| | RTX 5070 Ti (16 GB) | 87.6 | llamabench.ai | | RTX 3060 (12 GB) | 38.5 | llamabench.ai | So what should you buy? Based only on the numbers above: - gpt-oss 20B / Gemma 4 12B: any 16 GB card works. A 12 GB RTX 3060 runs both at usable speeds. - Qwen3.6 35B-A3B: a single 24 GB card. A used RTX 3090 does 140–153 tok/s. - Dense ~30B (Qwen3.8 27B, Gemma 4 31B): 24 GB fits them at Q4. A used 3090 does ~40 tok/s on Qwen3.8 27B (≈72 with MTP); the RTX 5090 does 75.2 and is ~1.6× faster than a 4090 on Gemma 4 31B. - gpt-oss 120B: a 96 GB GPU or a big-memory Mac. - Lowest power: the DGX Spark had the best tokens-per-watt on gpt-oss 20B in Geerling's tests, though it was the slowest box. Missing your card? The full dataset has 117 builds and 298 measured results, with parts lists, power draw and a link to every source: RigBase. Browse by GPU or by VRAM size, check fit on the Can it run? calculator, or download it all free as CSV/JSON (CC BY 4.0). If you've run llama-bench on a current model, submit it. It needs a proof link, and that's the only way a number gets in. Data snapshot: RigBase builds.json, first published 19 Oct 2026, updated 24 Oct 2026. Corrections welcome. Every row links to its source, so check there first.
Yo @geerlingguy I made a PR to internet-pi to add the Dual-WAN support that I was working on :) Added the tests that I've made in the PR description as well internet-pi#666 t.co
How I Keep My Mac Up to Date With Dotfiles and Ansible How I Keep My Mac Up to Date With Dotfiles and Ansible The commit log of my dotfiles repo reads like a diary of a bad week. "Fix git under Rosetta." "Target native Apple Silicon Homebrew." "Drop Rosetta leftovers." "Ansible: stop forcing cask installs over hand-installed apps." None of those commits are about adding a feature. They are all about the same thing: my Mac had drifted away from what I thought it was. Intel Homebrew in one place, native Homebrew in another, an app I installed by hand that Homebrew did not know about, a font the old install left behind that made the new one fail. That is the real job of a dotfiles repo. Not "back up my .zshrc." The job is keeping the machine and the repo telling the same story, so that when something drifts, you notice and fix it in one place. This article is how I do that on macOS with an Ansible-driven dotfiles setup. What the repo looks like, how a bare Mac becomes my Mac with one command, how I update it afterward, and the specific things that broke along the way. If you want to skip to the code, the whole thing is public at alexcloudstar/.dotfiles. --- What "Up to Date" Actually Means for a Mac When people say they keep their Mac up to date, they usually mean one of three different things. Mixing them up is why most dotfiles setups quietly rot. Config drift. Your .zshrc, .tmux.conf, and Neovim config on the machine no longer match the repo. You tweaked something in a hurry and never committed it. Software drift. You ran brew install something to follow a tutorial. Now the machine has a tool the repo has never heard of. Rebuild the Mac from the repo and that tool is gone. Toolchain drift. Node, Rust, Go, and Python versions move. Global packages pile up. The thing that works on this machine fails on the next one because it was set up in a different order. A symlink manager solves the first one. A Brewfile solves most of the second. Neither touches the third. My rule is simple. The repo is the only place installs get recorded. If I want a tool, it goes into a file first, then the machine catches up. If it is on the machine and not in the file, it is drift, and I either add it or remove it. --- Where Stow Stopped Being Enough My dotfiles repo started in January 2024 with a Neovim config and a stow-compatible layout. I wrote about that approach in [Dotfiles: The Secret Weapon for Effortless Configuration Management](/blog/dotfiles-the-secret-weapon-for-effortless-configuration-management/), and the advice there still holds for the first step. Put configs in a repo. Link them into place. GNU Stow is good at exactly that. It is small, it has no config of its own, and it does one thing. The trouble is that "one thing" is only the first of the three drifts. Stow manages files. It does not know about Homebrew. It does not install Node. It does not set your login shell. On a fresh Mac you still run a pile of commands from memory, or from a setup.sh that you stopped updating a year ago. Other people hit the same wall. In a write-up on migrating from Stow to chezmoi, the author describes Stow writing every edit straight into each machine's repo clone, refusing to link over existing files like ~/.zprofile on fresh machines, and leaving packages and macOS settings in separate scripts. Different fix than mine, same diagnosis: the symlinks were the easy part. So in April 2026 I added an Ansible playbook to the repo. Stow is still installed on my machine and nothing uses it. That is how I know the migration is done. --- Why Ansible for macOS Dotfiles Ansible is not the obvious choice for a personal laptop. It is built for fleets of servers, it is written in YAML, and it feels like overkill for one machine. I picked it for three reasons that matter more on a laptop than people expect. It is idempotent by design. Every task describes a state, not a command. "This symlink exists." "This package is installed." Run the playbook ten times and the tenth run changes nothing. That is the property that turns "setup script I run once" into "command I can run any time." It does everything in one language. Symlinks, Homebrew taps, formulae, casks, /etc/shells, rustup, nvm, global npm packages. One tool, one command, one place to read what the machine looks like. It has a dry-run mode. ansible-playbook site.yml --check tells you what would change without changing it. For a repo whose job is to reveal drift, that is the feature. The cost is verbosity. Ansible wants twelve lines for what a shell one-liner does in one. I accept that because the twelve lines are still readable six months later. For a well-known example of the same idea, look at Jeff Geerling's mac-dev-playbook. It covers Homebrew, Mac App Store apps, dotfiles, and macOS settings, with tags like dotfiles and homebrew for running a subset. As far as I can tell from its README, it is about installing. What to do a month later is the problem I actually have. --- The Layout: One Command, Six Roles The repo has one entry point. On a fresh Mac, the whole install is: git clone --recurse-submodules https://github.com/alexcloudstar/.dotfiles ~/.dotfiles cd ~/.dotfiles ./bootstrap.sh bootstrap.sh is a plain bash script. It checks that you are on macOS and not running under Rosetta, installs Xcode Command Line Tools if they are missing, installs Homebrew, installs Ansible with Homebrew, installs the one Ansible collection the playbook needs, and runs the playbook: ansible-galaxy collection install -r requirements.yml ansible-playbook site.yml --ask-become-pass "$@" That "$@" matters. Any argument you pass to bootstrap.sh goes straight to ansible-playbook, which is how ./bootstrap.sh --skip-tags claude works later. The playbook applies six roles in order, and each role has a tag with the same name: roles: - { role: homebrew, tags: [homebrew] } - { role: packages, tags: [packages] } - { role: shell, tags: [shell] } - { role: languages, tags: [languages] } - { role: symlinks, tags: [symlinks] } - { role: claude, tags: [claude] } The order is not arbitrary. Homebrew has to exist before packages. Packages have to exist before the shell role, because the shell role wants Homebrew's zsh. The languages role wants go from Homebrew. Symlinks come last so a half-finished install never leaves you with broken config pointing at tools that are not there. The repo itself is boring on purpose. One folder per tool (nvim/, zsh/, tmux/, yabai/, skhd/, gitconfig/), a local/ folder for scripts, and ansible/ for the playbook. No templating, no per-machine variables. One Mac, one config. --- Config Drift: Plain Symlinks Beat Clever Tools The symlinks role is the part Stow used to do. It is about thirty lines: - name: Symlink home-level dotfiles ansible.builtin.file: src: "{{ dotfiles_root }}/{{ item.src }}" dest: "{{ ansible_facts['env']['HOME'] }}/{{ item.dest }}" state: link force: true loop: - { src: "gitconfig/.gitconfig", dest: ".gitconfig" } - { src: "tmux/.tmux.conf", dest: ".tmux.conf" } - { src: "zsh/.zshrc", dest: ".zshrc" } state: link with force: true means the link is created if missing, replaced if wrong, and left alone if right. That also fixes the Stow complaint above: a stale file at the destination does not block the run. The reason I like symlinks over copies is the update path. When I edit ~/.zshrc, I am editing the file in the repo. git status tells me immediately that the machine and the repo disagree. There is no separate "sync" step to forget. You do not need a dedicated tool for this. Ansible's file module is enough, and the link lives next to the rest of your provisioning. --- Software Drift: Package Lists in One File Every package, tap, and cask lives in a single variables file, ansible/roles/packages/vars/main.yml. Today that is about 35 formulae and 15 casks across four third-party taps. homebrew_casks: - claude - docker-desktop - ghostty - google-chrome - iterm2 - raycast - visual-studio-code The task that uses it is short: - name: Install Homebrew casks community.general.homebrew_cask: name: "{{ homebrew_casks }}" path: "{{ brew_bin | dirname }}" state: present accept_external_apps: true If you are new to Homebrew, the split is simple. Formulae are command-line tools and libraries, built or poured into Homebrew's own prefix. Casks are macOS apps and fonts, installed with the vendor's installer into /Applications. Ansible has a separate module for each, so my vars file has two lists. Adding a tool is a one-line pull request to myself. Removing one is the same. And because the file is the source of truth, "what is on my machine" is a question I can answer by reading it. There is one wrinkle with third-party taps. Homebrew ignores formulae from taps it has not been told to trust, so yabai and skhd from asmvik/formulae need a brew trust first. The playbook runs it for those two formulae before the install step. Worth knowing if you pull from taps. --- Toolchain Drift: Check First, Then Install Languages are where "idempotent" gets tested, because their installers are not. nvm, bun, and rustup all install by piping a script from the internet into a shell. Run that twice and you get either a duplicate, an error, or a surprise. So the languages role asks before it acts: - name: Check installed go tools ansible.builtin.stat: path: "{{ ansible_facts['env']['HOME'] }}/go/bin/{{ item | basename }}" loop: "{{ go_tools }}" register: go_tool_stats - name: Install missing go tools ansible.builtin.command: "{{ brew_bin | dirname }}/go install {{ item.item }}@latest" loop: "{{ go_tool_stats.results }}" when: not item.stat.exists First task checks whether the binary exists. Second task installs only the ones that do not. Same pattern for nvm, bun, rustup, Node, and the global npm packages. The playbook installs nvm at a pinned version, Node at the latest LTS, vercel as the one global npm package, a stable Rust toolchain, and five Go tools. There is a tradeoff in that go-tools task, and I wrote it into a comment in the file: it only installs when the binary is missing. It never updates them. If I want newer gopls, I run go install by hand. I would rather have a predictable playbook than one that quietly changes versions on every run. That tradeoff is the whole design question for toolchains. A re-runnable playbook and an auto-updater are two different tools, and I keep them separate. --- How I Actually Update the Mac: The Upkeep Tag This is the part most dotfiles guides skip, and the reason I wrote this article. A normal run of my playbook installs what is missing and fixes what has drifted. It does not upgrade anything. Upgrading is slow, occasionally breaks things, and I do not want it happening every time I re-link a config file. So upgrades live behind their own tag: - name: Upgrade Homebrew formulae and casks community.general.homebrew: upgrade_all: true path: "{{ brew_bin | dirname }}" tags: [never, upkeep] - name: Remove old Homebrew downloads and versions ansible.builtin.command: "{{ brew_bin }} cleanup" changed_when: true tags: [never, upkeep] never is a special Ansible tag. A task tagged never is skipped unless you ask for another tag it carries. Add upkeep to the same list and the task runs only when I run: cd ~/.dotfiles/ansible ansible-playbook site.yml --tags upkeep That one command runs brew upgrade for formulae and casks, then brew cleanup to clear old versions. A plain ./bootstrap.sh never touches either. Two things to know. First, the homebrew role updates Homebrew's own index on every run, so you always install against fresh package data even when you do not upgrade. Second, never is not as safe as it looks. Ansible applies a role's tag to every task inside it, and that includes tasks tagged never. Run --tags packages and the upgrade tasks come along for the ride. I caught this in my own repo with --list-tasks, and I fixed it by adding a guard to both tasks: when: "'upkeep' in ansible_run_tags" Now the upgrade only runs when upkeep is one of the tags you asked for. ansible-playbook site.yml --tags <tag> --list-tasks is a good habit before you trust any tag: it shows exactly what will run. The mental model I settled on has two commands: - Converge: ./bootstrap.sh. Make the machine match the repo. Safe, fast, run it whenever. - Upkeep: --tags upkeep. Move versions forward. Run it on purpose. Separating them is the single best decision in the repo. --- The Gotchas That Cost Me an Afternoon Each Most of the value in an Ansible setup is in the problems you only find by running it on a real, messy machine. Here are the ones that left a mark in the commit history. Rosetta Pretending to Be Apple Silicon For a while my Apple Silicon Mac was running an Intel-era setup. Leftovers from the old Intel Homebrew, x86_64 toolchains, git needing a workaround under Rosetta. Mostly it worked, which is exactly the problem. The fix was to stop assuming and start checking. The playbook now fails fast if it detects an x86_64 shell on arm64 hardware: - name: Fail if running under Rosetta on Apple Silicon ansible.builtin.fail: msg: "Running under Rosetta (x86_64). Open a native arm64 terminal and rerun." when: - ansible_facts['os_family'] == 'Darwin' - ansible_facts['architecture'] != 'arm64' bootstrap.sh has the same guard in shell, because Ansible itself is not installed yet when the first check needs to run. The languages role goes further and reinstalls rustup natively if it finds an x86_64 binary. The lesson is general. If your setup has an assumption, encode it as a check that fails loudly. A guard that costs two lines is cheaper than a month of slow, subtly wrong builds. Docker Desktop Needs sudo, and Ansible Has No Terminal The docker-desktop cask links docker-compose into /usr/local/cli-plugins and runs sudo mkdir to create the folder. Inside a playbook there is no terminal to type a password into, so Homebrew died with sudo: a password is required and rolled the install back. The fix is to create the folder first, as a separate task that Ansible can escalate properly: - name: Create Docker CLI plugins directory ansible.builtin.file: path: /usr/local/cli-plugins state: directory mode: "0755" become: true The pattern applies to any cask that wants sudo. Find what it is trying to create and create it yourself, ahead of time, with become. A Real Folder Where a Symlink Should Be My Claude Code config lives in a private submodule and gets linked into ~/.claude. On one run a real ~/.claude/skills directory was already sitting where the link should go. A symlink cannot replace a directory with files in it, so the task would fail. The role now looks for real directories at link destinations and moves them aside instead of deleting them: - name: Move aside real directories blocking Claude Code symlinks ansible.builtin.command: argv: [mv, "{{ item.item }}", "{{ item.item }}.pre-dotfiles-bak"] creates: "{{ item.item }}.pre-dotfiles-bak" when: item.stat.exists and item.stat.isdir and not item.stat.islnk I found out later that one of those backed-up folders held a skill I still needed. Moving aside instead of deleting is the only reason I got it back. Never let automation destroy something it did not create. Dry Runs Cannot Always Dry-Run --check is the best feature in Ansible for this use case, with one caveat. Tasks that need sudo, like adding Homebrew's zsh to /etc/shells, fail in check mode when there is no password available. My dry run reports one failure on that task when I run it without a terminal, and that is expected. If you want a fully clean dry run, run it from a real terminal with --ask-become-pass. Hand-Installed Apps The last one is about apps you installed by dragging a .dmg into /Applications. Homebrew refuses to install a cask over an app that is already there. You have three options: - Leave them alone. accept_external_apps: true, which my casks task uses, tells Ansible to skip apps that already exist. - Adopt them. brew install --cask --adopt <name> tells Homebrew to take over an existing app. Its help text says it adopts existing artifacts that are identical to the ones being installed, so it only works when the app on disk matches the version Homebrew would install. The Ansible homebrew_cask module has no option for it, so it is a manual step outside the playbook. - Trash and reinstall. Move the app to the Trash, run brew install --cask <name>, and Homebrew owns it from then on. Your settings live in ~/Library, so they survive. I looked at adopting first, decided it did not fit the playbook, and ended up trashing and reinstalling nine apps. Docker Desktop and OBS needed a second attempt. If you are going to do it, do it once, early, and then let Ansible own them. --- Stow vs Chezmoi vs Ansible vs Nix: Which Should You Pick? There is no single right answer, and anyone who tells you otherwise is selling something. Here is how I think about it after living with a few of them. | Tool | What it manages | Good at | Where it hurts | | :--- | :--- | :--- | :--- | | GNU Stow | Symlinks | Tiny, no config, easy to understand | Files only. Nothing for packages or settings | | chezmoi | Dotfiles, with templates | Per-machine differences, encryption, one-command bootstrap | Copies files instead of linking, so a separate apply step | | Brewfile | Homebrew packages | The lightest way to pin what is installed | Packages only | | Ansible | Everything | One tool and one command for the whole machine | Verbose YAML, needs sudo, overkill for one config file | | nix-darwin | Everything, declaratively | The most reproducible option | Steep learning curve, and GUI apps often still come from Homebrew | My own rough rules: - One machine, simple config, no packages to track: Stow or plain symlinks. Do not over-build. - Several machines that differ a little: chezmoi. Its templating handles per-machine differences better than anything else on this list. - One or two Macs and you want the whole machine described: Ansible, if you are comfortable with YAML. - You like going deep and want reproducibility above all: look at nix-darwin. People who use it commonly pair it with Homebrew for GUI apps, as in this write-up on using Nix as a Homebrew user. The split I see most often in real setups is the same one: a tool for the user's files, and a tool for the system. Mine just happens to be one tool for both. If you need a refresher on keeping your own machine's tool sprawl in check before you even pick one, I wrote about [auditing a dev machine with SnapPoint](/blog/snappoint-a-hard-reset-for-your-dev-machine/). --- Should You Copy This Setup? Probably not line for line. A dotfiles repo is personal, and mine has opinions: yabai and skhd for tiling, Neovim with lazy.nvim, iTerm2 and Ghostty side by side, Homebrew Python and no version manager. What I would copy is the shape: one entry point, one role per concern with tags, check before you install, converge and upkeep as separate commands, guards that fail loudly, and move things aside instead of deleting them. If you want a working starting point, fork the repo. The README lists every language, formula, cask, and Neovim plugin it installs, explains each role, and documents the flags for skipping parts you do not want: ./bootstrap.sh --skip-tags claude # no access to the private submodule ./bootstrap.sh --skip-tags packages # keep your own brew setup The claude/ folder is a private submodule, so skip that role or point .gitmodules at your own repo. Everything else is public. The first tagged release is v1.0.0. If it saves you an afternoon, a star on alexcloudstar/.dotfiles helps other people find it. Once the base is in place, the same repo is a good home for your AI tooling config too. I covered how to organize that in [writing a Claude Code skill](/blog/writing-claude-code-skill-developer-guide-2026/), and a dotfiles repo is exactly where those folders belong. --- Frequently Asked Is Ansible good for managing dotfiles? It is good for provisioning a whole machine and fine for linking files. It is not the best tool if all you need is symlinks. Use it when you also want packages, shell setup, and language toolchains handled by the same command. How do I set up a new Mac with Ansible? Install Apple's command line tools, Homebrew, and Ansible, then run a playbook from your dotfiles repo with --ask-become-pass. A small bootstrap script that does all of that, like the one in my repo, turns it into a single command. How do I keep Homebrew packages up to date from a dotfiles repo? Keep the package list in a file in the repo, install from it, and run brew upgrade as a separate step. In my setup that step is an Ansible tag, upkeep, that never runs during a normal install. Can I use brew install --cask --adopt in Ansible? Not through the homebrew_cask module, which has no adopt option. Run it manually once per app, or use accept_external_apps: true to leave hand-installed apps alone. What about secrets in a public dotfiles repo? Keep them out. Use environment variables, ignored files, or a private submodule for anything sensitive. My Claude Code config is a private submodule for that reason. --- My Take I used to think of dotfiles as a backup. A place to put files so I would not lose them. That framing is why my first version was a pile of configs and a script I stopped updating. The better framing is that a dotfiles repo is a contract. It says what my machine is supposed to be. The playbook enforces that contract, and the commit history records every time reality and the contract disagreed. That is also why the boring parts matter most. A guard that rejects the wrong architecture. A task that creates a folder before a cask needs it. A flag that moves a stale directory aside instead of deleting it. None of it is clever. All of it is the difference between a repo that works on the day you write it and one that works the day you actually need it. I do not think everyone needs Ansible for this. If Stow plus a Brewfile covers you, stay there, and be happy. But if you have ever rebuilt a Mac and spent the evening remembering what you had installed, write the contract down. Pick the smallest tool that can enforce it. Then run it often enough that the repo and the machine never get a chance to drift far apart. If you want to see what that looks like in practice, the whole thing is at alexcloudstar/.dotfiles. Clone it, break it, and tell me what I missed.
mini-rack ⭐ 4k A collection of small server rack builds for homelab and portable setups. 🔗 geerlingguy/mini-rack #opensource t.co