Contents ↑
e-EvolutionBuilt with Gentle-AI
Field report · oMLX server + gentle-shell harness · Apple M5 Max 128 GB

Unattended Agents on oMLX and gentle-shell

oMLX gentle-shell

Logos identify the projects this report is about. It is an independent contribution.

Introduction

This article addresses a concrete question: is it possible to complete real work from beginning to end using local models, consumer hardware, and only open-source technology?

To investigate it, we developed a documentation pilot: turning PDF manuals and older screenshots into wiki documentation, with images of the new ADempiere version and an assistant that answers questions. The implementation combined the oMLX inference server, the Gentle Shell agent harness, and open-weight models from the Qwen family, running on a personal workstation.

I am the lead maintainer of ADempiere, an open-source project for ERP, CRM, and SCM. Its official repository provides public access to the source code and development history, giving readers context for the project behind this pilot. I also lead a company specializing in the development, support, and professional maintenance of solutions based on this technology. That experience helped us choose a concrete, useful task with verifiable results.

The purpose was not to compare this configuration with frontier models or tools such as Claude Code or Codex, but to establish whether it could complete the proposed work, identify the obstacles, and document the conditions needed to make it possible.

Below, I share the experience, the problems encountered, the solutions developed, and the results obtained, together with the limitations of the evidence.

Executive summary

We tested whether local models on consumer hardware, using open-source tools, could carry out useful documentation work without constant human intervention. The pilot used Qwen models served by oMLX on an Apple M5 Max with 128 GB of memory, with pi and gentle-shell coordinating agents working on an ADempiere wiki.

With the proposed server and harness fixes applied during the pilot, the project met its objective: the documentation was completed from beginning to end in wiki format, with images of the new ADempiere version and an assistant that answers questions, using local models, consumer hardware and open-source tooling. The benchmarks document improvements in performance, memory use and stability that supported that outcome; they do not independently audit the wiki's content. Historical batch results, newer upstream comparisons and an unpaired live run answer different questions.

Results are limited to one Apple consumer workstation configured for local AI and one model family. Upstream merged PR2 as #4442 and PR1's fix as #4453 on 2026-10-12; the other fixes are proposals, not claims of upstream adoption, and none is a guarantee for smaller machines.

Detailed contributions, benefits and acknowledgements

Contribution overview

What we contribute

To oMLX

  • PR1: TurboQuant batched decode. Each left-padded row attends only its own slice, in decode and in MTP verify. A bug fix. Upstream closed it in favor of #4453, which builds the same fix on the #4408 kernel and lists us as co-author (merged 2026-10-12). Details
  • PR2: prefill after a restored TurboQuant prefix. A long suffix is prefilled over a dense copy of the restored prefix, then re-quantized for decode. A bug fix, merged upstream as #4442 (2026-10-12). Details
  • Pause and resume instead of abort. Under memory pressure a request keeps its prefill progress and parks, decoding rows pause, and both resume when memory frees; a request that can never fit gets a structured error, and image prompts become parkable. Off by default or gated, opened on 2026-10-12 as a stack of 12 pull requests, #4478 to #4489. Details
  • Prefix-cache fixes A1 and A2. Mid-conversation system messages no longer rewrite the prompt head (A1), and a client's synthetic messages no longer strip thinking in the middle of an agent loop (A2). Proposed separately, after the bug fixes. Details
  • Observability and a memory bound. Eviction summary, per-request cache outcome and TTFT breakdown logs, and memory.mlx_cache_limit_gb to bound the Metal buffer cache, as separate later proposals. Details

