Skip to content

Vulkan: a model runs end to end, token-exact — 16 native kernels, variant pipeline, two CI gates - #80

Merged
localai-bot merged 18 commits into
mainfrom
row/BACKEND-VULKAN-A1
Aug 7, 2026
Merged

Vulkan: a model runs end to end, token-exact — 16 native kernels, variant pipeline, two CI gates#80
localai-bot merged 18 commits into
mainfrom
row/BACKEND-VULKAN-A1

Conversation

@localai-bot

@localai-bot localai-bot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

A model runs end to end on Vulkan, token-exact against the vLLM oracle.

opt-125m: the engine selected device type 3 (VULKAN)
BACKEND PROOF — all 9 OPT ops dispatched on device type 3 with 0 declines
                (kPagedAttention selections=1152)
STRICT correctness gate: 6/6 prompts token-exact (96/96 tokens)
                vs the vLLM 0.25.0 oracle

This is the strong form of the claim:

  • STRICT, not distributional — the gate first verifies vLLM's own greedy is deterministic on these prompts (0 multi-valued cells over K=5 runs).
  • The engine picked Vulkan itself through CurrentPlatform(); no test-side override.
  • 0 declines is the load-bearing half. A provider can be selected and then decline inside its kernel and forward down to the CPU tier. 1152 kPagedAttention selections with zero declines is what separates "attention ran on Vulkan" from "a host fallback wearing its name".

