git clone https://github.com/incoai/splash.git
cd splash
make -j4
./splash serve --model mlx-community/Qwen3.8-27B-4bit--model names an upstream Hugging Face model: an MLX repository
(MLX targets) such as mlx-community/Qwen3.8-27B-4bit, an NVFP4
repository of Model Optimizer (Model Optimizer targets)
or compressed-tensors (Compressed-tensors targets)
such as nvidia/Qwen3.8-27B-NVFP4 or unsloth/Qwen3.8-27B-NVFP4, or a GGUF
repository and variant, OWNER/REPO:VARIANT, such as
unsloth/Qwen3.8-27B-GGUF:UD-Q4_K_M.
Splash identifies the model from its own metadata and pairs the DFlash2 draft
trained for it. The first serve sets up Python dependencies and downloads the
model and its draft; each start loads the weights into memory
(Weight loading), follows the model's revision
(Revisions) and warms up, the console counting the steps
(Warming up · 3 of 11 steps): the first start after an update also prepares
the GPU's programs then, which takes a couple of minutes
(Warmup and pipelines). Splash packages, the prebuilt format of earlier
releases, are no longer loaded (Splash packages). Public
repositories need no login; private or gated ones need HF_TOKEN or hf auth login, and a gated
one its terms accepted on its Hub page. Ctrl+C
stops serving, and a second Ctrl+C stops the engine at once; stop before
upgrading.
An engine that fails is restarted at once. If it fails again within 60 s of
starting, the next restart waits 5 s, and each such failure in a row after it
doubles the wait, up to 60 s (failed restarts count); 60 s of service starts
the count anew. Splash stops restarting at once after a failure every restart
would meet again: a setting the engine refuses, a Mac that cannot run Splash,
model files it cannot serve (missing, moved or corrupt), a missing engine
executable, or a start whose GPU commands another process's memory starved
(another Splash server?), which a retry would take from that process. A
restart that failed for want of memory the Mac refused is retried, at most 60 s
apart, for as long as it fails, and neither counts toward the six below nor
starts them anew. For any other cause it stops after the sixth failure in a
row, each within 60 s of starting. Once it stops, the server logs why
(Engine stopped · …), naming the crash trace when SPLASH_CRASH_TRACE=1
recorded one (crash traces), and
exits with status 69 (EX_UNAVAILABLE), where a stop exits 0, a failed start
1, refused arguments 2 and a stop before the server runs 130 or 143; a stop
that lands as the engine stops for good may still exit 69. Until then requests
get 500 engine_failed in the same words. What
runs the server, such as launchd with KeepAlive, decides whether to start it
again, and a new server counts failures from none, so give it a
ThrottleInterval for a cause the next start meets again; the app shows why it
stopped. While the engine restarts, generation requests get
503 engine_recovering, whose message names the last failure and whose
Retry-After the time until the next bound on the restart's start: when it
should be ready, as long after its spawn as the start before took, then up to
30 s more while it waits for memory the Mac refused (errors). An engine whose
loop leaves a status request unanswered for 30 seconds, while requests are
pending or during a background status refresh, is failed and restarted by the
same rules; so is one that leaves a frame unread for 30 seconds. A request the
engine had started at each of two engine failures in a row that a request may
cause (an execution failure, broken output, or a crash by a fault signal; not
a kill, nor a command short of GPU memory) is refused with 400
request_quarantined for an hour the Mac is awake, or until the server
restarts. A restarted engine is asked for the first engine's context, with
--max-context as without, and serves what its plan holds of it
(Neural Engine prefill): one whose plan
holds less, as once the Mac's memory budget shrank, serves less, and the
server serves that until a later restart regains it; /status reports it as
capabilities.max_context_tokens.
Ctrl+C or SIGTERM stops the server: it stops its engine with SIGTERM, which gives a persistent cache's newest restore points up to 6 s to reach the disk, and kills an engine still running 15 s later; later SIGTERMs are ignored, and a second Ctrl+C stops the engine at once. The app stops a server with one signal and kills it only after 30 s.
Timeouts and keep-alives count only time the Mac is awake: a request in flight
when the Mac sleeps continues when it wakes. While requests run, Splash keeps
the Mac from sleeping automatically, as caffeinate -i does; the display may
still sleep, and closing the lid or choosing Sleep still sleeps the Mac.
serve --allow-idle-sleep lets it sleep automatically.
The server writes its console lines, its engine's and Python's tracebacks
from one thread, in the order they come, so that a log of both streams, as
2>&1 or the app's server log, keeps that order: status and request lines go
to stdout, errors, warnings, tracebacks and the engine's lines to stderr. A
stdout that stops draining therefore holds up stderr too. Output that stops
draining, as a log pipe whose reader stalls, holds up that thread alone, never
a request, the engine or an HTTP answer. Once 1024 lines wait, each further
line is dropped; once the console writes again, a Warning · N console lines dropped … line takes their place, and /status counts them as
console.dropped_lines. A failed engine's last lines, why it failed or what
it saved as it stopped, come before the lines the server prints after the
failure, its report among them, which wait up to 1 s for the engine's stderr
to end; the failure itself reaches the requests in flight and schedules the
restart at once. At exit the server waits up to 1 s for its last lines, run
with or without python -u.
A Homebrew install links the completions into Homebrew's directories for them
under brew --prefix: share/zsh/site-functions, etc/bash_completion.d and
share/fish/vendor_completions.d, where a shell set up for Homebrew's
completions finds them. In a source checkout, source
install/completions/splash.bash for Bash, install/completions/_splash for
Zsh after compinit, or install/completions/splash.fish for fish.
Completion suggests commands, the models in install/suggested-models.tsv
(the list the desktop app offers too) and installed models, a GGUF's
OWNER/REPO:VARIANT included, without network access. It also completes local
paths for --model-dir, --draft-model and --mmproj.
splash serve --help lists every option with examples:
| Option | Default | Purpose |
|---|---|---|
--model |
Required | The upstream model, OWNER/REPO, or a GGUF's OWNER/REPO:VARIANT; with --model-dir, the name the API reports. See upstream model loading. |
--model-dir |
None | Prepare a local GGUF file or GGUF/MLX model directory without the Hub. See local model files. |
--mmproj |
None | A local GGUF target's vision projector; requires --model-dir. |
--revision |
Default branch | Select an upstream target branch, tag, or commit. See revisions. |
--draft-model |
The family's released DFlash2 draft | A compatible repository or local directory in its place. See drafts. |
--language-only |
Off | Skip vision loading; image and PDF input is rejected. See vision. |
--offline |
Off | Start the installed model without asking the Hub, as HF_HUB_OFFLINE=1 does. See revisions. |
--port |
SPLASH_PORT or 8000 |
HTTP port. |
--host |
127.0.0.1 |
HTTP bind address. See network and authentication. |
--allowed-host |
No extra names | Additional HTTP Host name, e.g. mymac.local; repeatable. |
--allowed-origin |
No other origin | Origin whose pages may call the API from a browser or webview, e.g. tauri://localhost; '*' for any; repeatable. |
--api-key |
SPLASH_API_KEY or none |
Require this key, as a bearer token or x-api-key, on every route but the chat page, /health and /ready. |
--no-webui |
Off | Disable the chat page. |
--max-memory |
Auto | Lower the ceiling on Metal allocations, e.g. 28G; not combined process RSS. See memory and context. |
--max-context |
Auto | Set the context limit, e.g. 100K or 1M (K = 1024 tokens, M = 1,000,000), up to the model's limit; past the 256K native window a model with YaRN turns it on. See memory and context. |
--kv-format |
int8 |
Target KV storage: int8 or bf16. See KV cache precision. |
--state-format |
fp32 |
Recurrent state storage: fp32 or bf16. See state precision. |
--ple-memory |
Auto | Qwen3.8-Flash-Next: read the PLE n-gram table from the SSD through a host row cache of this size, e.g. 2G; off loads the whole table. Auto streams it through a cache of at most the rows of a 128K-token prompt, less where memory is short. See PLE table streaming. |
--expert-memory |
Auto | MoE targets: the memory routed experts keep, e.g. 16G; they stream through a store of exactly this size, which neither grows nor gives memory back, and read the other experts from the SSD when a step routes them; a size that holds every expert, or off, loads them all. Auto loads them all where memory holds them, else gives them what the context leaves, given back down to their floor while requests need it. See streamed experts. |
--sparse-kv-memory |
Auto | Qwen3.8-Flash-Next: memory for the KV blocks read back from the SSD for requests whose KV is there, e.g. 1200M; never below its floor, which auto keeps. See KV on the SSD. |
--idle-release |
10m |
Time without a request before the engine unwires its memory and frees the weights; the next request restores them. off keeps both, though POST /v1/memory/release still frees the weights. See weight loading. |
--max-cache-disk |
auto |
SSD quota for cached KV pages and states: auto follows the volume, a quarter of its free space beyond a floor of 10 GiB (5% of a volume under 200 GiB), at most 32 GiB; a size such as 16G is held as given while the volume keeps that floor; 0 turns the cache off. Kept for the session, or across restarts with --persistent-cache. See SSD cache. |
--max-cache-disk-writes |
0 (no limit) |
The most the SSD cache writes a day, e.g. 200G. See SSD cache. |
--persistent-cache |
Off | Keep the SSD cache across restarts. See persistent cache. |
--cache-dir |
~/Library/Caches/Splash/prefix-cache |
Where --persistent-cache keeps its files; needs --persistent-cache. |
--served-model-name |
None | Additional model name the API accepts; repeatable. See API model aliases. |
--announce-served-name |
Off | Report the first --served-model-name in responses; needs --served-model-name. |
--default-reasoning-effort |
SPLASH_DEFAULT_REASONING_EFFORT or the template's |
Effort of Chat and Responses requests that set none. See default reasoning effort. |
--thinking-token-budget |
SPLASH_THINKING_TOKEN_BUDGET or none |
Thinking tokens of a request that sets no budget. See thinking token budget. |
--thinking-end-text |
SPLASH_THINKING_END_TEXT or the template's close |
Text that ends thinking where a budget runs out, </think> its last token; unset, the close the chat template writes after a reasoning. See thinking token budget. |
--max-request-size |
128M |
Largest HTTP request body. See request limits. |
--max-image-pixels |
4194304 |
Maximum resized pixels per image. An image's vision scratch grows with its patches (pixels / 256), to about 600 MiB at the default. |
--request-timeout |
None | Time a request may take from its arrival; a request's own timeout can only shorten it. |
--queue-size |
32 |
Requests admitted at once, running or waiting; more get 503 with Retry-After. |
--decode-share |
0.5 |
Decode time owed per unit of prefill time while other requests generate. Higher keeps their output faster during a long prompt and slows that prompt; 0 alternates one command each. |
--disable-ane |
Off | Prefill on the GPU alone. By default a dense model's long prompts also use the Neural Engine when that is faster. See Neural Engine prefill. |
--activations |
auto |
On M5 and later Macs, whose GPUs multiply int8 at twice their bf16 rate, auto rounds activations to int8 where that is faster and leaves the others bf16: prefill chunks of more than 64 rows through a target's MLX 4- and 5-bit weights in groups of 64 (not a MoE model's routed experts), rounded per row and 64 inputs, about a quarter less time to the first token on Qwen3.8-27B MLX 4-bit; a Ternary-Bonsai (rotated PQ2_0) model's decode, 13-16% less time a cycle on a 20-core M5 Pro; and attention over the INT8 KV cache where a split reads 8 pages or more, 4-7% sooner first tokens at 32K-64K (see KV cache precision). bf16 is a precision opt-out for evaluations and bf16 parity: every activation stays bf16, though a persistent cache keeps what an auto run prefilled, so a parity run starts in a fresh --cache-dir. What auto costs, as next-token KL against bf16: about 2.2e-3 for Qwen3.8-27B MLX 4-bit's prefill on the GPU alone, 2.8e-4 for Bonsai's decode, and with INT8 queries 1.37x the KL the INT8 KV cache alone gives Qwen3.8-27B. The Neural Engine split, which multiplies its part in int8 whatever this option, has its own switch, --disable-ane. See kernel selection. |
--verify-rows |
auto |
The rows of each draft block decode cycles verify: auto lets the verify budget choose a MoE target's rows, which is faster but makes its greedy and seeded outputs vary from run to run, and verifies every row of a dense target; all verifies every row the output limit leaves on every target, so those outputs repeat when a request runs alone with the same cached prefixes. See drafts and sampling. |
--allow-idle-sleep |
Off | Let the Mac sleep automatically while requests run; by default it stays awake until they finish (the display may still sleep). |
Sizes (--max-memory, --max-cache-disk, --max-cache-disk-writes,
--max-request-size, --ple-memory, --expert-memory, --sparse-kv-memory) are a whole number of bytes, or one with a K, M or
G suffix in binary units: 28G is 28 GiB, and 28GB, 28GiB and 28g say
the same. With a suffix the number may have a fraction, rounded down to whole
bytes: 2.5G is 2,560 MiB. Token counts (--max-context) are a whole number, or one with a K
suffix of 1024 tokens or an M suffix of a million: 100K is 102,400 tokens and 1M is
1,000,000. --max-memory, --max-context, --max-cache-disk,
--ple-memory, --expert-memory and --sparse-kv-memory also take auto, their default,
--ple-memory off and --expert-memory off load the whole table and every expert, --max-cache-disk 0 turns the SSD cache off,
and --max-cache-disk-writes 0, its default, sets no write budget. Durations
(--idle-release, --request-timeout) are seconds, or a number with an s,
m or h suffix, such as 90s, 30m or 1.5h; --idle-release off turns
the timer off, and only POST /v1/memory/release then frees the weights.
The default listener is 127.0.0.1:8000. To accept LAN connections:
splash serve --model mlx-community/Qwen3.8-27B-4bit --host 0.0.0.0 --api-key YOUR_KEYConnect to the server's LAN IP. --host selects the IPv4 bind address;
--allowed-host NAME accepts an additional HTTP Host name, such as the Mac's
.local name, a custom DNS name or a proxy hostname. It does not change the
listener or allowlist client IPs. A request that names the server any other way
gets 403 with the --allowed-host flag that would accept it; the check keeps out
web pages that rebind a DNS name to this address. The chat page works over plain
HTTP from another machine too.
Set SPLASH_API_KEY in the server and agent shells to require authentication;
serve --api-key KEY overrides the server's environment value. Every request
then needs Authorization: Bearer KEY or x-api-key: KEY (one that sends both
needs the key in both), /status and /metrics included; only the chat page,
/health, /ready and browsers' OPTIONS preflights stay public. The chat
page asks for the key once the server answers 401 and does not persist it. Use
serve --no-webui to disable the page. Authentication is off by default, and
the server then ignores any key a client sends, so a client that insists on
one can send any value.
A page served elsewhere, or an application's webview such as Jan's and other
Tauri apps', calls the API from another origin, which its browser names in
Origin. --allowed-origin ORIGIN admits one, such as tauri://localhost or
http://localhost:3000; repeat it for more, or pass '*' for every origin. The
server then answers the browser's preflight OPTIONS request and names the
origin in Access-Control-Allow-Origin on its responses, which let the page
read Retry-After and WWW-Authenticate too. A page of any other origin gets
403. Its browser hides that response from the page, which sees a network error,
so the server prints the refused origin and the --allowed-origin flag that
would accept it, once per origin. A file:// page or a sandboxed frame sends
Origin: null instead, which only '*' admits; the server does not print its
refusal. Origins match exactly: a pattern such as tauri://* or
http://*.example.com is refused at startup; only a bare '*' admits every
origin. With '*' every page open in a browser that reaches the server can use
it, so set --api-key too; the server warns at startup without one.
Use --port 8001 or set SPLASH_PORT=8001 to select another port. The agent
launchers take the same --port, as in splash pi --port 8001, or read the
same SPLASH_PORT; --port overrides it. They pass every other argument,
--help included, to the agent; to pass an agent's own --port, start its
arguments with --, as in splash opencode --port 8001 -- --port 4096.
Separate ports allow separate servers. Each plans its memory from the whole Mac,
as if it ran alone, so give each a --max-memory that leaves room for the
others. The packaged agent launchers connect to loopback, so use a listener
that includes loopback when launching agents locally.
Splash sizes memory and context when it starts. Its Metal budget is the GPU's
recommended working set less a small margin; --max-memory can only lower it,
such as --max-memory 28G, and the log says when a larger one is ignored. The
automatic plan takes the whole budget, its streamed stores starting with no more
than the host's memory leaves the engine when it starts (below); on a Mac that
also runs other applications, --max-memory leaves them room whatever they hold
later: the automatic expert store and PLE row cache are planned smaller with it.
The budget caps Metal allocations, not combined process RSS. The context limit
is the model's native window, 256K tokens for every supported family, when the
budget holds it beside the weights and, unless --disable-ane, the
Neural Engine split this Mac's calibration of a dense
model takes, and otherwise as much as the budget holds beside them.
--max-context sets it, such as --max-context 100K (K is 1024 tokens, M a
million), up to what the budget holds without the split: beyond what the split
leaves, the split takes fewer channels or the GPU runs alone, with a warning.
Past the native window a model with YaRN turns it on, up to the model's limit
(below). A larger value than the budget holds stops startup with the most the
model and memory allow.
One plan sizes everything within the budget (engine::planStreamedMemory).
Its floors come first: the weights that load, the arenas, the runtime
reserves, the SSD cache's state staging, one lane's state and the KV runway,
the stores of the parts that stream at their smallest (or at the size
--expert-memory or --ple-memory sets), and for a block-sparse
target the floors of a request whose KV is on the SSD
(KV on the SSD). The rest goes by priority. Under auto, a MoE
target's routed experts load with their layers where that leaves KV its
allowance, and otherwise stream through a store of all that the allowance and
the PLE table's share leave (streamed experts); the PLE
table streams through a row cache of what the allowance leaves, up to its
share, at most the rows of every id a 128K-token prompt stages, however much
memory the budget has (PLE table streaming); KV and
the states take what remains. The allowance is the KV of a context, in the fewest pages whose
extents hold it as one request's KV capacity rounds them: a block-sparse
target's first 64K tokens, kept in memory, or --max-context where that is
less. A dense target's allowance is --max-context, so that its experts load
whole wherever that context fits beside them and stream to hold it where it
does not, or else the first 64K tokens too: its experts load whole only where
they leave that much context, so a larger budget never serves a shorter one.
Nothing in
the plan rests on the host's memory at the start: an automatic expert store's
cap is all that the budget gives it, and it starts at its floor, the chunks
above it released; the memory ledger grows it into the room the host leaves
before the engine is ready, and later as memory allows, as after giving them
back (streamed experts). The chunks it starts without stay
in the plan (memory_plan.budget.host_held_back_bytes), so that what loads
whole, the floors, the context and the KV admission keeps in memory follow the
budget alone, and a relaunch serves its first engine's context whatever the
host holds then. The automatic SSD cache gives its state staging up where that
alone would stream a part, unless the target's KV spills to it. A size of
--expert-memory or --ple-memory sets its store exactly, charged among the
floors, and the plan holds the context and KV beside it; an expert store of
that size is all its floor. --sparse-kv-memory sets its own part. Under auto
the plan holds them beside the expert store at its cap, and what the store
gives back while memory is short only adds room (streamed
experts). A size below the part's smallest store, a
--sparse-kv-memory beyond what the floors leave and a --ple-memory size for
a table that cannot stream are refused, and startup logs how each part was
sized. Only a loaded model sizes
the arenas, which rest on its weights' formats and grow where a part
streams: prefill groups for streamed experts, a step's staged rows for a
streamed table. One load gives them for whatever streams, so a start loads
its weights once where the plan without the arenas already loads each part
as the plan with them does, as for a model with no part to stream, and
otherwise loads them again with the stores that plan gives (Weights loaded again): automatic routed experts the first plan streams load at their
smallest store until then; a store --expert-memory sizes loads once. An
automatic store sized for a run that spills keeps all of it as its floor.
Where the run then cannot spill (the SSD cache turns off or its KV file does
not open), the weights load once more for a store above its smallest size,
whose floor is then 85% of its slots; a store at its smallest size is all its
floor either way, and the weights do not load again.
A block-sparse target serves the window where its floors hold a request of
that length and the SSD cache has room for its KV, and otherwise the longest
context they hold, or the KV memory holds if that is more. /status reports
the bounds under memory_plan.budget.context_bounds: the model's window,
one request's KV in memory (resident), the longest context the floors hold
(spilled) and the room the SSD cache has for active KV beside every lane's
state (disk), and in bound which one sets the limit (model, memory or
disk). The resident context is one a lone request takes: its KV in whole
extents beside its lane's state and what a target whose KV spills holds from
its start, the Index runway and the page tables the readers of spilled KV
allocate. A block-sparse target's context therefore follows the free space of
the cache's volume at a first start; a dense target's never does, nor does a block-sparse one's
with --max-cache-disk 0. A relaunch keeps its first engine's context
whatever room the volume has left since: where its plan spills, the cache
opens however little room the volume has, and each request's commitment
checks the room as it starts. The plans that size the streamed stores take
the room the volume leaves when the start begins, before the weights load.
On the Qwen families, a --max-context past the native window, up to the
1,010,000 tokens Qwen validates these models to, turns on YaRN RoPE scaling,
as Qwen describes for them: the factor is the context over the native window,
so that the stretched window is the context asked for, such as 2 for
--max-context 512K and about 3.81 for 1M. Flint's full layers have no RoPE
to stretch, so its native window is its most, and a longer --max-context
stops its startup. Without --max-context, or at 256K or less, YaRN stays off, so
answers and the persistent cache are the native window's. The budget must
hold the context: about a million tokens of the 27B's int8 KV on a 64 GB Mac,
more of the 35B's, whose KV per token is a third of the 27B's; where it does
not, startup stops with the most it holds, which a --max-context of that
much then serves. The scaling is YaRN's defaults and static: every request,
short or long, takes the same factor, which changes answers at every length,
so pass more than 256K only when a request needs it, as startup says. Each
such context has its own factor, and so its own persistent cache. It scales
the target's attention RoPE alone; the draft keeps its own, which keeps its
acceptance at a million tokens. A long uncached
prompt is slow to prefill, as attention grows with depth. Past the native
window a prefill command takes fewer rows, so that its attention does no more
than a full chunk's at the native window's end, which keeps it well within the
120 s command watchdog on every supported Mac: 512 rows at a million tokens. On an M5 Max the
35B prefills a million tokens in about 45 minutes and then decodes at about a
quarter of its short-context speed, and the 27B takes about half an hour for
512K. The KV of one long request also leaves the cache little room.
The startup summary and maximum_context_tokens in /status show the effective
context limit, and a block-sparse target's startup line its bounds. /v1/models and /v1/models/{id} report the same limit as
max_model_len and its compatibility alias context_length, including model
aliases. Clients can impose a smaller limit. This is a capacity limit, not a
guarantee of a fast first token for a long uncached prompt. If the model cannot
fit, startup prints a memory budget breakdown and stops.
A request fails with capacity_exhausted only alone and once nothing else
holds memory it could get: every cached prefix it may take evicted, the expert
store at its floor and no write to the SSD cache in flight. Short of that it
waits, and beside other requests one of them yields instead. Its
message names what it could not allocate, its lane's KV mode (Resident, its
KV in memory, or Spill, its KV partly on the SSD), its
phase and its tokens, and the Metal memory outside the server's buffers then
(device_untracked_bytes, as /status reports it under memory_audit at
startup: 17 MB on a 64 GB M5 Pro serving Flash-Next), which the budget charges
past the runtime reserves. Only a Spill lane's, the one whose KV goes there,
says why its KV gave no more memory back: the SSD cache took no more of it,
where its writes stopped for the quota, the write budget or a closed cache, or
otherwise the request does not fit even with its KV released down to its floor.
A request refused at its start because neither memory nor the SSD
cache holds it names its lane, tokens and
device_untracked_bytes the same way, with what its least start needs
(memory_needed_bytes) and what memory has for it (memory_available_bytes).
Qwen3.8-Flash-Next's PLE n-gram table (per_layer_token_embd.weight, 320M rows
of 160 values, 28.8 GB in IQ4_NL; other row widths come from GGUF metadata)
is read by a handful of rows per token: 16 per position, eight heads each of
the 2- and 3-gram hashes of the token and the tokens before it. With
--ple-memory auto, the default (memory and context),
or with --ple-memory SIZE, such as --ple-memory 2G, the table stays in its
GGUF on the SSD: no ple.bin
image is written, and the weights shrink by the stored table size. Startup
memory requirements also include the cache and Metal staging buffers. The
rows each command gathers are read through a host cache with a SIZE-byte
cache and working-memory reservation, auto's share of the budget: at most
the rows of every id a 128K-token prompt stages, 2M rows, 237 MiB with
IQ4_NL's 90-byte rows. A BF16 or F16 table streams as stored, 320 bytes a
row (the gather rounds F16 values to bf16), so auto, which reserves rows by
count, reserves about 1.9 times the bytes of a Q8_0 table. The cache hits only rows read before, and a request
reads few distinct rows (a 128K-token prompt about 1.1M): caches from
256 MiB, which no run filled, to 2 GiB hit as often, while each GiB more of
the expert store makes a 24 GB Mac decode 11-23% faster, so the experts take
the rest, and the KV and its caches what they leave: auto streams the table
however much memory the Mac has.
The engine refuses a SIZE that holds no row, and a SIZE that holds the
whole table loads it with the weights instead, as --ple-memory off does. A
table whose rows the GGUF does not store as the gathers read them (NVFP4,
which the loader rebuilds, or any table of a rotated GGUF) loads with the
weights, and the engine refuses a SIZE for it.
This reservation is not a strict bound on host allocations: prefetch scratch,
excess vector capacity, job metadata, allocator overhead and reader stacks are
additional, and oversized stages grow their scratch. Other models ignore a
size with a warning.
- A step's rows are staged before its PLE layer gathers them: the CPU copies
each distinct row the step reads once into a small Shared buffer of the
step's scratch, from the cache or, for the rows it lacks, with uncached
(
F_NOCACHE) row reads (90 bytes for IQ4_NL, 170 for Q8_0) by the engine's 32 SSD readers, which streamed experts share (SsdReaders,runtime/model/SsdReaders.hpp), and the staging thread itself (SparseRowStore,runtime/model/SparseRowStore.hpp). The gather then reads them with the table's own kernel, so a row's values are computed exactly as from the table. - A prefill knows its rows on the CPU and stages them before its command is submitted. A decode cycle's rows are those of the draft's proposals, which only the GPU has: the verify hashes them on the GPU, and a host step of the same command stages them between the hash and the gather, keeping the GPU awake through it (Streamed experts).
- A prefill command reads ahead the rows of the prompt tokens that follow its rows, as many as a command holds, while it runs; the next command's first stage finds them cached or finishes what is still in flight. A read ahead runs only while no stage or expert load reads.
- A row enters the cache only after the file was found unchanged since it was read. The table is read from the source its metadata was parsed from, so a new file at its path changes nothing; a write to that file, a failed read (after one retry) or a short file fails the step before the command that requires those rows is submitted. The engine stops as it does for a weight read error, and the server starts it again. A row a read-ahead fails to read is dropped, and the stage that needs it reads it again.
- The cache counts as fixed runtime memory in the plan
(
streamed_table_bytes): the governor charges it beside Metal's allocations, and the startup memory audit bounds it by the plan and counts it toward the hard budget beside Metal's footprint since the weights that serve began to load, not a first load that held the table whole before the plan with the arenas streamed it. It is not released while idle. - Once the weights that serve loaded, before the engine is ready, the engine
writes every page of the cache's rows, ids, recency links and index
(
SparseRowStore::commit), so that the cache holds all of its host memory from the start: the host memory the engine measures from then on, which the start's checks and the expert store's growth go by, counts the whole cache rather than the rows read so far. Auto's cache takes its share at most; a--ple-memorysize takes all of it. Stages and prefetches allocate their working memory as they run. /statusreports it understreamed_table: the table and its bytes, the cache's size, capacity, rows and evictions; per use (prefill,decode) its stages, requested and distinct rows, hits, reads, hit rate and stage latency (stage_ms); the reads' bytes, retries, errors and latency (read_ms); the prefetches; andpoisonedwith thefailure. A latency is a histogram in milliseconds:p50,p95andp99(within a factor of two),max,totalandsamples.
Where memory cannot hold every routed expert of a MoE target beside the
context (--expert-memory auto, the default,
memory and context), or with --expert-memory SIZE,
such as --expert-memory 16G, its routed experts stay in their files on the
SSD, and a store holds as many of them as fit (ExpertStore,
runtime/model/ExpertStore.hpp): the weights shrink by the routed experts'
bytes, and the store counts in their place at its cap
(target_weights_bytes and host_held_back_bytes). Under auto its cap is
all that the plan leaves the experts; it starts at its floor, the chunks
above it released, which it takes as memory allows, and gives memory back
down to its floor while requests or the Mac need it
(memory and context). The floor is every slot when KV
can spill to the SSD cache, else 85% of them. With --expert-memory SIZE
the store is exactly SIZE bytes, its slots, staging and tables, rounded to
whole slots and chunks, which startup says where they differ: its cap and
its floor both, so it neither grows nor gives memory back, and the context
and KV take the rest of the budget. A SIZE below every expert
streams them even where memory holds them all. A store holds at least a
layer's experts of each format mix and every expert of a layer whose mix no
other layer shares; the engine refuses a SIZE below that, and --expert-memory off loads every expert, as a SIZE that holds them all does.
- Each MoE layer of a command loads the experts its rows route that are not resident: after the layer's routing, a host step of the command claims slots for them, recycled by adaptive replacement (ARC: the experts decode steps route again stay while the others pass through, and a prompt's experts go first), and reads them from their files into a staging of a decode cycle's experts on the engine's SSD readers, and a repack kernel writes them into their slots before the expert passes over them run. A decode step's load is one such round; a prefill command's loads take as many rounds as its rows may route experts, each staging half of the staging while the other half is read ahead. The kernels find every routed expert through a table of its slot and the chunks that hold its slots, so resident experts, one chunk of every expert, and streamed experts run the same kernels.
- The grouping puts the tiles of the experts already resident first and those a load brings last (their slot-table entry is marked absent). A decode step's expert passes over the resident ones and the shared expert run while its load's host step reads from the SSD, on the grouping's copy of the slot table; the passes over the experts it brought and the combine run after the repack, through a second table of the slots the load routed, which the host step writes and the GPU reads no earlier. Outputs are bitwise the same: each expert's tiles, their kinds and the combine order do not change.
- An expert's rows a GGUF stores as they are are read straight into the
staging (
F_NOCACHE, widened to whole 16 KiB pages, which the kernel reads without the buffer cache: an M5 Pro reads 460 KiB pieces from four threads at 9.3 GB/s instead of 8.1, with less than half the CPU). - While the GPU repacks a round or runs a layer, the next round's experts or the next block's likely experts are read ahead: those the next block's router picks from this block's input in a decode step; after a long prefill chunk's last repack, one more host step reads nearly all of the next block's into the whole staging. A load takes the read-ahead over without waiting for its reads of experts it does not route, and a read ahead of an expert no load routes may fail without failing anything. A decode step loads and predicts only the experts its lanes' verify rows route (Drafts); the selector writes those rows before the step's load.
- Experts of one format share their slots, held in chunks, each a buffer of
its own of at most the device's largest buffer (
maxBufferLength). One chunk holds each format's floor: under auto every slot when KV can spill to the SSD cache, else 85% of them, or every slot of the store--expert-memory SIZEsizes; the rest lie in chunks of about 1 GiB (more where they would take more than 47, since the expert kernels' address table holds at most 48 chunks), the last of fewer slots; a floor larger than the device's largest buffer takes the elastic chunks it needs too. The kernels reach the chunks through their GPU addresses, which the backend writes for each expert dispatch as it encodes the command, and makes them resident for that dispatch's encoder rather than for every command on the queue (Residency). Chunks are few because each one a decode encoder makes resident costs GPU time: 36 chunks of about 512 MiB cost a streamed decode cycle 2.3% of its GPU time on the M5 Max, about 0.065% a chunk. A store whose floor is all of it has no elastic chunk. With a floor of 0.85, a format that starts at its cap has about one elastic chunk per 6.7 GiB of its slots (0.15 of them, in chunks of about 1 GiB), one where they are less, so a 17.2 GiB store, a floor and 3 elastic chunks, costs about 0.25%; one that starts below its cap has as many of the cap's chunks as the slots above its lower floor take, at most 7 in a store under 6.7 GiB. A size of one block (layers in formats of their own) is one chunk of every expert. - A decode step's loads keep the GPU awake through their host steps (Host steps), so the work after a load starts at once and at full clocks rather than after the GPU's wake-up.
- A prefill command runs a group of up to four chunks of 2048 rows layer by layer, so each layer's experts are read once for all of them, a prompt's with images too. Since every command reads nearly all of each layer's experts however few its rows, short prompts ready together share one command, and one that would take longer than the 500 ms slice runs in passes over the layers, each its own command of about the slice: other requests' decode steps run between passes, and requests that arrive meanwhile start and join the next command, or, at a higher priority, give this one up, which starts again after them. A cancel of a command's only request waits for one pass at most, one of several for the command's last. A pass reads its own layers' experts, so passes add no reads; a command that starts again reads its passes' layers again (Admission and eviction).
- A load checks its files unchanged after it read them. A read is tried once more when it fails. A load that fails (a routed expert cannot be read, or a file was written) fails its command, and every later load fails too.
- The chunks above the floor are elastic. Where a request in service needs memory the engine's limit refuses, the engine takes what can go in one fixed order (admission and eviction): cached prefixes nothing in use holds that leave memory at once, by the cache's order; the caches the model rebuilds; the cached prefixes that first need a copy on the SSD cache, whose writes it waits for rather than take what follows; on a target whose KV can stay on the SSD cache, the memory of spilling requests' pages of its priority or a lower one, then that request's own spill; then the store's last chunk, one at a time; and only then anything in use. A start, optional work and memory the host refused never take a chunk. When the host runs short of its 1 GiB margin, the engine gives it cached prefixes and the caches the model rebuilds, never a chunk; macOS's warning alone takes nothing. A chunk given back keeps its experts that fit: they are copied to free slots below it, the experts the slots' recycling would recycle first evicted to make room, and later steps read the evicted ones from the SSD again. Under macOS's critical pressure the engine gives the Mac, each second, the 1 GiB margin and what requests the host refused wait for, in the same order, every spilling request's pages and store chunks above the floor among them, never the store below its floor.
- The store's cap is a reservation the engine repays: between commands, the
store tries to take one chunk back, one it gave back or started without,
its slots empty, once 30 s have passed since its last try and since a
request's claim last went unmet or took a chunk, a prefill between its
passes, a spilling request's pages, its own spill or something in use (a
request the cache served does not count), as long as no request waits for
memory and the weights are not released. The chunk takes memory
--max-memoryleaves free, which needs normal pressure and 1 GiB to spare beyond the chunk; for what that lacks the engine evicts cached prefixes that leave memory at once, only when they cover it, never one in use, the prefix the newest conversation resumes from or those that requests waiting to start would resume from. A chunk such prefixes pay for whole comes back under macOS's warning too while the host keeps its margin. A prefix that would first need a copy on the SSD cache stays; the engine copies such prefixes ahead for what the chunk lacked, so a later try finds them free to go. A store a burst shrank thus comes back a chunk per 30 s once requests stop going short, even when the burst's KV, cached, fills the room. Each chunk given back or taken back logs a line (Expert store gave back,Expert store took back). Elasticity serves concurrency, the prefix cache and the rest of the Mac; the advertised context is planned with the store at the cap the engine's limit gives it and does not change. - The store's live chunks and staging are released while idle with the weights and restored with them: its floor and staging, as the request waiting needs them; its elastic chunks come back afterwards as above, a chunk at a time (Weight loading).
/statusreports the store understreamed_experts: its bytes at its cap and at its floor (cap_bytes,floor_bytes, counted as--expert-memorycounts them), its live chunks and those at the cap (chunks,cap_chunks), the chunks it gave back and took back (shrinks,regrows), its live chunks' bytes (arena_bytes) and staging bytes, its live slots (the live chunks, bytes and slots are zero while the store is released), its experts and those resident; per use (prefill,decode) its loads, the distinct experts they routed, those read and of them those read ahead, the experts read ahead whole, the hit rate, the bytes read and the host steps' round trips, one per round (host_steps:pickup_ms,work_ms,resume_ms); and thefailurethat stopped it.
Select the target KV format when starting the server:
splash serve --model mlx-community/Qwen3.8-27B-4bit --kv-format bf16BF16 avoids target KV quantization, uses approximately twice the target KV
memory, and can be slower at long contexts. Model weights are unchanged. Restart
to switch formats. Omit --kv-format or use --kv-format int8 for the default.
The SSD cache supports both formats, preserving their stored
bytes without further quantization.
On M5 and later Macs, whose GPUs multiply int8 at twice their bf16 rate, INT8 KV
also quantizes the attention queries to INT8 where each split of dense attention
reads at least 8 pages of 32 tokens. Prefill chunks of about 225 tokens or more
take INT8 queries even with no history, and verify takes them from about 7K
tokens of history. --activations bf16 keeps the queries bf16 with either
KV format.
The linear-attention (GDN) layers of the targets carry a recurrent state, which every lane holds and every cached state keeps, in memory and on the SSD. Select its storage when starting the server:
splash serve --model mlx-community/Qwen3.8-27B-4bit --state-format bf16BF16 halves the recurrent state, most of a cached state's bytes, so memory and
the SSD cache hold nearly twice as many cached states, and a
decode step reads and writes half the state. The kernels compute in FP32
either way and round the state to BF16 where they store it: at the end of each
prefill chunk, at each state a chunk captures inside it (a replay point,
junction or checkpoint), and at the end of each decode step. So where a
prompt's chunks end and its states are captured also changes its state in the
last bits (sampling). Restart to switch
formats; the SSD cache keeps each format's states apart. Omit --state-format
or use --state-format fp32 for the default, the precision the models'
configurations ask for.
The SSD cache keeps cached prompts on the SSD when memory runs short. Evicted KV pages and request states that a later request could resume from are written to the SSD, and a request that matches them reads them back instead of computing that part of its prompt again. Without it, a request that runs out of memory cannot keep its progress checkpoints and replays its prompt after each suspension. It does not raise a dense target's context limit; a block-sparse target keeps the KV of its long requests there (KV on the SSD).
While memory runs short, the cache also copies each conversation's newest restore point at least 2048 tokens deep there ahead of need, the state its next turn resumes from and the KV of the prompt up to it, while its memory copy stays. It does so while a reclaim has reached cached KV or a state that had no copy there within the last minute: 10 s after the point was published, once its request has ended, unless the conversation has gone on from it by then, so a step of a tool loop that goes on within that time costs no write. A point that falls due while memory is not short waits, and is copied once memory runs short if it is still its conversation's newest. When memory runs short again, that conversation leaves memory at once instead of waiting for its writes. Without memory pressure the cache writes nothing ahead, so a Mac whose memory never runs short spends no SSD writes on it. Nothing else is copied ahead: not the KV a reply adds past the point, which no state resumes from, a shorter prompt, which is quicker to compute again, or a progress checkpoint. These copies take only the room the cache has free beside its other copies and the room spilling requests hold for their KV: they give up no copy, a point that room cannot hold is not copied, and a full cache gives up the copies of what memory still holds first. They wait while the cache reads a prompt back, writes what memory needs now or writes a spilling request's KV, and while a request waits for that memory, and take at most a quarter of its writes in flight. The persistent cache copies every point, whether memory has run short or not and while its request runs too, and makes room for it.
--max-cache-disk sizes it. The default, auto, holds at most a quarter of
what its volume has beyond the space it keeps free (10 GiB, or 5% of a volume
smaller than 200 GiB), its own files counted, and at most 32 GiB. A size such
as 16G is held as given while the volume keeps that space free, so on a
volume of its own the cache may take all but that. Free space is what df
reports, without the purgeable space the Finder counts. The engine measures it
twice a second and whenever a write finds the volume full: as other files fill
the volume the cache shrinks, giving up its oldest copies the way a full quota
does, and it grows back as they go. Several servers on one volume each settle
under auto at a share that leaves the free space above the floor, and with
sizes take its room in turn, down to the floor. A size keeps no margin above
the floor: while a local snapshot, such as Time Machine's on the startup disk,
keeps the blocks the cache frees, the copies it rewrites can take free space
below the floor, as far as a full volume, before the snapshot goes. Off a local
volume, such as a network share, the quota alone is the limit. 0 turns the
cache off. A full cache keeps the only copy of the prefix the newest
conversation resumes from and of those that requests waiting to start will
resume from: only a spilling request's KV displaces them.
State writes stage through a buffer of one state (64 MiB for 35B, 153 MiB for
27B) that the memory plan sets aside within --max-memory; on a 24 GB Mac
serving 27B that costs 4,096 tokens of context. A size sets it aside whatever
it costs, as before, and a quota too small for one state leaves the cache off.
auto sets it aside only where the memory plan holds the model and the
requested context beside it, and gives it up to the
Neural Engine split where the plan holds the split
only without it; otherwise startup says why the cache is off, and the plan is
as without it. Memory alone decides this, so every start of a dense model on a
Mac advertises the same context, whatever room the disk has; a block-sparse
target's follows the room for its KV
(memory and context). The disk decides only whether
the cache's files open: auto opens them only where its limit, a quarter of
what a local volume has beyond the floor, would hold two states and 1 GiB of
KV, on the volume of every place the cache may keep its files. A relaunch
whose plan spills opens temporary files whatever room their volume has, as
the first engine's context it keeps may rest on them. Where no files open,
startup says why the cache is off, and the staging stays unused.
A write that finds the volume full while it shows room beyond the floor met a limit the engine cannot see, such as a volume quota, or a volume that filled and freed again before the engine measured it. Once a second write does, the cache keeps within what it holds, serving and replacing its copies within that until the server stops, and the log says so once. Off a local volume a second write that finds the volume full does the same.
--max-cache-disk-writes SIZE sets a write budget that spares the drive: the
most the cache writes a day on average, such as 200G, at most a quarter of a
day's at once. It is off by default, when every write the cache needs goes
ahead. Once set, the budget decides which states are written: while it is
spent, a state a later request could reuse is dropped instead of written, so
is a suspended request's progress checkpoint, and restore points wait for their
copies. It paces writes rather than capping them: the state a conversation
resumes from is written all the same, KV pages are written only for a state, to
keep the chain of one on the SSD reachable or to complete a restore point's
copies, and pass it too, and each server process has a budget of its own that
every start begins with a quarter of a day's of. All of these count against the
budget, so later states pay for them. A quota smaller than the working set can
cause repeated reads and writes.
Startup prints one line: where the files are, the limit and why, the write
budget when one is set, and that --max-cache-disk 0 turns the cache off.
/status reports under disk the quota (capacity_bytes), what the volume
leaves of it now (limit_bytes), used_bytes, volume_available_bytes, what
holds writes back (writes_gated: no, budget, or closed once a file
stopped taking writes), write_budget_bytes, the bytes the next state's
write not in use may take now, and writes_deferred_ms, how long writes held
back for SSD reads a step waited for. Without a write budget writes_gated is
never budget and write_budget_bytes is 0. It counts the KV pages copied
ahead (kv_copies), and under write_behind the restore points queued for
their copies, the one being copied among them (waiting), copied
(durable), gone on from first (unneeded) and not copied for want of room
or a file that takes writes (refused). An idle engine can report points
queued. Without the persistent cache a point is copied only while a reclaim
has reached cached KV or a state with no copy on the SSD within the last
minute, 10 s after its publication once its request has ended; otherwise it
waits for memory to run short, which may never happen, and is not work the
engine owes. With the persistent cache each point is written once it falls
due, 10 s after its publication at the earliest, and at a clean stop (up to
6 s), so there waiting is SSD work still to come and drains by itself.
Restarts recompute everything unless --persistent-cache is on (see
persistent cache); otherwise the cache keeps evicted KV
pages and states for the session only, in temporary files whose space returns
when the engine exits.
--persistent-cache keeps the SSD cache across restarts of the same model, so a
restart, an upgrade or a crash keeps conversations' prefixes; an upgrade that
changes what the namespace digests (below), such as a weight image's size,
starts its cache cold once, and one that only lays out images of the same sizes
and headers anew keeps it. It follows
--max-cache-disk, which sizes it as it does the SSD cache; it needs the SSD
cache, so a start with --persistent-cache and --max-cache-disk 0 is
refused.
Files live in ~/Library/Caches/Splash/prefix-cache/<namespace>/ (mode 0700;
--cache-dir sets the root, which the engine keeps out of Time Machine when it
creates it). The namespace digests the weights' manifest, the KV and state
layouts, the YaRN factor (of a --max-context past 256K) and the cache format, so no model
reads another's cache. The manifest names each image's path, declared size and
header, and the SHA-256 of the assembly's model.json, which records the
resolved sources and each linked file's path, size, times and digest
(Installation). One engine holds a directory at a time;
another waits 20 s, then uses temporary files, as
auto does when the root's volume is not local or leaves the cache too little
room.
One limit, the quota or what the volume leaves, bounds the whole root, and this
cache may use all of it: other models' caches nobody holds count against it,
and while the root holds more, as this cache grows or the volume fills, those
caches are removed, the least recently used first, before this one gives up a
copy of its own. A cache nobody holds when a running server removes it may be
the one another server is starting to open; that start then begins cold.
Every start, with --persistent-cache or without it, removes the namespaces
under its root (--cache-dir, which needs --persistent-cache, else the
default) that nobody used for 14 days, so a cache left behind in the default
root when the persistent cache is turned off goes too; one under another
--cache-dir goes at the next start that names that root. Only a directory
named as a namespace, 32 hex digits, counts as a cache: nothing else under the
root is touched.
The restore points the SSD cache copies ahead (SSD cache) are what the next start takes back. The persistent cache copies every point, whether memory has run short or not, and while its request still runs too, so that a start after a crash takes it back, and makes room for it as for any copy: a full quota keeps restore points whole, dropping copies no point needs first, then the oldest point. A point larger than what the cache may hold now, its quota or what the volume leaves it, is not written. A request that computes blocks of a point's chain again leaves their copies to the point: they count as its copies, not as the request's KV. One whose KV spills writes its own over them before its pages spill.
A start takes back every recorded point whose chain is whole. A clean stop
writes the waiting points (newest first, up to 6 s, whatever the write budget)
and syncs the files. After a crash the next start takes back what reached the
disk, each state with the class it had last: a checkpoint a request went on to
reuse as an ordinary state comes back as one. Every start serves its first
minute on probation: after two starts in a row fail within it, or one whose
engine reports its own failure, the next start begins empty. /status reports
all this under disk (persistent, taken_back).
Qwen3.8-Flash-Next attends only the blocks its index selects
(sparse attention), so the KV of a long request can stay
on the SSD cache while memory keeps each page's index and the blocks decode
steps read. No option turns this on: it needs only the
SSD cache, and with --max-cache-disk 0 the target's context is
what memory holds, as a dense target's is.
- Each attempt to start a request decides it; a resume keeps it. A request
whose KV, its prompt and
min(max_tokens, 4096)tokens of output, is 2,048 tokens or fewer, or takes no more in memory than spilling it would at its peak, keeps its KV in memory, and so does one whose KV, beside what the requests in service are projected to grow into, fits the memory free now with what a start may take (the cached prefixes nothing in use holds, the caches the model rebuilds and the memory spilling requests of its priority or a lower one give back, below, never the pages of a prefix it reuses nor the expert store's chunks), counted in whole extents with the states its prefill holds; any other spills. A request whose KV fits in the pages a spilling request keeps whole anyway keeps its KV in memory. So does one that cannot spill, the SSD cache closed or its quota short, where it fits the context memory holds of one request and memory holds its KV now: beside other requests in service, with what a start may take, as above; alone, with what a request in service may take, the buffers the next request starts from among it. Where memory does not hold it, it waits for the other requests in service, and makes one of a lower priority yield only while all of them are of a lower priority. Alone, where memory holds its prompt, it keeps its KV in memory with its output bounded to what memory holds, as the SSD cache bounds a spilling request's, and ends at that bound; where memory does not, it takes the SSD cache's bound on its output or waits for its quota as a spilling request does, and is otherwise refused at its start rather than fail partway through its prefill. The refusal names what the SSD cache has for it and, ascapacity_exhausteddoes, the request's lane and tokens anddevice_untracked_bytes, with the memory its prompt needs (memory_needed_bytes) and what memory has for it (memory_available_bytes). - A request in memory whose KV cannot grow, in decode or in prefill, spills from then on rather than yield its lane or fail, where that covers what it lacks: what spilling the pages only it holds frees, past its first 2,048 tokens and its last 64 pages, less the hot block cache's floor its decode's reads need (390 MiB), which takes a request of about 46K tokens or more. It waits while its pages' copies land, and its growth then gives back the memory of those it lacks, as below; a prefix restored from the SSD cache has its copies already. From then on it is a spilling request like any other, a prefill going on as one's. No other request and no shortage of the host's makes a request spill. When other memory is short, a state, a spilling request's reads, or the host's, what can go goes in a fixed order: a cached prefix, the caches the model rebuilds, the pages of spilling requests (below; for the host only under macOS's warning or critical pressure), then, for a request in service, a chunk of the store above its floor, of which a target that can spill has none.
- A spilling request writes each committed page once to the SSD cache and
keeps the page in memory while memory lasts: it decodes on its pages in
memory and reads nothing back. Once memory is short, a page whose copy has
landed gives its memory back, the oldest first, and keeps only its index,
24 KiB of the int8 page's 414 KiB: as much as a request lacks, in whole
extents, for the request's own growth or reads, for a request in service or
a start of its priority or a higher one, and under macOS's warning or
critical pressure for the host, which takes every request's, the lowest
priority's first. Where every request holding them
reads pages back already, a request's growth, one the host refuses among
them, first takes the pages it lacks in place. Where those would leave too
few index pages free for the next, an extent more gives its memory back
with them and refills them; without that extent pages give back nothing in
place, since the memory left may hold no new index pages. A prefix's page
that several requests share gives its memory back only where the one
asking may take it from each of them, and beside it the memory each of
them then needs for its reads, and never while a request that starts with
its KV in memory restores it; it does so once, and counts once in what a
start may take. A request's reads then miss only the blocks of the pages
given back. Alone at the memory wall, a spilling request waits while its
writes land rather than fail. Its first 2,048 tokens, which rows with less
history read whole, its last 64 pages and the pages still being written
stay whole. The memory a page gives back leaves with the extents it empties
and pays for the index pages it moves into, so pages keep giving memory
back under critical pressure too. With
--kv-spill always, for validation, each page keeps only its index as soon as its copy lands. - A decode step reads, after each attention layer selects its blocks, the
selected blocks not in memory into one hot block cache that all requests
share, a page's in one read from its first missed block to its last on the
32 SSD readers, and attends them there.
The cache holds at least, for n decoding requests, n + 1 times the blocks a
request's 8 rows select, 8,192 blocks a layer (390 MiB) for one, taken
whole as a request some of whose pages gave their memory back starts decoding,
by the same release as its first pages give memory back, whichever request's
claim it was for;
--sparse-kv-memoryraises its target, toward which it grows only while a request holds a floor. A decoding request's floor goes only with the request. While no request reads above the floor, the cache's memory, up to 975 MiB after the last spilling request ends, is among the first to go when memory is short, as a cache the model rebuilds. It shares the readers' memory with the history staging below, which the plan sets aside the larger of, so a target within the staging takes nothing from the expert store. - A prefill streams each attention layer's history from the SSD cache into staging, a layer's pages read whole (36 KiB each) while the previous layer runs. A second buffer, which lets the next layer's reads overlap this layer's attention, is planned where the readers' memory holds it already, or where what the streamed stores and the first 64K tokens of KV leave holds it: where a store binds that is nothing, and the store keeps the room; otherwise the buffer takes it from the KV kept resident beyond them. A request reads nothing back, and takes neither the staging nor the hot block cache, until its first pages give memory back. A prefill whose history is on the SSD runs alone, and with others waiting it takes the fewest rows whose work covers its reads. The staging holds nothing between prompts: a decoding request's floor of the hot block cache takes its place, and the next prompt that streams allocates it again.
- The next turn finds its pages' index in memory, or reads it back, and
continues without prefilling the conversation again; with
--persistent-cache, after a restart too. - The
--max-cache-diskquota holds spilled KV beside the cached copies. Spilled KV of requests in service may take all of the room its volume has beyond the floor, whileautokeeps cached copies to a quarter of it: a request of 1,010,000 tokens takes 13.5 GB of slots, about 24 GiB free with the floor. A spilling request commits slots for its KV when admitted; one the cache cannot hold even alone is refused, one that other requests' commitments keep out waits, and one whose prompt fits but not its output ends at the bound it was admitted with, which Messages reports asmodel_context_window_exceeded. A request that cannot spill, the cache short of room, its file closed or its write budget spent, keeps its KV in memory where it fits the context memory holds of one request (context_bounds.resident) and memory holds it now, or alone its prompt under memory's bound (above). Past what it committed, as when its output outgrows the 4,096 tokens admission counted, a request commits each page's slot as it writes it, while the room lasts; its pages stay in memory beyond it, and the other requests' committed pages are still written. - The KV a spilling request writes is in use and always written, so one long
request may overdraw
--max-cache-disk-writesonce. Until the budget covers what spilling requests were promised, a new request that would spill keeps its KV in memory where it fits the context memory holds of one request and memory holds it now, or alone its prompt under memory's bound (above), waits for the other requests in service where memory does not hold it beside them, and is refused otherwise (kv_write_budget, 503 with aRetry-Afterof the seconds until the budget covers them again as it refills). - A block that cannot be read back fails only the requests that read it, as
kv_unreadable, which they may retry; the copy is dropped.
A request's outputs are the same whether its KV spills or not, bit for bit,
for the same schedule and cache hits. /status reports the plan's floors
under memory_plan.budget.kv_spill and what spilling does under kv_spill
(status). Startup logs one line: the context
and what bounds it, the KV a request may keep in memory, the hot cache and
its floor, the staging buffers and the room the SSD cache has for active KV;
or why long requests keep their KV in memory.
Allow space for the target and BF16 draft downloads: up to about 21 GB for the
Qwen3.8-27B 4-bit examples and 24 GB for Qwen3.6-35B-A3B. Other variants have
different sizes. Downloads use the Hugging Face cache, and brew upgrade splash
keeps the installed models. Splash keeps no other copy of the weights on disk.
Agents keep their sessions in their own homes
(files Splash writes).
Each start follows the model's default branch, or the branch or tag
--revision names: one Hub request of at most 5 seconds asks for its current
commit, and when the Hub answered, another asks for the commit of the draft
repository's default branch (Drafts). A new commit downloads only
the files that changed and replaces the installed model once it is installed,
freeing the earlier commit's files, unless another tool's files share its
snapshot. An older --revision downloads them again, as a new commit that then
fails to load would need. There is no update flag; to stay on one commit,
pass it as --revision. A 40-hex --revision never moves, nor does its draft,
and --offline (or
HF_HUB_OFFLINE=1) forbids the Hub: both start the installed model without a
request.
When the Hub does not answer, or a new commit cannot be installed, the
installed model starts, with a line naming the reason. Without the Hub, Splash
rebuilds a missing or damaged installation from the files it downloaded
before, for the same revision. It pins the commits it installs in the Hugging
Face cache (refs/splash), so pruning the cache keeps an installed model's
files, and records there the commit of a default branch, as hf download
does (refs/main). Installation gives the mechanics.
To download new models to another disk, set the cache location before serving:
HF_HUB_CACHE=/Volumes/Models/huggingface splash serve --model mlx-community/Qwen3.8-27B-4bitHF_HUB_CACHE selects the Hugging Face download cache. Alternatively, set
HF_HOME to relocate the Hugging Face home directory, including its default
hub cache. Model links stay in Splash's data directory; existing downloads
are not moved, and an installed model keeps reading its files where they are,
also when a new draft has Splash assemble it again.
| Path | Holds |
|---|---|
~/Library/Application Support/Splash |
The data directory of a packaged install: models/, the installed models' links into the Hugging Face cache and the metadata derived from GGUFs; runtime/, the locks of running servers; and thinking.key, which encrypts the Messages reasoning a response hides. A source checkout keeps the first two in install/models and build/runtime. |
Hugging Face cache (HF_HUB_CACHE, by default ~/.cache/huggingface/hub) |
Model and draft downloads, with Splash's pins under refs/splash and the commit of a default branch as refs/main (revisions). |
~/Library/Caches/Splash/prefix-cache (--cache-dir) |
The persistent cache, with --persistent-cache only, within the SSD cache's limit. Every start removes the caches there that nobody used for 14 days. |
$TMPDIR/splash-cache-* |
The SSD cache's files, on by default, without --persistent-cache, or when another process holds the persistent cache's directory or it cannot be used: one of KV pages and one of states, together at most about the SSD cache's limit: under auto a quarter of the volume's free space beyond its floor, up to 32 GiB, or the size --max-cache-disk sets while the volume keeps its floor. Each is unlinked as it is created, so no directory lists it and macOS's storage settings count it as System Data, and its space returns when the engine exits, even after a crash. --cache-dir does not move them; they follow TMPDIR. |
~/Library/Logs/Splash/crash |
Crash traces, with SPLASH_CRASH_TRACE=1 only. |
Pi's models.json (~/.pi/agent, or PI_CODING_AGENT_DIR) and the Hermes profile (~/.hermes/profiles/splash, or splash-<port>) |
The settings splash pi and splash hermes write (agent launchers). Every agent keeps its sessions in its own home; splash claude, codex and opencode pass their settings at launch and write none. |
Repeat --served-model-name NAME to accept additional API model IDs. The full
--model ID still selects the model. By default /v1/models lists that ID
first, followed by unique aliases; each alias's root identifies the loaded
model, and generation and scoring responses report the real model ID even when
requested through an alias. The model list and lookup support both names.
Some clients check a response's model against the name they requested and
reject the real ID. For them, add --announce-served-name, which requires
--served-model-name: responses then report the first alias, whichever
accepted name the request used, and /v1/models lists that alias first and
gives every other entry, the real ID included, that alias as its root, so
splash <client> configures clients with it. /status keeps reporting the
loaded model.
splash serve --model mlx-community/Qwen3.8-27B-4bit --served-model-name local-qwenAliases cannot contain whitespace, control characters, \, %, ?, #,
or empty, . or .. path segments. This keeps model discovery URLs unambiguous.
--default-reasoning-effort (or SPLASH_DEFAULT_REASONING_EFFORT) sets the
fallback for Chat reasoning_effort and Responses reasoning.effort when absent
or null. Accepted values: none, minimal, low, medium, high, xhigh,
max. An explicit request value wins; the CLI flag takes precedence over the
environment. Unset, the model's template default is unchanged. An effort, the
default or a request's own, reaches the template as enable_thinking, false
for none, and as reasoning_effort, not as a token budget, so a template that
switches reasoning by either follows it. A template that rejects an effort
renders an alias: xhigh for high and max, low for minimal; one that
rejects none renders by enable_thinking alone while thinking is off. A
template that cannot give the thinking a request asks for, one that always or
never thinks, renders as it can, and the request reasons as its prompt does. A
template that refuses a request's messages returns 400 with its reason.
Flint's template has no thinking switch: its generation prompt is
<|assistant|> alone, whatever the effort or enable_thinking, and the model
decides each turn whether to reason. Both reach its template and change
nothing; a reply whose output opens with <think> returns that block as its
reasoning, and any other reply is all answer
(chat formats).
splash serve --model mlx-community/Qwen3.8-27B-4bit --default-reasoning-effort none/v1/models reports, as reasoning, what efforts do to the model's default
template, rendered as at startup: thinks, whether a request without an effort
reasons; efforts, the efforts besides none that the template renders, and
differently, each named by the one of those rendering alike that no alias
stands in for (low, medium and xhigh for Qwen3.8; none for Qwen3.6, whose
template switches reasoning on and off alike at every effort); and default,
the one of them a request without an effort renders like,
--default-reasoning-effort in place of the template's (xhigh for high on
Qwen3.8). For Flint, whose model decides, thinks is null and efforts
empty.
/apply-template uses the same default. Anthropic thinking keeps its protocol
semantics (off when omitted) unless the request's chat_template_kwargs set
enable_thinking; judgment endpoints always disable thinking. The
built-in chat page offers Auto, the efforts /v1/models reports and Off, and
no menu for a model that decides; it sends no effort unless the user picks one.
The console's Started line names the request the engine took, by the id its
Done, Cancelled or Error line names too, and shows how each chat request
reasons: the effort it sent, or absent and the default it took, its
enable_thinking when it set one, and whether it thinks, as in
Started · request 12 · input 2,473 · cached 0 · effort absent → low · thinking on.
A Chat or Messages request's chat_template_kwargs are passed to the template
as variables and outrank the effort, and in Messages thinking, so
{"enable_thinking": false} turns reasoning off and reaches the template as
reasoning_effort none too, as effort none does, and true turns it on,
at the template's default effort where the effort is none. enable_thinking
must be a boolean; null leaves it to the effort. They cannot set what Splash
passes itself, such as tools or add_generation_prompt.
A reasoning model can spend its whole output limit thinking and return no
answer. A thinking token budget caps the thinking: once a request has selected
that many tokens in its thinking block, the engine selects the thinking end,
which closes the block with </think>, and the model answers within the output
limit that remains. Its reasoning_tokens then equal the budget plus the end's
tokens before </think>. Where a grammar constrains the output, as for
structured output and in the cases tool calls lists, thinking can
end only between whole characters: where the budget's last token holds part of
a character, as a byte of a rare Chinese character or of an emoji can, the
request thinks on until the character is complete, a token or a few, and its
reasoning_tokens count those tokens too.
- Chat Completions and Responses take
thinking_token_budget, a non-negative integer;-1sets no cap, and an absent or null value takes the server's. - Messages caps
{"type": "enabled"}thinking at its requiredbudget_tokens, an integer of at least 1024, as in Anthropic's API; one at or abovemax_tokens, which Anthropic's API allows with interleaved thinking, sets no cap. Adaptive thinking takes the server's budget. --thinking-token-budget N(orSPLASH_THINKING_TOKEN_BUDGET) is the server's budget; unset, thinking has no cap.--thinking-end-text TEXT(orSPLASH_THINKING_END_TEXT) is the server's thinking end: text without the template's special tokens, then</think>as its own token, at most 64 tokens in all. The server checks the text with the model's tokenizer when it starts, and stops with an error on one that breaks these rules. Unset, the end is the close the chat template writes after a reasoning, which the server probes at startup: a line break then</think>for Qwen's templates,</think>alone for Flint's. The next request then renders the close of a reply whose thinking the budget ended as the tokens the budget selected, so that its prompt keeps to the reply's tokens through the cut and can read the reply's cached KV past it; an end that closes otherwise, such as</think>alone on Qwen's templates, departs from the reply at its close. Where the cut follows a space or a line break, the prompt still departs there: Qwen's templates trim them from the reasoning.
A model whose thinking is cut mid-thought may go on planning in its answer, and
may close that with a </think> of its own. A chat answer drops every </think>
after the reasoning's end, the budget's or the model's own, and with thinking
off: only the tag goes, the newlines around it stay, and a tool call's arguments
and a structured answer's JSON keep theirs. A text completion keeps its text as
written. An end text that turns to the answer first helps it answer at once,
such as this one adapted from the sentence Qwen suggests for its models: a blank
line sets it apart from the cut thought, and the model writes the one after
</think> itself.
splash serve --model mlx-community/Qwen3.8-27B-4bit --thinking-token-budget 12000 \
--thinking-end-text $'\n\nConsidering the limited time, I have to give the solution based on the thinking directly now.\n</think>'A budget applies only where generation starts in a thinking block, and one the
output limit reaches first changes nothing; the output limit can also cut the
end short. Where the context leaves a request less room than its output limit,
the budget keeps its share of that room, and the answer the rest, a limit past
the budget plus the room counting as the budget plus the room: a budget of
24,576 under a limit of 32,768 with 20,000 tokens of context left ends thinking
at 15,000, and one of 8,192 under a limit of 2,147,483,647 with 122,072 left at
7,676. A model that opens its thinking itself, as Flint does, never starts
in one, and a budget caps nothing for it. Where the output may call a tool, a
call's opening ends thinking too, so the end never lands inside a call. The
engine takes any <tool_call> token for that opening, while the reply's
reasoning ends only where <tool_call> is followed by \n<function=: a stray
<tool_call> in the thinking ends the budget's count while the reasoning goes
on, and the request's reasoning_tokens can then exceed the budget. Only the
end is forced, and a close the model selects itself before the budget ends the
count. Text completions have no reasoning split and refuse a budget with 400.
Agents must already be installed; splash claude|opencode|codex|hermes|pi
connects to the running server. Arguments pass through, for example
splash codex resume --last or splash hermes chat -q "Hello". splash
reaches the server on loopback without a proxy. Where the environment names a
proxy (http_proxy, https_proxy or all_proxy, in either case), it adds
127.0.0.1, localhost and ::1 to the agent's NO_PROXY and no_proxy,
keeping the hosts already there and leaving * as it is, so no proxy receives
the server's key. A proxy set only in macOS system settings is left to that
setting's own exceptions.
splash pi adds a splash provider to Pi's models.json (splash-<port> for
a server on another port), preserving other providers, settings and sessions.
Where every chat template of the server renders a later system message in
place (/status chat_template.later_system, tokenizer and chat
templates), the provider's model says so to
Pi, which otherwise folds the system messages it adds as a session goes on
into the first: the prompt then keeps its start, and the server its cache.
The chat page names no model, and the launchers configure each client with the
model ID the running server reports, so a client need not list a model in its
own catalog for Splash to serve it under its full repository ID.
splash opencode, pi and hermes configure an output limit per response of
32K tokens (CLIENT_RESPONSE_TOKENS in install/clients.py); OpenCode and
Hermes, which reserve it out of the context they compact at, get a quarter of a
context under 128K instead. Hermes 2026.9.7 and later ignore it and, like Codex,
leave the limit to the server; Claude Code keeps its own, which
CLAUDE_CODE_MAX_OUTPUT_TOKENS raises.
splash hermes runs Hermes in the splash profile (splash-<port>) of the
user's Hermes root, ~/.hermes or the root HERMES_HOME belongs to, and
creates it with hermes profile create on first use. It writes only the
profile's model settings; Hermes's tools and the root's config.yaml remain
the user's.
With SPLASH_API_KEY set, Pi's models.json and the Hermes profile name the
variable instead of holding the key: Pi and Hermes read the key from their
environment, so one started without splash needs the variable in its shell.
A file these launchers rewrite keeps its mode; one they create is private.
Before 1.2.0, Splash gave Hermes a home of its own: hermes in
~/Library/Application Support/Splash/runtime in a release, or in a checkout's
install/agents (earlier install/profiles or build/runtime), and
ports/<port>/hermes there for a server on another port. Hermes took each home
for its root, except install/profiles/hermes, whose root was the checkout's
install/: a Hermes that manages its own runtime installed its tools in its
root and pointed the hermes command at them. An upgrade leaves these homes and
their sessions in place, and Splash no longer uses them. While one still exists,
run hermes pm install once in a normal shell; it installs Hermes's tools under
~/.hermes/tools and points the hermes and hermes-acp launchers in
~/.hermes/hermes-agent/.hermes/bin at them. Once those launchers name no old
root, or do not exist, as with Hermes before its managed runtime, delete the old
homes with the sessions Splash started there, and the files Hermes added to a
checkout's install/, which git status lists as untracked.
Splash 1.1 kept prepared copies of the weights in
~/Library/Caches/Splash/weights (or the directory SPLASH_WEIGHT_CACHE
named), which nothing reads now and which can be deleted. While the default
directory remains, the native runtime says so when it starts, with its size.