Local inference field note / 18 September 2026
I moved to DwarfStar, and the bottleneck moved too
antirez’s DwarfStar now carries Qwen3.8-Flash-Next, so my daily server left Ivan’s fork. Reading got 66 percent faster, and the next thing to fix was the disk cache, not the model.
- prefill speed, 534 against 322 tokens per second, upstream against the fork on the same sessions
- +66%
- to bring a 120K-token conversation back from disk after the fix
- 1 s
- on disk from one 77K-token conversation: seven checkpoints at 34.5 KB per token
- 11.6 GB
On this page
The short answer
I run a local model, Qwen3.8-Flash-Next, on a 64 GB Mac, and my assistant Hermes talks to it all day. The server is DwarfStar , Salvatore Sanfilippo’s inference engine. Until this week I ran Ivan Fioravanti’s fork of it, as set up in part three , because upstream did not carry this model. On 14 September it did, so I moved. Reading a prompt got 66 percent faster.
The next morning, replies from my assistant took minutes instead of seconds, and the server was as fast as ever.
The cause was a setting I had carried over unchanged. DwarfStar keeps one conversation in memory and writes the others to disk, so that it can bring them back when their turn comes. That morning three conversations took turns, and the 24 GB of disk I had allowed for those copies was not enough to hold them. The server kept deleting the copy it was about to need and re-reading the whole conversation instead, 50 to 60 thousand tokens at a time. Raising the disk budget to 64 GB fixed it: a switch between two 100K-token conversations now restores in about a second.
The rest of this note is the move itself, with the new server command; the arithmetic of why 24 GB was not enough and how to size it; and two things I tried or found upstream on the way: holding two conversations in memory at once, which has a fix waiting in PR 765 , and a case where a saved copy sits on disk but is never found.
Moving from ds4-metal to DwarfStar
This note replaces the server half of the previous recipe . Upstream is faster to read and to start, a little slower to write at mid-depth, and simpler to run: one self-contained 137 GiB model file, with the model’s 95 GiB lookup table inside it and read from the SSD on demand, instead of the fork’s separate 30 GB lookup file, which was a lower-precision copy of the same table. That removes two startup options the fork needed, the --ple flag and its eviction setting.
To move: clone antirez/ds4 at f06d3eb or later and build it with make; fetch the upstream model files with ./download_model.sh qwen38-q2 in the checkout, which brings the 137 GiB GGUF and the vision projector from antirez/qwen3.8-flash-next-gguf ; clear the checkpoint directory, because the two builds do not share a format; then start the server with:
cd ds4 ./ds4-server \ -m gguf/Qwen3.8-Flash-Next-Q2.gguf \ --vision gguf/mmproj-Qwen3.8-Flash-Next-Q8_0.gguf --metal \ -c 163840 --prefill-chunk 1024 --mtp \ --kv-disk-dir ~/.ds4/server-kv --kv-disk-space-mb 65536 \ -n 16384 --host 127.0.0.1 --port 8001
Compared with the fork’s line: no --ple, no DS4_QWEN4_PLE_EVICT_TOKENS, a new model file, and a disk cache budget of 64 GB instead of 24, for the reason this note is about. Hermes does not change at all: same endpoint, same model names, same config. Ivan’s model files can go once you are happy; mine were 72 GB.
Swipe to compare columns →
| Ivan's fork 6c1e836 | Upstream 9139e2a | |
|---|---|---|
| Startup to ready | ~60 s | 21 s cold, 6 s warm |
| Prefill at 60K depth | 322 tok/s | 534 tok/s |
| Decode at 60K depth | 54.8 tok/s | 47.3 tok/s |
| Decode at 80K to 160K | 43 to 46 tok/s | 44 tok/s |
| Cost of a 10K-token tool result | ~30 s | ~19 s |
| Exact-sampling stall | 4 to 6 in 16 turns | 3 in 15 |
| --mtp alone | 0 stalls | 0 stalls |
| Memory | idle 45, busy 52 GiB wired | max 51.9 GiB wired |
Decode is about 14 percent slower on upstream at 60K, and from 80K to 160K it sits inside the fork’s range. For an agent that spends most of its time reading tool results, prefill is the number that matters, so I switched. The rare mid-reply hang from the previous note, where the exact-sampling option replays the whole context, is still there upstream: three stalls in fifteen turns with it on, none with --mtp alone, so the recipe still leaves --mtp-exact-sampling out.
On my machine the switch was that edit to the macOS service definition and a restart, on the morning of 15 September.
The morning it got slow
The time went to re-reading conversations and waiting in the queue, not to generating. Hermes’s own log records how long each reply took to produce:
| Day | Turns |
|---|---|
| 15 September, 20 turns | 2.6 to 150 s, most under 90 s |
| 16 September, 06:00 to 08:20, 11 turns | 143, 12, 186, 258, 44, 554, 642, 69, 277, 490, 875 s |
The server, in the same two hours: prefill 486 to 522 tokens per second, decode 38 to 44. Identical to the day before.
06:45 to 07:41, three conversations. My main session was at 87K to 103K tokens. Two scheduled finance jobs were growing, one from 9K to 60K tokens, the other from 10K to 58K. The server holds one conversation live. Every time the next request came from a different conversation, the log said live kv cache miss, and the request queued behind whichever one was already re-reading. Calls in this window took 68 to 363 seconds each, including calls of mine that had 90K tokens cached and only had to wait. At 07:30 I pressed stop on a reply that had made thirteen calls.
07:46, compaction. The main session reached 126K tokens and Hermes compacted it, which means it summarised the older messages to shrink the session: 208 messages became 146, about 70K tokens. Compaction is one full prefill of what is kept, and it landed between the two windows.
07:55 to 08:19, two conversations in one session. After the compaction, my turns at 70K to 77K alternated with a background skill review that Hermes runs as a concurrent turn inside the same session, at 62K. Thirteen calls, two in flight at a time, most of them 200 to 266 seconds from request to reply, and every one reported only 14,336 cached tokens: the system prompt and nothing else. The skill system rejected every write the review attempted, so that time changed nothing.
Why the disk cache did not help
A conversation leaves far more on disk than its final copy, and that is what overflowed the budget. The previous note ’s cost table has a row for this, “switching between two long sessions: a full prefill, about 8 minutes at 160K”, and I wrote it believing the disk cache made that rare.
Three facts explain the morning.
- One checkpoint costs 34.5 KB per token. The final copy of a 77K-token conversation is 2.6 GB.
- A prefill writes a checkpoint every 10,240 tokens, and each one holds everything before it. In my log the first one appears at 20K, so reading a 77K conversation from scratch left seven files, 0.7 to 2.6 GB each, 11.6 GB in total, until the superseded ones are cleaned up. The 63K conversation it alternated with left another 8.9 GB the same way.
- 8.6 GB of the previous day’s checkpoints were still on disk.
| GB | |
|---|---|
| The previous day's files | 8.6 |
| Conversation A, 77K, seven checkpoints | 11.6 |
| Conversation B, 63K, six checkpoints | 8.9 |
| Needed | 29.1 |
| Budget | 24 |
When the budget is full the server evicts the lowest-scoring checkpoint, and the score is mostly how often a file has been restored. A file the current prefill has just written has never been restored, so it scores lowest. Every one of the 434 checkpoints evicted in that 26-hour log had zero hits: the evictor kept deleting the newest file, the one the next switch was about to ask for. The only file that kept coming back was the 14,336-token system prefix, which every conversation shares. So each switch got the system prompt back and re-read the other 50 to 60K tokens at 510 tokens per second, 95 to 120 seconds, and then waited in the queue.
How to size it. The server does not document --kv-disk-space-mb, and the default is 4 GB. Because every checkpoint holds the whole prefix, the chain a cold prefill writes grows with the square of the conversation’s length: 11.6 GB measured at 77K, about 32 GB at 130K. So budget the chain of the longest conversation you might ever read from scratch, plus the latest copy of every other conversation that can be active the same day, 4.5 GB each at 130K. For me that is about 32 plus 3 × 4.5, under 50 GB, so 64 GB holds it. When the budget holds the whole chain, the superseded prefixes in it are the first thing the evictor drops when it needs room, which is the right order; it was only when the chains did not fit that it reached the files still in use. If disk is short, --kv-cache-continued-interval-tokens sets how often the chain is written; doubling it to 20480 halves the chain at the price of re-reading up to 20K tokens after a crash. Both the quadratic chain and the eviction order under a full budget are issue 444 , open since June with a proposal to store checkpoints as a tree of chunks.
The fix
If you run this setup, the change is one flag on the server:
--kv-disk-dir ~/.ds4/server-kv --kv-disk-space-mb 65536
Size it as above: the chain of your longest cold prefill, about 32 GB at 130K, plus 4.5 GB for every other conversation that can be active the same day. Clear the directory when you change models or builds; the checkpoint format is not the same across them.
I made the change at 09:46 on 16 September, forty seconds of downtime, and tested it the next day with two long sessions alternating for eight turns: every switch restored from disk in about a second, each turn cost only its own new tokens, and nothing was evicted. The turn-by-turn numbers are in the details, with the three-conversation case and the one bug it turned up.
What two resident sessions do and do not fix
Two resident slots only help once both are occupied, unless you run the fix that is waiting upstream, and on this machine they cost memory and, under load, your reply’s speed. While I was diagnosing this, upstream gained --batched-session N: N conversations held in memory at once, with the weight-reading work batched across them and the caches kept per session. It should remove the switch entirely. I built the latest upstream and tested it the same way.
| Situation | Result |
|---|---|
| Sequential, from a cold start | 24 to 28 s per switch, same as one slot: 8 live misses, 8 disk restores, slot 2 never used |
| Sequential, once both slots are resident | 21 to 22 s per switch: 0 misses, 0 restores |
| Sequential, from a cold start, PR 765 build | 21 to 22 s per switch: 0 misses, 0 restores, B took the empty slot |
| Two requests at once | each 10K prefill takes 70 s instead of 22, and the session that is decoding drops to 2.3 to 3.3 tok/s |
Sending A, then B, then A never uses the second slot. The scheduler scores each idle slot by how much of the incoming request’s prefix it already holds and picks the highest. An empty slot holds nothing and scores zero. The slot holding A shares the test harness’s 11K system prompt with B, Hermes’s own is the 14K one, and scores 11,154, so B goes into A’s slot and pushes A out to disk; A comes back and pushes B out. The second slot is only ever filled when two requests overlap in time, and from then on both conversations stay resident and the scorer routes each correctly. I reproduced it at 160K and at 128K and reported it as issue 1069 . It turned out that kmike had found the same thing in August, with sub-agents as the trigger, and has a fix waiting in PR 765 that routes a request to a slot whose cache it can actually reuse and takes empty slots first. I built that branch and ran the same two sessions through it: B went to the empty slot, A came back to its own, and across nine turns the server logged no live miss, no disk restore and no eviction. That is the row with the PR in the table above, and it is the one to want.
The concurrent slowdown is by design. A prefill yields to decoding sessions once every 128 tokens, and at 500 tokens per second that is about four times a second, so a session that is writing its reply gets only a few tokens a second, 2.3 to 3.3 in my runs, instead of forty-five. When a cron job starts reading a long page while you are waiting for a reply, your reply crawls. With one slot the cron job waits for your reply to finish; with two, its reading slows your reply down.
Memory is the other constraint. Two 160K slots reached 56.3 GiB wired, which is the most this 64 GB machine lets the GPU pin, with 5 percent free. Two 128K slots, both holding 120K to 130K tokens, peaked at 54.2 GiB with 10 percent free and no growth in swap, 3.6 GiB more than the same test with one slot, which peaked at 50.6 GiB. So two slots fit at 128K, with less headroom than I run with today. The startup line still printed the one-slot plan of 48 GiB, because the second slot’s caches are allocated when first used, so a server that starts is no evidence that it fits.
My daily server stays at one slot. The disk cache is the fix for switching. Batching is for people who need two conversations to make progress at the same time and have the memory for it.
What to expect
| Situation | Cost |
|---|---|
| A normal turn | 2 s per 1K new tokens of prefill, then 42 to 47 tok/s |
| A large web page as a tool result | 19 to 24 s per 10K tokens |
| Switching between two long conversations | about 1 s restore, then the new tokens only |
| Compaction | one prefill of what is kept, about 100 s for 50K |
| Graceful restart, one conversation | restores from disk, then the new tokens only |
| Graceful restart after another conversation was live | a full prefill, 3 min at 90K; upstream issue 1053, fix in review |
| Crash | untested; at worst a full prefill |
Known limits
- The restart-restore miss, issue 1053 , also reproduces with DeepSeek models on the same engine, so it is not specific to Qwen. It has an open fix, PR 1058 ; two people found a second gap in its smoke test, so it is not merged yet.
- The disk cache has no sizing guidance, and the default budget of 4 GB is one checkpoint of a 120K conversation. Size it, and clear it when you change models: the checkpoint format differs between the fork and upstream. The eviction order that bit me is issue 444 .
- Batched sessions: cold-start assignment, issue 1069 , fixed by PR 765 once it merges, and the decode starvation during a neighbour’s prefill.
- A tool conversation whose last reply was plain text is checkpointed under a different key from one whose last reply was a tool call, and the next request does not find it. In the overnight three-conversation test below, that conversation re-read 110K to 131K tokens on every turn with its checkpoint sitting on disk. It is the in-process form of issue 1053 , and PR 1058 is the fix to watch.
- Besides the cache budget, Hermes’s compaction threshold is a setting I have not tuned. It is set at 143K of a 160K window, and Hermes’s preflight check fires at 123K, so the session reached 126K before compacting, which makes every cost above bigger. At 49K, on the earlier model, a long task compacted four times in an hour and lost file text each time; I have not found the right number in between.
The details
Everything below is for people who want to reproduce the numbers. If you only wanted the diagnosis and the fix, you have them.
How the numbers were taken
The server log is timings, token counts and cache decisions, but since 15 September the same server also serves a finance agent, and a failed tool-call parse can log a few hundred characters of model output. Every number in this note was extracted with pipelines that print only fields after ctx=, chunk=, avg=, tokens= and the like, never a raw line. The Hermes side came the same way from its API-call lines, which carry prompt size, cached tokens and latency. The pipelines printed nothing else.
The test harness
session_bench.py, from the previous note, grows a Hermes-shaped session by one tool call and one 10K-token tool result per turn, resends the history the way Hermes does, and records prompt size, cached tokens, tokens re-prefilled according to the server log, and time to first token. For the alternation tests I saved two sessions and replayed them alternately, one turn at a time. The drivers pause Hermes’s cron jobs, stop the daily service, run the phases, and restore the service with a hard stop, then check that launchd owns the process answering on the port.
The switching test
Two tool-heavy sessions of 70K and 80K tokens, grown by a 10K tool result per turn, alternating for eight turns on the daily server with the cleared 64 GB cache, on 17 September.
Swipe to compare columns →
| Turn | Prompt | Restore time | Turn time | Restored tokens |
|---|---|---|---|---|
| A1 | 80K | 0.43 s | 22.8 s | 70,653 |
| B1 | 90K | 0.55 s | 23.2 s | 80,166 |
| A2 | 90K | 0.50 s | 23.4 s | 80,268 |
| B2 | 100K | 0.99 s | 24.6 s | 90,166 |
| A3 | 100K | 0.61 s | 25.3 s | 90,285 |
| B3 | 110K | 0.58 s | 24.2 s | 100,451 |
| A4 | 110K | 1.43 s | 25.5 s | 100,596 |
| B4 | 120K | 1.27 s | 25.5 s | 110,372 |
Every switch restored the whole conversation from disk in about a second, and each turn cost only its own new 10K tokens, 23 to 26 seconds. Zero evictions, with the sessions at 110K and 120K by the end. The day before, the calls in the review window took 200 to 266 seconds, queueing included.
Three conversations, overnight
The control test had two conversations. The morning had three, so on the night of 17 September I ran three on the daily server with the 64 GB cache, no restart: A and B were the switching-test sessions, one turn on at 120K and 130K, C was built fresh to 100K, then three rounds of A, B, C, each turn adding a 10K tool result.
| Round | What happened |
|---|---|
| 1 | A restored 120K from disk in 1.5 s, turn 26 s. B restored 130K in 1.4 s, turn 27 s. C re-read all 111K, 231 s. |
| 2 | A restored 130K in 1.1 s, 25 s. B restored 139K in 1.8 s, 32 s. C re-read 120K, 251 s. |
| 3 | A restored 140K in 0.8 s, 31 s. B restored 151K in 1.7 s, 32 s. C re-read 131K, 274 s. |
Two findings. The cache did fill: 64 GB, 28 files, 67 evictions. Sixty-five of the victims were superseded chain prefixes with zero hits, which is what the evictor should drop first, and neither A nor B ever lost the checkpoint it needed. The sizing rule above comes from this run.
C’s misses are not evictions. Its checkpoint was written after every turn and never evicted; the log shows it stored under the plain-text key while A’s and B’s were stored and found under the tool-conversation key. The only difference in the conversations is that C’s replies contained no tool calls. That is the keying gap behind issue 1053 , seen here without a restart, and it means a long conversation can pay a full re-read on every turn while its saved copy sits on disk. Until PR 1058 lands, the daily setup does not hit it, because Hermes replies with tool calls in every long session I have.
What went wrong
Two mistakes of my own, as in the last note. The first driver waited for “all background jobs” after a concurrent phase, which included my wired-memory sampler and the driver’s two-hour timeout, so it sat with the test server on the GPU for half an hour while Hermes was down. When I killed it, its restore ran twice, and the second pass’s cleanup rule, “kill any test server under the ds4-metal directory”, also matched the daily service, which now lives in ds4-metal-upstream. Launchd relaunched it in ten seconds. Both drivers now wait on explicit process ids, restore once, and never kill the process launchd owns. The second run had no memory sampler at all; the 128K figure above comes from a third, two-minute run with two samplers.
Who did what: I set the questions and made the calls, including which fixes to apply and which to leave alone. Claude, in Claude Code on the same machine, read the logs and the engine source, wrote the drivers, ran the tests, and drafted this note and the issue. The mistakes are its as much as mine.
Sources
- Part three: A 2-bit 125B model became my daily assistant on a 64GB M4 Max
- DwarfStar, Salvatore Sanfilippo's inference engine
- Upstream's Qwen3.8-Flash-Next model files
- ds4-metal, Ivan Fioravanti's fork
- Issue 1053: shutdown checkpoint not found on restart
- PR 1058: key tool-conversation checkpoints by the client-visible transcript
- Issue 1069: batched sessions evict a live slot instead of taking an empty one
- PR 765: route batched-session requests to a slot whose KV cache they reuse
- Issue 444: disk KV eviction removes the files the next request needs
- Hermes Agent
Measured on a 64GB M4 Max between 14 and 17 September 2026 on upstream DwarfStar 9139e2a (daily) and 8db1d1d (batching tests).