Measured on llvmpipe, the software rasterizer — no Vulkan GPU is reachable from this VM (it's a KVM guest; the AMD here is the CPU). This is a correctness proof, not a performance one. No speed number is measured, claimed or owed.

What this PR contains

Implements VK-A1 in full plus the first bricks of VK-B from .agents/specs/vulkan-full-support.md.

Infrastructure

  • glslang 16.5.0 pinned. The recorded 16.4.0 ships no release assets, so it could never have backed a CI gate; committed SPIR-V reproduces byte-for-byte under 16.5.0, proving both provenance and stability across a minor bump.
  • CI job vulkan-spirv-freshness — mutation-proved red-then-green.
  • CI job build-test-vulkan — GPU-free on llvmpipe. VLLM_CPP_VULKAN=ON previously appeared nowhere in ci.yml, which is why the Vulkan suite had rotted RED since accelerator-seam row S5.
  • Specialization constants as the variant mechanism. llama.cpp spells a variant as a #define, so every dtype × quant × coopmat combination is a separate SPIR-V module (242 string_to_spv call sites at pin 237ad9b96). One specialized module serves the whole axis instead — vt_matmul alone covers 54 variants, and its module got smaller because the dead branches fold away.
  • SPIR-V words moved out of the header into a generated .cpp: 3,487 → 55 lines.

Kernels (8 → 16 native): dense GEMM both orientations, block-paged attention, KV-cache write, QKV split, rotary apply, embedding, greedy argmax.

Platform seam: FLASH_ATTN registered for kVULKAN on exactly Metal's footing. MLA still returns EMPTY deliberatelykMlaDecodeAttention/kMlaPrefillAttention/kConcatAndCacheMla have no Vulkan kernel, and naming a backend there would route an MLA model into one that cannot serve it.

Measured findings worth reading

The baseline in the spec was wrong. vt::GetOp does not throw for unimplemented Vulkan ops and hasn't since S5 gave unified-memory devices a portable CPU reference tier. Re-counted at runtime: of 87 CPU-registered ops, 16 are native on Vulkan, 71 are served by the tier, 0 throw. So the campaign's real content is moving ops off the host — a performance project, not a make-it-run one. vt::GetReferenceTierHits() must reach 0.

The workgroup size cannot be a specialization constant at vulkan1.1. local_size_x_id makes glslang emit ExecutionMode LocalSize 1 1 1 plus the legacy BuiltIn WorkgroupSize vector — LocalSizeId needs SPIR-V 1.2 + VK_KHR_maintenance4 (core in Vulkan 1.3). Measured: every workgroup ran one thread against a ceil(n/128) dispatch, NMSE 1e-140.99 on kAdd. An A/B isolated it to launch geometry, not specialization. Recorded beside the #define so nobody retries it.

RoPE is split the way vLLM splits it. RotaryEmbedding builds cos_sin_cache once in __init__ and the forward only applies it (rotary_embedding/base.py:160-252). The table build stays on the portable tier — where the double-precision pow/cos/sin lives — and the per-token apply is native. Faithfulness and numerics agreed: an f32 transcription of the angle would be wrong at long context and would still pass a short-sequence gate.

Paged attention has no Vulkan port source anywhere. Zero block_table/paged matches across all 132 .comp + 26 .glsl at the pin. The online-softmax skeleton follows flash_attn.comp; the block-table indirection and windowing come from our CPU kernel. It cannot mirror the CPU's three passes — that needs a probs array of one float per key in the window, unbounded at long context — so it sits in the NMSE tier and claims no bit-exactness. Deliberate tier choice, not a widened tolerance.

Per-call refusals, not regressions. fp8 KV cache and mrope forward through GetOpFallback rather than throwing — throwing would remove a capability the reference tier already provides.

Gates

Clean -Werror build with Vulkan ON: 0 warnings.

gate result
test_opt_paged_engine (e2e, Vulkan) 1/1 — 6/6 prompts, 96/96 tokens token-exact
test_vulkan_backend 10/10, 480 assertions
test_backend_cross_device 11/11, 123 assertions
test_opt_load 1/1, 958 assertions
generator suite 13/13
gen-vulkan-spirv.py --check green on both generated files

CPU-only build (AUTO=OFF) rebuilt clean and its full ctest re-run, since v1/attention/backend.cpp is shared.

Pre-existing red, not from this branch: check-fusion-consistency fails on minimax_h3_video_vae_device GEMM merge drift, verified at 075b9f21^.

Still not native

Quant, MoE, MLA, linear attention (GDN/KDA), the rotary table build, and every sampler beyond greedy argmax — 71 ops on the portable tier. Those are VK-C through VK-H.

🤖 Generated with Claude Code

mudler added 17 commits August 6, 2026 20:18
Task-by-task plan for sub-project VK-A1 of .agents/specs/vulkan-full-support.md.
Plan only: no source, no shader, no build.

The decision the plan implements: keep the committed-SPIR-V route so the build
stays hermetic on every box, but change the VARIANT mechanism from GLSL #defines
to SPIR-V specialization constants. llama.cpp needs 242 string_to_spv call sites
largely because a #define variant is a whole new module; a specialization constant
is one module specialized at pipeline creation, so artifact count tracks shader
FILES rather than the dtype x quant x coopmat-tier cross product.

Seven tasks: pin glslang and prove the committed SPIR-V reproduces; a CI staleness
gate with a mutation proof; SpecId metadata in the generated table; specialization
plumbed through Dispatch with the cache keyed by it; the workgroup size collapsed
from three copies to one constant; SPIR-V words moved out of the header; the
feature-matrix/backend-matrix drift repaired.

Two preconditions are recorded because they block execution rather than the plan:
disk is at 99% (4.8 GB free), which makes any build unreliable, and neither box
has a GLSL compiler, so no .comp can change until Task 1. One deliberate scope
narrowing against the spec's wording is called out in the self-review: the
vt::arch_tactics generalization is left to VK-C, since a tactic registry with
exactly one tactic would be speculative.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…oduces

VK-A1 Task 1. Neither box had a GLSL compiler, so no .comp file could be changed
without invalidating the committed SPIR-V, and no CI gate could check it.

Two measured corrections to the recorded pin. The header claims
"Glslang Version: 11:16.4.0", but 16.4.0 ships NO release assets -- only 16.5.0
and main-tot do, and Ubuntu packages 15.1.0 -- so the recorded version is not
fetchable and could never have backed a CI job. And the committed SPIR-V
reproduces BYTE-FOR-BYTE under 16.5.0: `gen-vulkan-spirv.py --check` passes,
exit 0.

That second result is stronger than the check was written to obtain. --check
compares SPIR-V bytes and ignores only the version comment, so a byte-identical
result under a DIFFERENT, newer compiler proves both that the committed artifact
is genuinely what it claims to be, and that the emitted SPIR-V did not move
across a glslang minor bump. The freshness gate can therefore pin the download
URL instead of asserting a version string, which would have been brittle for no
gain.

Nothing is linked against glslang; it is a build-time tool. The plan is updated
in the same change to record the corrected pin and why.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
The committed-artifact route trades a build-time shader toolchain for the
obligation to regenerate by hand, and nothing enforced that obligation: --check
existed but no job ran it, so a .comp edit without a regenerate shipped silently.
That is the one way the hermetic-build trade can go wrong, and it goes wrong
quietly.

The glslang DOWNLOAD URL is the pin. An exact version-string assertion was
deliberately not used: the committed SPIR-V is byte-identical under both the
16.4.0 that produced it and the 16.5.0 pinned here, so gating on the string would
be brittle without buying anything, and a future glslang codegen change will
surface as STALE either way.

Mutation-proved rather than assumed: rewriting relu's select as an equivalent
expression (v > 0.0 ? v : 0.0  ->  v < 0.0 ? 0.0 : v) turns the gate RED (exit 1),
and reverting turns it green. A comment-only edit would not have proved it, since
-g0 can emit identical SPIR-V.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
The Vulkan unit gate was RED on main and nothing could see it.

test_vulkan_backend asserted that unimplemented ops throw. Since accelerator-seam
row S5 (af0b21b) they do not: a unified-memory device that misses GetOp lazily
installs the CPU kernel as a priority -1000 provider, and Vulkan is eligible (GB10
integrated and llvmpipe both report unified). The Metal sibling was updated for
this as Metal work continued (test_metal_backend.cpp:215-231); Vulkan was not,
because VLLM_CPP_VULKAN=ON appeared NOWHERE in ci.yml -- the backend was built on
no machine, so its whole suite ran nowhere and the assertion rotted from the
moment S5 landed.

MEASURED on this tree before repairing it: of 87 CPU-registered ops, 8 are NATIVE
on Vulkan, 79 are served by the portable reference tier, and ZERO throw. So the
record's "vt::GetOp throws" and "8 of 83" are both wrong, and every op a model
needs already resolves on Vulkan -- on the host. (Op resolution is not an
end-to-end claim: get_attn_backend_priority() is still deliberately EMPTY, a
separate gate at the platform seam.)

The assertion is rewritten in the Metal sibling's exact form, so the two backends
fail the same way: the op resolves, and the provider that served it is checked BY
NAME against kReferenceProviderName, so a host kernel can never masquerade as a
native Vulkan one.

The new build-test-vulkan leg runs GPU-free on llvmpipe and executes both the
backend gate and the cross-device numerics gate against the CPU oracle. Verified
locally: 8/8 (87 assertions) and 6/6 (73 assertions).

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…rkgroup size

Two halves: the metadata the variant mechanism needs, and a MEASURED negative
result about the constant everyone would reach for first.

The generator now parses OpDecorate SpecId out of each emitted module and records
the sorted IDs beside the blob. The host will pass specialization values BY ID,
and Vulkan SILENTLY IGNORES a map entry whose ID the module does not declare, so
without this table a host/shader drift is wrong numbers rather than a clean
failure. Parsing the module is the only source of truth: glslang exposes no
side-channel listing, and a hand-maintained list beside the shaders is exactly the
duplicate that drifts. Covered by unit tests including the two ways a naive
scanner breaks (a non-SpecId decoration, and stepping over unrelated opcodes by
word count).

The workgroup size is NOT that constant, and the attempt is recorded rather than
silently abandoned. VT_TG is written down three times (vt_common.glsl, each
.comp's local_size_x, kWorkgroupSize on the host, which derives the workgroup
COUNT from it) -- precisely the shape a specialization constant should collapse.
It cannot at this target: layout(local_size_x_id = 0) makes glslang emit
`ExecutionMode LocalSize 1 1 1` plus the legacy BuiltIn WorkgroupSize vector,
because the modern LocalSizeId mode needs SPIR-V 1.2 + VK_KHR_maintenance4 (core
in Vulkan 1.3) and this backend targets vulkan1.1 deliberately. MEASURED on
llvmpipe: the literal LocalSize 1 wins, each workgroup runs ONE thread against a
ceil(n/128) dispatch, and cross-device NMSE went from ~1e-14 to ~0.99 on kAdd. An
A/B isolated it -- keeping the constant and reverting only local_size_x_id is
fully green -- so specialization constants work; the launch geometry is what
cannot use them here.

Half-converting is worse than not converting: a host-settable VT_TG whose value
the actual workgroup size does not follow would silently corrupt. So VT_TG stays a
#define with the measurement written next to it, and the mechanism is left for
axes that need not agree with launch geometry (dtype, quant format, coopmat tier).
The gates now assert ZERO declared constants as a fact, so the first shader that
takes one flips them loudly.

SPIR-V words are byte-identical to before (109,436 bytes); only the table gained
columns. Vulkan gate 9/9 (101 assertions), cross-device 6/6 (73).

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
… first axis

The variant mechanism, now exercised by a real shader instead of a synthetic one.

Dispatch takes specialization values, the pipeline cache is keyed by name PLUS
those values, and the count is checked against the module's declared SpecIds --
Vulkan silently ignores a map entry whose constantID the module does not declare,
so an unchecked drift is wrong numbers rather than an error, the same class as the
existing binding-layout check. The VkSpecializationInfo and its map entries are
named locals that outlive vkCreateComputePipelines, not temporaries whose address
escapes; that use-after-free class has bitten this project twice already under
CUDA-graph capture.

vt_cast is the first axis. Its source and destination dtype were push constants
re-tested per element inside VT_LOAD/VT_STORE; they are now specialization
constants 0 and 1, so the driver folds them at pipeline creation and eliminates
the branch that cannot be taken. ONE committed module still serves every (src,
dst) pair -- which is the whole argument against llama.cpp's spelling, where the
same axis is a GLSL #define and therefore a separate SPIR-V module per
combination. The module got SMALLER, 109,436 -> 109,216 bytes, because the dead
dtype branches are gone.

Gated on the property that matters: the cache must GROW BY TWO across two dtype
pairs. Results alone prove nothing here, since the shader defaults are f32->f32
and a specialization that silently did nothing would also look right. Numerics are
checked in the BIT-EXACT tier, not the NMSE tier -- the f32->bf16->f32 round trip
must equal the CPU codec exactly -- and cross-device is unchanged at 6/6, 73
assertions.

Clean full -Werror build with Vulkan ON: exit 0, 0 warnings. CMakeLists is
untouched, so VLLM_CPP_VULKAN AUTO still resolves OFF and the CUDA gate build is
unaffected by construction. Vulkan gate 10/10, 405 assertions.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
… in the header

At 7 modules the generated header was 3,487 lines and two TUs included it. At the
target shader surface it is megabytes of constexpr array initializer that every
includer re-parses, and every regeneration is a huge header diff. The words now
live in a generated vulkan_spirv.cpp; the header carries the struct plus extern
declarations and is 55 lines. Adding shaders now costs ONE TU's compile time
instead of all of them. Doing this at 7 modules is cheap; at 100 it is not.

Consumers move from sizeof/sizeof to the generated kSpirvModuleCount, since an
extern array has no bound at the use site, and --check now covers both files.

FIXES A LATENT BUG INTRODUCED BY THE SPECIALIZATION WORK. The descriptor pool was
sized maxSets = one per MODULE, which was right when a module meant a pipeline. It
no longer does: vt_cast alone reaches one pipeline per (src, dst) dtype pair, each
allocating its own descriptor set, so the pool would have been exhausted by the
Nth specialization and failed inside vkAllocateDescriptorSets -- a bare VkResult a
long way from its cause. The pool is now sized with explicit specialization
headroom and that allocation names the pipeline and the likely reason.

The split is asserted, not assumed: a test fails if the word arrays reappear in
the header, which would otherwise be invisible until compile times grew.

Clean -Werror build with Vulkan ON: exit 0, 0 warnings. Vulkan gate 10/10 (405
assertions), cross-device 6/6 (73), generator suite 9/9, --check green on both
generated files.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
Repairs the BACKEND-VULKAN drift this campaign was scoped to fix, and corrects
two claims the campaign spec itself made three commits ago.

DRIFT: feature-matrix carried INVENTORIED / "runtime absent" against
backend-matrix's ACTIVE for the same row. backend-matrix was right.

CORRECTION, which matters more. The spec stated -- from the backend matrix -- that
Vulkan's unimplemented ops make vt::GetOp THROW, and put the surface at "8 of 83".
Both are false. Accelerator-seam row S5 (af0b21b) gave unified-memory devices the
portable reference tier, and Vulkan is eligible. Re-counted at RUNTIME on a
Vulkan-ON build: of 87 CPU-registered ops, 8 are NATIVE, 79 are served by the
reference tier (the CPU kernel against shared memory), and ZERO throw. Every op a
model needs already resolves, so the campaign's real content is moving those 79
off the host -- a performance project, not a make-it-run one, and
GetReferenceTierHits() is the progress metric that must reach 0.

HONEST LIMIT, stated wherever the numbers are: op resolution is not an end-to-end
claim. get_attn_backend_priority() is still deliberately EMPTY, which is a
separate gate at the platform seam, and no model has been run on Vulkan.

Also records the measured negative result so nobody retries it: the workgroup size
cannot be a specialization constant at the vulkan1.1 target, because
local_size_x_id emits ExecutionMode LocalSize 1 1 1 and computes ~1/128 of each
tensor (NMSE 0.99 on kAdd).

No speed number measured, claimed or owed.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
First VK-B brick: 8 native kernels -> 10, and the first op the reference tier was
carrying that a model actually spends its time in.

NUMERIC CONTRACT ported from cpu_ops.cpp MatmulChunked (:187-260): one invocation
per OUTPUT ELEMENT with the whole K reduction on it, sequential f32 accumulation,
rounded once on store. The CPU kernel deliberately never splits a K reduction
across threads, so keeping one invocation per element makes the two backends share
an accumulation ORDER rather than merely a tolerance.

DELIBERATELY NOT TILED. This is the portable correctness tier: no shared-memory
blocking, no cooperative matrix. llama.cpp's mul_mm.comp (scalar + coopmat1 by
define) and mul_mm_cm2.comp (NV coopmat2) are the performance port and belong to
VK-C with its tactic selection -- which needs exactly this as a known-correct
same-device A/B reference rather than a rewrite to compare against.

