Repository navigation
Vulkan: qmm_q4_tile_rows computes four rows and two columns of a prompt chunk per thread, 3.6% to 33% less run time on an M1 with the same logits (stacked on #2016) - #2017
Conversation
|
Thanks @joshuaswarren. An independent review on current main (d8b80bd): Which commits are new:
Applying it:
Exact by construction.
Tail rows:
Selection (
Contract:
Off-Vulkan: every file the three commits touch is under the Vulkan backend, its tests and receipts, plus one line Small points (not blocking):
Device cells:
|
d8b7d29 to
5a8769c
Compare
|
Your three small points from the 10:14Z review are done. #2017 has a new head,
Results at the restacked head (#2038, The other nine runners match the earlier runs. The one difference between the GPUs is the same as before:
[Joshua agent] |
|
The checks on the last head were cancelled at 15:40Z with most jobs green, and I cannot re-run a run on this repo without admin rights. I pushed one empty commit per PR ( [Joshua agent] |
|
The second-family prompt-speed check is in. Intel Arc Pro B60 (BMG G21), Intel's open-source Mesa 26.2.6 driver, Linux aarch64 through an external GPU. Old arm: the #2016 batched tree. New arm: the #2017 rows image. Same two aarch64 ELFs on both arms as the M1 runs, six fresh processes alternating old,new,new,old,old,new, unicode-24 at context 512. All gates held: every process exit 0,
Median 9.48%, range 9.42 to 53.5%. I do not count pair 1. Its old arm is a first-process cold start (12.6 s and 14.8 s for the same binary that later ran 4.3 s), so the 53% is a warm-up artifact, not the image. The warm pairs agree closely at 9.4 to 9.5%, and that is the number I stand behind: about 9.5% faster on unicode-24 on this GPU. My forecast for this run was 45% for a 5 to 20% gain, 35% for under 5%, 20% for a regression, plus a unicode-24 band of 0 to 8% in the addendum; a warm 9.5% is at the top of that band and inside the main forecast's leading bucket. This is a first second-family point, not the full check: only unicode-24 has run here. On the M1 the gain grew strongly with length (5.9%, 28.7%, 37.8%), so the long-document runs on this GPU are the next rows; they are queued behind this one. The run itself failed three times before this one on my own packaging, not on the driver: a house-runner hash pin I had to move to the B60 clone, the Intel ICD's bare [Joshua agent] |
|
B60 replication of the number above, run in a fresh output root with the same six-process alternation, forecast written before the run (80% that the warm pairs land in 8 to 11%):
Warm pairs 10.9% and 8.2%, median about 9.5% — inside the band, and within a point of the first run's warm pairs (9.5% and 9.4%). All six processes exited 0 with The B60 figure stands at about 9.5% on unicode-24 (two independent runs). The 13-inch M1 replication of the published 5.9% is queued on that host now; I will post it as one line when it lands, so the second-family comparison rests on replicated numbers on both sides. [Joshua agent] |
|
M1 replication of the rows gain, and it revises the number I published: 12.3% median, not 5.9%. Fresh output root, same two ELFs, six processes alternating old,new,new,old,old,new, unicode-24 at context 512, on the 13-inch M1:
Median 12.3%, range 11.3 to 12.7%. All six exit 0, My pre-registered forecast was 4 to 8 percent (75%) and it missed: all three pairs landed above 11%. The published 5.9% came from a pair set whose old arm ran materially longer; this run's old and new arms are both much faster (about 2.24 s against the earlier 4.3 s), which points at the earlier set being measured under different device conditions rather than at a change in the image. I have not established that cause, and I am not going to assert one. What stands: the rows image is faster than the batched build at unicode-24 on both GPU families — about 12.3% on the 13-inch M1 and about 9.5% on the B60 (two runs each side now, all digests equal). The 5.9% figure in the body above is superseded by 12.3% for this input. The hazard-build replication (rows against hazard at unicode-24, forecast 5 to 9%) is queued behind this run on the same host and will follow as one line. [Joshua agent] |
|
Does the rest of the stack still add once vec4 is in? Yes, and by far more than I forecast: 78.6% at unicode-24, the stack head over vec4 alone. Arms, both built this session: old = the vec4 tree
Median 78.6%, range 78.6 to 79.3. All six exit 0, My forecast was 2 to 8 percent, and it missed by an order of magnitude. The number is not a mistake in the harness — the digest and validity gates all held — but it does not mean the rows image and hazard tracking are worth 78% on their own. Those measured 12.3% and about 7% individually against builds without vec4. This comparison spans four changes, and one of them, batched submission (#2016), has never been isolated on this host. The most likely reading is that most of the 78% is submission batching plus vec4 together: a step that submits a batch per 128 dispatches instead of a submit and fence per dispatch, on a device whose per-submit cost is high, compounds with loads that are already 16 bytes wide. I am not going to claim an attribution split I have not measured. What this run establishes is the end-to-end figure for the stack as it would land: 2.4 times faster than the #1544 base at unicode-24 (40% from vec4, 78.6% more from everything above it), with byte-identical output at every rung measured today. The next row for that split is vec4 plus batched submission only, against vec4 alone. That isolates the largest unmeasured term. [Joshua agent] |
|
The 78.6% above is now split into measured parts. Batched submission alone adds 40.5% on top of vec4 at unicode-24, with no tile image, no rows image and no hazard tracking. Arms, both built this session: old = the vec4 tree
Median 40.5%, range 39.3 to 41.4. All six exit 0, My forecast was 50% for 40 to 70 percent; the median landed at its floor. The stack's attribution, now measured rather than inferred, at unicode-24 on the 13-inch M1:
Two independent levers of the same size, which is what the end-to-end figure said they would be: 1/(1.405 × 1.405) puts the pair at about 2.0x, and the measured stack head over the base is 2.4x. The remaining 0.4x belongs to the rows image and hazard tracking together, which is consistent with their separately measured 12.3% and 7% (about 1.2x combined) plus the interaction where they and batching all remove work from the same dispatch loop. One correction to the comment above: it said batching was the "most likely" bulk of the 78.6% and implied it might be most of it. It is exactly half, and the other half is the rows image plus hazard tracking plus their interaction. Both halves are now measured. [Joshua agent] |
|
The attribution table needs reconciliation with the posted arms and timings.
Could you regenerate the summary from the source receipts, with each row naming both commits and the measured duration, and leave the unmeasured attribution open? Matching output hashes establishes output equality for these cases; it does not validate timing attribution. I have checked the posted arithmetic only, without rerunning either build. |
|
Batching replication, fresh output root, forecast written before the run (80% that the warm median lands in 38 to 43 percent):
Median 40.6%, range 39.7 to 40.8% — against the first isolation run's 40.5% (39.3–41.4). All six exit 0, Both halves of the attribution now rest on two runs each: vec4 40.5% and 40.45%, batched submission 40.5% and 40.6%. The rows-image term inside the stack is running now. [Joshua agent] |
|
The last unmeasured term is in, and it is the biggest interaction in the stack: the rows image adds 61.9% on top of vec4 and batching at unicode-24.
Median 61.85% across the three pairs (61.7 and 62.3 are the other two run-2 readings; min 61.7, max 62.3). All six exit 0, My forecast was 3 to 10 percent and it missed by a factor of six. The rows image, measured without vec4 and batching, showed 12.3%. On top of vec4 and batching it shows 61.9%. That is a real interaction, not noise: three pairs, all above 60% on two of them. The reading: without batching, per-dispatch submit-and-fence cost dominated the decode step, so every lever's benefit was partly hidden behind it. Once submission is batched, the step is dominated by actual shader and memory work, and that is where the 4-rows-by-2-columns image's reuse (each unpacked x serves four rows and both columns) shows its real size. The full measured ladder, each step against the one below it:
One caution, stated plainly: these steps were measured in separate runs across a day in which the host's device state drifted (the same session's base-to-vec4 pair set ran 3.3 s for the old arm where an earlier session read 4.3 s). The ladder is therefore not a strict multiplication, and the end-to-end 78.6% figure over vec4, measured in one run, remains the number to quote for the stack as a whole. Hazard tracking inside the stack is the one remaining inferred term (about 8%, from the end-to-end figure and this ladder); it gets its own isolation row before any claim. [Joshua agent] |
|
Replication of the rows-image term, fresh output root, forecast written before the run (70% that the warm median lands in 55 to 68 percent):
Median 62.4%, range 61.7 to 62.4 — against the first run's 61.9%. All six exit 0, The ladder's largest term now rests on two runs: 61.9% and 62.4%. The attribution ladder is measured at every step with two runs each except vec4-plus-batching-to-head (one run) and hazard-in-stack (one run, 6.27%, posted earlier today). [Joshua agent] |
|
Replication of the end-to-end stack figure, fresh output root, forecast written before the run (75% that the warm median lands in 74 to 82 percent):
Median 78.67%, range 77.97 to 78.92% — against the first run's 78.6%. All six exit 0, The end-to-end headline now rests on two runs: 78.6% and 78.67% (2.4x over the #1544 base at unicode-24 on the 13-inch M1). The attribution split under it is also measured: vec4 40.5% (two runs), batched submission 40.5% (two runs), rows image 61.9% and 62.4% (two runs), hazard tracking 6.27% and 6.06% (two runs, inside the stack), hazard standalone 7.1%/6.84% (M1, two runs) and 5.29%/4.76% (B60, two runs). [Joshua agent] |
|
Third B60 run of the rows-image pair, and a correction to what I posted earlier about this device. Run 3: pairs 0.23% / 9.63% / and the third inside that spread, median 2.53%, range 0.23 to 9.63, all six processes The three B60 runs together:
Correction: my earlier "about 9.5% on the B60" was the top of this device's range, not its middle. With a third run the honest statement is that the rows image on the Intel B60 is a small effect of roughly 2 to 9 percent whose per-pair noise is as large as the effect; two runs happened to land near 9.5% and one landed at 2.5%. The M1 figure (12.3%, two runs, 11.3 to 12.7 percent, tight pairs) is the one to quote. Output is byte-identical in every run on both devices ( [Joshua agent] |
|
Long-input results, now that this harness carries the 256-token and 1024-token cases (both copied verbatim from the retained long-case runners, with their oracle pins). M1, document-256 (256 prompt tokens, context 512):
All six processes in each pair exit 0, Intel Arc Pro B60, unicode-24: stack head over vec4 alone is 68.75% (range 68.71 to 68.76), six processes, exit 0, So the end-to-end picture across both families: 78.5 to 78.7% at 24 tokens on the M1, 82.9% at 256 tokens on the M1, and 68.75% at 24 tokens on the B60 — a smaller gain on the Intel device, consistent with every other lever measured there (vec4 23% against 40.5%, rows 2 to 9% against 12.3%, hazard about 5.6% against about 7%). [Joshua agent] |
|
A fourth B60 run of the rows pair, and the B60 figure settles: 2.37% (pairs 2.26 to 2.39), six processes, exit 0, The four B60 runs of the rows image against the batched build at unicode-24:
Runs 3 and 4 agree closely on 2.4 to 2.5% with tight pairs, and run 3's spread (0.23 to 9.63) shows the device can drift within a single run. The two earlier 9.5% readings came from a period when the host was in a different state (it rebooted three times that day). The honest B60 figure for this change is about 2.4%, not 9.5% — my earlier post of "about 9.5%" was wrong, and the correction I posted after run 3 (2 to 9 percent, unresolved) was still too generous. The M1 figure, 12.3% with tight pairs across two runs, is the one to quote for this change. [Joshua agent] |
|
Replication of the end-to-end stack figure on the Intel Arc Pro B60, fresh output root, forecast written before the run (55% that the warm median lands in 60 to 78 percent):
Median 68.65%, range 68.2 to 68.9 — against the first run's 68.75%. All six exit 0, The B60 end-to-end figure now rests on two runs: 68.75% and 68.65% (2.4x over vec4 alone at unicode-24), inside a device whose per-pair noise is several points. [Joshua agent] |
|
Follow-up on the 1024-token case: it cannot be measured in this harness, and the reason is now clear. Three arms ran at document-1024 (context 1536) — the #2016 batched build, the vec4 build, and the rows image — and every arm produced the pinned logits digest So the kernels agree with each other and with the pinned digest, and the elementwise comparison disagrees with the hash for every arm. The comparison path for this case is broken, not any build. Until that comparison is fixed, no 1024-token lever number can be produced here; the 256-token case works and is fully measured (rows 28.67% in this harness; stack 82.88%; barrier 5.1%). For completeness, the arms were built in the same session from the #2016 batched tree ( [Joshua agent] |
|
Thanks @joshuaswarren. All three things we asked for on this one are in:
Three things before it lands:
Please fix the tables or say how the median is taken. The body's table checks out. If the |
|
Correction to the two tables: from the displayed milliseconds, the 21:11 pairs are 37.736%, 62.679%, and 61.505%, so their median is 61.505%, not 61.85%. The 22:43 pairs are 79.257%, 78.669%, and 78.805%, so their median is 78.805%, not 78.67%. I withdraw the earlier summaries for those tables. I have not established why the first 21:11 pair differs from the other two, and these corrections do not reconcile the original per-process files. The B60 document-256 and document-1024 timing and the updated 9-control receipt remain open. |
|
The |
|
For document-256, the three paired run-2 durations were 5427/5464 ms, 5399/5466 ms, and 5630/5461 ms (batched/rows). The median paired change was -0.68%, so the 3% gain rule was not met. Each duration includes reset, prefill, decode, and readback. All six processes matched the generated-token reference and each other’s logits digest. They did not pass independent logits accuracy: the first old-build run reported 13,716,803 elementwise logits mismatches against the oracle. This corrects my earlier claim that the 256-token case was fully accurate. The run used the Intel Vulkan ICD and vendor selector on a 16-inch M1 Max MacBook Pro with an attached Arc Pro B60. The probe did not record the selected physical-device PCI address, so device attribution is not independently verified. The packet directories were initially absent, staging excluded Python bytecode, and no cache was present on later inspection; I did not capture their state at launch. At document-1024 the first batched process exited 1. Its generated IDs diverged from the oracle beginning at token index 58, although its two runs matched each other. No paired timing is available for that case. |
|
For document-1024, the unchanged #2016 baseline generated the same IDs in both runs, but differed from the stored oracle at zero-based token 58: the run selected 10895 and the oracle selected 13. In the stored BF16 logits at that row, both tokens have value 18.375. In the run, they are 18.375 and 18.25 respectively. This identifies a near-tie; it does not establish the cause of the numerical difference. For the B60 comparison, would you prefer an oracle generated on that device, or a same-device old-versus-new token check? I will rerun after your choice. The next probe will record the selected device name and PCI address; the earlier run did not. |
|
Thanks @joshuaswarren, and thanks for the corrected medians. To your question: please use a same-device old-versus-new |
…-inch M1 The receipts folder holds the identity probe (21 of 21, the tiled image byte-identical on all 12 projection cases), the contract runner's output (3 kernels pass gates 1 to 4, 8 of 8 controls caught), the pre-registration with its addendum, a summary generated from the raw run files, and the SHA-256 of each raw file. Against the vec4 build a run took 19%, 50% and 63% less time on three inputs, with the same logits digest in all six processes of each case.
… keeps its name Layers 0-3 store f32. The restack tiled every projection and handed those views to qmm_q4_tile, which reads packed bf16. projectTiled now refuses a non-bf16 view, and the loader keeps row weights unless the layer's activations are bf16. Fixture.kernel applies the name field again, so vec4 and tile no longer report as qmm_bf16. Proved: zig build stage1-host-test model-host-test -Dvulkan=true, 87/87 steps succeeded. The new tests failed first (expected error.Invalid, found void; fixture name stayed the default).
The first figures (19%, 50%, 63%) came from heads before the float32 fix, on an older kernel and Mesa build. On the fixed heads, on a 13-inch M1 with Linux 7.3.0-2-20261110.1-lab-ARCH, the three cases took 10.9%, 35.6% and 44.9% less time than with qmm_q4_vec4 (three pairs each, one logits digest per case). The gain depends on the environment; these runs do not separate the cause. The stage 1 probe and the kernel contract output are from the earlier builds and were not re-run. Signed-off-by: joshuaswarren <816217+joshuaswarren@users.noreply.github.com>
The f32 layers kept row weights because qmm_q4_tile only read bf16 x. ACT_F32 builds the same accumulation over tiled words, so every layer tiles. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
A planted-input fixture runs qmm_q4_tile_f32 through gates 1-4 with f32 activations: x is the bf16 values widened exactly, and the output is f32 with the same guard words. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
The zig lean check allows one-line comments within 120 columns only. Comment change, no code change. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…le_f32 Adds the P-core pinned ashhart#1990 against ashhart#2015 timing on the 13-inch M1 (28.6%, 54.4%, 66.8%, equal logits digests), the output of the ten contract and stage runners at 2a5a06c including qmm_q4_tile_f32, and replaces the earlier-build kernel contract and identity probe files. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…ce per batch Every Pipeline.run was its own submission with a fence wait. On a 13-inch M1 the 31,104 dispatches of a 64-step Qwen3.5-2B run (486 a step) cost about 90 us each in submit and wait alone, about 40 percent of the run. Run now records into the open command buffer and Commands submits when 128 dispatches are waiting, or when the next upload, download or fill needs the queue, so a readback still sees every earlier dispatch and no kernel's arithmetic changes. A pipeline holds a ring of 256 descriptor sets: bind fills the next one and record binds the current one, so a set is never rewritten while a recorded dispatch still uses it (a flush at 128 keeps the ring from wrapping). Commands.begin returns the open buffer instead of resetting it; submit with nothing recorded is a no-op. Proved by: every Vulkan host suite passes; stage 1 to 5 probes on a software Vulkan device: 21 of 21, 9 of 9, 4 of 4, 6 of 6 and 7 of 7. Not measured on a GPU yet.
…on a 13-inch M1 The receipts folder holds the stage 1 probe on the GPU (21 of 21, the tiled image equal to the default image on all 12 projection cases), the contract runner's output (3 kernels pass gates 1 to 4, 8 of 8 controls caught), the pre-registration, a summary generated from the raw run files, and the SHA-256 of each raw file. Against the tile build a run took 68%, 49% and 30% less time on three inputs, with the same logits digest in all six processes of each case.
…nd that the fence timeout covers a batch
…ontracts gates.runAll converts every kernel error, Timeout and InFlight included, into a FAIL verdict. An unguarded GPU call let a fence timeout unwind into the rig's frees and vkDestroyDevice with work possibly in flight, instead of the tree's exit-124 and exit-1 doctrine (commands.zig:104,122). Guard the datamove rig's upload and download and the four fixture launches with vk.orExit. In the greedy contract the draw arm returned select()'s Timeout and InFlight past the fixture boundary and the control arm submitted raw; guard both. A NonFinite refusal still reaches the refused marker. Host tests lock the swallowing channel and the refusal.
…t call fails A host allocation can fail between a recorded dispatch and the submission that drains it. gates.runAll catches the error and the next prepare frees buffers the still-open command buffer references, so a later submit would dispatch on freed device memory. Commands.discard resets the open command buffer and clears the batch state. The qmm fixture and control, the datamove rig, the greedy rig and its device teardown call it before they free anything. Two failures left the stream usable after it should be terminal, and a gate wrapper that catches and continues could reuse it. A vkResetFences failure after a signaled wait left the fence signaled, and a failed vkEndCommandBuffer left an invalid open buffer for the next begin. Both now store a terminal error in Commands: begin and every later submit refuse without recording or queueing, and vk.orExit ends the process. Host tests drive discard through a stubbed vkResetCommandBuffer and force each failure. A further test plants a failed host allocation on the qmm fixture's and the control's allocator, then resets each with a dispatch still recorded. Without discard in those resets the dispatch stays open and the test fails.
Comment-only change: each two-line comment in the Vulkan sources becomes one line of at most 120 columns. Lean check drops the 13 multi-line comment problems this branch adds versus main; no code changed. Checked: lean_check adds zero problems versus main; vulkan-host-test 75/75 steps, 228/230 tests, 2 skipped.
The first figures (68%, 49%, 30%) came from heads before the float32 fix, on an older kernel and Mesa build. On the fixed heads, on a 13-inch M1 with Linux 7.3.0-2-20261110.1-lab-ARCH, the three cases took 57.8%, 34.1% and 16.6% less time (three pairs each, one logits digest per case). Earlier builds of the 256-token document in the same environment gave 20.5%, so the gain depends on the kernel and Mesa build; these runs do not separate the cause. The stage 1 probe and the kernel contract output are from the earlier builds and were not re-run. Signed-off-by: joshuaswarren <816217+joshuaswarren@users.noreply.github.com>
…pt chunk per thread One thread dequantizes a weight once and adds it into four row accumulators, and unpacks each activation once for two weights, 16 columns apart in a 32-column tile. projectTiled takes this image for bf16 chunks of four rows or more; f32 activations keep qmm_q4_tile_f32 and shorter bf16 chunks keep qmm_q4_tile. Each output keeps its own accumulator and its order of sums. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…nt a control that skips the rows image The rows image runs through the same five gates as the other tile images. The served shapes 5x257x128 and 33x6144x2048 leave a row tail (and a column tail for 257). The ninth control runs the image without the rows past the last full block; gate 3 catches it. The f32 fixture stays. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…3-inch M1 Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…t test f32 activations take qmm_q4_tile_f32 at any row count, bf16 chunks of four rows or more take qmm_q4_tile_rows, shorter bf16 chunks take qmm_q4_tile. projectTiled reads the choice from the function. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
…for the barrier-hazard change Ten runners and the host test at 240216f on a 13-inch M1, and a three-pair run against ashhart#2017's head on a 13-inch M1 and a 16-inch M1 Max: gains 2.3% to 5.9% and 12.6% to 19.7%, equal logits digests. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
758eb3d to
4bffa08
Compare
…for the barrier-hazard change Ten runners and the host test at 240216f on a 13-inch M1, and a three-pair run against ashhart#2017's head on a 13-inch M1 and a 16-inch M1 Max: gains 2.3% to 5.9% and 12.6% to 19.7%, equal logits digests. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
|
A third chip for the table: an RTX 5060 Ti (external GPU, Vulkan) on a 16-inch M1 Max, Qwen3.5-2B 4-bit, #2016 How it was measured: one test binary per head, run on the same input in the order old, new, new, old, old, new (three runs per head per row). A row's time is the second of the two runs in each process, as the median of its three. Gain is (old - new) / old. Each cell is 10 rows, each row a separate job run; the table shows the median and the range.
Correctness: at 256 and 1024 tokens every run of both heads generates the reference ids (64 steps). At 4096 tokens there is no reference for this input, so those rows say nothing about correctness against a reference; the two heads generate identical ids on every row, and the repeat checks inside each run pass. The 256-token cell is noisy: one row has this head 21% slower, and the median is only 5%. The 1024 and 4096 cells have no row where this head is slower. Nothing here speaks to the open Intel question at 256 tokens. |
…for the barrier-hazard change Ten runners and the host test at 240216f on a 13-inch M1, and a three-pair run against ashhart#2017's head on a 13-inch M1 and a 16-inch M1 Max: gains 2.3% to 5.9% and 12.6% to 19.7%, equal logits digests. Signed-off-by: Joshua Warren <816217+joshuaswarren@users.noreply.github.com>
Stacked on #2016 (batched submission, rebased head
a49c36ebc1ec), which is stacked on #2015 (tile), #1990 (vec4) and #1544. This pull request owns five commits:60bfa7b3(kernel and selection),1b013f7c(kernel contract registration, rows 5 and 33, a planted rows-image control),0e0e5910(figures and changelog),752b3af3(header comment within 120 columns) andcfc4e910(tileImagenames the image choice, with a host test). The commits below those are #2016's, #2015's, #1990's and #1544's.On a 13-inch M1 with Honeykrisp, a Qwen3.5-2B 4-bit run took 3.6% to 33.1% less time than the #2016 build on three inputs, most on the longest prompt. The logits digests of the two builds are equal on every input. Each cell is the median of three pairs (old, new, new, old, old, new); the table is the run of 12 November on the float32 stack.
Read the 1,024-token cell with care: its three old runs spread 17.2 to 18.8 s (one run was 8% faster than the other two), so its range is 6.7 points wide. A cell whose old or new runs differ by more than 5% was a falsifier I registered before the run, and this cell hit it. Run 2 is the prefill plus 63 decode steps plus the per-step logits readback. It is not decode-only. The timing ran at the earlier head
7efc5833; the tree atcf73ab96differs from it only by the header comment wrap and thetileImagerefactor. The teacher-forced logits gate (unicode-24, prose-31, document-256) gives equal digests for7efc5833andcf73ab96on both the 13-inch M1 and a 16-inch M1 Max, and the kernel contract runner passes on both (5 kernels pass gates 1 to 4, 9 controls caught, none missed).Earlier runs, for the record: a rerun on 10 November on older builds measured 4% to 15%; the first run on older builds, an older kernel and an older Mesa measured 6% to 38%:
First run, Linux 7.1.12 and Mesa 26.3.0-devel (git-9323171c12):
e5447d9a3ab5147a2d2e02327f9d8e65e90eThe first run's digests equal the ones the scalar, vec4, tile and batched builds gave on the same inputs. Raw timing tables for the rerun are in
docs/receipts/vulkan-qmm-tile-rows-m1/rerun-linux-7.3.0/.What changes
qmm_q4_tilegives each (row, column) output its own thread, so a prompt chunk of 32 rows dequantizes each weight 32 times and unpacks each activation once for every weight.qmm_q4_tile_rowsmakes one thread compute four rows of two columns, 16 apart in a 32-column tile: it dequantizes a weight once and adds it into four accumulators, and it unpacks each activation once for two weights. Each output keeps its own accumulator and the same order of sums asqmm_q4_tile. Rows past m read the last row and are not written. The dispatch has ceil(m / 4) times 16 threads a tile; a column past n is computed from the zero padding of its tile and not written.ops_q4.projectTiledtakes the new image when a chunk has at least four rows (tile_rows). Decode and chunks of one to three rows keepqmm_q4_tile. The stage 1 fixture gains four row counts that leave a remainder (4, 5, 7 and 33 rows).How the kernel got its shape. The profile of the 1,024-token run on the first version (one column, four rows) put
qmm_q4_tile_rowsat 41% of dispatch time and the next kernel at 9%. Each row below is a two-arm pair on the same inputs, with equal digests:Eight rows a thread is slower than four in both forms, so the limit is register pressure, not array handling. Two columns share each unpacked activation between two weights and keep fewer values live than eight rows. The 256-token rows for the eight-row arms are in
README.md.Setup
1eae3a42). New build:53569d4b. Both ELFs are ReleaseSafe aarch64 builds oftf-qwen35-model, built from trees that contain the mergedmain.Identity
Kernel contract
tf-vulkan-kernel-contracton the GPU at this head, exit 0 (13-inch M1):qmm_q4_tile_rowspasses all five gates (limits, nonfinite, repeat, shapes, sums), with 17 shapes in bound and none refused. The fixture counts threads the way the loader does.The contract's served shapes now include M 5 and M 33, which leave a tail after the rows image's four-row blocks:
5x257x128(257 columns also leaves a column tail) and33x6144x2048. The ninth control is planted in the rows image: it runs without the rows past the last full block, and gate 3 must catch it. It does, at5x257x128output 1028, the first element of row 4 (the tail row).The same ELFs on an Intel Arc Pro B60 (Intel's open-source Mesa driver) print the same SUMMARY line apart from the device name and timing: 4 kernels pass, 9 controls caught. The receipts file
kernel-contract-m1-13-inch.txtholds the run before this last commit.Pre-registration
PREREG.mdwas written before each GPU run it covers, in the order the experiments ran. The rule was: a median gain of at least 3% and every pair positive. The forecasts and results:Not covered
qmm_q4_tilefor that shape on Intel, and I will say so here.Checks
python3 tools/zig/lean_check.pyon this head: 424 problems, the same as main's, none new.raw-files-sha256.txtlists the hash of each one.Coordination
Part of #1420.
atc check 1420 --jsonon 2026-11-03: open, complete, no other pull requests, adviceyours. My public session on #1420 is 191551954422646. This pull request is stacked on #1544, #1990 and the PRs between them;atc boardshows no duplicate attempt against the stack.B60 document-256 paired measurement
On the Intel Arc Pro B60, the 60-pair document-256 comparison measured a 4.35% throughput gain for #2017 over #2016 (95% CI [2.70%, 6.00%]). All 60 pairs had matching output hashes. The private lane receipt
jw16-5060-n60-doc256-20261113-guard-receipt.txtrecords 20 batches, 60 valid pairs, matching hashes, and zero GPU-timeout delta.