Repository navigation
drm/asahi: g15: budget compute flights while another VM waits - #389
Open
crybo-rybo wants to merge 2 commits into
Open
crybo-rybo wants to merge 2 commits into
crybo-rybo wants to merge 2 commits into
Conversation
crybo-rybo
marked this pull request as ready for review
November 6, 2026 05:29
crybo-rybo
requested review from
chriskearney,
maralcbr and
scottjones
as code owners
November 6, 2026 05:29
crybo-rybo
force-pushed
the
g15-share-budget
branch
from
November 6, 2026 13:49
15f0ca1 to
7949870
Compare
Author
|
v2 pushed (
The PR description carries the full table and a revision history. |
crybo-rybo
force-pushed
the
g15-share-budget
branch
from
November 6, 2026 13:53
7949870 to
af8b3f8
Compare
Author
|
Patch-alone row for v2 added to the table (message-only re-commit, |
The G15 engine runs one VM at a time: Firmware::publish() stops at the first picked job whose VM differs from the newest flight's, so a job of another VM waits until every published flight has retired. A sustained compute client keeps up to MAX_INFLIGHT (2) flights published, and the compute horizon folds up to COMPUTE_HORIZON (16) of its ready jobs into one prefix, bounded only by the free compute slots, not by time. With llama.cpp, one SUBMIT is one compute command of ~100 dispatches and ~6-8 ms of GPU time (gemma4 12B), ~11-13 per token (estimates from the graph size, the eval rate and the stats counters), so the compositor waits for the rest of a token. On an M3 Pro (T6030, G15S), the GPU completion latency of a GLES probe (a clear plus glFinish, 16 ms of sleep after each of 300 samples) while gemma4:12b generates, ms (linux-aurora omacom#387): condition p50 p95 max >8.3 ms idle 0.84 1.03 3.47 0% 12B, default 54.28 197.43 202.83 62% 12B, ASAHI_MESA_DEBUG=sync 0.81 11.28 22.70 8% The sync run keeps one llama.cpp job in the kernel at a time, so no horizon forms and no second flight is published; it restores the idle p50, but costs ~70% of the throughput (12-14 -> 3.65 tok/s) because the CPU records the next command buffer while the GPU idles. The stats of the same boot: compute count 111291, prefixes 58839, max_us 244306 (the longest compute prefix interval), horizons of 2-16 jobs, gaps ~14 us each. Bound what one VM may have published while another waits, on the host: - The contention signal: when publish() stops at a job of another VM, note the time. The engine is contended for g15_share_window_ms (500) after it. A pending job of another VM is the wrong test, since it would have won the pick; the moment it was held is the reliable one. A compositor that redraws on damage renders at ~10 Hz while the user types, so a 100 ms window would lapse between keystrokes; 500 ms covers typing and scrolling pauses, for one drain per compute command (~2%) during the half second after the desktop goes quiet. - While contended, publish nothing behind a flight that holds computes, so one such prefix is in flight instead of MAX_INFLIGHT, and cap the computes of a prefix, horizon included, at g15_share_budget_us (4000) over the queue's mean compute command time, never below one while a compute slot is free. The member loop and the horizon stop at the cap (horizon stop reason 4, "budget"), and the submit-ahead depth estimate uses the same cap. Since at least one command is always admitted, a prefix that fit before still fits: the E2BIG/EAGAIN path when nothing fits is unchanged. Render-only flights are not budgeted and keep MAX_INFLIGHT, so publication among render-only clients is as without the budget; a client whose jobs mix computes into its frames is budgeted like a compute client while contended. - The mean is an EWMA (weight 1/8) per logical queue, fed at retirement from the CDM stage start/end ticks (24 MHz) that stat_retire() already reads per compute slot, over the intervals that are valid. Horizon jobs share the base job's queue. A queue with no sample yet gets one command per prefix while contended, which bounds the wait until its first prefix retires. Uncontended, or with g15_share_budget_us=0, publication is unchanged. No new firmware interface is used: the encoding, barriers, stamps, slots, the one-VM rule and recovery are the same; while contended the firmware sees more, smaller compute prefixes and more VM switches. A job of another VM should then wait for one budgeted prefix plus the VM-switch drain (~30-150 us), one render prefix more when the VM's own render-only flight precedes its compute prefix; with 7 ms commands and the default budget that is one command, about what the sync run shows, while llama.cpp keeps submitting asynchronously and pays one drain per prefix only while another client uses the GPU. The budget is a count from the mean, not a timer: a sudden change in command cost overshoots until the EWMA follows, one command is always admitted, and the flights published before a hold first records the contention still drain in full. asahi-g15/stats gets a "share:" line after the existing ones: prefixes with computes published under the budget, those it cut short, horizons it ended, holds of a job of another VM behind published flights (also hold[3]), and the two parameters. hold[0] now also counts holds at the budget's one-flight limit. The module parameter macro registers every parameter with permissions 0, so both are set at load time (asahi.g15_share_budget_us=...), not through sysfs. Tested on that M3 Pro at the defaults (budget 4000 us, window 500 ms), same probe and protocol, ms. "earlier" is a previous revision of this change that applied the one-flight limit behind every flight, render only or not; "this" is this revision, which differs from the module measured by comments only: condition p50 p95 max >8.3 ms stock 54.28 197.43 202.83 62% earlier 8.83 26.86 28.36 62% earlier, 20 nodes per submit 4.65 10.00 15.61 6.7% this 9.52 26.33 33.80 77% this, 20 nodes per submit 1.01 11.03 14.44 10% The "20 nodes per submit" rows also set GGML_VK_MAX_NODES_PER_SUBMIT=20 in the Ollama service, so llama.cpp submits a command buffer every ~20 graph nodes instead of ~100 and the budget has shorter commands to cut at. Eval rate: stock 12-14 tok/s across runs; earlier 13.99 and 13.61 tok/s; this 14.31 and 13.48 tok/s (one run each; no loss observed, within the stock spread). With a cap of one, the p50 is about one llama.cpp command and sits at the 8.3 ms threshold, so the >8.3 ms share swings between runs (62%, 77%) until the commands get shorter. llama.cpp's publication is the same under both revisions; the probe's render side is back on stock pipelining in this one. Neither patched boot's kernel log has an asahi fault, timeout, halt or recovery. Counters of the earlier revision's boot (asahi-g15/stats; + marks a delta over the run; that revision counted render-only prefixes in the share flights too): share flights +20708, capped 8071, hz_budget 8071, hold_vm +162563; compute count 20614 in 18810 prefixes (1.1 commands per prefix); horizons 17156 of one job and 1543 of two, against 2-16 in the stock boot; compute max_us 462055. The budget cannot split a command. That 462 ms maximum is a prefix's publication-to-retirement interval; with the cap at one it was most likely a single prompt-processing command, though the counters alone do not isolate one, and another VM still waits for all of it. Bounding prefill needs Mesa to end the CDM control stream more often, a separate follow-up. Built as an out-of-tree module against the installed 7.2.11-14-1-ARCH (linux-aurora 7.2.11.aurora14-1, 4fdd0fc) with the recipe's toolchain: clang 18.1.8, rustc 1.85.0, bindgen 0.73.2, with no new warnings. Loaded from the initramfs of a separate Limine UKI entry, the default entry left stock as the fallback. Not tested: T6031/T6032 and T8122 boards; compute clients other than Ollama's llama.cpp; render clients that submit computes (geometry or tessellation lowered to compute, WebGPU); external displays; g15_share_budget_us=0 on hardware (the host tests cover it); budget and window values other than the defaults; a matched headless throughput comparison; the first interaction after the window lapsed; runs longer than about an hour. Link: omacom#387 Assisted-by: Claude:claude-opus-5-5 Assisted-by: Claude:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Conor Rybacki <rybackiconor@gmail.com>
Add g15-share-budget-tests.py in the style of the other G15 host tests. It extracts the share budget's policy functions (is_contended, share_inflight, share_cap, ewma_update, ticks_ns) and Engine::share_budget() from g15/engine.rs, and share_budget_ns() and share_window_ns() from g15/params.rs with their defaults read from asahi_core.rs, compiles them with modelled module parameters and runs: - share_cap over 0-16 free slots, budgets and means: never above the free slots, at least one while a slot is free, all of them without a budget, one without an estimate, else the budget over the mean; and the omacom#387 cases (7 ms and 4 ms commands: one per prefix at 4 ms); - one flight while contended, all of them otherwise; - the contention window's boundary, and never contended without a hold; - the EWMA's seeding, zero samples, monotonic convergence to within one step of a steady sample, and no overflow; - the 24 MHz tick conversion, the parameter clamps, and budget 0 leaving every cap and the flight count as without the budget. Source checks pin the wiring the host cannot run: the member loop and the horizon stop at the cap (horizon stop 4 after the slot stop 2) and mark the prefix capped, horizon_ready() gets the cap, share_cap() gets the pass's budget and the queue's mean, publish() notes the contention at the hold of another VM's job and holds behind a flight with computes only (share_inflight() gated on the newest flight's computes, the hold count with it), the share stats count compute prefixes, and stat_retire() feeds the EWMA in ns over valid intervals. The asahi-g15/stats format strings before the change must stay as they are and in order, with the "share:" line after them; the share line is rendered and its keys parsed back. With --mutations, 15 mutants of the extracted bodies must build and die at their intended assertions, and 6 copies of engine.rs with a call site disconnected (the share_cap budget argument zeroed, the share_inflight condition forced false, the compute gate dropped, the hold count on the ungated limit, the share stats counting render prefixes, the horizon's capped mark dropped) must fail the source check they target. Registered in host-tests.json with the Rust 1.85.0 pin, the kernel's minimum. Assisted-by: Claude:claude-opus-5-5 Assisted-by: Claude:claude-fable-5-1 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Conor Rybacki <rybackiconor@gmail.com>
crybo-rybo
force-pushed
the
g15-share-budget
branch
from
November 6, 2026 14:23
af8b3f8 to
bed2d45
Compare
Author
|
v2.1 (
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On an M3 Pro (J516S, T6030, G15 backend), a sustained Vulkan compute client such as llama.cpp under Ollama delays every other GPU client by 40-200 ms, the compositor included, so the whole desktop stutters while a model generates (#387). Context priority doesn't help:
file.rsrefuses HIGH on anything but G17, and on G15 priority only orders the host pick anyway.The G15 engine runs one VM at a time.
Firmware::publish()stops at the first picked job whose VM differs from the newest flight's, so a job of another VM waits until every published flight has retired. A compute client keeps up toMAX_INFLIGHT(2) flights published, and the compute horizon folds up toCOMPUTE_HORIZON(16) of its ready jobs into one prefix. That is bounded by the free compute slots, not by time. With llama.cpp, one SUBMIT is one compute command of ~100 dispatches and ~6-8 ms of GPU time (gemma4:12b), ~11-13 per token, with no CPU wait between them (estimates from the graph size, the eval rate and the stats counters). So the compositor waits for the rest of a token, andasahi-g15/statsshows horizons of 2-16 jobs and a longest compute prefix interval of 244 ms.ASAHI_MESA_DEBUG=syncconfirms this: it keeps one llama.cpp job in the kernel at a time, so no horizon forms and no second flight is published. That restores the idle p50, but it costs ~70% of the throughput because the CPU records the next command buffer while the GPU idles. So the fix belongs in the engine's publication policy.Change
Policy only, in
drivers/gpu/drm/asahi/g15/. No new firmware interface is used: the encoding, barriers, stamps, slots, the one-VM rule and recovery are untouched. While contended, the firmware sees more, smaller compute prefixes and more VM switches.engine.rs:666-683): whenpublish()holds a job of another VM behind published flights, it recordscontended_ns. The engine counts as contended forg15_share_window_msafter that (is_contended(),Engine::share_budget(),engine.rs:489,:546). Testing for "a job of another VM is pending" doesn't work: that job would have won the pick. The moment it was held is the reliable signal.MAX_INFLIGHT(share_inflight(), gated ontail.computes != 0inpublish()). Render-only flights are not budgeted and keep their second flight, so publication among render-only clients is exactly as before. A client whose jobs mix compute commands into its frames (geometry or tessellation lowered to compute, WebGPU) is budgeted like a compute client while contended. A VM's own budgeted compute prefix may still follow its render-only flight; the waiter then sees one render prefix more than the budget.g15_share_budget_usdivided by the queue's mean compute command time, and never below one while a compute slot is free (share_cap(),engine.rs:567; applied inpublish_prefix_into()atengine.rs:943).horizon_ready()) gets the same cap.sentadvanced.QueueState.avg_compute_ns(queue.rs:56).stat_retire()feeds it at retirement from the CDM stage start/end ticks (24 MHz) it already reads for each compute slot, counting only the valid intervals (poll.rs:538-588,ewma_update()/ticks_ns()atengine.rs:578/:589). A queue with no sample yet gets one command per prefix while contended.g15_share_budget_us=0: publication is exactly as before. A lone compute client keeps two flights and the full horizon, so its publication decisions are unchanged; a matched headless throughput comparison was not run.The module doc at the top of
engine.rsdescribes the budget. Commit 2 addstools/asahi/g15-share-budget-tests.pyand registers it intools/aurora-ci/host-tests.json(Rust 1.85.0 pin).Testing
J516S (MacBook Pro 16" M3 Pro, T6030 G15S), Omarchy 4.5.0, Hyprland, AC power, kernel 7.2.11-14-1-ARCH (linux-aurora 7.2.11.aurora14-1). The load is gemma4:12b generating throughout under Ollama (Vulkan). The probe is a GLES clear plus
glFinish, 300 samples, sleeping 16 ms after each; its source is in #387. The numbers are the probe's GPU completion latency, not displayed-frame latency, and "over 8.3 ms" is the share of probe samples, not of frames. In ms:ASAHI_MESA_DEBUG=sync(control, not a fix)GGML_VK_MAX_NODES_PER_SUBMIT=20in OllamaGGML_VK_MAX_NODES_PER_SUBMIT=20in Ollamagapsaveraged ~14 µs each.Counters from
asahi-g15/statsin the v1 boot (+ marks a delta over the run; v1 counted render-only prefixes inshare: flightstoo, this PR counts compute prefixes only):share: flights +20708 capped 8071 hz_budget 8071 hold_vm +162563count 20614 prefixes 18810(1.1 commands per prefix; the stock boot's cumulative stats werecount 111291 prefixes 58839)max_us 462055: one prefix took 462 ms from publication to retirement. That interval includes any wait behind its predecessor, and the counters alone don't isolate a command; with the cap at one it was most likely a single prompt-processing command, but that attribution is inferred (see the limits below).journalctl -k: no asahi faults, timeouts, halts or recoveries in either patched boot.How it was built and loaded; host tests; checkpatch
asahi.koas an out-of-tree module against the installed 7.2.11-14-1-ARCH at4fdd0fc5c931, with the recipe's toolchain: clang 18.1.8, rustc 1.85.0, bindgen 0.73.2 (tools/aurora-bindgen), pahole 1.32. The installed.configwas used unchanged..BTFand.BTF.baseare present.host-tests.py --only, the six g15 suites that read the changed files): input-error, pb-growth, stats-drain, queue-credit, probe-init and the new share-budget suite pass. In the new suite (--mutations), all 15 runtime mutants of the policy bodies die at their intended assertions, and 6 source-level wiring mutants (theshare_capbudget argument zeroed, theshare_inflightcondition forced false, the compute gate dropped, the hold count using the ungated limit, the share stats counting render prefixes, the horizon's capped mark dropped) fail the source checks they target. The CI self-test (148 tests) is OK.checkpatch.pl --strictreports nothing in the code apart from two module parameter description strings over 100 columns, which match the existingg15_*descriptions. Its only trailer remark is theCo-Authored-Bycasing, which other commits in this tree use too.Parameters
Both parameters are load-time only:
module!registers every parameter with permissions 0, as with the otherg15_*parameters. Set them withasahi.g15_share_budget_us=…on the kernel command line, or withoptions asahi …in modprobe.d, which needs a regenerated initramfs because asahi loads from it. Theshare:line prints both, so a capture records which policy ran.g15_share_budget_us(u32, default 4000): the compute GPU time of one VM's flight (one published prefix) while another VM waits. 0 turns the policy off; other values are clamped to 100..1 000 000 µs.m3_render_batch_budget_usdefault.g15_share_window_ms(u32, default 500, clamped to 1..10 000): how long after a held job the budget applies.asahi-g15/statsgets a new last line; every existing line keeps its format:The fields count prefixes with computes published under the budget, those it cut short, horizons it ended, and holds of a job of another VM behind published flights (also
hold[3]), followed by the two parameters.hold[0]on the horizon line now also counts holds at the budget's one-flight limit.Limits and follow-ups
hk_cmd_buffer.c); a single long dispatch stays indivisible even then. I'll propose that separately in omacom/mesa.GGML_VK_MAX_NODES_PER_SUBMIT=20is a complementary user-space knob: it makes llama.cpp's commands shorter so the budget can cut finer. It goes in an Ollama service drop-in (Environment=GGML_VK_MAX_NODES_PER_SUBMIT=20). It gave the best result above for 3-6% of the eval rate, but it does not guarantee short prefill commands: the node threshold applies to graph submission generally, and a single dispatch can dominate. I measured it only with this patch, not on the stock module.m3/m3_runtime.rsandm3/m3_batch_policy.rs, not from testing:may_join()admits later packets into render batches only), andpipeline_slot()never publishes compute behind an active batch.m3_render_batch_budget_usbounds render batches only.Not tested:
g15_share_budget_us=0on hardware (the host tests cover the policy-off path)Merge Danger
Door: two-way. The change is policy only, and
asahi.g15_share_budget_us=0restores today's publication without a rebuild.Blast Radius: small to medium. It affects the G15 backend only, so T6030 and T6031, and only while a job of another VM has recently been held. Then a client whose flights hold computes publishes one prefix at a time, which costs one VM-switch drain per prefix. Render-only flights are not budgeted. Render clients whose jobs include compute commands are budgeted like compute clients while contended.
Revisions
Links: #387 (report, probe source), the source-inspection comment with the horizon/flight diagnosis.
🤖 Generated with Claude Code