ONE MODULE SERVES 54 VARIANTS: three dtypes each for a, b and out, times the two
orientations, as specialization constants. Folding the orientation at pipeline
creation also removes a per-ELEMENT branch from the inner K loop. llama.cpp spells
the same axis as #defines and emits a module per combination; this is the
mechanism VK-A1 built, now paying for itself at 54:1.

Gated against the CPU oracle at ragged shapes (M=13, K=37, N=9 -- none a multiple
of the workgroup size, so a kernel that only handled whole tiles would fail rather
than pass on a friendly shape), both orientations separately since MatmulBT is a
different indexing path and not a transpose of the same code.

Three gates moved and are updated as executable facts rather than loosened: the
registered/unregistered op lists, the module count and name table, and the
reference-tier probe, which now uses kPagedAttention because kMatmul is no longer
a fallback and would not exercise the tier.

Clean -Werror build 0 warnings. Vulkan gate 10/10 (417 assertions), cross-device
7/7 (80), generator 9/9, --check green.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
The two ends of the model: token ids in, token ids out. 10 native kernels now.

EmbeddingKernel (cpu_ops.cpp:661-672) is one output ELEMENT per invocation, with
the id width (i32 vs i64, which vt::Embedding accepts both of) as a specialization
constant rather than a per-element branch. Only the low 32 bits of an i64 id are
read: an id is a vocabulary index that the host already range-checked, so it is
far below 2^31, and reconstructing the full value would need VK_KHR_shader_int64
at a 1.1 floor for nothing. The CPU kernel's VT_CHECK on the range has no shader
equivalent -- a shader cannot throw and a per-element bounds branch would cost the
gather -- but vt::Embedding validates on the host before dispatch, so the error is
already caught by the time this runs.

