Repository navigation
Running oMLX unattended for coding agents: a TurboQuant batched-decode fix, and pausing requests instead of aborting them under memory pressure #4440
Replies: 3 comments
|
The pause-and-resume stack is now open as 12 PRs, rebased on current
P-a is ready for review. The rest are drafts: each one lists the commits to review, and I rebase the next one after each merge. Every new behaviour is behind a setting that is off by default. Following #4396, the env-only switches became settings ( |
|
The direction here is right. Keeping in-flight work instead of aborting everything under memory pressure is worth having, and the analysis in #4440 made the problem much easier to see. I can't take a 12-PR stack of about 17k lines for it, though. That's more new state in the scheduler and memory enforcer than I can maintain. I went back to the root cause and implemented a smaller version in #4509. It stores the live requests to the paged SSD cache at the hard watermark, drops the batch in one step, and resumes them from the stored prefix on the same stream. It also keeps the computed prefix when a client cancels, so a resend picks up where it stopped. The PR credits your idea. Once #4509 is merged I'll close this stack. If you have feedback on the approach in the meantime, please leave it on #4509. |
|
@e-Evolution I merged #4509, including the external-prefill cancellation and TurboQuant prefill fixes. Your analysis helped shape the approach, and your testing caught the TurboQuant gap before merge. I'm closing #4478 through #4489 in favor of the merged implementation. The cached-media text-suffix route and never-fits/capacity API are not included, so I'd like to keep this discussion open for those proposals and agree on their scope before any new PRs. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
I run oMLX as an unattended server for coding agents on an Apple M5 Max (128 GB). The agents are pi with the gentle-shell harness (package
gentle-pi) and subagents, working on a documentation pilot: an ADempiere wiki with images of the new version and an assistant that answers questions. The model is Qwen3.8-27B-oQ4e-mtp: 4-bit, Lightning MTP and TurboQuant 4-bit KV. Prompts are long (median 107k tokens, p90 168k), and several sessions run at once.While making this setup run unattended I found four things, and I'd like your view on how to contribute them:
main: left-padded rows attend over their padding, and batched decode is slow. The fix is ready as a focused PR: fix(turboquant): attend each left-padded row's own slice in batched decode and MTP verify #4441.mainaborts every in-flight request. It is large, so I'd like to agree on the approach here before opening anything.Full write-up (method, every number and its limits): https://e-evolution.github.io/omlx/. The numbers come from my own runs, one per configuration unless stated, so read them as evidence rather than an independent reproduction.
1. TurboQuant batched decode with left-padded rows (PR ready)
BatchTurboQuantKVCacheleft-pads its rows to a common length. In batched decode (B > 1):mask[..., :kv]), so verify never takes a per-row path.Reproduction on
main(synthetic caches, no download; the tests are in the PR):mainOn the real model, I ran a greedy B=3 restored decode of 20k / 45k / 135k prompts on
main25aebb3 (the TurboQuant files are unchanged upstream since) and on the PR, from one venv withmain's pinned dependencies, one run per build. Both complete without aborts or degeneration; the difference is speed and memory:mainWith MTP off the B=1 reference is deterministic.
main's batched rows leave it at about token 1-2 on the 20k and 45k rows, and the fix's at about token 8-10. Without logprobs I can't classify those divergences, so this is consistent with the padding defect, not proof of it.Per-layer attention at 20k / 45k / 135k goes from 86.0 to 3.05 ms (L = 1) and from 88.3 to 8.4 ms (L = 3, verify).
An earlier run on
mainb7c7929 with the v0.7.0 app runtime aborted this batch (6 memory aborts at a 120 GB peak). That does not reproduce on 25aebb3, and with one run per build I can't say whether upstream's commits or the runtime changed it.My branch also fixed the lossy verify rollback, which #3127 has since fixed on
main. I verified that it matches my rollback on every unmasked column, so the PR is down to 2 commits (+815 / -1, 28 tests). Built onmain013bc3f, it merges cleanly into currentmain(e02ae51), and CI's command on that merge givesmainplus the 28 new tests, with no new failures.2. Pause and resume instead of abort (proposal; I'd like your view first)
Problem
At hard memory pressure, the enforcer on
maincallsabort_all_requestson the non-emergency path. Every request loses its prefilled KV and its generated text, and the client has to re-issue, usually cold. I ran a forced-pressure scenario: three concurrent prompts of about 130k, 115k and 100k tokens under a 48 GB custom ceiling, withhard_threshold0.8. Onmain(b7c7929), all 3 requests were aborted in each case: text decode, image decode and image prefill. A request that can never fit is also not distinguished from one that only has to wait. A 60k prompt withmax_tokens2,000,000 gets HTTP 200 onmain.This looks related to #4213, where the prefill memory guard stops an agent workflow, and to the direction of #4252 (free hot cache instead of rejecting).
Design
max_tokensare kept.max_tokens) + transient exceed capacity gets HTTP 400context_exceeds_capacity, with seven numeric fields andretryable: false. A request that fits alone waits instead of failing.GET /v1/omlx/capacityand anX-oMLX-Max-Context-Tokensheader expose the numbers, so an agent harness can compact before it hits the limit.Defaults. Nothing changes unless the operator opts in:
memory_guard_tier: custom) ormemory.memory_scheduling=on. On a dynamic tier, the ceiling follows other apps' memory, so promising what fits alone does not hold there.Results
On current
main(c0b1056), my fork rebased on it againstmain, one venv withmain's pins for both, one server at a time, the same ceiling for both trees, one run per scenario:main(c0b1056)mainnow completes the image-prefill case, so the difference there is gone; decode is where requests still get aborted. Againstmainc0b1056 the premises show no regression: 2 x 130k concurrent at a 51 GB peak against 116 GB, concurrency at parity, and single-request decode within noise.Earlier, on
mainb7c7929 (both builds through the v0.7.0 app runtime; one run per pair):main(b7c7929)main48 GB)Identity. With MTP off, 12 forced decode pauses (B=1) give identical tokens and 0.0 logprob drift for 3/3 requests of 2,000 tokens. With MTP on, outputs diverge at bf16 near-ties 42-79 tokens after a pause. The same prompts are identical with MTP off, which points to MTP verify regrouping (a hypothesis: it does not by itself rule out a pause-state effect that only shows with MTP on).
Size, and what I'd like to agree on
The cycle is about 13k changed lines, about two thirds of them tests (real scheduler and enforcer rigs on a tiny hybrid model and the real paged SSD cache). It also needs a prefill park and a prefill progress commit that are not on
main. I propose a stack of PRs, each with its own tests, opened one at a time:memory_schedulingsetting (resolved by the memory enforcer; nothing reads it yet);I've built all of them on
main013bc3f: each applies on the previous one and passes the default suite. The never-fits slice is adapted to #3482 and #4252. Thememory_schedulingsetting comes before the slices it gates, and slices 8, 10 and 11 consult it from their first commit, somain's default admission stays the same after each merge unless an operator sets an explicit memory ceiling ormemory_scheduling=on.Questions:
3. Slow suffix prefill after a restored TurboQuant prefix (PR ready)
A paged-SSD hit restores TurboQuant layers as quantized caches (#1842), and the scheduler keeps them quantized while it prefills the uncached suffix, so every chunk attends to the whole quantized prefix through the tiled quantized-attention path. On
mainc0b1056, a 36k-token suffix after a 74k-token restored prefix took 197.9 s against 82.2 s cold for the same positions (2.41x); with TurboQuant off the two match. The fix copies the restored layers into dense KV once before a suffix longer than one prefill chunk (0.43 s for 15 layers x 74k tokens) and re-uses the prefix codes unchanged at the end of prefill: 87.3 s against 94.7 s cold (0.92x). Output differs from cold only at near-ties, exactly as without the fix, and the prefill reservation now plans the dense layers (+2.23 GB). 2 commits, 16 tests: #4442.4. Related findings (details in the write-up)
user_note_safefalls back tostrict, which rewrites the prompt head. An opt-in policy that renders updates in place keeps the prefix (related: Prefix-cache match capped at ~25k tokens on verified linear (append-only) conversations — possibly cache_control breakpoint sensitivity #3608, Bug: Qwen3.6 false-positive mid-system preservation with leading developer block on /v1/responses #1966).last_query_index, so every image turn breaks the prefix. An opt-in thinking-retention guard gives 97.4-99.7% prefix hits on image turns (related: TurboQuant KV breaks preserve thinking #3378, preserve_thinking fails with Qwen3.6_35B on 0.3.7rc2 #900, Main Agent Session KV Prefix Cache Always Misses After Upgrading to 0.3.8.x (Working on 0.3.6) #1003).I'm happy to adapt the order or scope to your preferences, and to keep each PR small. Thanks for oMLX.
Victor Perez (victor.perez@e-evolution.com), lead maintainer of ADempiere (https://github.com/adempiere/adempiere/tree/hotfix/3.9.4.002)
All reactions