To gentle-shell

  • The todo fix (#1367). Todo state moves out of the system prompt into hidden conversation messages sent only when the list changes, and the stale reminder fires once, so the system prompt stays byte-stable within a session. One commit, ready on fix/todo-prompt-cache-1367. Details
  • A prompt-stability invariant test. It asserts that each request extends the previous one, and can be generalized to every extension that touches the system prompt. Details
  • An end-to-end cache check and a payload recorder (optional). They catch prompt rewrites against a local server that reports cached tokens, and make "it got slow" reports reproducible. Details
  • Four more harness findings, with evidence. YOLO directive and memory addendum toggles in the system prompt (raised in #1367), the worker role without agent_browser, and squad workers that ignore the pinned model. Details

Why it is worth integrating. Paired benchmarks compared the fork with the upstream version named in each section, one build at a time. Recorded traffic and the live run provide observational evidence, not paired upstream comparisons. Run counts and limitations are stated with each result. Every behaviour change was written test-first; about two thirds of the pause-resume cycle's changed lines are tests. The work is split into small, reviewable pull requests with a stated merge order. Beyond the two bug fixes, every slice of the pause-resume stack is off by default or gated on an explicit memory ceiling, so with every flag off it behaves as main on these paths. The order and shape are proposals; we will adapt them to your preferences.

Benefits for oMLX

  • Faster, leaner batched decode (PR1). Batched TurboQuant decode 5–12× faster per row (from about 1.5 tok/s per row on main 25aebb3e); B=3 phase 345 → 66 s with MTP on and 780 → 65 s with MTP off; peak 11–15 GB lower. Upstream's #4453, built from this diagnosis, reports 62–65 tok/s per row with three concurrent requests (maintainer's measurement, Qwen3.6-35B-A3B with TQ4). Evidence
  • Restored prefixes no longer slow prefill (PR2). Suffix prefill after a restored TurboQuant prefix: 2.41× → 0.92× of a cold prefill. With the fork's progress commit, a re-issue after a cancel takes 80–82 s instead of 192–193 s. Evidence
  • Pause instead of abort (opt-in stack, historical C7 run against b7c7929e). Under forced pressure 9 of 9 requests finish with 0 aborts, against 0 of 9; tokens are identical after 12 pauses with MTP off; a request that can never fit gets a structured 400 in 0.1 s. Evidence
  • No lost work. A cancelled prefill keeps 73,728 committed tokens instead of 0. Evidence
  • Better cache reuse for any client (A1, A2). A2 removes a cause worth about 0.94M re-prefilled tokens in 1.5 days; image turns reach 97.4–99.7% hits. A1 keeps mid-conversation system messages from rewriting the prompt head. Evidence
  • Bounded memory and observability. mlx_cache_limit_gb bounds the Metal buffer cache (evidence: the P2 peak); the eviction summary, per-request cache outcome and TTFT breakdown support every live measurement here. Evidence

Benefits for gentle-shell

  • A byte-stable system prompt. System-prompt rewrites 21 of 61 → 0 of 90 requests; 31 of 31 consecutive turns append-only; re-prefills above 20k tokens from 1–2 per hour to 0. Evidence
  • Time and compute back. Before the fix, ~1.05M tokens were re-prefilled in ~1.5 days (≈80–85 min of GPU time), and each affected turn waited ≈2–6 min longer. Evidence
  • Relevant beyond oMLX. Prompt rewrites can also reduce reuse on other local servers and hosted APIs with prefix caching. Actual savings depend on each backend's cache rules and pricing; they were not measured here. Evidence
  • Small and verified. One commit, 388 changed lines (296 of them tests); 63 todo tests pass; an independent review found no blocking issues; #1924's reminder is kept. Evidence
  • Regressions caught early. The fix carries a prompt-stability invariant test for the todo tool, which can be generalized to every extension that touches the system prompt. Proposal
What we ask next. oMLX: PR2 was merged as #4442 and PR1's fix went in as #4453 (both 2026-10-12). Next, a review of the pause-resume stack, opened in merge order as #4478 (ready for review) to #4489 (drafts) and announced in Ideas discussion #4440. gentle-shell: status:approved on #1367, so the fix can go in as a pull request, or be folded into another change if you prefer. Details: Proposed contribution.

Both projects are open, and that is what let us read the code, measure it and fix what we found. In the same spirit, we would like to give these improvements back rather than keep a private fork. Thank you to jundot for oMLX and to Alan-TheGentleman for gentle-shell, and for the great work each of you shares through your project.

About this report

A field report for the maintainers of two projects: oMLX, the inference server, and gentle-shell, the agent harness. It follows one developer workstation running coding agents unattended: how the work started, how each problem was measured, what was changed in a fork, how it was validated, and which parts we would like to contribute upstream. Both projects were improved with the same method: measure on real traffic, attribute each loss to a cause, fix test-first, and validate against the other side.

Scope. The oMLX work started on v0.7.0 and is now based on v0.7.1.dev1, upstream main 013bc3f3. This is a field report on specific versions, not a general validation of the current fork or of today's upstream main. The C7 runs and the live production run used upstream oMLX main at b7c7929e, the fork branch at the commits named in each section, and gentle-shell main at aa2c0389. Two re-validations are newer: the TurboQuant fix (PR1) on upstream main 25aebb3e, and the whole fork against main c0b1056b, which also surfaced a second upstream TurboQuant defect (PR2). The branches proposed upstream are rebased on main 013bc3f3.

Author: victor.perez@e-evolution.comModel: Qwen3.8-27B-oQ4e-mtp (4-bit, Lightning MTP, TurboQuant 4-bit KV)Client: pi coding agent + gentle-shell harness 4.0.0 (package gentle-pi; commits per test below) + subagentsoMLX: v0.7.0 → v0.7.1.dev1 (upstream main 013bc3f3); fork app 0.7.1.dev1-fork.7Period: 2026-09-30 → 2026-10-11Source code: github.com/e-Evolution/omlx (commit map)

Results and limits at a glance

Read each result with its baseline. Repeated requests within one run are not independent benchmark repetitions. Follow the evidence links for exact settings, logs and commit identities.

QuestionBaseline and runtimeEvidenceLimit
Was the documentation pilot completed end to end?The author's project outcomeThe author confirms that, with the proposed server and harness fixes applied during the pilot, the project completed the wiki documentation with images of the new ADempiere version and an assistant that answers questions, using local models, consumer hardware and open-source tooling.Reported completion with the proposed fixes applied, not the original configuration or an upstream release containing them; the benchmarks are not an independent content-quality audit.
Do batches finish unattended?Historical v0.7.0 comparisonP5: 3/3 fork batches, 0/3 upstream. EvidenceMeasures batch completion separately from the final project outcome; not re-run against newer main.
Does PR1 improve batched decode?main 25aebb3e; same pinned venvB=3: 5–12× faster per row; 11–15 GB lower peak. One run per build and MTP setting. EvidenceNo degeneration reproduced on this baseline; outputs are not token-identical to B=1.
Does PR2 improve restored-prefix prefill?main c0b1056b; pinned venvRestored/cold suffix ratio: 2.41× → 0.92×. One run per configuration. EvidenceOne model and prompt shape; extra dense-cache memory must fit.
Do requests survive pressure after the prefill fix?Fork 4aa66150 against main 013bc3f3; same venvText and image decode finish 3/3 on the fork at the compared ceilings; main finishes 0/3. One run per scenario. EvidenceForced pressure, not typical load; some other upstream scenarios were not re-run.
Does the server sustain real agent traffic?Unpaired fork.5 live run; v0.7.0 app runtime333 requests; 94.8% of prompt tokens cached; 0 memory aborts. EvidenceLog window 02:00–04:10; no memory pressure, so pause/resume was not exercised.
Does the todo fix stabilize prompts?Recorded traffic before/after the gentle-shell fixSystem-prompt mutations: 21/61 → 0/90 requests; 31/31 append-only pairs. EvidenceObservational samples with different workloads; not a controlled latency or hosted-cost benchmark.

At a glance

116 → 44 GBmemory peak, 2×130k concurrent (vs upstream main at b7c7929e), 3× faster
3/3 vs 0/3unattended agent batches finished without a human (vs v0.7.0)
97–99.7%prefix-cache hit on turns that add a new image
~30 s/hdecode frozen by media prefill with one image session after the cache fixes (n=2). Before the fixes ~1100 s/h was observed on a heavier, overlapping workload, so the two are indicative, not a controlled comparison
9/9 vs 0/9requests finished under forced memory pressure, 0 aborts with pause and resume (vs upstream main at b7c7929e)
5–12×faster batched TurboQuant decode per row with PR1 on upstream main 25aebb3e (B=3, 20k/45k/135k prompts), 11–15 GB lower peak, one run per build
2.41× → 0.92×suffix prefill after a restored TurboQuant prefix, against a cold prefill of the same positions, on upstream main c0b1056b with PR2 (36k-token suffix after a 74k prefix, one run)
21/61 → 0/90recorded requests that rewrote the system prompt, before and after the gentle-shell todo fix (gentle-shell)
~1.05Mtokens re-prefilled in ~1.5 days by system-prompt changes in the harness, ≈80–85 min of GPU time, before the gentle-shell fix
0 / 333memory aborts across 333 live agent requests at up to 5 concurrent, 94.8% of prompt tokens from cache (fork.5, server log 02:00–04:10)
Harness bug found along the way (gentle-shell). In affected parent sessions with open todos, the todo extension rewrites the system prompt on every user prompt, with a stale counter that changes each time. On the tested oMLX setup, these rewrites break prefix reuse from the changed region onward. Other backends may also lose reuse, depending on their cache rules; hosted-provider effects were not measured here. It is still present on upstream main (d42264b4, 2026-10-11). A tested fix is ready on fix/todo-prompt-cache-1367, tracked in #1367. See the gentle-shell section.
Upstream bug found along the way (oMLX). In batched decode with TurboQuant, left-padded short rows attend over zero padding columns, and every padded decode step goes through a slow dequantize-everything path. In synthetic tests the short row's output norm drops to 0.32× of the reference. With Lightning MTP, padded verify forwards also miss the per-row path, and the verify-tail rollback re-quantized the whole batch every cycle; upstream has since fixed the rollback in #3127. A tested fix for the other two defects is ready as our first pull request (PR1).
  • On newer main (25aebb3e, with its pinned dependencies), PR1 is primarily a performance fix. A B=3 batch of 20k/45k/135k prompts completes on both trees without degeneration, and PR1 makes batched decode 5–12× faster per row, cuts the B=3 phase from 345 to 66 s (MTP on) and from 780 to 65 s (MTP off), and lowers the peak by 11–15 GB. The correctness signal is weaker: with MTP off, main's batched rows leave the deterministic B=1 output at about token 1–2, PR1's at about token 8–10. See the re-validation.
  • Historical (main b7c7929e on the v0.7.0 app runtime): the same batch degenerated in one run (4-gram 0.33/0.29/0.18, 121 GB) and aborted in another (6 memory aborts, 120 GB). This does not reproduce on 25aebb3e.

The use case

The setup is one Apple M5 Max (128 GB) serving one local model to several pi coding-agent sessions. pi runs the gentle-shell harness (package gentle-pi) on top, which adds the agent workflow: orchestration, a todo tool, review integration and subagent delegation. Subagents run as separate pi processes, each with the same harness. The sessions read whole source files and take screenshots. Prompts are large: across 1,260 recorded requests the median was 107k tokens and the p90 168k. Each turn re-sends the growing conversation, so the prefix cache decides whether a turn costs 1 second or several minutes.

The goal was simple to state: leave the machine working until every job finishes. That means using free memory for concurrency, never throwing away completed work, and pausing rather than aborting when memory is tight.

One local oMLX Fork server, two clients: gentle-shell subagents and the IntelliJ AI assistant send requests at the same time, and oMLX serves 3–4 active requests side by side on Qwen3.8-27B (95.5% cache efficiency). 10× speed.

Field illustration from the later fork.5 recordings, not a benchmark of the original configuration.

What broke on stock v0.7.0

How it developed

The question

After the 0.7.0 memory-guard tiers landed, the question was whether a request could pause when memory runs out and resume when it frees, instead of aborting. We checked where that belongs, client or server, and settled on both. The pi extension pauses and auto-continues. The server keeps prefill progress and parks instead of aborting.

Premises before code

We wrote six falsifiable premises (P1–P6), each tied to a benchmark that runs against upstream v0.7.0 on the same machine, model and prompts. Nothing counted as done until its premise passed against upstream.

Implementation in small reviewed slices

The work was done test-first, with one reviewable commit per behaviour and an independent review on each slice (four review lenses on the high-risk ones). It went into a fork branch off v0.7.0 and was packaged as a side-by-side app for daily use.

The batching surprise

Real concurrent use exposed the padded-attention corruption and very slow batched TurboQuant decode, about 1.5 tok/s per row. Fixing the correctness bug and adding a single-launch ragged kernel brought per-row decode to about 13 tok/s.

The cache-reuse investigation

Once nothing aborted, the remaining cost was re-prefill. We recorded every request payload with a small client-side recorder and attributed each cache break to a cause. About 2.7M tokens were re-prefilled over roughly 1.5 days. The largest cause was in the gentle-shell harness and two were in the server; all three were fixed.

Harness and server, tested together

Testing the gentle-shell harness against the running oMLX server, with server-side monitoring, exposed a cache invalidation that neither side showed on its own: while todos were open, the harness rewrote its system prompt on every user prompt. The fix went to gentle-shell, and the method became part of this report (details).

Live validation

Recorded traffic and daily use helped validate the changes exercised by those workloads. Structured logs (cache outcome, TTFT breakdown, eviction summary) supported the measurements; features tested only in unit or forcing tests are identified separately.

Pause and resume, end to end

A forcing benchmark showed that the prefill park works but cannot save a batch, because decoding rows and image prompts still aborted. We built the full cycle: decode pause, memory-aware resume, a never-fits error and parkable image prompts. Its first validation run failed in several ways. Each failure was traced in the logs and fixed test-first, until every request finished under forced pressure with 0 aborts.

Live production run

The packaged fork (0.7.0-fork.5) then served real unattended agent work for 1 h 39 min, with the concurrency limit raised live from 3 to 5. It logged 0 memory aborts and served 94.8% of prompt tokens from the prefix cache. Memory stayed far from the pressure line, so pause and resume never fired.

Premises and results

Start with the newer re-validations below. The original comparisons are retained as historical evidence; they are not measurements of today's upstream version.

Historical comparisons: v0.7.0 and main b7c7929e

All runs used an isolated server on a separate port with a fresh cache directory, the same model and the same prompts. They compared upstream v0.7.0 against the fork.

PremiseUpstream v0.7.0ForkResult
P1 · No lost work (cancel mid-prefill, re-issue)0 tokens cached73,728 tokens cachedPASS
P2 · 2×~130k concurrent, no aborts4 aborts · peak 116 GB0 aborts · peak 44 GBPASS
P3 · Greedy batch correctness (B=3 vs B=1)degenerate short rowsno degeneration; divergences only at bf16 near-tiesPASS
P4 · Concurrency pays (3 mixed requests)538 s vs 246 s serial · 6 aborts241 s vs 247 s serial · 34.6 vs 29.7 tok/s aggregatePASS
P5 · Unattended pi batch0/3 completed3/3 completedPASS
P6 · Single 110k request, no regressionbaselineprefill +1.6% · decode +8.5%PASS

P4's wall-clock criterion is close to run-to-run noise: one of five fork runs missed it by 0.2 s. The aggregate-decode criterion passes with margin.

Re-run against main b7c7929e (v0.7.0 app runtime)

We rebased the fork onto upstream main (b7c7929e) and re-ran the premises. Each build ran alone on the machine, through the same runtime (the v0.7.0 app's Python, dependencies and compiled kernels, mlx-vlm 0.7.1), so the two differ only in source.

PremisemainFork rebased on mainReading
P1 · Re-issue after mid-prefill cancel0 cached · 110,031 re-prefilled73,728 cached · 36,303 re-prefilledPASS
P2 · 2×~130k concurrent0 aborts · peak 116 GB · 1,427 s0 aborts · peak 44 GB · 461 sPASS main survives, but near the memory ceiling and about 3× slower
P3 · Greedy batch correctness (B=3 vs B=1)B=3 batch aborted (6 aborts, 120 GB peak); does not reproduce on 25aebb3eno row degenerate; 4-gram ratios 1.00 / 1.00 / 0.99fork correct divergences at bf16 near-ties
P4 · Concurrency pays (3 mixed requests)248 s concurrent · 251 s serial · 0 aborts227 s concurrent · 224 s serial · 0 abortswithin noise no gain claimed against main
P6 · Single 110k requestprefill 755 tok/s · decode 21.1 tok/s744 tok/s (−1.4%) · 20.6 tok/s (−2.4%)within the −5% limit

Three points to note. First, main no longer aborts in P2 or P4 the way v0.7.0 did. Second, the fork's concurrency gain in P4 is within noise against main. Third, P5 needs the live agent client, so it was not re-run. The clear wins against main b7c7929e on this runtime are no lost work (P1), a much lower memory peak and faster completion under concurrency (P2), and batch correctness (P3). P3 was re-run on newer main for PR1, where the picture changes (below).

Re-validation on newer upstream main

Batched decode for five concurrent requests on one Qwen3.8-27B model (TurboQuant KV), cache efficiency 95.5%. 10× speed.

Field illustration of concurrent decode from the fork.5 recordings. The paired B=3 measurements below are separate evidence; this B=5 clip is not their recording.

The evidence above was measured on b7c7929e with the v0.7.0 app runtime. It was re-checked on newer upstream main commits in two rounds: PR1's premise on 25aebb3e (this subsection), then the whole fork against main c0b1056b (next subsection). Every branch proposed for upstream is now rebased on main 013bc3f3 (2026-10-10), with its focused tests and CI's command re-run there (Proposed contribution). The upstream commits between 25aebb3e and 013bc3f3 do not touch the TurboQuant files PR1 changes.

PR1 on 25aebb3e. PR1 was rebased on 25aebb3e at the time, so its premise, P3, was re-run on that tree. main 25aebb3e and PR1 (61bd8a06, two commits on 25aebb3e) ran from one venv built from main's pins: mlx 0.32.3, mlx-vlm 0.7.7, mlx-lm 94cdcae1, transformers 5.17.0. Only the source tree changed between them. The prompts are about 20k / 45k / 135k tokens, byte-identical to the earlier runs, with 512 new tokens and greedy sampling. Three B=3 rows are restored from the prefix cache, decode together, and are compared with the same prompts restored at B=1. One run per build, one server at a time, with the live app quit.

BuildMTPAbortsPeakDistinct 4-gram, B=3 (20k / 45k / 135k)Batched decode tok/s per rowB=3 phaseRun wall
mainon0117 GB1.00 / 1.00 / 0.991.54 / 1.53 / 1.49345 s1015 s
PR1on0102 GB1.00 / 1.00 / 0.988.35 / 8.83 / 7.8466 s682 s
mainoff0112 GB1.00 / 1.00 / 0.980.66 / 0.66 / 0.66780 s1492 s
PR1off0101 GB1.00 / 0.99 / 0.988.17 / 8.15 / 8.0565 s782 s
Caveats. One run per build, so walls and peaks are single samples. The venv has no compiled custom kernels (there is no Metal toolchain to build them here), so prefill and run walls are slower than in the shipped app, equally for both trees; TurboQuant attention uses JIT Metal kernels and is unaffected, and the B=3 decode comparison runs on the same runtime for both. Identity is judged by text only. Whether the earlier aborts and degeneration went away through upstream commits or through the runtime is unknown.

The whole fork against main c0b1056b

The fork was rebased again, onto upstream main c0b1056b (the commit tagged v0.7.1.dev1). Both trees ran from one venv built from main's pins (mlx 0.32.3, mlx-vlm 0.7.7), one server at a time, with the live app quit. The fork (6fbb3274) ran with the fork app's settings; main ran with plain settings. The forced-pressure scenarios are the C7 ones (below): three greedy prompts of about 130k / 115k / 100k tokens started 0.5 s apart, hard_threshold 0.8, the same custom ceiling for both trees. Pooled-buffer reclaim is always on now, because upstream removed the switch that disabled it. One run per scenario.

ScenarioCeilingForkmain c0b1056b
Text decode44 GB3/3 0 aborts; 2 prefill parks, both resumed0/3 abort-all mid-prefill after 25 s
Image decode42 GB3/3 0 aborts; 2 decode pauses, both resumed (also 3/3 at 44 GB)0/3 two abort-alls and one prefill-guard rejection
Image prefill44 GB3/3 0 aborts; 2 prefill parks, both resumed3/3 0 aborts
Never fits (60k prompt, max_tokens 2,000,000)42 GB400 context_exceeds_capacity in 0.11 s, all 7 fields; capacity endpoint 200; header 288,739200; endpoint 404; no header

Premises, fork against main c0b1056b, same venv:

PremiseForkmain c0b1056bReading
P1 · Re-issue after mid-prefill cancel73,728 cached · TTFT 198.9 s0 cached · TTFT 192.2 scache PASS, no TTFT gain on this commit; with the prefill fix the re-issue takes 80–82 s against 192–193 s on main (re-validation), traced to an upstream defect, fixed below
P2 · 2×~130k concurrent2/2 · 0 aborts · peak 51 GB · 564 s2/2 · 0 aborts · peak 116 GB · 870 sPASS lower peak, faster
P3 · B=3 batch, MTP on13.0 / 12.2 / 10.9 tok/s per row · peak 58 GB1.55 / 1.54 / 1.50 tok/s per row · peak 117 GBno regression no visible cost from #3127's rollback; the fork carries PR1 and the ragged kernel
P4 · Concurrency (3 mixed requests, 2 runs)292.4 s concurrent · 287.7 s serial · 12/12289.3 s concurrent · 285.9 s serial · 12/12parity both prefill-bound without compiled kernels
P6 · Single 110k request, decode22.77 tok/s22.61 tok/sno regression +0.7% at 110k, +4.6% at 20k

P6's first measurement (3 runs, 256 decode tokens each, not interleaved) showed the fork's decode 7.1% slower, the same with every fork setting off. It did not reproduce. A re-measurement with 1,024 decode tokens and three interleaved sessions per tree, MTP on, gives the medians in the table, and a per-verify-cycle cost of +0.9% (standard error 1.4%) at 110k after controlling for drafted tokens. With MTP off both trees decode at 16.8–17.0 tok/s at 110k and 24.9–25.4 at 20k. The first gap came from MTP acceptance in a short sample (1.90 against 2.01 tokens per cycle) plus drift between runs that were not interleaved, so it was closed as noise without a bisect.

Re-validation with the prefill fix: fork 4aa66150 against main 013bc3f3

The runs above predate the restored-prefix prefill fix, which changes how a resumed park or a re-issued request continues over restored TurboQuant blocks. So the forced-pressure scenarios and P1/P2 ran again on the fork with the fix (4aa66150), against main at 013bc3f3, with the same venv, ceilings, prompts and settings. Every resume was checked in the log for the fix's dequantized … restored TurboQuant layers line, or its Kept … quantized fallback, and for the reservation that follows it. One run per scenario.

ScenarioCeilingFork + fixmain 013bc3f3Fix in the log
Text decode44 GB3/3 0 aborts; 2 prefill parks, both resumed0/3 abort-all mid-prefill after 22 snot reached: the parks resumed from 0 committed tokens
Image decode44 / 42 GB3/3 0 aborts at both; 1 and 2 decode pauses, all resumed0/3 two abort-alls, one admission rejection (44 GB)not reached: a decode pause resumes its exact cache, with no suffix prefill
Image prefill44 / 42 GB3/3 0 aborts at both; at 42 GB one prefill park, resumed solo; wall 640 s (2,132 s before the fix)not re-run (no scheduler or TurboQuant change since c0b1056b)7 dequantize lines for the 2,048-token image block restored by each prompt, including the resumed park; reservations across 16 caches
Never fits42 GB400 in 0.11 s, all 7 fields; capacity endpoint 200; header 288,900not re-run-
Packed prefill, MTP off44 / 42 GB3/3 0 aborts; packed, but no park at either ceiling-not reached (no restore)
Runtime. These runs used the venv, which has no compiled custom kernels, so the absolute prefill numbers are lower than in the app, equally for both trees. The packaged fork now runs on the official 0.7.1.dev1 app runtime (oMLX Fork 0.7.1.dev1-fork.7, the fork's sources over the official app as donor, mlx 0.32.3 and mlx-vlm 0.7.7), with all five compiled custom kernels loading (5/5).

Upstream defect: slow suffix prefill after a restored TurboQuant prefix

Three prompts prefilling in parallel while a fourth request keeps generating tokens. 12× speed.

Field illustration of concurrent prefill from the earlier fork.5 recordings, not a recording of the restored-prefix fix or its paired benchmark below.

Tracing P1's missing TTFT gain on c0b1056b led to a second upstream TurboQuant defect. After a paged-SSD prefix hit, the uncached suffix of a prompt prefilled about 2.4× slower per token than a cold prefill of the same positions. It reproduces on main, so it is not a fork bug, and with TurboQuant off the two take the same time.

Mechanism

Fix

Before a suffix that needs more than one prefill chunk, the scheduler copies the restored TurboQuant layers into dense KVCache layers once, so suffix chunks run the same dense path as a cold prefill. At the end of prefill only the suffix is quantized and appended to the original TurboQuant cache, so the restored prefix codes are reused bit for bit instead of being quantized again. The step runs only when the dense layers, sized for the whole prompt, fit under the prefill peak target, the same gate as the prefill reservation; otherwise it logs that it kept the quantized prefix. A one-chunk suffix, such as a chat turn, keeps the current path. Two commits with 16 tests, on main 013bc3f3.

Results

Qwen3.8-27B, TurboQuant 4-bit KV on 15 of 16 KV layers, one server at a time from the pinned venv. A = 73,787 tokens, stored; B = A + 36,358 tokens with a 73,728-token prefix hit; positions [73,728, 110,086) of B compared with a cold prompt from the same session. One run per configuration.

RunRestored suffixCold, same positionsRatioReservation after the restore
main c0b1056b, MTP off197.9 s82.2 s2.41×across 1 caches (+0.14GB)
main + fix, MTP off87.3 s94.7 s0.92×across 16 caches (+2.23GB)
Fork + fix, fork settings, MTP on84.8 s85.0 s1.00×across 16 caches (+2.23GB)
On hardware under pressure (fork with the fix, forced-pressure runs, above): image prompts that restore a TurboQuant block, two dense suffix prefills admitted together, and a park and resume across the dense step all completed with 0 aborts. A re-issue after a cancel took 80–82 s against 192–193 s on main. The quantized fallback never fired, and decode-pause resumes never reach the step, because they restore their exact cache. The fix is proposed as a separate small pull request after PR1, independent of the pause-resume stack.

The strategy: pause, don't abort

The most valuable idea of the project was a change of question. When memory reaches the hard limit, the stock server aborts in-flight requests, which throws away every token they prefilled and generated. We asked whether the server could pause a request instead. The paged SSD cache already existed and makes a pause cheap.

The first version, the park, covered only chunked prefill. A forcing benchmark (P7) showed that it works, and also where it stops. The next spike hit a decoding request, which could not be paused, and all three requests were aborted. That led to the full cycle. It pauses and resumes requests in prefill and in decode, image prompts included, and rejects a request that can never fit with a structured error. Below is what the code does, checked against the implementation (omlx/scheduler.py, omlx/process_memory_enforcer.py, omlx/settings.py).

The first version: park a prefill

  1. Save progress while prefilling (D1, prefill_progress_commit_blocks). Every N completed blocks (4 blocks of 2048 tokens in the fork app), the prefilled blocks are stored in the paged SSD cache under the request's own block table. This needs the SSD cache. Without it the commit is skipped and a park resumes cold.
  2. Park instead of abort (T7, memory_pressure_park). At hard pressure (not emergency, and within a budget of 30 deferral polls), the memory enforcer asks the scheduler to park a victim before it considers an abort. The scheduler drops the victim's live KV cache, which frees the memory. It keeps the client stream, prompt, sampling parameters and arrival time. It pins the committed blocks against eviction, both in the in-memory block pool and in the SSD index. The request stays registered and waiting, and the client gets no error.
  3. Resume from cold cache. In the first version, a parked request was re-admitted after park_resume_ok_polls (default 3) consecutive ok polls, when the monitor went silent, or when nothing else could be admitted. The re-admission does a normal prefix lookup, which restores the committed blocks, and prefill continues from the last committed boundary. The full cycle replaced this resume rule with a memory-aware one (below).
What the evidence showed before we forced it. The park mechanism is covered by 32 test functions in tests/test_scheduler_park.py plus enforcer tests. Under normal settings it did not fire in our workload:
  • In P2 the fork finished with 0 aborts and 0 parks.
  • No live log contains a Parked request event.
  • The four live park attempts (2026-10-02) found only decoding requests and fell back to abort.
To test it deterministically we built a forcing benchmark.

Forcing it: the P7 park benchmark

The setup was three concurrent long prompts of about 130k, 115k and 100k tokens, started 0.5 s apart, on an isolated server. It used a low custom memory ceiling (42 GB, hard watermark at 80%), and the pooled-buffer reclaim was disabled so pressure reaches the park decision. We ran it once with text-only prompts and once with an image early in each prompt, on the full fork and on upstream main, one server at a time.

07:04:13  Hard memory pressure: parking a prefill victim on 1 scheduler(s) instead of aborting (poll 1/30)
07:04:13  Parked request e2f2… at 129024 tokens committed (park 1/3), reason: hard memory pressure
07:04:14  Resumed parked request e2f2…: cached=129024
07:04:23  Park skipped for e2f2…: decoding, not in chunked prefill (memory pressure falls back to abort)
VariantParksResumeOutcomeRe-issue after abort
Fork, text (2 runs)1 per run0.5 s, all 129,024 committed tokens restoredThe next spike hit the resumed request while decoding. It could not be parked, and all 3 aborted.Warm. Restores of 129,024 tokens logged, 126,976 of 130,029 in a longer run.
Fork, image (2 runs)0n/aSkipped every time ("multimodal prompt … prefills in the external loop"), then aborted.Warm. Restores of 129,024 and 98,304 tokens logged (about 98% of the prompt).
main, text0n/a (no park path)2 requests rejected by the prefill preflight (HTTP 400), then re-issued cold.Cold (0 cached).
main, image0n/a3 of 3 aborted, and the cold re-issues aborted too.None.

P7 proved the path works: a prefilling request was paused, its memory freed, and it resumed from the SSD cache in about half a second with every committed token restored. It did not save a batch, because decoding rows and image prompts still fell back to abort.

The full cycle

  1. Single victim (C1). When nothing is pausable, abort one victim (largest live KV, newest on ties) instead of all. The enforcer waits for that memory to drain before it picks another. Emergency and model-unload keep abort-all. Opt-in: OMLX_MEMORY_ABORT_SINGLE_VICTIM.
  2. Decode pause and resume (C2). The pending sampled token is emitted first. The row's KV is extracted and stored as an exact SSD chain, keyed to the request and pinned, and the row leaves the batch while its stream stays open. The sampler, logits processors, stop-sequence position, parser state and remaining max_tokens are stashed. Resume restores the chain and re-inserts the row with no prefill, and the RNG is not reseeded. A singleton Lightning MTP row is first handed to standard decode. Batched MTP, DFlash, VLM-MTP and SpecPrefill rows are excluded.
  3. Memory-aware resume (C3). A paused request resumes when estimated free memory covers its need: the KV of its final length plus a transient. It never resumes at hard pressure. FIFO among paused requests, and the head-of-line hold reserves the room.
  4. Never fits vs fits later (C4). If weights + KV(prompt + max_tokens) + transient exceed capacity, the request gets HTTP 400 context_exceeds_capacity with seven numeric fields and retryable: false. A request that fits alone waits instead. GET /v1/omlx/capacity and the X-oMLX-Max-Context-Tokens header expose the numbers.
  5. Harness compaction (C5, client side). The pi extension compacts and retries on context_exceeds_capacity, and sets its compaction threshold from the capacity endpoint. It has unit tests but no live validation yet.
  6. Image prompts (C6). A text-only suffix after a cached last image is prefilled in the chunked path, so it can be paused. This route is taken only after checking that the suffix's mRoPE positions are arange + delta on every plane. Opt-in: OMLX_MEDIA_TEXT_SUFFIX_CHUNKED.

Pause and resume are behind memory_pressure_park (default off). The never-fits gate, the fits-later wait and a progress-based admission-stall clock sit behind memory_scheduling (auto | on | off, default auto). auto turns them on only when the memory guard has an explicit ceiling (the custom tier); otherwise admission stays as on main. The C7 runs all set a custom ceiling, so their evidence holds under the default.

C7: the extended benchmark, and what failed first

C7 extends P7 to decode-phase pressure, image prompts, a never-fits request and greedy identity after a pause. The target is 0 aborts, with one server at a time and the live app quit. The first run (branch e80eb1f7, 42 GB ceiling, hard 0.8, one run per pair) did not meet it:

ScenarioBranch (first run)main
Text decode0/3. One prefill park and 2 decode pauses, all resumed (260,112 tokens restored), then 1 single-victim abort and 2 admission rejects0/3, abort-all
Image decode0/3, all aborted at once (abort-all)0/3, abort-all
Image prefill1/3; 2 requests failed after 60 s of admission_paused0/3, abort-all
Never fitsHTTP 200 (gate inactive), capacity available: falseHTTP 200
Ceiling probes, text decode48 GB: 9 pauses and resumes, then 3 aborts. 52 GB: 2 pauses, 3/3 done. 56 GB: no pause, 3/3 done.n/a

Every pause logged restored N tokens exactly (stall 1.6–3.5 s), so the mechanics worked. The gaps were in policy and wiring. Each was traced to a root cause in the server logs and fixed test-first, one commit each:

GapRoot causeFix
D1 · pauses thrash into abortsThe park hold's "nothing admitted" escape released a paused request on the next step without a fit check, so it paused again until the budget ran out.Resume line min(admission line − 2%, hard watermark − 5%), anti-thrash rule, solo run with nothing admitted, decode budget 8 (5a881160)
D2 · never-fits gate dead on the VLM enginenn.Module.parameters() skips the adapter's underscore attribute _vlm_model, so the weights summed to 0 and no estimate existed.Size the weights through the adapter (e5353d70)
D3 · abort-all during image prefillThe single-victim selection found no live request, because the external-loop prefill is not in running, and fell back to an abort-all that freed nothing.External-loop prefill runs solo, no-live-victim aborts nothing below emergency, one decode pause before the emergency abort-all (19aa28ef, 31cab33d)
D4 · identity unjudgeableThe references themselves were aborted at 42 GB.References alone at 56 GB, with headroom (bench only)
D5 · third request fails after 60–90 sThe admission-stall clock kept running while other requests made progress (a solo decode, chunk-by-chunk prefill).Count only time without progress; 600 s bound for deliberate holds (65899460)
D6 · pause effect not isolatedDivergences were B=3 vs B=1 batch numerics, some before the first pause.Test-only OMLX_DEBUG_FORCE_DECODE_PAUSE_AT for B=1 forced pauses (f3c11356)
D7 · 46 GB image prefill rejectedWith nothing admitted, the current-usage preflight counted memory no admitted request owned: queued vision embeddings plus about 1.9 GB not attributed.Admit a lone head that fits under the ceiling alone (47c0c70b); wait for a draining store write (3b72cde7, defensive)
D8 · MTP-on divergences after pausesLikely MTP verify regrouping after a pause moving bf16 near-ties (hypothesis; not isolated with MTP on).Identity re-run with MTP off: tokens identical (bench 95dd47e8)

Two intermediate runs show the progression:

Final results

Same model, machine and scenarios, one run per pair. main (b7c7929e) ran the same scenarios at 48 GB. The branch's image-prefill run used 46 GB, which is harsher for the branch.

ScenarioBranch runCompletedAbortsDecode pauses / resumesTokens restoredPeakMax stallmain
Text decodef3c11356 · 48 GB3/306 / 6691,29341 GB1.75 s0/3 abort-all mid-prefill after 15 s
Image decodef3c11356 · 48 GB3/305 / 5561,49443 GB1.73 s0/3 abort-all, 168 s prefill stall
Image prefill47c0c70b · 46 GB3/301 / 1132,10242 GB1.61 s0/3 abort-all, 417 s prefill stall

Never fits (42 GB, 31cab33d), a 60k-token prompt with max_tokens 2,000,000:

Branchmain
ResponseHTTP 400 context_exceeds_capacity in 0.1 s, all 7 fields, retryable: false, no pause or parkHTTP 200 (stream closed in 0.1 s)
GET /v1/omlx/capacity200, available: true404
X-oMLX-Max-Context-Tokens288273absent

Greedy identity after a pause. B=1 at 56 GB, so there is no memory pause, and forced decode pauses at output offsets 100, 323, 1044 and 1706. Each test run is compared with a run without pauses.

RunPausesResult
MTP off (47c0c70b; references on 3b72cde7)12identical Tokens identical for 3/3 requests of 2,000 tokens; logprob drift exactly 0.0. One request's streamed text differs at char 1496. Its tokens are equal, and the reference without pauses shows the same quirk around a stray mid-reasoning <think> token: a pre-existing text-assembly issue, not a pause bug.
MTP on (f3c11356)12near-ties First divergences at tokens 142, 145 and 179: 42–79 tokens after the first pause and before the second, at margins of 0.125–1.0 nat. With MTP off the same prompts are identical. That points to MTP verify regrouping moving near-ties, but it is a hypothesis: identity with MTP off does not by itself rule out a pause-state effect that only appears with MTP on.

Premises against main, measured at e80eb1f7, before D1–D8:

PremisemainBranchReading
P1 · Re-issue after mid-prefill cancel0 cached · 110,031 re-prefilled73,728 cached · 36,303 re-prefilledPASS
P2 · 2×~130k concurrent2/2 · 0 aborts · 116 GB · 939 s2/2 · 0 aborts · 44 GB · 501 sPASS
P3 · Greedy batch (B=3 vs B=1)3/3 degenerate (4-gram 0.33 / 0.29 / 0.18), 121 GB, on b7c7929e with the v0.7.0 app runtime; does not reproduce on 25aebb3e (re-validation)no degeneration (1.00 / 1.00 / 0.99), 55 GBno regression
P4 · Concurrency (2 runs)0.96× · 12/12 ok0.98× · 12/12 okequal
P6 · Single 110k (3 runs, median)740.7 tok/s · decode 20.42729.5 (−1.5%) · 20.75 (+1.6%)within noise
What this proves, and what it does not.
  • Under forced pressure the branch completes every request in the three scenarios with 0 aborts. Requests are paused and resumed in decode, image prompts included, while main aborts all three in each.
  • A pause is exact: with MTP off, the tokens and logprobs after 12 pauses equal those of an uninterrupted run.
  • A request that can never fit gets a structured answer in 0.1 s that a harness can act on, instead of running into an abort.
  • Limits:
    • one Mac, one model family, and one run per pair;
    • decode and image decode were measured at f3c11356 and never-fits at 31cab33d, so those scenarios were not re-run after the last admission fixes;
    • the branch runs also had the fork's Metal cache limit and batched-decode gate on;
    • a pause stalls every row for 1.6–3.5 s while the exact chain is written, because the store is synchronous;
    • the premises must be re-run on the final head, because D1–D8 changed admission after e80eb1f7.

Reproduction commands

The harness is benchmarks/unattended/bench_premises.py on the fork branch bench/p7-park (95dd47e8). It overlays a checkout's omlx/ on the app runtime. Run one oMLX server at a time, with every other instance quit. The fork variant sets OMLX_MEMORY_PRESSURE_PARK=true and OMLX_PREFILL_PROGRESS_COMMIT_BLOCKS=4.

# branch build: export once, so every command below gets the same env
export OMLX_BENCH_FORK_REPO=<pause-resume checkout>
export OMLX_BENCH_EXTRA_ENV=OMLX_DISABLE_PRESSURE_RECLAIM=1,OMLX_MEMORY_ABORT_SINGLE_VICTIM=true,OMLX_MEDIA_TEXT_SUFFIX_CHUNKED=true

# references for one scenario: alone, 56 GB
python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \
  --c7-scenario decode --runs 1 --p7-ceiling-gb 56 --c7-refs-only --out refs/ref-decode

# pressure run: decode | image-decode at 48 GB, image-prefill at 46 GB
python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \
  --c7-scenario decode --runs 1 --p7-ceiling-gb 48 --c7-refs-from refs/ref-decode --out out/branch-decode

# never fits
python benchmarks/unattended/bench_premises.py --variant fork --premise C7 \
  --c7-scenario never-fits --runs 1 --p7-ceiling-gb 42 --out out/branch-never-fits

# identity with MTP off: references, then forced pauses
python benchmarks/unattended/bench_premises.py --variant fork-nomtp --premise C7 \
  --c7-scenario identity --runs 1 --p7-ceiling-gb 56 --c7-refs-only --out out/idrefnm
python benchmarks/unattended/bench_premises.py --variant fork-nomtp --premise C7 \
  --c7-scenario identity --runs 1 --p7-ceiling-gb 56 --c7-refs-from out/idrefnm \
  --c7-force-pause-at auto --out out/idforcenm

# main build: re-export, then run the same scenario commands (with main's own --out paths)
export OMLX_BENCH_FORK_REPO=<main checkout>
export OMLX_BENCH_EXTRA_ENV=OMLX_DISABLE_PRESSURE_RECLAIM=1

# unit and rig tests, then upstream's CI selection (needs pytest-xdist, as CI installs it)
pytest tests/test_scheduler_decode_pause.py tests/test_memory_resume_thrash.py \
  tests/test_memory_external_prefill.py tests/test_scheduler_capacity_vlm.py \
  tests/test_scheduler_forced_decode_pause.py tests/test_scheduler_solo_admission.py
pytest tests/ -m "not slow and not integration" -n 3 --dist loadgroup

Our runs also passed OMLX_BATCHED_DECODE_HOLD_MAX_S=120, OMLX_MID_SYSTEM_POLICY=inline_note and OMLX_THINKING_RETENTION_GUARD_TOKENS=20000 to both builds. Those belong to other fork features, and main ignores them. Scenario prompts: about 130k, 115k and 100k tokens, started 0.5 s apart, greedy, max_tokens 2000 (32 for image prefill).

Live production run (fork.5)

The C7 results come from forced pressure on an isolated server. We then ran the packaged fork as the daily server for real unattended coding-agent work: pi with the gentle-shell harness, one orchestrator session and four gentle-shell subagents doing browser-driven documentation capture for a documentation project, plus a second, unrelated agent session. Every request went to the local model through the profile pin.

SetupValue
BuildoMLX Fork 0.7.0-fork.5: feat/pause-resume-cycle on upstream main commit b7c7929e (#4148, committed 2026-10-05 16:29:00 +0900), running on the official 0.7.0 app runtime (mlx-vlm 0.7.1; upstream main pins 0.7.4). The packaging script's --allow-pin-mismatch option (5dd56ca5) makes that explicit: it builds on the older runtime, warns and records the mismatch in the build summary.
Model and machineQwen3.8-27B-oQ4e-mtp (TurboQuant 4-bit KV, MTP), M5 Max 128 GB
Fork defaultsShipped as a bundled defaults file, read as a layer below settings.json, so settings.json or the admin UI overrides them without a rebuild (c31820cb, 78291feb). They were previously baked into the app environment. The bundle turns on the prefill progress commit, park and pause, single-victim abort, the cached-media text suffix and memory_scheduling: on.
Harness versionsgentle-shell (package gentle-pi 4.0.0) at upstream main commit aa2c0389, "Merge pull request #1835" (feat/background-jobs), committed 2026-10-07 01:30:59 +0200, run from a local checkout with gentle-shell.mjs --link. This main does not contain the #1367 todo prompt-cache fix, which was still open. One other session ran the fix branch fix/todo-prompt-cache-1367 (955523df, 2026-10-05); its main sessions used a remote provider, so only its subagent reached oMLX.
Batched-decode gateRemoved from the fork app (159673d5): decode pauses now resolve memory pressure without losing work, so the 200k combined-context cap only held long requests back while memory was free
Fork-only packagingThe defaults bundle, the gate removal and --allow-pin-mismatch belong to the fork's app packaging only. They are not part of any upstream pull request.
Memory guardTier aggressive: ceiling 101.7 GB, soft at 95% and hard at 98% (about 96.6 and 99.7 GB). memory_pressure_park on.
Memory schedulingOn for the whole window, from the fork defaults (verified live: capacity endpoint available: true and the X-oMLX-Max-Context-Tokens header present)
Window2026-10-07 02:31–04:10 local, about 1 h 39 min, 393 samples every 15 s. max_concurrent_requests was raised live through the admin API, with no restart: 3, then 4 at 03:02:00, then 5 at 03:08:15.
LimitSamplesOpen requests (avg / max)Waiting (avg)Min system freeCache efficiency
3 (02:31–03:02)1215.2 / 62.242%94.5%
4 (03:02–03:08)254.9 / 50.930%94.8%
5 (03:08–04:10)2474.0 / 60.120%95.4%

Open requests are those with an open stream, running or queued, and waiting are the queued ones (both from /api/status). System free memory comes from macOS memory_pressure and includes the agents' browsers and other apps. Cache efficiency is the dashboard's cumulative figure at the end of each level.

Server log, 02:00–04:10 (includes the 31 min before sampling started at 02:31; the per-level table above covers 02:31–04:10)Count
Requests · prompt tokens · served from the prefix cache333 · 14.5M · 94.8%
Memory-pressure transitions (soft, hard, emergency)0
Decode pauses, resumes and parks0
Solo admissions, admission waits, never-fits 400s0
Memory aborts0
Other aborts3 at 03:09:01. The orchestrator cancelled 3 subagent tasks, and the server logged stream aborts with no enforcer action.
Batched TurboQuant decodeRagged single-launch path at B=4 (03:04) and B=5 (03:08). No completion hit the token limit: 320 ended on tool calls and 6 on stop.
Subagent waveSessionsRequestsSystem prompts per sessionAppend-only turnsPer-session cache
02:28–03:09492188 of 8895.5–98.3%
From 03:32 (still running)4162 by 04:101158 of 15895.6–98.5% (through 04:14)
What this run does not show. The pause-resume cycle was not exercised. Under the aggressive tier, pressure starts at 95% of a 101.7 GB ceiling, and this workload stayed far below it. The evidence for the cycle remains the forced-pressure C7 results above. The next step is a live run with an explicit lower ceiling, for example 45 GB on the custom tier.

Harness findings from the same sessions

The same sessions also surfaced harness-side prompt changes. They are listed in the gentle-shell section.

Field footage: sustained load

Screen recordings from the same night (about 04:28–04:43, shortly after the measured window), sped up. The oMLX dashboard is shown in its Spanish locale. Terminal and IDE content is pixelated for privacy; the dashboard numbers are as recorded. This was ordinary load, so no pause or resume appears on screen; the pause/resume evidence is the forced-pressure C7 runs above. Use the playback controls to pause, replay or inspect the recordings. The other recordings accompany the use case, batched decode and prefill sections.

Over 3 minutes of real work (20×), oMLX Fork keeps 3–4 requests from gentle-shell agents and IntelliJ in flight. Requests finish and new ones start, and no error appears in the visible frames.

What was built

ChangeProblem it addressesEvidence
Prefill progress commit (D1)A cancel or abort mid-prefill threw away everything. Progress is now committed to the paged SSD cache every N blocks, for text and media prompts.P1
Park instead of abort (T7)Under memory pressure, a chunked prefill is parked (progress already in the SSD cache) and resumed in arrival order instead of being aborted. See the strategy.Unit tests; P7 (parked at 129,024 tokens, resumed in 0.5 s)
Pause-resume cycle (C1–C6, D1–D8)Decoding rows and image prompts could not be parked, so pressure still aborted everything. Now one request is paused in prefill or decode, saved as an exact SSD chain, and resumed on the same stream when its memory need fits. Never-fits requests get a structured 400 and a capacity endpoint.C7: 9/9 completed, 0 aborts (main 0/9); identical tokens after 12 pauses with MTP off
mlx_cache_limit_gb (T5a)The Metal buffer cache grew without bound and pushed process memory past the hard watermark.P2 peak memory
Batched-decode context gate + backfill/aging (T5b, T12b)Two very long rows batched together gain nothing. A held long request no longer blocks short ones, and aging bounds the wait to 120 s.P4; a limit sweep showed no gain above 200k combined
TurboQuant padded-attention fix + ragged kernel (T5d, T5e, T12a, T12c)Left-padded short rows attended over padding (correctness), and batched decode was about 1.5 tok/s per row (performance).Norm ratio 0.32 → 1.0; per-layer B=3 39.8 → 1.7 ms; P3, P4
inline_note mid-system policy (A1)A mid-conversation system update after a tool result fell back to strict and was merged into the leading system message, rewriting the prompt head.RED test: first divergence at the end of the system head
Deterministic thinking retention (A2)A client's synthetic "attached image" user message moved the template's last_query_index and stripped thinking mid-loop, breaking the prefix on every image turn.10/10 image turns at 97.4–99.7% hit
Observability (A5)Eviction was silent at INFO, there was no per-request cache-outcome reason, and there was no TTFT breakdown.Used for every live measurement here

The cache-reuse investigation

With aborts gone, the cost moved to re-prefill. We attributed every large cache break in the recorded traffic to a cause. The breaks came from the prompt changing in the middle, which invalidates everything after it.

CauseTokens re-prefilled (~1.5 days)Where it is fixed
The harness rewrote the system prompt (todo block with a per-prompt stale counter in affected parent sessions; recorded tool-section changes also included subagent traffic), and the server's strict fallback folded system updates into the system head~1.05MHarness fix (gentle-shell #1367) + A1 (server)
Client's synthetic image message moving last_query_index~0.94MA2 (server)
Client compactions~0.7MExpected
Real user messages (the template strips earlier thinking)~0.1MKept for quality on the 8-bit model; preserve_thinking: true removes it on the current model (measured)

The largest cause was in the harness. How it was found, what it cost, the fix and its validation are in the gentle-shell section.

The server-side change A1 is complementary. pi also sends system-prompt changes as mid-conversation system messages, and oMLX's strict fallback merged those back into the head. Any client doing the same benefits from A1.

preserve_thinking: true, measured again

The earlier result. On the 8-bit model with DFlash speculative decoding, keeping all past thinking gave near-perfect reuse, but at about 53k tokens of context the model started emitting literal </think> and raw tool-call XML as text, its draft acceptance fell from 65% to 54%, and the session did not recover. A2 was designed around that result. It keeps thinking only within the current agent loop and strips at real user messages, exactly as the template does with preserve_thinking: false. A deterministic guard strips once when retained loop thinking exceeds N tokens, then stays append-only. The guard is set to 30k.

The question. With false, every user-role message strips the reasoning of the previous segment, so the prompt shrinks and prefill restarts at the first assistant message of that segment. pi sends harness notices (todo updates, subagent results and wakes, background-job notices) as user-role messages, so they trigger the same strip. That was the last cache loss left in the parent session after the gentle-shell fix. true had never been tested for long on the current model (4-bit oQ4e with Lightning MTP), so we measured it on 2026-10-11.

Method. The installed fork.7 app was the only server running. The setting was changed at runtime through the admin API (PUT /admin/api/models/<id>/settings, which applies without a reload and persists), after a backup of the model settings. The client was the installed gentle-shell in pi RPC mode. Each pi request was joined to its server log line by its prompt and cached token counts.

Fixed settings in every run: thinking forced on (budget 3,000 tokens), the 30k retention guard on, MTP on, TurboQuant 4-bit KV, temperature 1.0.

P2: 27 prompts, same scripttruefalse
Context at prompt 1 → prompt 2741.8k → 200.0k41.8k → 135.4k
User-turn boundaries that reused the whole cached prefix26 / 263 / 26
Uncached tokens per boundary, mean / max893 / 1,8704,048 / 15,056
Time to first token at a boundary, mean / max6.8 / 15.9 s8.5 / 31.3 s
Prefix-cache hit rate, all requests99.1%98.1%
Summed prefill time690 s660 s
Output tokens / thinking characters kept in context106k / 278k66k / 115k
MTP acceptance (flat across 40k–200k buckets)83.9%84.1%
Decode tok/s, lowest context bucket → highest20.8 → 13.823.1 → 15.1
Literal markers in text, tool arguments and reasoning; visible-text loops; length stops; parse errors00
Reasoning repetition-detector signals (unclassified)30
Tool errors27
Wall time for the 27 prompts6,670 s3,773 s

Primary-record audit. Recounting tool_errors in preserve-thinking/p2-false-turns.jsonl gives 7, correcting the earlier reported 9. The corresponding p2-true-turns.jsonl contains 3 thinking_loops detector signals; the original summary counted only loops in visible text. The detector flags lines of at least 30 characters repeated at least four times within one message. These signals remain unclassified and may include false positives; they are not confirmed degeneration. The local records were audited without rerunning the experiment and are not currently linked as a public archive.

In P1, true made all 11 parent requests full-prefix hits, against 4 block-aligned divergences in 13 with false. The uncached tokens fell from 6,208 to 1,803 and the summed prefill from 17.2 to 8.1 s. The subagent and job sessions behaved the same under both settings.

Findings.

Decision. No literal degeneration markers were observed with true in this one run on this model; reasoning repetition-detector signals remain unclassified, and these observations do not establish the absence of quality risk. On 2026-10-11 it was switched on in daily use for further observation on workloads where single turns read large files, accepting the measured context-growth and wall-time tradeoffs. The things to watch are degeneration markers, boundary TTFT, how often sessions reach compaction, and wall time per task. The A2 guard stays at 30k as a backstop.

Live result

gentle-shell: making the harness cache-friendly

This section is for the gentle-shell maintainers. The largest single cache loss in this report was in the harness, not in oMLX. While todos were open, gentle-shell's todo extension rewrote the system prompt on every user prompt. Over about 1.5 days of real use it caused about 1.05M re-prefilled tokens, roughly 80–85 minutes of GPU time, and turns that took 2–6 minutes instead of seconds. We improved the harness with the same method as the server, and the fix is ready on fix/todo-prompt-cache-1367, tracked in #1367.

The harness's existing tests did not expose the cache cost: the todo tool behaved correctly and no request failed, but some agent turns took minutes instead of seconds. We identified the cause by exercising the harness against a local inference server whose cache behaviour we could observe. Prompt-stability tests can catch the rewrite itself; server measurements establish its operational cost.

The process

The symptom

The oMLX dashboard kept showing long prefills, 40k–110k tokens, on agent turns that should have been nearly free, with concurrent requests frozen behind them. Nothing failed and every harness test passed.

Server-side evidence

oMLX's prefix-cache log states where each request diverges from the closest stored sequence. One session diverged at the same position, about token 37.7k, every few turns. A divergence at a fixed position points to something rewriting the prompt there, not to eviction.

Client-side evidence

A small pi extension recorded the exact payload of every request. Diffing consecutive requests found the first changed byte inside the system prompt, on the todo addendum's stale counter, at the same token position the server reported.

Attribution

Doing the same across all recorded sessions attributed about 945k re-prefilled tokens in main sessions and 107k in subagents to system-prompt or tool-section changes, with the todo addendum as the main driver.

Fix, test-first

Todo state moved out of the system prompt into hidden conversation messages, sent only when the list changes. The stale reminder fires once, with no counter, and the state is re-injected once after a compaction. Tests drive the extension through its public hooks and assert that the system prompt stays byte-stable.

Validation on both sides

On the client, the requests that rewrote the system prompt went from 21 of 61 to 0 of 90, and 31 of 31 consecutive turns were append-only. On the server, re-prefills above 20k tokens went from 1–2 per hour to 0.

Re-tests on upstream main

Each re-test drove the harness against the live server. One window on aa2c0389 showed no problem because its trigger never fired, and a later re-test on 43541a0d reproduced it. Our first comment was corrected publicly. The fix was rebased, its 63 todo tests pass, and it passed an independent review.

Subagents and background jobs

A full harness scenario with the fix (todo list, foreground and background subagents, a background job) kept one system prompt per session in the parent and in every child.

The last loss, shared with the server

The remaining parent cache losses came from harness notices. pi sends them as user-role messages, and the chat template strips earlier reasoning at every user-role message when preserve_thinking is off. With it on, every parent request reused its whole prefix.

What it cost on the server

This is about 1.5 days of real use, all on one machine. Figures marked ≈ are derived from measured rates.

CostWithout the harness fixHow it is derived
Tokens re-prefilled~1.05MDivergences attributed to system-prompt or tool-section changes (main sessions plus subagents)
GPU time spent re-prefilling≈ 80–85 min1.05M tokens at the observed average prefill rate of 205–211 tok/s. Deeper contexts are slower, so this is a lower bound.
Added wait per affected turn≈ 2–6 min40k–110k tokens re-prefilled per event at 300–430 tok/s. The rate falls to about 110 tok/s near 170k context.
Other requests frozenup to 155–158 s per eventImage-bearing prompts prefill synchronously, so concurrent decode stops for the whole re-prefill.
SSD cache writes≈ 90 GBRe-prefilled blocks are stored again: about 513 blocks × 0.1787 GB per 2048-token block (KV plus GDN sidecar). That churns a 150 GB cache and evicts useful history sooner.
Concurrency lostheld requests wait longerLong re-prefills keep the decode lane busy, so the batched-decode gate holds other sessions for longer.

On a hosted API, a changed prefix may reduce cached input and increase latency or cost, depending on that provider's cache rules and pricing. No hosted-provider cache or billing experiment was run here; the measured effects are specific to the tested local setup.

How the invalidation was identified

  1. The symptom on the dashboard. During normal use, the oMLX panel kept showing long prefills (the blue progress bar) on turns that should have been nearly free. One example: 43.2k tokens at about 300 tok/s on an 80k prompt. A concurrent request's decode sat frozen behind it ("last token 46 s ago").
  2. The server's own diagnosis. The prefix-cache log for that request reads:
    prefix cache restore: cached=36864 suffix=43171
    re-prefills 43171 of 80035 tokens; closest stored sequence shares the first 37735
    The same session diverged again later at 37,739 tokens. A divergence at the same place every few turns points to something rewriting the prompt at a fixed position, not to eviction.
  3. Ruling out the server cache. SSD eviction was checked against the losses: the cache holds about 8 hours of history, and none of the large losses lined up with evictions. Model reloads were hours away. The block grid explains at most 2,047 tokens per event.
  4. The client-side diff. Comparing the recorded payloads of two consecutive requests from that session showed the first difference inside the system message, at character 70,384 of about 104,700. That is roughly token 37.7k, matching the server's divergence point:
      …pending: install dependencies and verify loading.
    + (stale: 2 turns without an update — bring the list up to date now)
      </addendum>
  5. Attribution across sessions. Doing the same across all recorded sessions attributed about 945k re-prefilled tokens in the main sessions and about 107k in subagents to system-prompt or tool-section changes. The todo addendum was the main driver.
  6. Confirmation. After the fix, the same session's system prompt stopped carrying todo state at 00:26:37. Every later turn was append-only, apart from one expected cache break at the switch.

The fix: keeping the system prompt stable

The gentle-shell harness appended a todo block to the system prompt on every user prompt. Once the list went stale it added a counter, (stale: N turns without an update …), that changed on every prompt. The block sat around token 30–35k of a ~41k system prompt, followed by other sections. In affected parent sessions, every tick or todo edit broke reuse of the rest of the system prompt and the conversation after it. This does not apply to every subagent: on the upstream versions examined in the re-tests, children and background jobs with GENTLE_PI_AGENTS_CHILD=1 do not load the todo hook. System-prompt or tool-section changes recorded in child traffic must therefore not all be attributed to this hook. Single turns lost 70k–110k tokens of cache this way.

We fixed it in the harness. The system prompt is now byte-stable within a session. Todo state travels as a persisted conversation message, sent only when the list actually changes. The stale reminder fires once, with no counter. After a compaction, the state is re-injected once. The fix is tested (45/45 todo tests, with the unrelated failures reproducing on a clean base), was used daily, and is proposed upstream in gentle-shell #1367. The validation below ran on the fix branch fix/todo-prompt-cache-1367 at 955523df (2026-10-05, gentle-shell 4.0.0), not on upstream main.

Re-tests on upstream gentle-shell main

We re-checked upstream gentle-shell main at aa2c0389 during the live production run and posted the results to #1367 on 2026-10-07. Eight subagents sent 344 requests with one system prompt per session; 336 of 336 consecutive pairs were append-only, and per-session prefix-cache hits were 95.5–98.4%. The todo block appeared in no system prompt, and the problem did not recur. For accuracy: aa2c0389 still appends the todo block at before_agent_start, and its trigger (open todos plus a new user prompt) did not occur in this run. The issue stays open for its author and maintainers to decide.

Re-test on 2026-10-10: the problem is still present on upstream main, and the fix had to be applied. We re-tested gentle-shell main at 43541a0d with a test that drives the todo extension's public hooks and with a live pi session against oMLX. Upstream main still appends ## Todo list and its stale counter to the system prompt in before_agent_start (extensions/gentle-todo.ts). In a session with open todos, every new user prompt changed the system prompt: the block appeared, then the counter went from stale: 2 to 3 and 4. Live, the session produced 4 system messages, 3 of them "Updated system prompt section" updates carrying the todo block. Resuming a session also added a todo block (+515 characters) that the opening system prompt never had. The recent reminder change (#1924) adds a request-local reminder message, which is cache-friendly, but the block in the system prompt remains.

The trigger is the main (parent) session. gentle-shell subagents and background jobs do not load the todo hook, which is why the earlier subagent-only window showed no change. Each update is sent as a system message, and the Qwen chat template accepts a system message only at the start, so it is folded into the system head right after the base prompt (about 4,050 tokens). Everything after that point has to be prefilled again. The oMLX log of the live run shows it:

RequestUpstream main 43541a0d: cached / prompt tokensWith the fix
1 (cold)0 / 4,0902,048 / 4,091
2 (tool result)4,085 / 4,2584,086 / 4,255
3 (todo block appears)2,048 / 4,3514,086 / 4,342
4 (stale: 2)4,096 / 4,3944,337 / 4,507
5 (stale: 3)4,096 / 4,4184,502 / 4,531

The session was short, so each break cost only a few hundred tokens here. In a 100k-token session the same break re-prefills almost the whole conversation on every user prompt while todos are open.

The todo list still has to change; the point is where the change goes. The fix does not freeze the todo state. It appends todo updates at the end of the conversation, as hidden messages sent only when the list actually changes, instead of rewriting the system prompt, so the cached prefix is preserved. The stale reminder is sent once, with no counter. We rebased the fix onto 43541a0d (it keeps #1924's reminder). With it, every request reused its full prefix except the expected re-render of the previous answer, and the 63 todo tests pass. It also passed an independent review with no blocking findings. The re-test, the cache table and the rebased fix (branch fix/todo-prompt-cache-1367) were posted to #1367 on 2026-10-10, correcting the 2026-10-07 comment above.

Status on 2026-10-11. Upstream main at d42264b4 still appends the todo block to the system prompt in before_agent_start (appendSystemPromptOnce in extensions/gentle-todo.ts). The fix branch is one commit on 43541a0d. The same patch applies unchanged to 563fede6, where it runs in daily use. It will be rebased onto current main before a pull request is opened.

Subagents and background jobs

On upstream main, the trigger is the parent session: subagents and background jobs run with GENTLE_PI_AGENTS_CHILD=1 and do not load the todo hook. To check the whole harness with the fix, we drove a gentle-shell build that carries the same patch (identical git patch-id to 4b929257, applied on upstream 563fede6) in pi RPC mode against the live server. One parent session ran a todo list, a foreground subagent, a background subagent and its wake, a background job and its notice, and two follow-up prompts.

Harness notices and thinking retention

Those 4 parent divergences happened right after harness notices: a todo update, a subagent result or wake, a job notice. pi sends these as user-role messages, so the Qwen template treats each one as a new user query. With preserve_thinking: false it then strips the reasoning of the previous segment, the prompt shrinks, and prefill restarts at that segment's first assistant message. On the server side, preserve_thinking: true removes the loss: the same scenario had 11 of 11 parent requests reuse their whole prefix, and the uncached tokens fell from 6,208 to 1,803 (measurement). For harness authors this is worth knowing. A notice that arrives as a user-role message is a cache boundary for templates that strip reasoning at the last user query, so how often notices are sent matters.

Other harness findings

Running the harness and the server together during the live production run (fork.5) surfaced more prompt changes that neither side shows on its own. They belong to gentle-shell, not oMLX. The first two are already raised in #1367: the YOLO directive as a follow-up question (related to #1029, volatile RDD status in the system prompt), and the memory addendum toggle in the 2026-10-07 comment. The last two are not reported yet. On aa2c0389 the todo block is still injected into the system prompt at before_agent_start; it did not fire in this window because no new user prompt arrived while todos were open.

  1. Turning on gentle-shell's YOLO mode injects its directive into the system prompt. In the orchestrator session (2026-10-06 22:53, before this window) that cost one re-prefill of 34,379 tokens: 111.9 s of prefill, against about 1.6 s for the next cache-hit turn.
  2. An Engram memory addendum was added to and removed from the second session's system prompt twice, and toggled once in the orchestrator's. Each toggle invalidates the cache from that point on.
  3. The gentle-ai-worker role lacked the native agent_browser tool, while the harness guard requires it for browser work.
  4. Squad workers launched by a third-party extension did not use the profile's pinned local model. They started on a cloud model and failed for lack of credentials.

Why it matters for harness projects such as gentle-shell

A proposal for gentle-shell

Open findings

Decode fairness does not cover multimodal prompts

Any request with an image anywhere in its history carries vlm_inputs_embeds and prefills in the synchronous external loop. That happens even when every image is already inside the restored cached prefix and the uncached suffix is pure text. Both chunked branches require vlm_embeds is None, so concurrent decode freezes for the whole prefill: 25–46 s observed, and 155 s on cold 43k prefills. The fix is now built as part of the pause-resume cycle (C6, opt-in OMLX_MEDIA_TEXT_SUFFIX_CHUNKED). It routes a text-only suffix that follows a cached last image through the chunked path, after checking that the suffix positions are t + rope_delta on every mRoPE plane. Tiny-model tests show identical output with the route on and off. Its effect on the decode freezes has not been measured on hardware yet.

TurboQuant decode attention at depth

A regression over 975 single-row requests gives about 250–295 ns per context token per MTP cycle, against roughly 32 ns for a pure KV read at this model's KV size. The intercept is 70–75 ms against a weight roofline near 30 ms. These are estimates from logs. We have not yet done kernel-level profiling.

MTP depth-1 verify uses stock qmm

Depth-1 verify (M=2) runs in 97.5% of cycles, and no verify-qmm route accepts M=2: the routes cover 3–6, 4–8 and 7–24. We estimate 10–20 ms per cycle from this. MTP is still net positive in every context bucket (1.3–1.45×).

How it was validated

Evidence status. The benchmark figures, traffic counts, test-suite outcomes and pilot completion in this report are reported by the author, not an independent reproduction. A reference audit checked published link destinations, internal anchors, available code and tests, arithmetic, and the measured-to-published commit mapping. Link availability or test-source existence does not prove a run passed. This page does not currently link an immutable archive of the primary run logs, result files and exact configuration for each measurement, or an independent wiki-content audit. PR1's non-identical outputs and PR2's unexercised hardware fallback remain limitations; branch links can move, so use the commit hashes for fixed evidence.

Source code and commit references

Every commit and branch named in this report links to the fork on GitHub, e-Evolution/omlx (gentle-shell references link to e-Evolution/gentle-shell). Upstream commits are reachable from the fork with the same hashes. The PR branches are fix/turboquant-padded-batched-decode (PR1) and fix/turboquant-restored-prefix-prefill (PR2).

The fork branches were published on 2026-10-11. Before publishing, local machine paths were replaced in benchmark defaults, one test fixture and one docstring (output directory, cache volume name). Comparison of all 17 measured-to-published Git tree pairs found no differences under omlx/. The differences are confined to benchmark path defaults, supporting documentation and examples, the path fixture and the C7 docstring reference. The complete trees are therefore not byte-identical; reproduction requires explicit local path settings. This corroborates source continuity, not the historical measurements themselves. The commit hashes changed. The report shows the published hashes; the table maps each to the hash that was measured, which also appears in the logs. Hovering a published hash shows the same mapping. The earlier PR1 commit 61bd8a06, replaced by the rebased branch, is kept under the tag report/61bd8a06.

Published commitMeasured as
65899460082a870c
5dd56ca50b932a39
19aa28ef25685e36
159673d5301bde14
4aa661504e81e443
f3c1135685c1f902
95dd47e88aa178c5
04bb7899aa06f32e
c31820cbb000a28f
5a881160c36b92fb
78291febc9694977
3b72cde7d34413ec
6fbb3274e24198f5
47c0c70be80a1842
e5353d70f6c601b0
31cab33df7dd2bc8
e80eb1f7f92fbd7c
Scope of the evidence: one M5 Max with 128 GB, one model family (Qwen3.8-27B, 4-bit, MTP, TurboQuant KV), Python 3.11, macOS. The fork was rebased onto upstream main (b7c7929e) with a single docs conflict. Upstream's CI selection (pytest tests/ -m "not slow and not integration", Python 3.11) gives 16,941 passed on main and 17,635 passed on the rebased fork, with the same 3 environment-only failures on both. Benchmarks ran each build alone through the same runtime, which pins the v0.7.0 app's dependencies rather than main's. The exception is PR1's P3 re-validation on 25aebb3e, which ran both trees from one venv with main's pins (mlx 0.32.3, mlx-vlm 0.7.7) and no compiled custom kernels. The pause-resume branch (47c0c70b) gives 17,908 passed with the same 3 environment-only failures. With xdist, as CI runs it, the fork rebased on c0b1056b (4aa66150) gives 18,072 passed against main's 16,921, with the same 3 environment-only failures plus one load-sensitive cluster test that passes alone; the c0b1056b re-validation ran both trees from one venv with main's pins. The branches on 013bc3f3 are listed in Proposed contribution.

Proposed contribution

To oMLX

We would like to send these upstream as small pull requests, each with its own regression tests and before/after numbers on the same hardware, following docs/CONTRIBUTING.md. Upstream routes feature proposals to GitHub Discussions > Ideas, and blank issues are disabled, so the proposal is posted as Ideas discussion #4440 (2026-10-11). The two TurboQuant fixes, PR1 and PR2, are bug fixes under 1,000 changed lines, so they were opened directly on the same day: PR1 as #4441 and PR2 as #4442. On 2026-10-12 the maintainer merged PR2 as #4442, with a follow-up for the dense dtype when no KV layer is plain and the embedding is quantized, and closed PR1 in favor of #4453, which extends the #4408 kernel to take each row's left padding and lists us as co-author. The pause-resume cycle is over 1,000 changed lines and adds a subsystem, so it was first posted for feedback on #4440; on 2026-10-12 its stack was opened as twelve pull requests, #4478 (ready for review) to #4489 (drafts). Each slice states its dependency and merge order. The order is a proposal and we will adapt it to your preferences.

Every branch below is rebased on upstream main 013bc3f3 (2026-10-10, MLX 0.32.3, mlx-vlm 0.7.7). On that base, CI's command (-m "not slow and not integration" -n 3 --dist loadgroup, Python 3.11, a venv with main's pins) gives 16,928 passed on main, 16,956 with PR1 (+28 tests) and 16,944 with PR2 (+16), with the same 3 environment-only failures (test_gemma4_rendering.py). Before opening them, PR1 and PR2 were also checked against the newer main e02ae514 (2026-10-11): both merge without conflicts, and CI's command gives 16,938 passed on main, 16,966 with PR1 and 16,954 with PR2, with the same 3 failures. The whole stack was rebuilt on the same base from the fork, every slice from P-a to S9, with the memory_scheduling gate (G) before S4; each slice applies on the one before, and its focused tests and CI's command pass at its tip (two load-sensitive tests that pass alone aside). For the pull requests, the stack was rebased on main f3ad34d2 (2026-10-12), and following upstream #4396 its two environment-only switches became settings (memory.abort_single_victim, scheduler.media_text_suffix_chunked) and the forced-pause test knob was dropped. CI's command gives 17,119 passed on that main and 17,175 (P-a) to 17,580 (S9) at the slice tips, with the same 3 failures; one load-sensitive cluster test also failed at S2 and S4 and passes alone 3/3 there.

About two thirds of the cycle's changed lines are tests. For S1–S9 and G, the per-slice counts in the table below add up to 8,632 test lines out of 12,961 (67%); with the prerequisites P-a and P-b, 11,658 of 17,652 (66%). The scheduler is concurrent (the MLX thread, the memory enforcer and the SSD writer interleave), so every edge case found on the real model has a regression test on the real scheduler and enforcer rather than on fakes. That is why it is split into chained pull requests, each carrying its own tests.

#Pull requestLines (tests)DefaultStatus
PR1fix(turboquant): attend each left-padded row's own slice in batched decode and MTP verify. On newer main primarily a performance fix for padded batched decode, with a correctness signal. Two commits: per-row attention for decode, and the per-row path for padded verify (own mask kept across mlx-vlm's per-layer slice). A third commit, an exact rollback of the verify tail, was dropped: upstream #3127 fixed the same lossy rollback on main.816 (544): +815 / −1, 28 testsalways (bug fix)Closed in favor of #4453 (merged 2026-10-12, co-authored). Branch fix/turboquant-padded-batched-decode on main 013bc3f3: 16,956 passed against main's 16,928, the same 3 environment-only failures. Real model, P3 on 25aebb3e with main's pinned dependencies (one run per build; the TurboQuant files are unchanged upstream since then): no aborts or degeneration on either tree; batched decode 5–12× faster per row (1.5 → 8.3 tok/s with MTP on, 0.66 → 8.1 with MTP off), B=3 phase 345 → 66 s and 780 → 65 s, peak 11–15 GB lower; with MTP off, batched rows stay on the B=1 output longer (about token 8–10 against 1–2). Per-layer decode attention runs 9–28× faster (microbenchmark). Historical, before the rebase (main b7c7929e on the v0.7.0 app runtime, PR1 with the dropped rollback commit): the B=3 batch aborted on main (6 aborts, 120 GB peak), and PR1 gave 4-gram 1.00 / 0.99 / 0.99 and 0 aborts.
PR2fix(scheduler): prefill long suffixes over a dense copy of a restored TurboQuant prefix (details). Two commits: the dense step, and the embedding-dtype fallback when every KV layer is TurboQuant.547 (360), 16 testsalways (bug fix)Merged as #4442 (2026-10-12), independent of PR1 and of the stack. Branch fix/turboquant-restored-prefix-prefill on main 013bc3f3: 16,944 passed, the same 3 environment-only failures. Restored suffix 2.41× → 0.92× of cold on main; quality differences only at near-ties, as without the fix; reservation +2.23 GB.
P-aPrefill progress commit, including the commit executor in its own module (prerequisite)2,133 (1,293)offOpen since 2026-10-12, rebased on main f3ad34d2: P-a #4478, P-b #4479, S1 #4480, S2 #4481, S3 #4482, S5 #4483, G #4484, S4 #4485, S6 #4486, S7 #4487, S8 #4488, S9 #4489. P-a is ready for review; the rest are drafts in merge order.
P-bPrefill park instead of abort, plus the media progress commit and the shutdown watchdog (prerequisite)2,558 (1,733)off
S1Single-victim fallback and exact-chain cache primitives1,284 (687)off
S2Decode pause and resume (pause primitive, resume fast path, stop and parser continuity, enforcer wiring, MTP handoff)2,735 (1,912)off
S3Memory-aware resume priority (resume when the need fits; FIFO hold as the reservation), adapted to #3482532 (348)off
S5Cached-media text suffix through the chunked path (makes image prompts parkable), with the position proof also in the packed prefill path997 (758)off
GMemory scheduling gate first: the memory_scheduling setting (off without an explicit ceiling), its resolution and propagation by the enforcer, the admin settings and the live state; nothing reads it yet696 (464)adds the setting
S4Never-fits gate, fits-later wait and capacity API (endpoint, header, 400 schema; VLM weights sizing), adapted to #3482 and #4252; a parked request's own committed blocks reported as resumed prefill (#4383)2,550 (1,612)memory scheduling
S6Resume under the hard watermark without thrashing, solo run, decode pause budget922 (537)off
S7Enforcer: spare external-loop prefills, no abort-all without a live victim, emergency pause-first1,012 (736)off / memory scheduling
S8Admission progress clock, solo admission, a lone head that fits alone re-measured on a stale reading (the forced-pause test knob of this build was dropped from the opened pull request)1,743 (1,238)memory scheduling / off
S9Follow-ups: the hot cache raises the never-fits line, and the stall clock across park holds490 (340)memory scheduling / park

Line counts are additions plus deletions of each slice against the slice before it, on main 013bc3f3. "Memory scheduling" means off unless an explicit memory ceiling is set or memory_scheduling=on. G adds that gate before any path reads it, and S4, S7 and S8 consult it from their first commit, so with every flag off each prefix of the stack behaves as main on these paths; each gated slice has tests that drive it with the gate off and expect main's behaviour. The C7 evidence on c0b1056b (re-validation) covers P-b, S1–S3, G and S6–S9, the MTP-off identity run covers S2, the never-fits run covers S4, and S5 has unit and tiny-model identity tests only. The rebuild cherry-picked the fork's commits in slice order with mechanical resolutions; the one test that used the fork's defaults file (G's admin-state test) now sets the mode in settings.json, and a test-fixture fix for upstream #4312 moved into S6, whose tests need it. These fork commits are in no pull request: the packaging commits (defaults bundle, gate removal, --allow-pin-mismatch), the defaults-file layer and the shared on/off env reader that goes with it, the benchmarks/unattended harness (linked as reproduction only), and the fork-side test that combines PR2 with the prefill park (it follows once both are on main).

Later, separate oMLX proposals

Pull requestSizeNotes
perf(turboquant): ragged single-launch batched kernel—Follow-up to PR1. After PR1, batched decode runs at about 8 tok/s per row on newer main (about 7 on the v0.7.0 runtime), against 17–40 tok/s at B=1; the fork's ragged kernel is the next speed step. Upstream's #4453 now runs a padded batch in one kernel launch per layer (maintainer-reported 62–65 tok/s per row with three concurrent requests, Qwen3.6-35B-A3B with TQ4), so this proposal would first be re-measured against it.
feat(memory): memory.mlx_cache_limit_gb~170Bounds the Metal buffer cache. Includes i18n entries.
feat(cache): eviction summary and per-request cache outcome; TTFT breakdown log2 PRsObservability only.
feat(api): inline_note as a third mid-system policy~700Opt-in, next to strict and user_note_safe.
feat(api): deterministic thinking retention with a configurable non-query marker list~1.6kWe will generalize it beyond one client's marker text first, and check templates one by one.
feat(scheduler): batched-decode context gate with backfill and aging—On hold. The fork app dropped it in fork.5 (159673d5) because decode pauses now handle the memory case.

oMLX bug reports we would file (or comment on)

Before each oMLX PR we will

To gentle-shell

gentle-shell requires an issue with status:approved before a pull request. #1367 is open with status:needs-review, and we have asked for approval so that the fix can go in as a pull request, or be folded into another change if the maintainers prefer.

ChangeLines (tests)Status
fix(todo): deliver todo state as messages to keep the prompt cache-stable. The system prompt stays byte-stable within a session; todo updates are hidden conversation messages sent only when the list changes; the stale reminder fires once; the state is re-injected once after compaction. Keeps #1924's reminder and the blocked/dropped statuses.388 (296)Ready. Branch fix/todo-prompt-cache-1367, one commit (4b929257) on 43541a0d: 63 todo tests, typecheck, check:runtime-modules and verify-package-files pass; the remaining pnpm test failures also fail on a clean base. Waits for status:approved; will be rebased onto current main first.
test: a prompt-stability invariant for every extension that touches the system prompt (RDD status, YOLO directive, memory)—Proposal (above)
An optional end-to-end check against a local server that reports cached tokens, and an opt-in payload recorder—Proposal
The other harness findings: YOLO directive and memory addendum toggles in the system prompt, the worker role without agent_browser, squad workers ignoring the pinned model—The first two raised in #1367 (related: #1029); the other two not reported yet, one issue each