GreedyArgmaxKernel (cpu_sample.cpp:40-56) is ONE INVOCATION PER ROW, and that is a
contract decision rather than laziness. The CPU scan uses a strict `>` so the
FIRST occurrence of the maximum wins; greedy decoding is compared token-for-token
against a vLLM golden, so a tie-indifferent tree reduction returns a different
token and fails the gate. A parallel reduction has to carry the index and break
ties toward the lower one at every merge, which is a separate change with its own
gate. Decode rows are few; the vocabulary scan is the slow axis and is left.

Both are gated EXACTLY, not by NMSE -- a gather moves bytes and an argmax picks an
index. The embedding case uses REPEATED ids (13 twice) so a kernel that consumed
ids positionally fails, and covers i32 and i64 separately since they are different
index paths. The argmax case plants a DELIBERATE TIE in row 0 at columns 2 and 5
and requires 2, which is what a `>=` or a tie-indifferent reduction would get
wrong; the oracle is asserted to honour it first, so the test cannot pass by both
sides being wrong together.

Binder gains AddU32Only for operands bound through a single view -- integer ids
and f32-by-contract logits -- because a descriptor a shader does not declare must
not be written.

Clean -Werror build 0 warnings. Vulkan gate 10/10 (434 assertions), cross-device
8/8 (88), generator 9/9.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
Re-measured with the same runtime probe rather than re-grepped, so the number is
comparable to the VK-A1 baseline: native 8 -> 12, reference-tier 79 -> 75, still
zero throwing. The four that moved are kMatmul, kMatmulBT, kEmbedding and
kGreedyArgmax.

Carries the same honest limits everywhere the counts appear: paged attention, the
KV cache ops, the whole RoPE family, quant, MoE, GDN/MLA and every sampler beyond
greedy argmax are still the CPU kernel running on shared memory;
get_attn_backend_priority() is still EMPTY; no model runs end to end on Vulkan;
no speed number is measured, claimed or owed.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
13 native kernels now. This is the ONE kernel in the backend with no Vulkan port
source: grep over all 132 .comp + 26 .glsl at pin 237ad9b96 finds ZERO
block_table/paged matches, because ggml attention takes a contiguous KV buffer
plus a mask. So the online-softmax skeleton follows flash_attn.comp's shape while
the block-table indirection, GQA mapping and windowing are ported from our own
cpu_paged_attn.cpp PagedAttentionKernel (:52-171), which is also the oracle.

ONLINE SOFTMAX RATHER THAN THE CPU'S THREE PASSES, and the reason is structural,
not preference: the CPU kernel materialises a `probs` array of one float per key
in the window (:121,131), which is unbounded -- thousands at long context -- and a
shader has no such allocation. The passes fuse into the standard running
max/denominator recurrence, same mathematical result with a different rounding
order, which is why this op sits in the NMSE tier and claims no bit-exactness.

ONE WORKGROUP per (query token, query head), lanes splitting the head dimension,
each key's q.k dot a cooperative vt_tg_sum. No broadcast of the score is needed:
vt_tg_sum returns smem[0] to every lane and its leading barrier makes
back-to-back calls safe, so all lanes derive the score from the same dot with the
same instructions. An earlier draft added a lane-0 broadcast and two extra
barriers for this; removing them is both simpler and one less barrier hazard.

fp8 KV cache DECLINES rather than throws. Those pages are 1-byte and must be
dequantised as Dequant(fp8) * k_scale|v_scale before the f32 softmax
(cpu_paged_attn.cpp:79-93), which this shader does not do -- and throwing would
REMOVE a capability the portable reference tier already provides. It forwards
through GetOpFallback, which op_provider.h:94-100 documents as exactly the place
for a per-call refusal, since GetOp has no shape or dtype to inspect.

Gated against the CPU oracle in THREE configurations, because they are different
branches and a single causal case would leave two unexercised: causal,
causal+softcap (cap * tanh(s/cap)), and a sliding window. The block table is
deliberately NON-IDENTITY (page j at cache block kBlocks-1-j) so a kernel that
ignored it and indexed the cache linearly fails, and 37 tokens over block size 4
leaves the last page partly occupied so a whole-page walk reads past the
sequence.

Native 12 -> 13, reference-tier 75 -> 74. Clean -Werror build 0 warnings. Vulkan
gate 10/10 (447 assertions), cross-device 9/9 (103), generator 10/10.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…s box

Refreshes the measured counts (native 12 -> 13, reference tier 75 -> 74) after
paged attention landed, re-measured with the same runtime probe.

Also records the answer to "can we test Vulkan on AMD here": NO, and the reason
is worth keeping. The dev box is a KVM virtual machine and the AMD is the CPU
(Ryzen 9 9950X3D), not a GPU -- display is QEMU virtual VGA 1234:1111, /dev/dri
has no renderD* node at all, and Vulkan enumerates only llvmpipe. The useful
corollary is that RADV (radeon_icd.json) is already installed, so an AMD GPU
passed into this VM would enumerate with zero code changes and VK-I's
discrete-GPU staging path -- still dead code, since GB10 and llvmpipe are both
unified -- would become testable. The blocker is hardware passthrough, not
software.

Noted but not acted on: environment.md describes the dev box as having an RTX
5070 Ti, which this VM does not see. Left for the owner rather than guessed at.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
14 native kernels. This completes the attention block's device side: paged
attention reads the cache, this writes it.

Ported from cpu_cache.cpp ReshapeAndCacheKernel (:33-72), which is two memcpys per
token and CONVERTS NOTHING. So the dtype here selects only the storage WIDTH to
copy at -- 32-bit through the uint32 view, 16-bit through the uint16 view, never
mixing -- and the gate is BIT-EXACTNESS rather than NMSE. The host refuses a
source/cache dtype mismatch rather than silently converting.

SLOT -1 IS NOT AN ERROR and the gate proves the kernel knows it. Upstream pads the
slot mapping and marks padded tokens negative (:60); those tokens are SKIPPED,
leaving the page as it was. The mapping is i64, so the shader reads both halves
and tests the HIGH word for the sign -- reading it as unsigned would turn -1 into
0xFFFFFFFF and index astronomically out of range.

The gate seeds the cache with RANDOM contents rather than zeros, so "the padded
token left the page intact" cannot pass vacuously, and uses scattered
out-of-order slots (20, -1, 3, 11, -1, 0, 23, 7, 15) so a kernel that assumed
slot == token index fails.

Native 13 -> 14, reference tier 74 -> 73. Clean -Werror build 0 warnings. Vulkan
gate 10/10 (454 assertions), cross-device 10/10 (109), generator 11/11.

NEXT IS RoPE, AND IT NEEDS A DECISION FIRST. The CPU kernel computes the angle in
DOUBLE (cpu_ops.cpp:701-705: std::pow for the frequency, std::cos/std::sin of
pos * freq). In f32 that loses precision badly at long context, where pos is in
the thousands and the angle is large. Shipping an f32 transcription would be
subtly wrong in exactly the regime that matters, so it is not done here.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…efore more work

Refreshes the measured counts after the KV-cache write landed (native 13 -> 14,
reference tier 74 -> 73), and records the reason the next brick stopped at a
decision rather than a commit.

RoPE's CPU kernel computes the angle in DOUBLE (cpu_ops.cpp:701-705). An f32
transcription loses precision badly at long context, where pos is in the thousands
and the angle is large -- subtly wrong in exactly the regime that matters, and it
would still pass a short-sequence gate, which is the dangerous combination. Two
ways out are on record: require shaderFloat64 and transcribe faithfully, or
implement kRopeFromCache (the apply, pure f32 multiply-add) natively and leave
kRopeCosSinCache (the once-per-model table build, where the double math lives) on
the reference tier. The second matches vLLM's own structure and costs no device
feature; it is the recommendation.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…LLM oracle

opt-125m, greedy, on llvmpipe:

  the engine selected device type 3 (VULKAN)
  BACKEND PROOF -- all 9 OPT ops dispatched on device type 3 with 0 declines
                   (kPagedAttention selections=1152)
  STRICT correctness gate: 6/6 prompts token-exact (96/96 tokens)
                   vs the vLLM 0.25.0 oracle

The strong form of the claim, not "it produced plausible text". The gate first
verifies vLLM's own greedy is deterministic here (0 multi-valued cells over K=5
runs) so it is the STRICT bar rather than the distributional one; the engine picks
Vulkan itself through CurrentPlatform() with no test-side override; and ZERO
declines is the load-bearing half, because a provider can be selected and then
decline INSIDE its kernel and forward to the CPU tier -- 1152 kPagedAttention
selections with no declines means attention really ran on Vulkan rather than the
host fallback wearing its name.

Three pieces got it there.

RoPE, SPLIT THE WAY vLLM SPLITS IT. RotaryEmbedding builds cos_sin_cache once in
__init__ and the forward only applies it, which is why ops.rotary_embedding()
takes the cache rather than a base and a scaling factor
(rotary_embedding/base.py:160-252, common.py:145-185 @ e24d1b24fe96). So the table
build stays on the portable tier -- where the double-precision pow/cos/sin lives
-- and the per-token apply is native. Faithfulness and numerics agreed here: an
f32 transcription of the angle construction would be wrong at long context and
would still pass a short-sequence gate. mrope DECLINES through the provider seam
rather than throwing, like fp8 KV. Gated on both NeoX and GPT-J pairings, with
positions deliberately NOT 0..n-1 so a kernel using the token index instead of the
position fails.

kQkvSplit mirrors QKVParallelLinear's qkv.split([q_size, kv_size, kv_size]) --
three INDEPENDENT widths, because under GQA k and v are narrower than q.

FLASH_ATTN registered for kVULKAN on exactly Metal's footing. The real
precondition is not the device name but that our kPagedAttention and
kReshapeAndCache read and write the same NHD layout get_kv_cache_shape allocates,
which they do since both are ports of the CPU pair. MLA still returns EMPTY
DELIBERATELY: kMlaDecodeAttention / kMlaPrefillAttention / kConcatAndCacheMla have
no Vulkan kernel, and naming a backend there would route an MLA model into one
that cannot serve it.

Native 14 -> 16 (measured with the runtime probe, not counted by hand). Clean -Werror Vulkan-ON build 0 warnings; Vulkan gate 10/10 (480
assertions), cross-device 11/11 (123), test_opt_load 1/1 (958), generator 13/13.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
…kernels

opt-125m greedy: engine selects VULKAN on its own, all 9 OPT ops dispatch
natively with 0 provider declines (kPagedAttention selections=1152), STRICT
token-exact 6/6 prompts / 96/96 tokens vs the vLLM 0.25.0 oracle.

Recorded with its limits attached everywhere the claim appears. It is a
CORRECTNESS proof measured on llvmpipe, the software rasterizer, because no
Vulkan GPU is reachable from this VM -- no speed number is measured, claimed or
owed. 16 of 87 ops are native; the other 71 still run on the portable CPU tier.
Quant, MoE, MLA and linear attention have no Vulkan kernels at all, and MLA is
refused at the platform seam rather than mis-routed.

The 0-declines figure is the part worth keeping: a provider can be selected and
then decline INSIDE its kernel and forward to the CPU tier, so selections without
declines is what separates "attention ran on Vulkan" from "a host fallback wearing
its name".

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
@localai-bot localai-bot changed the title VK-A1: Vulkan shader-variant pipeline, two CI gates, and a corrected baseline Vulkan: a model runs end to end, token-exact — 16 native kernels, variant pipeline, two CI gates Aug 6, 2026
mudler added a commit that referenced this pull request Aug 7, 2026
…the MP4

Row `SERVE-VIDEOS-OAI`, branch `row/SERVE-VIDEOS-OAI`. An unmodified OpenAI
(Sora) client now works against our `/v1/videos`, ADDITIVELY over the
vLLM-Omni-derived fields we already take.

SPLIT, deliberately. The original change was 1241 non-exempt lines, over the
900 check-pr-size.py cap. It is split along its real seam rather than trimmed to
fit: this row is the request/response WIRE SHAPE (no generation code, no VAE, no
examples/server change), and REFERENCE CONDITIONING (`input_reference` -> fl2va
plus the two `metadata` ref2va modalities) is a stacked follow-up row. No cap and
no ratchet was raised.

Request aliases. `model`, `size` ("1280x720") and `seconds` land on the existing
native members. `seconds` is taken as a number OR a numeric string, because
OpenAI's schema types it as a string enum ("4"/"8"/"12") and a literal client
would otherwise be rejected on a type. PRECEDENCE is defined and gated: the
NATIVE field WINS (`width`/`height` over `size`, `duration` over `seconds`),
which is what guarantees every body that parsed before means exactly what it
meant before, applied PER-AXIS so an explicit `width` alone still lets `size`
supply the height. Both spellings are VALIDATED either way, so a malformed
`size` is a 400 even when explicit `width`/`height` override it.

`model` warns, never rejects: a Sora client cannot know the local model's name,
so refusing would defeat the compatibility and ignoring would hide a real
mismatch. The requested name and the divergence ride the job for its whole life.

GET /v1/videos/{id}/content returns the finished MP4. Without it a caller can
start and poll a job but never FETCH the result over HTTP. Unknown id -> 404;
queued/running -> 409 naming the status (a pending job must never answer with
bytes: a partially muxed file reaches the client as a valid-looking truncated
MP4); failed -> 500 with the failure; a vanished output -> 500, not a 200 with
zero bytes.

GATE (CPU, foreground): test_video_api 11/11 (125 assertions),
test_openai_api_server 40/40 (509), `server` builds clean. Additivity is gated
over a REAL socket: with no VideoRunner all four routes are absent (a bare 404,
no ErrorResponse envelope), with one they serve and the unknown-id 404 is ours.

TWO OUT-OF-ROW CI REPAIRS, carried here because they block EVERY merge, not just
this one. Both are `agent-record` job failures on main, both reproduced on an
unrelated PR, and neither is repaired by weakening a checker.

(1) check-fusion-consistency has failed since run 31129401136 because it flags
minimax_h3_video_vae_device.cpp. Repaired the way AGENTS.md names, with a
conscious allowlist entry carrying the verified reason: w1 already ships merged
and is already one MatmulBT, so nothing is unmerged; the seam is unusable because
the VAE block is f32 end to end where the method is kBF16, and every VAE Linear
carries a rank-1 bias the bias-free method has no slot for.

(2) check-role-discipline failed on every feature PR. CI checks out
refs/pull/N/merge, a SYNTHETIC merge GitHub builds whose entire message is
"Merge <head> into <base>": it names neither the row branch nor the PR, and it
NEVER lands on main, so a gate about MAIN's history was run on a commit that is
not main's history. Reproduced on PR #80 (Vulkan) to prove it is not this row's
doing. The fix reads the SECOND parent, which is the PR head: a merge of a branch
whose own commits name the row IS arrival through a row PR, one hop away. Gated
as a non-weakening: a new test asserts a merge naming no row ANYWHERE, and a
plain local `Merge branch 'wip'`, both STILL fail. Suite 40/40.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Opus 5 (1M context)
main moved 17 commits under this branch (#82, #83, #84 plus the public-doc gate
restoration), so every earlier green result was re-run on the merged tree rather
than carried over.

Three record conflicts, all resolved BY KEY rather than by taking a side:

* docs/STATUS.md — main had COMPACTED this paragraph and lowered the size ratchet
  to 284062. Taking our side wholesale would have silently reverted their
  compaction and blown the gate, so the resolution keeps THEIR paragraph and
  swaps in only our Vulkan clause, then trims it to fit.
* docs/BENCHMARKS.md — a keyed table. Kept THEIR rows (they updated MiniMax-H3
  and MXFP4 and added H3-RENDER-CLOSE) and appended only our Vulkan row.
* .agents/state.md — append-only log, unioned theirs then ours.

Re-verified on the merged tree: clean -Werror Vulkan-ON build 0 warnings, e2e
opt-125m on Vulkan STRICT token-exact 6/6 prompts / 96/96 tokens with 0 provider
declines, test_vulkan_backend 10/10 (480), test_backend_cross_device 11/11 (123),
clean CPU-only build 0 warnings, CPU ctest 336/337 with the one failure
(test_engine_core_proc) passing serially in 0.01s — the known starve-under-`-j`
pattern on a box running several suites at once, not a regression.
@mudler
mudler force-pushed the row/BACKEND-VULKAN-A1 branch from 1833103 to 461ce6b Compare August 7, 2026 00:38
@localai-bot
localai-bot marked this pull request as ready for review August 7, 2026 00:40
@localai-bot
localai-bot merged commit 5397e91 into main Aug 7, 2026
10 of 13 checks passed
mudler added a commit that referenced this pull request Aug 7, 2026
…the MP4

Row `SERVE-VIDEOS-OAI`, branch `row/SERVE-VIDEOS-OAI`. An unmodified OpenAI
(Sora) client now works against our `/v1/videos`, ADDITIVELY over the
vLLM-Omni-derived fields we already take.

SPLIT, deliberately. The original change was 1241 non-exempt lines, over the
900 check-pr-size.py cap. It is split along its real seam rather than trimmed to
fit: this row is the request/response WIRE SHAPE (no generation code, no VAE, no
examples/server change), and REFERENCE CONDITIONING (`input_reference` -> fl2va
plus the two `metadata` ref2va modalities) is a stacked follow-up row. No cap and
no ratchet was raised.

Request aliases. `model`, `size` ("1280x720") and `seconds` land on the existing
native members. `seconds` is taken as a number OR a numeric string, because
OpenAI's schema types it as a string enum ("4"/"8"/"12") and a literal client
would otherwise be rejected on a type. PRECEDENCE is defined and gated: the
NATIVE field WINS (`width`/`height` over `size`, `duration` over `seconds`),
which is what guarantees every body that parsed before means exactly what it
meant before, applied PER-AXIS so an explicit `width` alone still lets `size`
supply the height. Both spellings are VALIDATED either way, so a malformed
`size` is a 400 even when explicit `width`/`height` override it.

`model` warns, never rejects: a Sora client cannot know the local model's name,
so refusing would defeat the compatibility and ignoring would hide a real
mismatch. The requested name and the divergence ride the job for its whole life.

GET /v1/videos/{id}/content returns the finished MP4. Without it a caller can
start and poll a job but never FETCH the result over HTTP. Unknown id -> 404;
queued/running -> 409 naming the status (a pending job must never answer with
bytes: a partially muxed file reaches the client as a valid-looking truncated
MP4); failed -> 500 with the failure; a vanished output -> 500, not a 200 with
zero bytes.

GATE (CPU, foreground): test_video_api 11/11 (125 assertions),
test_openai_api_server 40/40 (509), `server` builds clean. Additivity is gated
over a REAL socket: with no VideoRunner all four routes are absent (a bare 404,
no ErrorResponse envelope), with one they serve and the unknown-id 404 is ours.

TWO OUT-OF-ROW CI REPAIRS, carried here because they block EVERY merge, not just
this one. Both are `agent-record` job failures on main, both reproduced on an
unrelated PR, and neither is repaired by weakening a checker.

(1) check-fusion-consistency has failed since run 31129401136 because it flags
minimax_h3_video_vae_device.cpp. Repaired the way AGENTS.md names, with a
conscious allowlist entry carrying the verified reason: w1 already ships merged
and is already one MatmulBT, so nothing is unmerged; the seam is unusable because
the VAE block is f32 end to end where the method is kBF16, and every VAE Linear
carries a rank-1 bias the bias-free method has no slot for.

(2) check-role-discipline failed on every feature PR. CI checks out
refs/pull/N/merge, a SYNTHETIC merge GitHub builds whose entire message is
"Merge <head> into <base>": it names neither the row branch nor the PR, and it
NEVER lands on main, so a gate about MAIN's history was run on a commit that is
not main's history. Reproduced on PR #80 (Vulkan) to prove it is not this row's
doing. The fix reads the SECOND parent, which is the PR head: a merge of a branch
whose own commits name the row IS arrival through a row PR, one hop away. Gated
as a non-weakening: a new test asserts a merge naming no row ANYWHERE, and a
plain local `Merge branch 'wip'`, both STILL fail. Suite 40/40.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Opus 5 (1M context)
mudler added a commit that referenced this pull request Aug 7, 2026
…the MP4

Row `SERVE-VIDEOS-OAI`, branch `row/SERVE-VIDEOS-OAI`. An unmodified OpenAI
(Sora) client now works against our `/v1/videos`, ADDITIVELY over the
vLLM-Omni-derived fields we already take.

SPLIT, deliberately. The original change was 1241 non-exempt lines, over the
900 check-pr-size.py cap. It is split along its real seam rather than trimmed to
fit: this row is the request/response WIRE SHAPE (no generation code, no VAE, no
examples/server change), and REFERENCE CONDITIONING (`input_reference` -> fl2va
plus the two `metadata` ref2va modalities) is a stacked follow-up row. No cap and
no ratchet was raised.

Request aliases. `model`, `size` ("1280x720") and `seconds` land on the existing
native members. `seconds` is taken as a number OR a numeric string, because
OpenAI's schema types it as a string enum ("4"/"8"/"12") and a literal client
would otherwise be rejected on a type. PRECEDENCE is defined and gated: the
NATIVE field WINS (`width`/`height` over `size`, `duration` over `seconds`),
which is what guarantees every body that parsed before means exactly what it
meant before, applied PER-AXIS so an explicit `width` alone still lets `size`
supply the height. Both spellings are VALIDATED either way, so a malformed
`size` is a 400 even when explicit `width`/`height` override it.

`model` warns, never rejects: a Sora client cannot know the local model's name,
so refusing would defeat the compatibility and ignoring would hide a real
mismatch. The requested name and the divergence ride the job for its whole life.

GET /v1/videos/{id}/content returns the finished MP4. Without it a caller can
start and poll a job but never FETCH the result over HTTP. Unknown id -> 404;
queued/running -> 409 naming the status (a pending job must never answer with
bytes: a partially muxed file reaches the client as a valid-looking truncated
MP4); failed -> 500 with the failure; a vanished output -> 500, not a 200 with
zero bytes.

GATE (CPU, foreground): test_video_api 11/11 (125 assertions),
test_openai_api_server 40/40 (509), `server` builds clean. Additivity is gated
over a REAL socket: with no VideoRunner all four routes are absent (a bare 404,
no ErrorResponse envelope), with one they serve and the unknown-id 404 is ours.

TWO OUT-OF-ROW CI REPAIRS, carried here because they block EVERY merge, not just
this one. Both are `agent-record` job failures on main, both reproduced on an
unrelated PR, and neither is repaired by weakening a checker.

(1) check-fusion-consistency has failed since run 31129401136 because it flags
minimax_h3_video_vae_device.cpp. Repaired the way AGENTS.md names, with a
conscious allowlist entry carrying the verified reason: w1 already ships merged
and is already one MatmulBT, so nothing is unmerged; the seam is unusable because
the VAE block is f32 end to end where the method is kBF16, and every VAE Linear
carries a rank-1 bias the bias-free method has no slot for.

(2) check-role-discipline failed on every feature PR. CI checks out
refs/pull/N/merge, a SYNTHETIC merge GitHub builds whose entire message is
"Merge <head> into <base>": it names neither the row branch nor the PR, and it
NEVER lands on main, so a gate about MAIN's history was run on a commit that is
not main's history. Reproduced on PR #80 (Vulkan) to prove it is not this row's
doing. The fix reads the SECOND parent, which is the PR head: a merge of a branch
whose own commits name the row IS arrival through a row PR, one hop away. Gated
as a non-weakening: a new test asserts a merge naming no row ANYWHERE, and a
plain local `Merge branch 'wip'`, both STILL fail. Suite 40/40.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Opus 5 (1M context)
localai-bot added a commit that referenced this pull request Aug 7, 2026
…the MP4 (#71)

Row `SERVE-VIDEOS-OAI`, branch `row/SERVE-VIDEOS-OAI`. An unmodified OpenAI
(Sora) client now works against our `/v1/videos`, ADDITIVELY over the
vLLM-Omni-derived fields we already take.

SPLIT, deliberately. The original change was 1241 non-exempt lines, over the
900 check-pr-size.py cap. It is split along its real seam rather than trimmed to
fit: this row is the request/response WIRE SHAPE (no generation code, no VAE, no
examples/server change), and REFERENCE CONDITIONING (`input_reference` -> fl2va
plus the two `metadata` ref2va modalities) is a stacked follow-up row. No cap and
no ratchet was raised.

Request aliases. `model`, `size` ("1280x720") and `seconds` land on the existing
native members. `seconds` is taken as a number OR a numeric string, because
OpenAI's schema types it as a string enum ("4"/"8"/"12") and a literal client
would otherwise be rejected on a type. PRECEDENCE is defined and gated: the
NATIVE field WINS (`width`/`height` over `size`, `duration` over `seconds`),
which is what guarantees every body that parsed before means exactly what it
meant before, applied PER-AXIS so an explicit `width` alone still lets `size`
supply the height. Both spellings are VALIDATED either way, so a malformed
`size` is a 400 even when explicit `width`/`height` override it.

`model` warns, never rejects: a Sora client cannot know the local model's name,
so refusing would defeat the compatibility and ignoring would hide a real
mismatch. The requested name and the divergence ride the job for its whole life.

GET /v1/videos/{id}/content returns the finished MP4. Without it a caller can
start and poll a job but never FETCH the result over HTTP. Unknown id -> 404;
queued/running -> 409 naming the status (a pending job must never answer with
bytes: a partially muxed file reaches the client as a valid-looking truncated
MP4); failed -> 500 with the failure; a vanished output -> 500, not a 200 with
zero bytes.

GATE (CPU, foreground): test_video_api 11/11 (125 assertions),
test_openai_api_server 40/40 (509), `server` builds clean. Additivity is gated
over a REAL socket: with no VideoRunner all four routes are absent (a bare 404,
no ErrorResponse envelope), with one they serve and the unknown-id 404 is ours.

TWO OUT-OF-ROW CI REPAIRS, carried here because they block EVERY merge, not just
this one. Both are `agent-record` job failures on main, both reproduced on an
unrelated PR, and neither is repaired by weakening a checker.

(1) check-fusion-consistency has failed since run 31129401136 because it flags
minimax_h3_video_vae_device.cpp. Repaired the way AGENTS.md names, with a
conscious allowlist entry carrying the verified reason: w1 already ships merged
and is already one MatmulBT, so nothing is unmerged; the seam is unusable because
the VAE block is f32 end to end where the method is kBF16, and every VAE Linear
carries a rank-1 bias the bias-free method has no slot for.

(2) check-role-discipline failed on every feature PR. CI checks out
refs/pull/N/merge, a SYNTHETIC merge GitHub builds whose entire message is
"Merge <head> into <base>": it names neither the row branch nor the PR, and it
NEVER lands on main, so a gate about MAIN's history was run on a commit that is
not main's history. Reproduced on PR #80 (Vulkan) to prove it is not this row's
doing. The fix reads the SECOND parent, which is the PR head: a merge of a branch
whose own commits name the row IS arrival through a row PR, one hop away. Gated
as a non-weakening: a new test asserts a merge naming no row ANYWHERE, and a
plain local `Merge branch 'wip'`, both STILL fail. Suite 40/40.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Opus 5 (1M context)

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
jefby pushed a commit to jefby/vllm.cpp that referenced this pull request Aug 7, 2026
…was vacuous

Self-reported violation and its prevention.

PR mudler#80 (Vulkan, merged 5397e91) landed EIGHT commits that touched src/ and
tests/ without updating docs/STATUS.md and docs/BENCHMARKS.md in the same commit,
reddening documentation-checkpoint for that push range: 9579f94, ba5ea0c,
196ea46, e32c5ed, 3bfa1f1, 34a3efe, 2c86f79, f4738bb. Each deferred its doc
update into a following record(...) commit.

WHY IT PASSED LOCALLY, which is the part worth fixing. check-doc-checkpoint.py
--staged inspects the STAGED paths, so it is VACUOUS when nothing is staged --
and nothing is staged after `git commit`, which is exactly when agent-preflight.sh
runs it. The gate reported OK on every one of those commits while checking
literally nothing. CI is diff-scoped over the pushed range and checks each commit
independently, so the failure only surfaced on main, where a diff-scoped range can
never be re-covered by a later run.

Preflight now runs --base origin/main --head HEAD whenever the branch is ahead.
That check is deliberately OUTSIDE the --staged block: my first attempt nested it
inside, which reproduces the identical hole one level up, since --staged is
precisely the flag you are not passing when the range is unchecked. Verified two
ways -- it prints "ok doc-checkpoint range" here, and pointed at the mudler#80 range it
raises 20 errors naming every offending commit. workflow.md carries the same
instruction with the reason.

NOT REPAIRED BY REWRITING HISTORY: main had already moved (mudler#86 landed on top) and
other sessions branch from it, so force-pushing to regroup eight commits' files
would cost more than the defect. Substance was never wrong -- STATUS, BENCHMARKS
and FEATURES on main all describe the shipped state with the llvmpipe-only and
no-speed-number caveats intact. What was violated is the per-COMMIT granularity
that keeps a bisect landing on a commit whose docs match its code.

Rule restated where it is enforced: a feature commit carries its OWN
STATUS/BENCHMARKS update; when the numbers are not yet known the honest line is
pending/void with the reason, which is what the gate's own message asks for.

FOLLOWING_AGENTS_PROTOCOL
Assisted-by: Claude Code:claude-opus-5 [Claude Code]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants