Документация написана на английском.
AIR → NIR: a Metal shader frontend for Mesa
This is the compiler third of running Metal on the Adreno of this port: a translator from Apple's
shader bitcode (AIR, the contents of a .metallib) to Mesa's NIR, so that ir3 — the same compiler
that already runs the port's OpenGL ES through freedreno — turns Metal shaders into Adreno code.
The other two thirds are not here and are not started: the _MTLDevice/IOGPUMetalDevice
object graph (AGXMetalA11 alone is 53 classes and 1158 methods, IOGPU.framework 711 more), and the
kernel ABI that submits work. Where a decision below constrains them it says so, and section 7
collects those constraints in one list.
Why this is worth doing at all, and what it costs, is argued in docs/metal.md section 9. This
document assumes those measurements and records the decisions taken on top of them.
Where it stands, and what that does not mean
Of the 456 modules of the device's system .metallib files (tools/air/check.sh, and
docs/air-report.md for the breakdown):
| of 456 | |
|---|---|
read by our own bitcode reader, byte for byte as llvm-dis reads them |
456 |
translated to NIR, passing nir_validate |
321 |
| compiled by ir3 into Adreno machine code | 321 |
| numerically identical to LLVM's own execution, and drawn | 255 |
| run on the phone's GPU — 7 drawn, 47 dispatched | 54 |
And of the 119 distinct air.* operations those modules call (tools/air/intrincheck.py):
| of 119 | |
|---|---|
| checked numerically, one operation at a time, against a reference outside this tree | 78 |
| of those, exactly defined and agreeing on every case (45 697 cases) | 71 |
| of those, approximate, so measured against libm and nothing concluded | 7 |
| of those, also run by a Gallium driver rather than folded (softpipe, 2 586 cases) | 47 |
| of those, run on the phone's own Adreno (714 cases) and agreeing there | 61 |
of those, disagreeing before the ir3 rounding fix this port now carries (0 after) |
5 |
of those, set aside: they return half, which air_run cannot read back |
10 |
the atomics and the barrier, against kernels written for them (atomiccheck.py) |
10 |
texture operations verified exactly, on several texels each (texopcheck.py) |
13 |
| texture operations with a case softpipe cannot run: a 16-bit result | 7 |
| texture operations with no case: multisample, depth, write | 11 |
321 of 456 is every entry point of the corpus: 271 fragment shaders, 47 compute kernels and 3
vertex shaders. The other 135 modules are air.visible library functions, not entry points at
all, and they have a measurement of their own:
| the 135 library functions | |
|---|---|
wrapped in a generated entry point and translated (--library) |
124 |
| refused: a texture, sampler or matrix argument the wrapper cannot pass | 11 |
| dispatched by a Gallium driver and read back | 124 |
| of those, agreeing with our own constant folding of the same shader | 104 |
| of those, run but disagreeing | 0 |
| ran, but the folder did not reduce the result to a constant | 20 |
| agreeing with LLVM on this machine, bit for bit | 21 of 21 |
tools/air/libcheck.py prints that table and refuses to print it when the rows do not add up.
The last row is the one that counts, and it is small for a stated reason: LLVM can be the
reference only for a function that calls no air.* at all, because an air.* call is an
undefined symbol on the host and defining one would put our own reading of that operation on
both sides of the comparison. 57 more call only exactly defined air.* operations and could be
compared that way if those definitions were written; 46 call an approximate one
(air.fast_pow and the like), which nothing compares bit-exactly anywhere.
What a library function's measurement is not: evidence about Metal's stitching. Metal links
these into a caller at pipeline time through the ABI their own metadata describes
(air.stitching_info, with the four functions that do it and each argument's size and
alignment). That ABI is the runtime's, not the compiler's, and it is not implemented. The wrapper
is this project's own calling convention, invented to make the bodies runnable.
And what the 456 itself is not: the number of shaders on the device. It is the number of
modules in the .metallib files under sysimage/rootfs. A library can also be a blob inside a
dylib — GPUCompiler's own GNR.videoprocessor carries one in __DATA, found while looking for
something else — so every statement here of the form "the corpus contains no X" is bounded by how
the corpus was collected, not by what the system contains. Where that distinction matters, this
document says so; extending the corpus to embedded libraries would move this denominator and
every percentage computed from it, so it is a change to make in one commit and recount, not a
figure to adjust.
What those numbers are not, because they will be quoted without this paragraph otherwise:
The arithmetic operations are checked; two of them have now been checked against the Adreno, and the other 76 have not. 71 of the corpus's declarations agree with C's own functions on every one of 45 697 cases, and 47 of them agree again when executed by softpipe instead of folded — which is a real answer to what used to be the largest hole here (section 4, item 4), and found three bugs getting there. But those two engines are both on an x86 host. An opcode whose Adreno instruction differs from what
nir_opt_constant_foldingand TGSI compute is invisible to all 48 283 of those cases, and the 24 narrow-type declarations softpipe cannot run at all are exactly the ones where ir3 differs most from it.On 20 October that stopped being true. All 78 declarations ran on the phone's own Adreno, and twice: once as the port stood, and once with the one-line
ir3rounding fix this tree now carries (tools/mesa/ir3-round-default.patch).agree differ set aside cases as the port stood ( logs/090218-run.log)56 5 10 714 with the rounding fix ( logs/090918-run.log)61 0 10 714 The 5 were the whole point of having gone there, because every engine on this machine agrees with C about them. All five are integer-to-float conversions:
air.convert.f.{f32,v2f32,v3f32}.u.*and, once the pool was sharpened to ask,air.convert.f.{f32,v2f32}.s.*as well —i2ftruncates exactly asu2fdoes, which the earlier pool had no value to notice. With the fix all five agree and nothing else moved, which is the useful shape of an after-measurement: the thing that was wrong is right, and nothing that was right has changed.An earlier version of this paragraph said 66 agree on 808 cases, and that was 10 declarations too many. It also said the 24 narrow-type declarations softpipe cannot run had all run on the Adreno and agreed, which was the part that mattered and the part that was wrong. Ten of them return
half,air_runreads back through a 32-bit float target and has none that is 16 bits wide, so the half is widened at once and NIR folds the conversion into a 32-bit one: the reference describes an operation the shader does not perform. They are now set aside by name —air.convert.f.f16.{s.i32,u.i16,u.i32,u.i8},air.fmax.{f16,v4f16},air.fmin.{f16,v4f16},air.mix.f16,air.saturate.f16— and 56 + 5 + 10 = 71 closes the account. What is true of the narrow types is narrower than before: thei1,i8andi16ones ran and agree; thef16ones were never asked. Nothing refused to run, though: no assertion, no fallback, no module missing.The row "run on the phone's GPU" in the first table above does not count these: it counts modules of the 456-module corpus, and these are generated modules that are not in it. The two measurements are deliberately not added together. That row now reads 51 for a different reason — 7 fragment shaders drawn and 44 compute kernels dispatched, which are disjoint sets because a kernel is not a fragment shader. It read 7 until the kernels ran, and it stayed 7 for a while after they did, which is the third time this one figure has been left behind by the work that moved it.
Two traps this frontend's own checks fell into, kept here for the instances; the general forms are in README's "Как врут собственные проверки", which collects all six and belongs there because the others came from the camera, the services and the build rather than from AIR. Comparing one file of Mesa's
mainagainst ours had to start from a fresh tarball extract, because ours carries our own patch — 202 487 bytes against 202 144 clean — so a comparison from this tree would have differed by exactly our own change. Andpatch -s --dry-runexiting 0 against that file was written down as "applies cleanly" when without-sit says "succeeded at 410 (offset 2 lines)": the context matched and the line number had moved, which is the more useful statement and the one the patch header now carries.A
continuethat adds nothing to a total is where this check has gone wrong three times. Not a maxim but a thing to grep for, and the general form is in README beside the other five. Three times on 20 October an absent check was dressed as a passing one, and each time the shape was identical: a loop skipped a case and incremented no counter, so "not examined" was reported as "examined and agreed".if idx >= len(flat): continuedropped every case whose value was missing from the log, and the total fell by exactly as much as nobody was watching;read_whole's predecessors read a configuration file with oneread()into a fixed buffer, so a file past its buffer silently lost its tail and the jobs in it never ran;check_logskipped every infinite expectation, which is how tenhalf-returning declarations came to be reported as agreeing on every case when three of every four cases were not compared at all.
So the rule is not "skip less". It is that every case set aside must reach the summary with its own number and its own reason, and that a verdict resting on very few cases must say how few —
air.all.v2i1agrees on two cases, and that is worth knowing before quoting it. Green has to mean "looked at and agreed", never "did not look".The Adreno truncates
u32 → f32where C, NIR and softpipe round. This is the first thing found here that no engine on this machine could have found, and it is why the suite was carried to the phone at all.air.convert.f.f32.u.i32and its 2- and 3-component forms, given0xFFFFFFFF(4 294 967 295 unsigned), return 4 294 967 040 where C returns 4 294 967 296.The arithmetic is worth writing out, because the two numbers look like a printing difference and are not. In
binary32the spacing between representable values in [2³¹, 2³²) is 2⁸ = 256, so the neighbours of 2³² − 1 are 2³² − 256 and 2³². Their distances from it are 255 and 1, so round-to-nearest gives 2³² =0x4f800000, which is what(float)4294967295uis in C, checked by compiling it. The Adreno's answer, 2³² − 256, is exactly0xFFFFFFFF & 0xFFFFFF00: the low eight bits dropped. That is truncation toward zero, not rounding. Metal's conversions are OpenCL's, which require round-to-nearest-even, so this is a real divergence and not a matter of taste. Only the cases where rounding decides anything differ — 1 of 4, 2 of 8 and 3 of 12 — and every value that is exactly representable agrees.Where it comes from is settled as far as inspection can settle it, and no further. Two of the three possible culprits are eliminated by reading code rather than by guessing:
- the frontend emits
nir_u2fN, the correct opcode (air_intrinsics.c, theconvertbranch); - ir3 turns that into a single
cov.u32f32with no compensation. For a 32-bit sourcecreate_covhas no special case at all — the special cases there are for 8-bit sources and forf32 → u8. The generated Adreno code for the module is five instructions, four of themcov.u32f32reading straight out of the constant buffer, with nothing between the load and the conversion.
So the truncation is the behaviour of the a6xx
cov.u32f32instruction itself. Whose job it is to compensate is not established, and needs the a6xx ISA documentation, which is not to hand: ifcovis specified to round, this is a hardware erratum ir3 should work around; if it is specified to truncate, then every API that requires round-to-nearest — OpenCL, Metal, and Vulkan'sConvertUToF— has to compensate, and ir3 does not. Not worked around in the frontend, because the frontend is emitting the right opcode and a workaround there would hide a fault that belongs to everything ir3 compiles.The prediction that followed has been tested and holds, by a peer session rather than here. The port's OpenGL ES goes through the same ir3 and the same instruction, so a GLES shader should truncate identically — and
userland/src/u2f_test.c(RC=rc.u2f) asks GLSL ES 3.0 the same question through the same Mesa, with no AIR anywhere in it. Four of its six inputs disagree with C onfreedreno; Adreno 7c+ Gen 3; OpenGL ES 3.2 Mesa 25.0.7, and the two that agree are exactly the two where truncation and rounding give the same answer — so agreement there is the absence of a question, not a confirmation. Everything ir3 compiles on this phone converts unsigned to float by truncating, the window server's drawing included, and this frontend is not party to it: it is physically absent from that test.The cause is ir3 and not the a6xx instruction, and an earlier draft of this section had that wrong. It said inspection had eliminated two of three culprits and the remaining one was the hardware. The steps were right and the conclusion was not:
create_covends with a rounding field that the reading stopped short of.covhas a rounding mode —ROUND_ZERO = 0,ROUND_EVEN = 1ininstr-a3xx.h— andir3_compiler_nir.csets it for a destination ofTYPE_F16orTYPE_F32fromfloat_controls_execution_mode, which is a SPIR-V declaration.nir_get_rounding_mode_from_float_controlsreturnsnir_rounding_mode_undefwhen nothing is declared, so neither branch fires and the field keeps its zero, which is truncation. GLSL ES declares nothing and this frontend declares nothing, which is exactly why two independent paths returned the same wrong number. The hardware can round; ir3 only asks when someone else has asked first, and supplies no default where IEEE-754 and GLSL both require round-to-nearest. How much this reaches is a number and not an adjective. Of the 456 modules of the corpus, 20 contain a conversion the patch can change — au2f, ani2for anf2f16, the three that round; none contains only the exact kinds (f2f32,b2f), where setting the mode cannot alter a value. So on the Metal side the blast radius is 20 modules, not all 313 that translate, and whether each of those 20 actually changes depends on the values it meets at run time.That bounds this frontend's half. The other half has now been measured too, by a peer session, and the answer has a shape worth keeping. The port's OpenGL ES reaches ir3 through Mesa's own GLSL compiler, which the count above does not look at, so what the patch does to the screen could only be answered by looking at the screen: one image, one command line, an ordinary boot to the home screen, patched against unpatched.
differ by 1–8 differ by more than 8 patched against unpatched (4 min 33 s apart) 10 835 2 508 unpatched against unpatched (8 min 36 s apart) 13 738 2 286 Both large patches in the pair were cut out and looked at rather than guessed from their coordinates: the Clock icon, whose hands had moved, and the status bar's clock. No geometry moved and no colour changed. The small residue sat in the bottom bands — search, dock, page dots, the transparent things with a blur behind them — exactly where a rounding change would show and at exactly the magnitude predicted, which is why the control mattered: two runs of the same binary, with a larger gap, differ by more. So the residue belongs to the run and not to the patch, and the honest statement is not "nothing changed" but "any change is smaller than what the screen does to itself between launches".
That is the useful form here, because the pair alone would have supported a confident wrong answer. The pixels were in the right place, of the right size, and told a coherent story about blur arithmetic; a measurement without a control answers a question nobody asked, and it cost one extra run to find out.
The fix is one line, and unlike the
ir3_cmdlinebug this one is in a file upstream still carries, with reproduction down two independent paths — so it is the first thing in this work that can actually be reported. The change is now carried here astools/mesa/ir3-round-default.patch, measured on the phone before and after down both paths.The same branch covers
TYPE_F16, and that half of it is still unanswered — this suite cannot ask it. Ten declarations of the corpus returnhalf, and every one of them was reported as agreeing on the Adreno while being tested on almost nothing: the pool gave them only values that overflowf16, so their expectations read[0, inf, inf, inf], and the comparison skipped every infinity without counting it, leaving the zero to carry the verdict. They are set aside by name now (the table at the top of this document has their row), and the reason is the harness and not the hardware:air_runreads back through a 32-bit float target, so a half result is widened at once and NIR folds the conversion into a 32-bit one. The generated Adreno code forair.convert.f.f16.u.i32is fourcov.u32f32and not onecov.u32f16.The pool has the value that would decide it. In
f16integers are exact to 2048 and spaced by 2 above, so 2051 is the discriminator — between 2050 and 2052 with an odd lower neighbour, where round-to-nearest-even gives 2052 and truncation gives 2050 — and 2049 is carried beside it as a midpoint where both rules agree, to show that a midpoint is not automatically a test.air_runnow has the 16-bit render target, and it is not enough. PickingR16G16B16A16_FLOATfor ahalfoutput was necessary and was only one of two causes; the other is in the generator, which packs every float-ish result into afloat4with an explicitfpext half ... to float(intrincheck.py,emit_buffered). The widening therefore happens inside the shader and no choice of target can undo it.Going further would mean guessing, so it stops here. A half-returning fragment shader needs
!"air.render_target"metadata naming the type, and the corpus is the authority on that vocabulary — the wholeair.*meaning was read out ofAGXCompilerCorerather than invented. Of the 456 modules, the render-target type names used arefloat4(275),int4(268),uint4(268) andushort(2). There is nohalf4among them. The token does occur once somewhere in the shared cache, which makes the spelling plausible and attests nothing about this use of it.And there is a second half to the f16 question that no harness can settle, because nothing is required. One F16 path through that
create_covbranch ispack_half_2x16, andnir_lower_mediump.csays of it, in Mesa's own words, "pack_half rounding is undefined". Where the specification requires nothing, a truncatingpackHalf2x16is a permitted choice and not a fault, so a measurement of it would be an observation about an implementation rather than an argument about conformance — and the patch's header now scopes its case to the destinations where a mode is required, integer to float, naming thepack_halfpath as a reviewer's question instead of leaving it to be found. A peer session had planned to reach the F16 branch throughpackHalf2x16for exactly that reason and dropped it on this, which is the better call: the experiment would have answered our internal question and been useless for the report.So the f16 question is two questions. For integer-to-half, round-to-nearest-even is required and this instrument cannot ask. For
pack_half, nothing is required and there is nothing to ask.And the spelling is the smaller problem. A
halfrender target appears in no module of the corpus, so a shader built around one would exercise a path through this frontend that Apple's own shaders never take and that nothing else here tests. A disagreement would then sit between two explanations — the GPU rounds wrong, or this frontend mishandles a construct it has never seen — and an ambiguous measurement is worth less than a stated gap. So the ten declarations stay set aside, and the rounding fix is measured for 32-bit destinations only, which is what the patch's header claims and no more.Which rule it is, as against merely "not round-to-nearest", is settled by one value.
0x01000003is 2²⁴+3, half way between the representable 2²⁴+2 and 2²⁴+4; round-half-to-even and round-half-away-from-zero both give 2²⁴+4, and the GPU gives 2²⁴+2. That is truncation and nothing else.0xFFFFFFFFalone could never have shown this, and neither could0x01000001, the other midpoint, where truncation and round-to-even happen to agree — which is why the pool carries both and why one midpoint is not a test of a rounding rule. What remains unestablished is only whose job the compensation is, and that still needs the a6xx ISA documentation.- the frontend emits
air.fast_powreturns NaN on the Adreno for a negative base, where softpipe returns the exact value.fast_pow(-0.5, 1)is-0.5in C and on softpipe, and NaN on the phone. This is recorded rather than reported as a bug: Metal'sfast::functions, like OpenCL'snative_ones, are explicitly undefined outside a restricted domain, and a negative base is outside it. It is worth naming because it sits inside the "approximate, nothing concluded" bucket, which is exactly where a real difference can hide behind a reasonable-looking ulp figure — the line says "worst inf ulp", and an inf ulp is not a rounding difference. Any corpus shader that callsfast_powwith a base that can go negative will behave differently on this phone than in any test on this machine.The atomics compute the right thing and the barrier waits; whether the atomics are atomic is still open.
tools/air/atomiccheck.pydispatches two kernels written for the purpose (section 4, item 5) and all eight operations the corpus uses agree — five on device memory (add.s,load,max.s,min.s,store), three on threadgroup memory (add.u,load,store) across twoair.wg.barriers. Among them a signed minimum taken across negative values, which is where an unsigned one passes unnoticed. The barrier is genuinely under test: softpipe advances its invocations in groups of four, so making the barrier a no-op leaves every thread seeing 4 of 16 increments, and the check says exactly that. Atomicity is not under test, because softpipe applies a group's atomic lane by lane and so cannot lose an update however the lowering is written. That needs the phone. Two things are settled without running anything: every atomic in the corpus asks formemory_order_relaxed, and a stronger order is now refused by name instead of ignored, which is what the lowering used to do.All 119 declarations are now accounted for, which is new as of tonight and is a weaker claim than "all checked" — the distinction matters and an earlier draft of this paragraph, and the commit message of
bbf6c3c, ran the two together:of 119 verified: the answer was compared with one computed elsewhere and agreed 94 measured, no verdict: approximate arithmetic, against this machine's libm 7 has a case, which softpipe cannot run: a 16-bit result or operand 7 has no case, for a reason given in section 4 item 6: multisample, depth, write 11 The 94 are 71 arithmetic operations, 10 atomics and the barrier, and 13 texture operations. The derivatives and the simd reductions are in none of these rows because no module of the corpus calls them: unexercised code, not untested behaviour, and saying "unverified" of them would overstate what is at stake.
The weakest of the three groups is the textures, and the gap is not in the fetch — which is now checked at the corners as well as the middle, and for integer and unsigned textures too — but in everything Metal's sampler state decides. The 64-bit
constexpr samplerword is still not decoded (section 3.3) and every coordinate checked is a texel centre or a clean footprint corner, where nearest filtering has no weights to round and clamping has no arithmetic. The six texture shaders that drew on the Adreno took their sampler from the API too, so what they are evidence about is binding, descriptors and ir3's texture instructions — exactly what they were built to be — and silence about everything Metal's sampler state decides. Ten modules of the corpus declare aconstexpr sampler, carrying three distinct state words, all ten inPortraitFilters.cifilter/default.metallib; everywhere else the API supplies it. This paragraph said "two" for a while, while the mapping table in section 7 said ten: two numbers for one fact in one file, and the disagreement was enough to settle it without any new evidence. Where the "two" came from is not established. What is worth recording is how the ten were counted, because the obvious way does not work: a plaingrepfor__air_sampler_stateover the 456 modules finds none, since bitcode is bit-packed and a name matches only when it happens to land on byte boundaries. The count above is fromllvm-disoutput, module by module.A function of the module that takes a texture: done, and the diagnosis was the work. All four such entry points translate — three with this fix, the fourth once the shuffle mask it also stopped on was decoded. The fix is a split of
translate_globals, and arriving at it took two reverts and three failed guesses, which is the part worth keeping.A texture is not a value in this frontend: it is resolved at translation time into a binding, and
tex_handlewants anAIR_VAL_PTRrather than an SSA def. So a pointer-class argument is bound from the caller's own resolution and takes no NIR parameter, which specialises the callee to its call site — free here, since each of the four has exactly one. That much worked at once.What did not was everything after. The callee needs the module's globals, and copying the entry point's values into it crashed three of the four in
nir_lower_goto_ifs→nir_repair_ssa→nir_block_is_unreachable. Two hypotheses died with evidence before the right one arrived: the callee has no unreachable block at all, andnir_validate_shader— the test a peer session proposed for it — fails identically onfbs_slice_trilinear, which translates perfectly, so its complaint about block indices in an unstructured impl was true and about something else. The peer's reading of the assertion was right and their route was not: it asks the block's own impl for dominance metadata, so a use in another function's block asks the wrong function — but the cross-function value arrived through the globals, not through an argument. A guard refusing a pointer argument with a byte offset was evaluated 26 times over the corpus and never fired, so arguments are not the carrier; the count is the evidence, because a silent guard and an unreachable guard look the same.So
translate_globalsis now two.plan_globalsdecides what each global is and runs once, because it appends inline samplers toinfo->resourcesand lays out shared and scratch memory.materialise_globalsbuilds the values and runs per function, because a byte offset is annir_defand a def belongs to the impl that made it. Module-level constants needed neither:air_getalready materialises one in whichever builder asks, which was checked rather than assumed.Of Apple's own 47 kernels, all 47 are dispatched on the Adreno and none crash.
tools/air/kernelcheck.pyprints where each one stops, as a table whose rows are checked against the total before it is printed:on softpipe on the Adreno dispatched and read back 6 47 softpipe's nir_to_tgsi: scratch and intrinsics it lacks41 — a shuffle mask our reader would not decode (ours, now fixed) 0 0 crashed air_run(a defect here, not a limit)0 0 logs/170547-run.log, 710 919 bytes with no truncation marker. The three that the shuffle-mask fix unblocked dispatched on the first run and read their images back; two of them bind six textures, which is well past where the sampler bug used to fault, so that fix is confirmed a second time by shaders it was not measured on.The deferred question is answered: ir3 runs them. 41 kernels stop at a software rasteriser that has no scratch memory and lacks compute intrinsics; on the hardware every one of the 47 dispatches and reads its result back, images and all. The phone replaced softpipe, and both columns that were ours — the harness's refusals and the frontend's — are empty.
What the rows do not say is right. There is no executed reference for these kernels anywhere: softpipe refuses 41 of them, which is why they came here, so a number out of the Adreno has nothing on this machine to disagree with. 47 have been shown to run, which is a statement about capability and not about correctness.
The last row was blaming the shader for our own gap. "A shuffle mask that is not constant" was impossible as written: LLVM requires a
shufflevectormask to be constant, so the message was describing something that cannot occur. What the three kernels actually carry is a mask like<i32 0, i32 poison, i32 1, i32 poison>— the shape a Metalhalf2-to-half4widening compiles to — and in it the zero is writtenCST_CODE_NULL, notCST_CODE_INTEGER 0, because that is how LLVM stores every zero of a scalar type. A mask is stored as an aggregate of element constants rather than as plain data exactly when one lane is poison, so those two encodings meet only in this case, and our reader accepted only the second. We refused on a zero we could read perfectly well, and recorded it as a limit of the shader.And the decoder for it already existed.
air_const_uint(air_module.h) has read both spellings since long before this, with a comment beside one of its callers saying that a zero arrives asCST_CODE_NULLabout as often as not. Four places testedckind == AIR_CONST_INTby hand instead of calling it — the shuffle mask, thellvm.memcpyandllvm.memsetlengths, and theair.wg.barrierflags — and the shuffle mask is the one where it cost something. All four go through the helper now, which removed more code than it added.The first write-up of this fix claimed it "closes a class" by adding that decoder. It did not: the class was already closed and one reader had opted out of it, which is a different and less flattering story. Worth keeping for the shape — a hand-rolled test beside an existing helper that does the same job, in a file big enough that neither author sees the other.
The four that used to crash now dispatch, and the crash was this harness's:
void *csampler = NULL; pipe->bind_sampler_states(pipe, PIPE_SHADER_COMPUTE, 0, num_tex, &csampler);bind_sampler_statesreadsnum_texpointers from the address it is given, and&csampleris the address of one, so it readsnum_tex - 1words past that variable and stores whatever is there assamplers[1..]. The draw path beside it has always built a propervoid *samplers[]array, so the new code failed to learn what the old code already knew. The fault was atfd6_texture_state + 0xd4, reached throughfd6_emit_cs_statefromfd_launch_grid: a wild pointer plus a small field offset, which is exactly the0x100000000034that was being dereferenced.What that mechanism does and does not predict. Both of freedreno's loops skip an empty slot (
if (!tex->samplers[i]) continue;infd6_texture_state, a zeroed dummy in the emit path), andfd_sampler_states_bindsets a slot's valid bit only for a non-NULL pointer — so a stack word that happens to be zero is read, stored and harmlessly ignored. The read goes out of bounds atnum_tex >= 2; the fault needs one of those words to be non-zero. So the texture count is not a threshold, and the pre-fix run says so plainly (logs/155708-run.log): eight kernels with two textures (BlitKernel_Unorm, threeBlitLuma*,MetalFaceMask_GenerateMask,confidence,fbs_bistochastize_final,fbs_slice_trilinear) and two with three (BlitChromaKernel_Uint,BlitChromaKernel_Unorm) all dispatched while the read was still out of bounds for every one of them. The four that died read 4, 4, 6 and 6. Said as a threshold of two, this would predict a crash for ten kernels that did not crash.The feature that looked discriminating was a confound, and the run settled it. Exactly 4 of the 47 kernels carry an inlined module-local function; three crashed and the fourth was refused earlier on the shuffle mask, so every one that reached dispatch died — 3 of 3, which is not a correlation so much as an invitation.
spillCorrectionPreProcesscarries no call at all and died with them, and all four now dispatch with the inlining path untouched. Had the inlining been "fixed" instead, three might have gone through and the fourth would have been left needing a second cause.Two other explanations were already dead before this one, and killing them was what left the adjacent line to be read: the driver's texture state is not uninitialised (
CALLOC_STRUCT(fd6_context)zeroes it), and it was not "an image with no buffer", because nine kernels of exactly that shape — one image, written — dispatched throughout (logs/155708-run.log). A peer session's reading of the same function proposedview_seqno[16]overflowing againsttextures[32]— a real fragility in freedreno — and it is not reachable from here, because this harness refuses a slot index of 16 or more by name. Checking whether someone else's bug was reachable, instead of looking one line further for our own, is how two of the three earlier attempts on this function were spent.air_runnow prints the texture count beside the image count, which is what made the paragraph above a measurement instead of an argument — and the first thing written from that run was still the wrong generalisation, "two is the threshold", corrected with a guess ("there are surely kernels reading two among the forty") when the counts were already in the log. A peer session counted them. That is where accuracy usually goes: not in the first claim but in its repair, which arrives feeling like diligence and is rarely re-checked against the same evidence.The row for a call of a module-local function was 5 until those calls were translated; it is now gone, its five having moved to softpipe (four) and the shuffle mask (one) — and that last one moved again when the mask was decoded, so all five are softpipe's. Twice in a row the dispatched count on softpipe stayed at 6 while a column stopped being ours, and the run above is where that finally paid: those same kernels dispatch on the hardware. Until 20 October, 24 of these were refused by
air_runitself — it bound textures and images for a draw and not for a dispatch — and binding them moved the count only from 5 to 6, because softpipe is the wall behind ours. What changed is whose wall it is: nothing is refused by this harness any more, and the 34 are stopped by a software rasteriser that lacks scratch memory and some compute intrinsics, both of which ir3 has. They are a question for the phone now rather than a question for us, which they were not before. Claiming 24 unblocked would be false; they moved from one wall to another and the second is not ours.Images are read back after a dispatch, which also matters for the textures: most of these kernels write only an image, so without the readback a dispatch would have reported success with nothing examined — and that is the shape this check has been caught by three times (README, "Как врут собственные проверки"). The readback is what
air.write_texture_2d/3dneeded as well, whichtexopcheck.pyhad listed as having no case for exactly this reason.So the atomics being checked (above) is still a statement about two kernels written here for that purpose, not about the 17 of Apple's 44 that use one.
1. Where the frontend lives: inside Mesa, as src/compiler/air
Decision: a Mesa frontend in Mesa's tree, shaped like src/compiler/spirv. Its entry point
is one function, air_to_nir() (nir_air.h), the analogue of spirv_to_nir(). In this
repository the sources are tools/mesa/air/; tools/mk-mesa.sh and tools/air/mk-host.sh copy
them to src/compiler/air/ of the pinned Mesa release, exactly as tools/mesa/hakios-drm/ is
copied to src/hakios-drm/, and tools/mesa/air.patch adds the two lines of meson that build
it. The tree is the shape an upstream merge request would have.
Why not a standalone library that Mesa links:
- NIR has no stable ABI. A
nir_shaderis a graph of Mesa-internal structures that change every release;nir_builderis a header of inline functions. A library outside Mesa producing NIR would have to be built against one exact Mesa and versioned with it — it would be inside Mesa in everything but name. A library producing something else (a serialized NIR, or its own IR) would add a second translation for nothing. - Everything else that feeds ir3 is in Mesa too: GLSL, SPIR-V, TGSI, CL. Living beside them
means the same NIR passes, the same
nir_validate, the same test framework (nir_tests), and ir3 changes that break us break in Mesa's own CI.
What it costs the eventual Metal runtime, which must compile from newLibraryWithData: (or, more
exactly, at pipeline creation — see 7.1) in-process: nothing extra. The runtime must have ir3
in its process anyway, and ir3 is only reachable as part of Mesa; the port already ships Mesa as
one dylib (libOSMesa.8.dylib, freedreno and ir3 inside). The frontend adds a few hundred KB of
code to that dylib and no dependency. The cost is the inverse one: the runtime's Mesa must be a
build with this frontend in it, and the runtime calls a Mesa-internal API (air_to_nir, then
ir3), so it is compiled against Mesa's headers and moves with Mesa's version.
2. How the bitcode is read: our own reader, not LLVM's
This is the biggest commitment of the project, so both sides in full.
For linking LLVM's bitcode reader (llvm::parseBitcodeFile, then walk an llvm::Module):
- It is finished work. It reads every bitcode version, upgrades old constructs (typed pointers,
old intrinsics) and verifies the module. All 456 modules of the corpus are already read by
stock
llvm-dis22.1.8 without a failure; day one would be spent on translation, not parsing. - It comes with passes. If a shader ever needs SROA, inlining or
mem2regbefore translation, LLVM has them, and a hand-written reader means hand-written equivalents (or NIR's, after translation). - Future AIR versions (iOS 17, 18…) come from newer Apple LLVMs; LLVM's reader follows them for free, ours must be extended.
Against it, and for our own reader:
- The cost lands in every Metal process. libLLVM is 50–100 MB of C++ with global constructors,
a static option registry and its own copy of libc++ assumptions, loaded into every app that
touches Metal. On this port that is every app: CoreAnimation, CoreImage and UIKit all link
Metal. The port's Mesa is built with
-Dllvm=disabled(tools/mk-mesa.sh) for exactly this reason today. - It would have to be cross-built for iOS with the port's toolchain (the
xnu-clangwrapper, the iOS SDK, ld64). That is a multi-hour build and a second large dependency to keep building — the project's experience with V8 on musl says what such a build costs to keep alive. - Apple already has an LLVM in the shared cache (MTLCompiler, AGXCompilerCore — 3.6 MB). Ours would be a second one in the same process, of a different version. Two-level namespaces keep the symbols apart, but it is a hazard nobody needs.
- The subset is small and measured. Over all 456 modules (
llvm-bcanalyzer -dump, then a histogram of records), the whole of what occurs is: 17 instruction records (BINOP, CAST, LOAD, STORE, GEP, CALL, RET, BR, PHI, CMP2, VSELECT, EXTRACTELT, INSERTELT, SHUFFLEVEC, EXTRACTVAL, INSERTVAL, ALLOCA), 6 constant records (INTEGER, FLOAT, NULL, UNDEF, AGGREGATE, DATA, CE_CAST), 13 type records and 8 metadata records. No switch, no invoke, no atomics as instructions (they areair.*calls), noptrtoint/inttoptr, noaddrspacecast, no debug info. The bitstream container itself is documented ("LLVM Bitcode File Format") and is a few hundred lines. - The reader has an exact oracle.
llvm-disprints every module; our reader prints its own parse in the same.llsyntax for the constructs it supports, and the corpus check diffs the two. A reader that agrees with LLVM on 456 modules is not a guess. LLVM stays a test tool, not a runtime one. - Mesa already chose this once. The DXIL backend (
src/microsoft/compiler/dxil_module.c) writes LLVM 3.7 bitcode by hand rather than link LLVM. The same reasoning applies to reading.
Decision: our own reader (air_bitcode.c: the bitstream; air_module.c: records → an
in-memory IR of the subset). The price is accepted with open eyes: about 2–3 thousand lines of C,
and every new record kind in a future AIR version is work. Unknown records are never skipped
silently: they make the module fail with the record's name, and the corpus report counts them, so
the price is visible as a number rather than as a wrong shader.
It is written, and it agrees with LLVM on all 456 modules of the corpus —
tools/air/reader-check.sh, 1 900 lines of C for the reader and its printer. What the check
compares is described in section 4; the two things it does not keep are function and parameter
attributes (nounwind, captures(none), the attribute groups) and the order of declarations,
because the translation uses neither. Everything else — types, constants, every instruction,
metadata, fast-math flags, alignments, calling conventions, linkage — comes back byte for byte.
What would reverse this: if a later AIR (a newer .metallib corpus) turned out to need large
parts of LLVM's auto-upgrade machinery, or if translation turned out to need LLVM's own passes
before NIR. Neither is true of iOS 16.5.
3. What a translation unit is
A .metallib is a container of one LLVM module per function (docs/metal.md 9.1). The unit
of translation is therefore one module → one nir_shader, and the stage of that shader is read
from the module's named metadata, never from the container: the container's TYPE tag and the
metadata are cross-checked by the corpus tool and disagreements are reported.
| module carries | NIR shader | notes |
|---|---|---|
!air.vertex |
MESA_SHADER_VERTEX, one entry point |
|
!air.fragment |
MESA_SHADER_FRAGMENT, one entry point |
|
!air.kernel |
MESA_SHADER_COMPUTE, one entry point |
threadgroup size is an API value (7.4) |
!air.visible |
a library shader: one exported, non-entry nir_function |
linked at pipeline time (7.5) |
Other functions in a module (helpers, stitching_traits methods, air.static_init constructors)
become non-entry nir_functions and are inlined. Constructors listed in @llvm.global_ctors
run at the top of the entry point; that is how function-constant-derived program variables get
their values (AV1SW's kPixelShift is computed in one from kBitDepth).
Pointers. Metal buffers are pointers, and AIR passes them as ptr addrspace(N) parameters.
The translator keeps every pointer as (where, byte offset): where is a buffer binding, the
threadgroup block, a private allocation or a constant global; the offset is an SSA value computed
from GEPs with the datalayout's sizes. This is NIR's 32bit_index_offset model and is enough for
the whole corpus (no pointer is ever converted to an integer or between address spaces). Pointers
stored to memory — argument buffers, which no system shader here uses — will need 64-bit global
addresses instead (7.2).
3.1 The interface: AIR metadata → NIR
The entry-point node is !{ptr @fn, !{outputs…}, !{inputs…}}; each input starts with its
parameter number, each output is a field of the returned <{…}> struct, in order. Every entry is a
flat list of !"air.<key>" strings, some followed by values. This table is the specification the
translator and its tests are written against. "Seen" is the number of corpus modules carrying the
key (of 456), so the order of work follows it.
Resources (inputs of every stage)
| AIR | seen | NIR | binding rule |
|---|---|---|---|
air.buffer, air.address_space 2 (constant) |
393 total | load_ubo |
UBO index = air.location_index |
air.buffer, air.address_space 1 (device) |
(of buffers) | load_ssbo/store_ssbo/ssbo_atomic |
SSBO index = air.location_index |
air.buffer, air.address_space 3 (threadgroup) |
3 | load_shared/store_shared, base from a driver uniform |
sized at dispatch (7.4) |
air.buffer_size N |
309 | UBO range hint (range of load_ubo) |
|
air.read / air.write / air.read_write |
ACCESS_NON_WRITEABLE etc. on the access |
||
air.texture + air.sample/air.read |
96 | nir_tex_instr, texture_index = location_index |
sampled/read textures |
air.texture + air.write/air.read_write |
image_store/image_load, image index = location_index |
storage images (7.3) | |
air.sampler |
15 | sampler_index = location_index |
|
!air.sampler_states (inline constexpr sampler) |
10 | inline sampler, index after the API ones | the state word is not decoded: passed to the runtime verbatim (section 3.3) |
!air.function_constants |
11 | constant folded from the values given to air_to_nir |
!{ptr @g, type, name, index, i1} |
air.struct_type_info, air.arg_type_* |
reflection only, not used for code | layout comes from the LLVM types |
Vertex stage
| AIR | NIR |
|---|---|
air.vertex_input + air.location_index n |
shader_in, VERT_ATTRIB_GENERIC0 + n |
air.vertex_id |
load_vertex_id (includes the base vertex, as Metal's does) |
air.instance_id |
load_instance_id + load_base_instance (Metal's includes the base instance, GL's does not) |
air.base_vertex / air.base_instance |
load_first_vertex / load_base_instance |
output air.position |
VARYING_SLOT_POS |
output air.vertex_output "name" |
VARYING_SLOT_VAR0 + k, k = position among user outputs; the name is kept on the variable |
output air.point_size |
VARYING_SLOT_PSIZ |
output air.clip_distance |
VARYING_SLOT_CLIP_DIST0 (array) |
output air.render_target_array_index / air.viewport_array_index |
VARYING_SLOT_LAYER / VARYING_SLOT_VIEWPORT |
Fragment stage
| AIR | NIR |
|---|---|
air.position |
load_frag_coord; origin_upper_left, half-integer centres (Metal's convention, and Adreno's natively); .w is 1/w as in GL |
air.fragment_input "name" |
shader_in, VARYING_SLOT_VAR0 + k matched to the vertex output by name (7.5) |
air.center / air.centroid / air.sample |
plain / centroid / sample on the variable (air.center is the default and is therefore read by not being looked for) |
air.perspective / air.no_perspective / air.flat |
INTERP_MODE_SMOOTH / NOPERSPECTIVE / FLAT |
air.front_facing |
load_front_face |
air.sample_id, air.sample_mask (input) |
load_sample_id, load_sample_mask_in |
air.point_coord |
load_point_coord |
output air.render_target i32 n, i32 idx |
FRAG_RESULT_DATA0 + n, dual-source index idx |
output air.depth + air.depth_qualifier any/greater/less |
FRAG_RESULT_DEPTH, info.fs.depth_layout |
output air.sample_mask / air.stencil |
FRAG_RESULT_SAMPLE_MASK / FRAG_RESULT_STENCIL |
air.discard_fragment |
demote (Metal keeps the fragment alive for derivatives) — not implemented; no corpus module uses it, and a shader that did would fail with intrinsic: air.discard_fragment |
Compute stage
| AIR | NIR |
|---|---|
air.thread_position_in_grid |
load_global_invocation_id |
air.thread_position_in_threadgroup |
load_local_invocation_id |
air.threadgroup_position_in_grid |
load_workgroup_id |
air.threads_per_threadgroup |
load_workgroup_size |
air.threadgroups_per_grid |
load_num_workgroups |
air.threads_per_grid |
load_num_workgroups × load_workgroup_size |
air.thread_index_in_threadgroup |
load_local_invocation_index |
air.thread_index_in_simdgroup / air.simdgroup_index_in_threadgroup / air.threads_per_simdgroup |
load_subgroup_invocation / load_subgroup_id / load_subgroup_size |
The scalar-or-vector type of each of these (uint, uint2, uint3, ushort…) comes from the
LLVM parameter type, and the NIR value is cut or converted to it.
Values and operations
| AIR / LLVM | NIR |
|---|---|
i1/i8/i16/i32/i64, half/float/double, vectors ≤ 4 |
the same bit sizes; wider vectors are split |
structs and arrays as SSA values (insertvalue, extractvalue) |
trees of SSA values inside the translator, never NIR values |
br, phi (unstructured CFG) |
an unstructured nir_function_impl, then nir_lower_goto_ifs — the path Mesa's OpenCL frontend uses |
alloca |
load_scratch/store_scratch on a per-invocation block (ir3 private memory) |
addrspace(2) global constant with initializer |
nir->constant_data + load_constant |
addrspace(2) global without constant (program-scope variables) |
a private copy per invocation (written by the constructors) |
fast flags on an instruction |
the NIR ALU is not exact; without them it is |
air.<op>.<type suffix> |
one handler per op in air_intrinsics.c; the suffix is checked against the LLVM types, not trusted |
3.2 The vocabulary, read off Apple's own compiler
What each air.* name means was a guess until this was read, and one of the guesses was wrong.
AGXCompilerCore — the back end of Apple's own shader compiler, in this device's shared
cache (sysimage/20F66__iPhone10,3_6, never another device's) — carries its whole operation
vocabulary as a run of C strings at file offsets 0x16cf9f–0x16d400, which is virtual
0x1bbf43f9f–0x1bbf44400 (__cstring begins at file 1 482 768 for 0x1bbf41010). 137 names,
and they are suffixes: the full function is air. + name + . + a type suffix, which is why
grepping the library for air.fast_exp finds nothing.
.add .sub .or .xor .and .min .max global local .s .u .max.u.i64 .min.u.i64 .xchg.i32
.cmpxchg.weak.i32 .store.i32 .load.i32 global.{add,sub,xchg,cmpxchg.weak,store,load}.f32
acos asin atan atan2 acosh asinh atanh pow powr cosh sinh tanh exp10 log10 cos sin tan sincos
rint round fmod ceil exp exp2 fabs floor fma fmax fmin fract ldexp log log2 mad rsqrt sqrt trunc
fast_{acos asin atan atan2 exp10 log10 acosh asinh atanh pow powr cosh sinh tanh cos sin tan
sincos round fmod mod exp exp2 ldexp log log2 rsqrt sqrt ceil fabs floor fract rint trunc
fmax fmin clamp saturate}
gamma_expand gamma_compress gamma_expand_xr gamma_compress_xr convert pack unpack
abs abs_diff add_sat sub_sat hadd rhadd clamp clz ctz mad_hi mad_sat mul_hi rotate popcount
extract_bits insert_bits reverse_bits any all any_msb all_msb saturate mix sign dot
dfdx dfdy fwidth atomic assert get_simd_width get_thread_execution_width
This settles the two operations whose meaning was a guess, and corrects one of them. The list distinguishes exactly the functions the Metal Shading Language Specification defines, one name per function:
| the question | what the list says | what we had |
|---|---|---|
is air.fast_exp the natural exp or exp2? |
exp and exp2 are separate names, and so are fast_exp and fast_exp2 |
right: natural exp |
does air.fast_round take a half away from zero (round) or to even (rint)? |
round and rint are separate, and so are fast_round and fast_rint |
wrong: it was nir_fround_even, which is rint |
air.fast_round is now trunc(x), stepped one away from zero when |x - trunc(x)| >= 0.5.
Not trunc(x + copysign(0.5, x)), which is the obvious formula and is wrong: for an x just
below a half that addition itself rounds up and the answer comes out 1 where it should be 0.
The subtraction is exact for every float, so the version used needs no magnitude guard and keeps
the sign of a negative zero; it was checked bit-exact against C's roundf over the halves, the
sub-halves, both zeros and 2^24.
Reading the list also closed gaps that had not been noticed: exp10, log10, powr, fmod,
mod, mad, abs, abs_diff, add_sat, sub_sat, mul_hi, popcount, clz, ctz,
rotate, reverse_bits, extract_bits, insert_bits, dfdx, dfdy, fwidth,
get_simd_width and rint itself are all implemented now. None of them occurs in the 456-module
corpus, so none is tested by anything here; they are in because the list says they exist and
guessing at them later would be the same mistake again.
Still unimplemented, and now known by name rather than by surprise: the inverse and hyperbolic
trigonometry (acos, asin, atan, acosh, asinh, atanh, cosh, sinh, tanh),
sincos (two results from one call), hadd, rhadd, mad_hi, mad_sat, any_msb/all_msb,
the gamma_* conversions, pack/unpack, and assert.
3.3 The sampler state word, which is not decoded
Metal's constexpr sampler — a sampler the shader declares itself rather than one the API binds —
arrives as a global holding one 64-bit word. That word's layout is not worked out. The
frontend passes it to the runtime verbatim (air_resource::sampler_state), assigns the sampler an
index after the API's, and says so rather than guessing: a wrong filter or wrap mode renders
plausibly and wrongly, which is the failure that costs a week later instead of an hour now.
What is known is only the shape. Three distinct words occur in the corpus, quoted here so that the inference below can be checked and extended rather than taken on trust — that is the whole reason for writing a guess down as a guess:
0x807bff0000088049 0x807bff0000088a00 0x807bff0000088a49
They share everything above the low twelve bits, and the differences are 0x049, 0xa00 and
their union 0xa49 — which looks like three three-bit fields at bits 0, 3 and 6 (the s, t and r
address modes) and two bits at 9 and 11 (the two filters). That is a guess from three samples and
it is not used by anything.
Unlike the operation vocabulary, Apple's compiler does not hand this over: AGXCompilerCore's
sampler_words is an identifier in a hand-written-assembly test harness, not the state format.
So it needs a different kind of evidence — the driver that programs the hardware from it
(AGXMetalA11) is where to look next.
How much it blocks: two modules. Of the texture shaders in the corpus, 29 take no sampler at all (they fetch by integer coordinate), 9 take one as an API argument, and 2 declare their own. So this is a narrow gap and not a blocked family — but it is the reason no result here says anything about Apple's filtering.
3.4 A question for daylight: what of Apple's may this repository contain?
Raised on 20 October and deliberately not settled at three in the morning. The working line is
explanation, not input: a short excerpt quoted beside an analysis so a reader can check it is
technical writing, while corpus bytes committed as an input — a fixture a test opens, a trimmed
.metallib, a dump something parses — is redistribution for use. The repository should never be a
place to get Apple's shaders; it may be a place that explains them.
By that line, two things in these documents are excerpts and are kept: the three sampler words
above, and the six-line listing of clearFragment01 in docs/metal.md section 9.5. Neither
substitutes for the thing it came from, and both carry the analysis they exist to support. Nothing
committed anywhere is corpus bytes as an input: docs/air-report.md holds module names, counts and
error strings, which are facts about Apple's shaders and not Apple's shaders, and no test here has
a fixture — tools/air/check.sh reads sysimage/rootfs, which is not in the repository and cannot
be.
The part needing a decision rather than a convention: whether that line is the right one, and what
it says about the twenty-odd screenshots in docs/ of the running port, which are Apple's rendered
artwork and a different class again. That belongs to whoever owns the repository, awake.
4. How it is proven right
A shader is reported as working only when its output was compared, never because it compiled. Three references, each independent of the translator:
LLVM on the CPU (the strongest).
tools/air/cpuref.pychanges the module's triple to the host's, compiles it withllc, fills a buffer perair.bufferargument with a fixed pattern and calls the entry point;air2nir --evalreplaces the same buffer loads in our NIR with the same bytes and folds it withnir_opt_constant_folding(no algebraic rewriting, so the arithmetic stays IEEE and comparable); the two sets of numbers are compared. LLVM — not our reader, not our translator — decides what the shader means, and neither side can see the other's answer. Needs no Apple hardware and no GPU.255 shaders agree, bit for bit, and none differ. The call into the shader is made from a wrapper written in LLVM IR rather than from C, because a shader returns a struct of vectors by value and how
llclowers that is its own business: a C declaration of the same struct agrees for a single vector and stops agreeing past it (this cost an afternoon's false "243 differ"). With the wrapper, onlyvoid(const void *, void *)crosses into C and the question does not arise.And what those 255 do not cover: not one
air.*function. Every one of them is plain LLVM IR, so the agreement is a strong statement about the reader, the interface mapping (buffers to the right UBO index, result fields to the right render targets in the right order) and the instruction translation — and no statement at all aboutair_intrinsics.c. The shaders that do callair.*are exactly the ones this check cannot judge: the texture ones have a varying for a parameter, the math-heavy ones are compute kernels orair.visiblelibrary functions. So this check says nothing aboutair_intrinsics.c; item 4 below is what does, and the paragraph that used to stand here — that nothing but M4's buffer readback could — was wrong about the reference being necessarily circular. The objection it raised is answered there rather than dropped, because it is the right objection.The first oracle for the intrinsics that needs no hardware is section 3.2: Apple's own compiler, which knows what each name means because it compiles it. Its vocabulary settled the two guesses and found one of them wrong. What that oracle gives is each operation's identity, not its numerical result, so it decides
roundagainstrintandexpagainstexp2and cannot decide how many ULP Apple'sfast_sinis allowed to be out by. The second is item 4, which takes that identity and checks the arithmetic against C's own function; what neither can do is tell you what the Adreno computes.What it cannot decide, and says so rather than passing: a shader with a non-buffer argument — a varying,
air.position, a thread id — because what is in it is the GPU's business (19 in AppleMetalGLRenderer, all of them the texture ones), and a compute kernel, which returns nothing and writes through pointers (47 of them). Reading the buffers back after the call would settle the kernels and is M4's acceptance test; it is not written.The same shader drawn by a Gallium driver, and the pixel read back.
air_run.cputs the translated NIR into apipe_shader_stateasPIPE_SHADER_IR_NIR, draws one quad into a 32-bit render target and prints the pixel. Both drivers of interest take NIR directly: ir3 is a NIR compiler, and softpipe takes it throughnir_to_tgsi.255 shaders draw and the pixel matches LLVM, bit for bit, with no GPU —
cpuref.py --all --drawcompares all three (LLVM's execution, the folded NIR, the drawn pixel). That is M1's acceptance test, run 255 times, with softpipe standing in for the Adreno.Three things this caught that compiling never would, each of which looks like a translation bug and is not one: an integer-returning shader read back through a float target (the bits are right, read the wrong way); a shader whose outputs outnumber the framebuffer's attachments, which draws nothing at all; and
clearFragment04, which writesFRAG_RESULT_DATA1alone, so target 0 holds the clear colour. The harness now takes its format from the shader's output type, binds an attachment per render target, and reads back the lowest target the shader actually writes.NIR's own checks:
nir_validateafter every pass (the translator runs with validation on), and unit tests on Mesa'snir_testsframework for the translator's building blocks.Each
air.*operation on its own, against C's own functions.tools/air/intrincheck.pytakes the declarations the corpus actually uses —air2nir --llover the 456 modules gives 119 of them — and for each one generates a fragment shader that calls it on constant arguments and returns the result, assembles that withllvm-as, and folds it withair2nir --eval. The reference is the C library's ownroundf,floorf,fmaxf,ldexpfand the IEEE definitions, evaluated at the operation's own precision.71 exactly defined operations agree on every one of 45 697 cases; none differ. Seven are approximate (
fast_sin,fast_cos,fast_exp,fast_pow,fast_atan2) and are reported with their worst distance from libm and counted as nothing — see below.Why this is not the circular reference rejected under item 1, which it looks like at first and which is the objection that kept it unwritten for a week. Writing C implementations of the
air.*operations would be circular: they would encode the same belief as the lowering, by the same author, and agree by construction. What this does instead is split the claim in two, and each half has an oracle that is not this tree:- which function each name is — settled by Apple's own compiler, section 3.2. That is where
air.fast_roundwas found to beroundand notrint. - whether the lowering computes that function — settled by the C library, which implements
roundbecause the C standard says whatroundis, and has never heard of this port.
Neither half alone is enough and together they are not circular. What makes it work is that Metal's math functions are OpenCL's, which are C's: there is a specification outside both Apple and Mesa for almost every one of them.
What it does not reach. For the seven approximate operations NIR's folder calls the same libm this script does, so
0 ulpthere carries no information beyond section 3.2 — it says the lowering pickedsinfand not that the Adreno'ssininstruction agrees withsinf, which it certainly does not to the last bit. Metal'sfast_prefix permits that. And for all 71 of the exact ones, what is folded is NIR's constant expression for each opcode on this machine: an opcode whose Adreno instruction differs from whatnir_opt_constant_foldingcomputes is invisible here. That gap closes only on the phone.What it found. Two things, in opposite directions:
air.fast_atan2was wrong at the origin. Mesa'snir_atan2returns 3π/4 foratan2(0, 0), and its own comment says why: GLSL licenses an implementation to deviate from IEEE there, so it pretends 0/0 = 1. Metal'satan2is OpenCL's and keeps the IEEE values —atan2(±0, x>0)is±0,atan2(±0, x<0)is±π. A shader taking the angle of a vector that happens to be zero got 135° instead of 0°, which is not the kind of deviationfast_permits.air_intrinsics.cnow handles the origin itself and leaves the rest to Mesa. Mesa's deviation is confined to the origin: the four(±0, ±0)cases, and no other.- NIR's
ldexploses an overflow, and that one is Mesa's. Its constant expression isdst = ldexpf(src0, src1); /* flush denormals to zero. */ if (!isnormal(dst)) dst = copysignf(0.0f, src0);— and!isnormalis true of an infinity as well as of a denormal, soldexp(0.5, 32767)folds to0where Metal, OpenCL and C all say+inf. The comment names denormals; the condition catches more than it means to. Present in Mesa 26.2.5, the newest release at the time of writing, byte for byte — checked by extracting that one file fromarchive.mesa3d.org, which is a plain file server, because freedesktop's GitLab is behind bot protection and the GitHub mirror paths 404. The merge request that looks like its origin is freedesktop 3982, "nir: Flush to zero with OOB low exponents in ldexp", whose title says both what it was for and what it missed: out-of-bounds low exponents, the high end never considered. The MR itself could not be read, so treat the origin as likely and the state of the current release as established. A one-line fix would beisfinite(dst) && !isnormal(dst). Worth reporting upstream by someone who can open the page; it is a bug for every Mesa frontend whose language keeps IEEE overflow, which is OpenCL and Metal at least. Not worked around here, because the frontend'snir_ldexpis the right opcode.
The check can fail, which is the first thing to establish about any check in this repository, after four that passed for the wrong reason. Reinstating the
fast_roundbug —nir_fround_evenin place of the trunc-and-step — is caught at0.5,-0.5,±2.5and in each component of the vector form separately; dropping the last component ofair.dotis caught on 83 of 100 cases. Every component of a vector result is compared, and every component of a vector argument is given a different value, because with the same value in all of them a lowering that swizzled or dropped a component returns the right answer for the wrong reason.Folded is not run, and the difference is not a matter of degree.
--evalasks NIR's constant folder;intrincheck.py --runbuilds the same operations into shaders that load their arguments from a buffer and hands them toair_run, which draws with them through a real driver's compilation and execution. The buffer is the whole point: ir3 folds too, so a shader callingair.fast_round(0.5)on an immediate is answered by the compiler and the hardware never sees the instruction — a GPU test built that way passes with the GPU switched off. A value loaded from a buffer cannot be folded, andair_runputs bytes the harness chose there (AIR_RUN_BUFFER, added for this).47 of the 78 declarations agree on every one of 2 586 cases, run by softpipe rather than folded; none differ. Seven more are the approximate ones. The remaining 24 touch a type narrower than 32 bits (
i1,i8,i16,f16) and TGSI's registers are 32-bit, so softpipe cannot answer for them:nir_to_tgsiasserts for the integers, which is loud and fine, and for a half it computes at 32 bits instead — it answered32767where a half rounds to32768, which is a verdict about softpipe and not about this frontend.--checkreads the screen out ofair_run's own first line and sets those aside by name. ir3 has real 16-bit registers, so they are a question for the phone and are open.Running them found two things the folding check could not, and could never have:
air.sign(-0)gave+0. Metal'ssignis OpenCL's, wheresign(-0)is-0andsign(NaN)is+0.nir_fsigndoes not promise either; its own documentation says that for a zero source "there is a preference for the result to have the same sign, but this is not required (it is required by OpenCL)". So a backend is licensed to lose it, NIR's constant folder happens not to, and softpipe does — which is exactly why folding the shader could not show it. Same shape as the atan2 bug: an opcode whose contract is weaker than Metal's requirement.air_intrinsics.cnow buildssignfrom comparisons, which promise what Metal needs, rather than asking for a preference.- The 16-bit gap above, which is softpipe's and not ours — but it took a wrong answer
(
32767for a half that rounds to32768) to notice, not an assertion, and a check that had trusted the number would have recorded a frontend bug that does not exist.
It has now run on the phone, and two operations of it are answered there.
--emit DIRwrites the modules, the bytes for their input buffer, amanifest.jsonof what each should return, arun.shfor this machine and anrc.jobsfor the device.--check DIR LOGthen judges the log wherever it was produced, so the arithmetic reference never leaves the host and the device needs nothing butair_run.manifest.jsonis deliberately not carried: nothing on the device reads it, and leaving it behind keeps the stronger claim literally true — the device cannot be made to agree by reading the answer.The reason this took a night longer than it needed to was a sentence in this document, and it was wrong. It said that "getting that directory into the boot image is the flash-root machinery of subsystem (2), which this work is not supposed to touch". It is not.
/redwood/sharealready existed and already carried a data file that a test reads (pattern.bin, whichinitverifies throughread()andmmap()), andEXTRAalready carried arbitrary host files into/redwood/bin.EXTRA_SHAREis ten lines ofbuild.sh. A boundary the brief drew for good reasons had been reached for as the reason not to look into something, and the check that would have cost a minute — reading what/redwood/shareis for — was never made. A constraint is not evidence about a thing it does not cover.What the first run on the phone actually found was in the harness, not the GPU (
logs/074504-run.log, 20 October 07:45). The flash root mounted, the Adreno came up, andair_runopened it:clearFragment01and then two generated modules each printedon Adreno 7c+ Gen 3 (the GPU, through the kernel's msm device).air.fast_sqrt.f32returned1.44972523e-09 2.15926367e+15andair.dot.v4f32returned-inf -inf— bit for bit what the host returns for the same module over the same pattern buffer, which is the comparison those two were put in the job list to make: a job line cannot setAIR_RUN_BUFFER, so they readair_run's own pattern bytes, which the host can read too, and their numbers are therefore comparable without the manifest and without a shell.The other 98 of the 100 modules carried did not run, because the job list ended with
/bin/sh /redwood/share/airops/run.shand the answer was/bin/sh: No such file or directory, status 127. iOS 16.5's own root has no shell at all —/binholdsdfandps, and nothing else. The/var/jb/bin/shthatrc.jobs.jbandrc.jobs.sileorun belongs to the package manager's prefix, which only aJB=1image carries. This was asserted, not checked: agrepforbin/shmatched sixrc.jobs*files and the matches were read as/bin/shwithout opening a line of any of them — the same shape of mistake as the claim aboutair_run's texture format a day earlier, a conclusion drawn from a pattern match instead of from the code. It is now written besideRC_JOBSinbuild.sh, because anyone writing a job list with a shell in it will meet the same 127.So the shell was removed rather than replaced.
air_rungrew--buffer FILE—AIR_RUN_BUFFERas an argument, which is the one thinginit'srun_commandcan express — and--emitnow writes anrc.jobswith one line per module. On the phone 27 modules ran in 0.6 s, about 22 ms each including opening the GPU, so 225 separate processes cost about five seconds and a loop inside one process would buy nothing worth the refactor. (An earlier estimate of 50–150 ms per module, taken from the spacing of nearbylaunchdlines, was wrong by a factor of five; the figure above is from the run that executed them.)Two more things had to be found before the suite would report anything, and neither was in the GPU. The first was in
initand is written up where it belongs (userland/src/init.c,read_whole): three configuration files were each read with oneread()into a fixed buffer with no check, so a file longer than its buffer silently lost its tail. The job list is 21 KB against a 8 KB buffer, so 94 of its 244 lines were read and about 150 modules did not run — which reads exactly like a test that found nothing. Caught before a run rather than after, by counting how many lines fit before claiming that 225 modules had passed.The second is a property of the loader's log and will catch anything verbose: the log keeps the first 256 KB and the last 1 MB and drops the middle, saying so in one line. With
XL_SECS=120andREST=1, iOS wrote more than a megabyte ofos_logafter the suite had finished, so the suite's own output was in the dropped middle — 27 modules survived, at the front.XL_FOLLOW's intermediate log does not help, because the drop has already happened by the time it is fetched, and a filter removes lines that are already gone. Two levers keep a run under the cap and neither is sufficient alone —QUIET=1, which dropslaunchd's own thousands of lines, and a shortXL_SECS— because the volume comes from the number of services and from the length of the session both. What is actually diagnostic is the size of the file: 1 466 58x bytes is the cap, so a log that size is truncated however many drop markers it carries (the intact run here has two and lost nothing; a truncated one had one).README.mdcarries this beside./xl log, with the three runs tabulated, because it catches anything verbose and not just this suite.How much room is left is worth knowing before the suite grows. In the run that worked, at
XL_SECS=30,air_run's own output was 93 459 bytes for 225 modules — 415 bytes each — and the other 1 092 137 bytes, 92% of the log, was iOS booting withQUIET=1already on. So the headroom is about 280 KB, or roughly 650 more modules if they cost nothing in time, and fewer because each one adds 22 ms of further boot noise.--cases 8should still fit; past that it will not, and the answer then is not a bigger cap but to stop booting iOS for a measurement at all:initruns programs itself, beforelaunchd, where the log is nearly empty and nothing can push the output out of it (RC=<file>, asrc.iosdoes it). The GPU is up by then, since the kernel brings it up, and a question about what the hardware does to a number does not need a window server. That is the shape this suite should take if it ever outgrows--cases 4; it has not yet, and the 225 modules above all arrived.- which function each name is — settled by Apple's own compiler, section 3.2. That is where
The atomics, with a kernel written to make a lost update show. This document used to say that none of Apple's kernels could test an atomic and why: "a sum written by threads that never raced looks exactly like a sum written by threads that did. Detecting a lost update needs the expected total to depend on every thread having contributed — which needs a kernel whose arithmetic is known, i.e. one written for the purpose rather than one of Apple's."
tools/air/atomiccheck.pyis that kernel, generated as AIR, assembled withllvm-asand dispatched byair_runover its 4×4 threads, with both device buffers' initial bytes chosen rather than left to the harness's pattern.Four of its five checks are ordinary: the counter reaches the thread count, the atomic load returns what was put there, the signed maximum is the largest thread index, and the signed minimum of
index - 100is-100— that last one is where an unsigned minimum would answer0xffffff9cas a huge number, and where it did when the lowering was broken on purpose to see whether this check works.The fifth is the one that can see a race, and it needs no knowledge of the order the threads ran in. Each thread writes the value its own
fetch_addreturned, so if the increment is atomic those sixteen returned values are the numbers 0 to 15 in some order. A lost update leaves a duplicate and a gap, and the gap names the increment that went missing. Each is written asold * 16 + index + 1, so a slot no thread wrote cannot look like a result and a thread writing another thread's slot does not read as correct either.A second kernel does the threadgroup atomics and the barrier, which the first cannot reach — they go through
nir_shared_atomicand a different path for the memory. Threadgroup memory is not zeroed at dispatch, and neither Metal nor Mesa promises it is, so the kernel initialises it: every thread storing 0 to the same two words does that without needing a branch. Then each thread adds 1 to one word and1 << its own indexto the other, and after the second barrier reads both back into its own slot of two device buffers. Every slot must hold 16 and0xffff, and the two ways it can fail are told apart by which slot is wrong: a lost add clears a bit in every slot and the bit names the thread whose increment went missing, while a barrier that did not wait leaves a smaller number in the slot of the thread that ran ahead.All eight operations agree. The barrier is genuinely under test, which was a surprise worth recording: softpipe does not advance its invocations one at a time but in groups of four, so with
barrier_opmade a no-op every thread sees 4 of 16 increments and the check says so, naming the twelve threads whose bits are missing.Atomicity is still not under test. Softpipe applies a group's atomic lane by lane, so it cannot lose an update however the lowering is written, and the permutation comes out correct by construction. The check passes there by being right rather than by being unopposed. What would make it evidence is the same two kernels on the Adreno, where sixteen threads really do race, and that has not happened. It is also only the eight operations the corpus proves exist, read off its own declarations;
air_intrinsics.calso handlesand,or,xorandxchg, and those stay untested because the names Apple would emit for them are a guess, and a test of a guessed name is a test of the guess.The texture operations, on more than one texel.
tools/air/texcheck.shruns the corpus's own texture shaders and 6 of its 55 return the exact texel on the Adreno; 49 cannot be run, most of them because they areair.visiblelibrary functions and not entry points. Two things that leaves undone, and the second is the serious one:- whole operations have no coverage from it at all — the texel fetch, the size queries;
- every one of those six samples the same texel,
(1, 1), because that is whereair_run's vertex shader puts the coordinate. A lowering that ignored the coordinate entirely and always fetched(1, 1)would pass all six of them.
tools/air/texopcheck.pygenerates a fragment shader per operation and coordinate, asking for the corners as well as the middle. All 35 calls return exactly what was put there, covering 13 of the 31 texture declarations:read_texture_2dwith 32- and with 16-bit coordinates, the two size queries,sample_texture_2dandsample_texture_3d, each of those for float, signed-integer and unsigned-integer textures, andgather_texture_2d. The coordinates are immediates here, unlike the arithmetic: a texture fetch is not constant-folded by NIR or by ir3, so there is nothing for the compiler to answer in the shader's place and no buffer is needed to defeat it.The integer-typed textures work because
air_runalready picks the texture's format from what the shader samples into, with its own comment saying why — "a Metaltexture2d<uint>read out of a float texture gives numbers that are neither right nor obviously wrong". An earlier version of this section said those declarations could not be checked for exactly that reason, which was written from the probe's output without reading the code beside it. They can, and the render target's type has to follow as well or the bits come back read the wrong way.A gather returns one channel of the four texels around the coordinate, and Metal fixes the order they come back in. They came back in GL's order,
(i0j1, i1j1, i1j0, i0j0), but that Metal's convention is the same is not established here — so the four values are compared as a multiset. That checks the footprint and the channel selection without committing to an order, and since every channel gives a different multiset a wrong channel still shows. The coordinate is1.75/4, which is1.25in texel space, so the footprint is{1,2}×{1,2}with nothing ambiguous about the rounding.Made to fail: swapping the first two coordinate components is caught on all five asymmetric coordinates and correctly not on the three symmetric ones, which is the difference between this and
texcheck.shstated as a measurement rather than an argument.What it does not check, and these are the texture half's real remaining gaps:
- Metal's own sampler state. The sampler is the API's — nearest, clamped — and every
coordinate is a texel centre, where nearest filtering has no weights to round and clamping
has no arithmetic. The 64-bit
constexpr samplerword remains undecoded (section 3.3). - The seven declarations whose result or coordinate is 16-bit. They have cases and softpipe
refuses them: TGSI's registers are 32-bit, so
nir_to_tgsiasserts rather than answering wrongly, which is the better of its two failure modes. ir3 has real 16-bit registers, so these are a question for the phone. write_texture_2dandwrite_texture_3d(4 declarations), which need the texture read back after the draw, and the multisample reads (5), which need a multisample texture. Neither is bound byair_run.- The depth sample and the multisample depth read (2): nothing binds a depth texture, and a shader reading an attachment nothing stored to is one of the four tests this port has already had pass for the wrong reason.
Where a shader has a known GLSL equivalent (AppleMetalGLRenderer's shaders are the GL pipeline's fixed functions), rendering both through the port's GLES and comparing images is a fourth check; it is not built yet.
The bitcode reader has its own oracle (section 2): its parse is printed as .ll and diffed
against llvm-dis for every module of the corpus.
4a. What it does and does not keep
Written down because a reader that silently drops something is worse than one that refuses:
- Not kept, and not needed: attributes (
nounwind,memory(none),captures(none),dereferenceable, the#Ngroups), the identification block, debug info, the symbol table's order, use lists, operand bundles. None of them carries meaning the translation reads. - Kept but unused so far:
air.struct_type_infoand theair.arg_type_*keys (reflection: the runtime will want them forMTLArgument), TBAA, the module's hash. - Refused rather than guessed: every record kind not in the subset, a pointer stored to memory
or cast to an integer, a
switch, a call through a pointer, a struct field chosen at run time, a vectorgetelementptr. Each refusal names what it was and lands in the report's table.
Two things the translation leaves to its consumer, by Mesa's convention rather than by choice:
system values (load_global_invocation_id and the rest are emitted as NIR's own, and the
driver lowers them with nir_lower_system_values / nir_lower_compute_system_values, exactly as
the SPIR-V frontend does) and I/O lowering (variables with locations, then nir_lower_io).
5. Milestones and how each is measured
The corpus report (tools/air/corpus.py report) is the progress meter: every number below comes
out of it, and it runs from tools/air/check.sh (section 6). Two corpora:
- the device image — the five
.metallibfiles the port installs (sysimage/rootfs+sysimage/cryptex): 456 modules. This is the corpus the milestones are stated against. - the full IPSW — all 134 MTLB libraries of the iOS 16.5 root filesystem and cryptexes,
16 841 functions, extracted from
rootfs.dmgwith 7-Zip. Reported alongside, because the device image is a selection and the system's own shaders are this set.
Each module goes through stages, and the report counts how many reach each: container (the
module is found and unwrapped) → llvm-dis (stock LLVM reads it) → reader (ours reads it and
agrees with llvm-dis) → translate (air_to_nir succeeds and nir_validate passes) → ir3
(compiles for Adreno 7c+ Gen 3, chip 0x06030500, without error) → softpipe (output matches the
CPU reference) → adreno (output on the phone matches the CPU reference). A module that stops is
counted under the first stage it fails and the reason (an unknown record, an unhandled air.*,
an unhandled metadata key), so "features not yet handled" is a ranked list, not a guess.
M0 — container and corpus: every module reaches llvm-dis; the report exists. Done, and further than stated: all 456 also pass reader, our own reader agreeing with
llvm-dison every one.M1 —
clearFragment01reaches adreno: translated, compiled by ir3, rendered on the Adreno, framebuffer equal to the colour in its constant buffer. Done (20 October, section 5a). Done except for the Adreno itself. It isnir_load_ubo+ a store toFRAG_RESULT_DATA0as predicted, and ir3 turns it into five Adreno instructions (fourmovs out of the constant file, where ir3 promoted the UBO load). Over a buffer of known bytes, LLVM's own execution of the module, our folded NIR, and the pixel drawn by a Gallium driver and read back all give the same four floats, bit for bit. What has not happened is the draw going through ir3 on the phone's GPU: the screen here is softpipe. Section 5a says what is left for that, and it is not much.M2 — the 283 modules with no
air.*call: a stated fraction reach ir3, a growing subset softpipe/adreno. 262 of the 283 reach ir3, 255 of them agree with LLVM numerically, and those same 255 draw a matching pixel. (With textures done the whole corpus reached 313 of 456, and 315 once calls to a function of the module were translated; this milestone's own count is the intrinsic-free subset.) The other 21 areair.visiblelibrary functions, which are not entry points at all (below). Counting every entry point rather than every module: 275 of 321, 86%. Nothing has run on the Adreno.M3 — textures and samplers, each form with a test that renders a known image. Translated, compiled, and six of them rendered on the Adreno and checked. The family took the corpus from 275 modules to 313, and all 313 compile for the Adreno. What is not done is M3's acceptance test: not one texture operation had been run until
air_runlearned to bind one. The forms written are 2D, 3D, 1D, cube and their array and multisample variants, for sampling (plain, explicit level, bias, minimum-level clamp), gathering, fetching by integer coordinate, writing, and the queries (width, height, depth, array size, level count, sample count). Refused with a named error, not guessed at: compare sampling (sample_compare,gather_compare), texture buffers, and a depth texture whose aspect selector is not 1.Six texture shaders have now been run and return the exact texel (
tools/air/texcheck.sh).air_runbinds a texture whose texel is(100+x, 200+y, 300+x+y+z, 400)— 4×4 for a 2D sampler, 4×4×4 for a 3D one — a nearest sampler with clamped addressing, and a coordinate at the centre of texel (1, 1). A single sample or fetch must then return that texel exactly: nearest filtering at a texel centre has no weights to round and clamping has no arithmetic. 2D and 3D, in float, int and uint, all six return it; none returns anything else.Binding the matching target mattered more than it looks: with a 2D texture bound for a 3D sampler the three 3D shaders also "passed", by accident. With a real 4×4×4 texture they return
101 201 303 400— the 3D texel — and only then are they evidence.And all six draw on the Adreno and return the same exact texels (20 October,
logs/003327-run.log,RC_JOBS=rc.jobs.airtex): three101 201 302 400for the 2D samplers and three101 201 303 400for the 3D ones, each under its ownair_run: … on Adreno 7c+ Gen 3 (the GPU, through the kernel's msm device). No crash, no fallback, both flash-root mount lines present, nothing said about missing firmware.That run was worth more than its five minutes, and a peer session had to argue for it: softpipe's agreement proves least exactly here. Softpipe takes a
pipe_sampler_viewand samples it in C, while a6xx wants real descriptors in memory, a sampler state block and the right binding slots for the texture instructions ir3 emits — so six exact texels on softpipe said nothing about any of it, and the whole texture path could have been one the phone never takes. It was also the most diagnostic moment this test will ever have: nearest filtering at a texel centre has no weights to round and clamped addressing has no arithmetic, so a mismatch could only have been binding or descriptors. That narrowness disappears as soon as real filtering is tested.Of 55 texture shaders, 49 are not run, and the reasons are the honest part: 24 are compute kernels (
air_rundispatches those as of 20 October, but softpipe'snir_to_tgsistops most of them — see the kernel table above); 11 areair.visiblelibrary functions; 4 sample a multisample texture the harness does not bind; 1 writes only depth; 1 hits an assertion in softpipe's own NIR-to-TGSI; 8 are other translation refusals. Not one returns a wrong number — the failures are all a refusal to run, which is the shape of failure to want.texturedFragment3DDepthis worth naming because it cost a wrong-looking result: it returns{undef, depth}and stores nothing to its colour attachment, so reading the colour gave the clear value — a number that means nothing and looks like an answer.air_runnow checks that the attachment it reads is one the shader actually stores to, and refuses when it is not.The sampler state is the gap that matters for correctness, and it has a section of its own (3.3). In short: Metal's
constexpr samplerarrives as a global holding one 64-bit word, and that word's layout is not decoded: the frontend reports it to the runtime verbatim (air_resource::sampler_state) and assigns the sampler an index after the API's. Until it is decoded, nothing knows whether a sample is meant to be nearest or linear, clamped or repeated — which is exactly the sort of thing that renders plausibly and wrongly.AGXCompilerCorehas asampler_wordssymbol, so the layout is discoverable the same way the vocabulary was.M4 — atomics, barriers, threadgroup memory, compute against the CPU reference. Atomics, the barrier and threadgroup memory are written (
air_intrinsics.c) and 16 kernels compile through ir3 with them; none has been run, so M4's acceptance test is untouched.
air.visible, and why 135 modules are none of these milestones
135 of the 456 modules — every one in portrait_filters.metallib — carry !air.visible
instead of a stage. They are CoreImage's stitchable kernels: library functions, with
air.visible_input/air.visible_output interfaces and air.stitching_info describing how
CoreImage assembles them into a shader at run time. They have no entry point, so there is no
nir_shader to make of one by itself; each becomes a nir_function linked into its caller at
pipeline time (nir_link_shader_functions). The translator refuses them with
stage: air.visible so that the report counts them as one known thing rather than as 135
failures. 21 of them call no air.* at all, which is why the intrinsic-free count (283) and the
M2 count (262) differ.
5a. Is there a path from a nir_shader to a draw on the phone? Yes.
This was the open question, because nothing here had run. The answer is that the harness can ride the Mesa already on the phone rather than being built from nothing:
- The port's Mesa already creates a Gallium screen for the Adreno:
osmesa_create_gpu_screen()intools/mesa/freedreno-darwin.patchopens the kernel'smsmdriver throughtools/mesa/hakios-drmand callsfd_drm_screen_create_renderonly(). It also already hasosmesa_surface_resource(), which wraps an IOSurface as apipe_resource. So the Gallium layer is reachable inside the library CoreAnimation already draws iOS through. - freedreno takes NIR natively (
PIPE_SHADER_CAP_SUPPORTED_IRSincludesPIPE_SHADER_IR_NIR;ir3_gallium.chandlescso->ir_type == PIPE_SHADER_IR_NIR), so nothing has to go through GLSL on the way. air_run.cis written against that interface and nothing else. Here it asks for a software screen (sw_screen_create(null_sw_create())); on the phone it wants the screenosmesa_create_gpu_screen()makes, and no line below that changes.
That is now built and in the image, and no export was needed. air_run is built inside the
Mesa cross build (AIR=1 tools/mk-mesa.sh), so it links Gallium's own static libraries and opens
the Adreno itself — drmOpenWithType("msm", …) through tools/mesa/hakios-drm, then
fd_drm_screen_create_renderonly(), exactly as osmesa_create_gpu_screen() does. It needs
nothing exported from libOSMesa and does not load it: a 9.8 MB arm64 Mach-O against libSystem,
libz and IOKit alone. userland/build.sh puts it in /redwood/bin, and rc.jobs.air runs it.
The same source takes the GPU where there is one and falls back to software where there is not,
so it is the binary tested on this machine — not a variant of it. Its first line says which it
got (air_run: clearFragment01 on softpipe here), and that line must be read before any number
below it is believed. AIR_RUN_SOFTWARE=1 forces software even on the phone, for comparing the
two on the same device.
It runs on iOS, and the numbers are exact there. In QEMU (CMDLINE="cpus=4 RC_JOBS=rc.jobs.air" qemu/run-ios.sh 420, a clean boot: 72 [ ok ] lines, SpringBoard up),
air_run translated clearFragment01, found no msm device, fell back to softpipe as designed,
drew the quad and read back
2.10170319e-18 0.178894654 1.48687698e+16 1.21394141e+33
— the same four floats, bit for bit, that LLVM computes on a computer and that softpipe draws there. So the whole chain works on the target operating system: the container, the reader, the translation, Gallium, the draw and the readback. What is left is one substitution — softpipe for ir3 on the Adreno — and that needs the phone.
On the phone, with the root on the flash, it runs and is exact — and it ran on softpipe. The
first run of it (19 October, logs/232842-run.log) booted the flash-root recipe properly (both
[ ok ] mount the iOS volume read only and [ ok ] the iOS volume is the root, the RAM part of the boot on /private/preboot), translated clearFragment01 and read back the same four exact
floats as everywhere else. But its first line said on softpipe, and the kernel said why:
adreno: THE GPU IS NOT STARTED: the small image has no fw/a660_gmu.bin
(the phone's /vendor/firmware/a660_gmu.bin goes into fw-gpu/)
The GPU driver had not started at all, because this working copy had no fw-gpu/ — the phone's
own firmware, which is not in the repository. Not the driver refusing work: no driver. This is
exactly what that first line is for: the pixel was right, and reporting it as a result on the
Adreno would have been false. Read the line before the numbers.
Second run, with the firmware in place: the GPU driver came up and air_run died reaching it.
logs/233855-run.log has the Adreno starting properly — the GMU, the zap shader through
TrustZone, the SMMU — and then nothing from air_run but one line, which is the whole reason
that line exists:
air_run: signal 11 at 0x10, pc 0x10037e404, lr 0x1002829b0,
called from 0x1002829b0 0x1000a139c 0x1000077e8 0x1c3fef344 (unslid)
Joined against llvm-nm of the binary by nearest preceding symbol, that is
pc driParseConfigFiles + 0x54 <- the fault, at address 0x10
lr fd_screen_create + 0x344
u_pipe_screen_lookup_or_create + 0xa4
main + 0x1f8
fd_screen_create passes the pipe_screen_config straight to driParseConfigFiles, which
dereferences it; this program handed it a zeroed struct. The port's own
osmesa_create_gpu_screen() fills it in with driParseOptionInfo and driParseConfigFiles over
driinfo_freedreno.h, and that sequence — the one CoreAnimation goes through on this device every
day — is now what air_run uses. A machine with no msm device never reaches that code, so
no amount of running it here would have found it; it took the first contact with real hardware.
Without the fault handler there would have been nothing at all to go on: iOS answers a crash here with "Failed to send exception EXC_CORPSE_NOTIFY" and no report. Four lines of borrowed code turned a silent exit into an exact address.
It drew on the Adreno. M1 is finished.
20 October, logs/000338-run.log, with the config bug fixed:
air_run: clearFragment01 on Adreno 7c+ Gen 3 (the GPU, through the kernel's msm device)
...
air_run: read back from Adreno 7c+ Gen 3 (the GPU, through the kernel's msm device),
ir3's instructions are above
air_run: the pixel at (0, 0) of render target 0 came back as:
2.10170319e-18
0.178894654
1.48687698e+16
1.21394141e+33
A shader Apple compiled for Metal, translated here, compiled by ir3, run on the phone's GPU, giving the colour out of its constant buffer bit for bit. Four independent references now agree on those four floats: LLVM's own execution of the module on a computer, our NIR folded over the same bytes, softpipe, and the Adreno.
Three things had to be asserted rather than assumed, because the pixel looks identical whichever path produced it:
- which screen was created — the first line, which is what caught the earlier run where the GPU driver had never started;
- which screen the pixel came out of — the same name printed again beside the numbers;
- that the shader went through ir3 at all —
--disasm, which puts ir3's own output in the log. It is; FRAG prog 1/1: 5 instr, 0 nops, 5 non-nops, 4 movand fourmov.u32u32 r0.x, c0.x: the same five instructions, mov for mov, thatir3_compilerproduced for this shader on the host months of work earlier. The constant file holds the Metal buffer, which is where ir3 promoted thenir_load_uboto.
And the driver takes work that is not the window server's
logs/000739-run.log, rc.jobs.air2: the same draw after SpringBoard is up — 287 mentions of it
in the log, the ringer sounding at 29 s, the window server holding the GPU and drawing iOS through
src/eagl.c. air_run still got Adreno 7c+ Gen 3 and still read back the same four floats.
So the kernel's GPU driver serves a second process while CoreAnimation is drawing. That question
was owed to the whole port rather than to this frontend, and the answer is yes. rc.jobs.air asks with the GPU idle; rc.jobs.air2 asks again after SpringBoard
is up and holding it. A refusal is a finding the whole port needs, not just this frontend, so
either answer is worth the run.
(Counting anything out of a QEMU log here: qemu/run-ios.sh prints every line twice, and
sort -u does not catch it because the pairs are separated by other output.)
5b. Dispatching a kernel
air_run dispatches a 4×4 workgroup, binds every buffer the kernel declares — constant ones as
UBOs, device ones as writable SSBOs, all filled with the same (i * 7 + 13) & 0xff pattern — and
prints the device buffers afterwards. MetalFaceMask_InitMinMax is the first kernel through it:
[ 0] 0x7fffffff [ 1] 0x7fffffff [ 2] 0x7fffffff
[ 3] 0x80000000 [ 4] 0x80000000 [ 5] 0x80000000
[ 6] 0xcac3bcb5 <- still the input pattern
which is exactly its NIR: six store_ssbos, INT_MAX at offsets 0, 4, 8 and INT_MIN at 0xc,
0x10, 0x14. Six words written, nothing beyond them touched, and the name means what it does — a
min/max table initialised to the extremes.
Two things that only showed because the input was a distinctive pattern, and would have been invisible against a zeroed buffer:
- the first dispatch ran zero threads and the buffer came back untouched. Metal's threadgroup
size is a dispatch argument, so the frontend rightly leaves
workgroup_size_variableset (section 7.4) — but softpipe reads the block size fromTGSI_PROPERTY_CS_FIXED_BLOCK_*, which is then zero. Whoever dispatches has to fix the size;air_runnow does. A kernel calledInitMinMaxwriting zeros to a zeroed buffer would have looked like a pass. - word 6 holding the pattern is what says the kernel wrote only its own 24 bytes.
What this does not test: atomicity. Seventeen of the forty kernels that translate use a global atomic, and a sum written by threads that never raced is identical to one written by threads that did. A test that could fail needs a kernel whose arithmetic is known — one written for the purpose rather than one of Apple's — with the thread's own identity in what it writes. Until that exists, a kernel that uses atomics and produces sensible numbers here has been shown to run, and nothing more.
6. Running it
tools/air/mk-host.sh # Mesa for this machine (x86-64): the frontend, ir3, softpipe
tools/air/check.sh # the reader against llvm-dis, the corpus report, LLVM, the operations
and on one shader:
build/mesa-host/build/src/compiler/air/air2nir --ll FILE[.metallib] [FUNCTION] # what the reader read
build/mesa-host/build/src/compiler/air/air2nir FILE[.metallib] [FUNCTION] # the NIR
build/mesa-host/build/src/compiler/air/air2nir --eval FILE[.metallib] [FUNCTION] # folded to numbers
build/mesa-host/build/src/gallium/drivers/freedreno/ir3_compiler \
--chip-id 0x007506030500 FILE[.metallib] [FUNCTION] # Adreno code
tools/air/cpuref.py --compare FILE[.metallib] [FUNCTION] # ours against LLVM's own answer
tools/air/cpuref.py --all --draw FILE.metallib # every function: LLVM, folded, drawn
tools/air/intrincheck.py [-v] [OP ...] # one air.* operation at a time, folded
tools/air/intrincheck.py --run --few [OP ...] # the same, drawn by a real driver
tools/air/intrincheck.py --emit DIR --few # modules + inputs + run.sh, to carry
tools/air/intrincheck.py --check DIR RUN.LOG # judge what the runner printed
tools/air/atomiccheck.py [--keep DIR] # the atomics, with its own kernel
build/mesa-host/build/src/gallium/drivers/freedreno/air_run \
FILE[.metallib] [FUNCTION] # draw with it, print the pixel
tools/air/texcheck.sh [FILE.metallib ...] # every texture shader, the texel it returns
tools/air/texopcheck.py [-v] [--keep DIR] # the texture calls, several texels each
intrincheck.py with no argument checks every operation the corpus calls; with one or more names
it checks only those, which is how it is used while changing a lowering:
tools/air/intrincheck.py atan2.
--run is the same operations executed instead of folded, and it is a different check, not a
slower one — section 4 item 4 says what each can see that the other cannot. --emit writes the
modules, the bytes for their input buffer, a manifest.json of what each should return and a
run.sh that uses only for, echo and parameter expansion, because what runs it may be the
phone's Darwin userland and not this machine's shell. --check then judges the log wherever it
was produced: the arithmetic reference never leaves the host.
0x007506030500 is how freedreno names the redwood's Adreno 7c+ Gen 3 (A635); it has no
gpu-id, which is why tools/mesa/air-ir3.patch adds --chip-id to that tool.
There is no hosted CI: the corpus is Apple's code and stays out of the repository (and out of
GitHub). check.sh is the CI; it takes about five minutes, most of it the two per-operation checks, so it runs before every commit that touches
the frontend, and the report it writes is committed as docs/air-report.md with the numbers of
that commit.
The frontend lives in tools/mesa/air/ and mk-host.sh links it into the pinned Mesa as
src/compiler/air — a link, not a copy, so there is no stale copy to build by mistake (a trap
this project has already fallen into once with tools/mk-mesa.sh and the freedreno patches).
tools/mesa/air-ir3.patch is the two-file change that teaches ir3_compiler to read AIR and
declares air_run beside it; it also removes a call of ir3_nir_lower_variant that tool makes
itself, which is a bug of the tool — ir3_context_init already calls it on its own clone, and
the const-state setup inside asserts the second time. It shows on every vertex shader and on
anything with image dimensions, and hid on everything else.
That bug cannot be sent upstream, because the file is gone: ir3_cmdline was deleted from
Mesa on 2025-07-23 ("freedreno: remove ir3_cmdline — Nobody is using it anymore", 986530d2,
MR 36313). It is real in the 25.0.7 this port pins, and in every release before the deletion,
and in nothing that upstream still carries. The other half of it is untouched in main
(ir3_context.c still calls ir3_nir_lower_variant), and with no second caller there is
nothing wrong there. So there is no report to file; checking before writing one saved a
maintainer the time.
It leaves a consequence worth being plain about: the ir3 column of the corpus report rests on
a tool upstream has deleted. While the port stays on Mesa 25.0.7 that is fine — the tool is in
the tarball and the patch applies — but it is not a route that survives a Mesa upgrade, and it
could never have been upstreamed. air_run does not share the problem: it uses the ordinary
Gallium interface (screen->context_create, create_fs_state, draw_vbo), which is what the
drivers and the port's own Mesa are built on. The durable way to get the ir3 number after an
upgrade is air_run against a freedreno screen, which means on the phone, because freedreno
needs a real msm device and this machine has none.
A working copy also needs the phone's own firmware for the GPU to start at all: fw-gpu/
(a660_gmu.bin, a660_sqe.fw, the a660_zap.* pieces), which is not in the repository and which
tools/new-workspace.sh does not copy. Without it the kernel says so plainly and the system falls
back to software, so a run that looks fine can be a run on no GPU.
Needed on this machine: meson, ninja, bison, flex, python3 with venv, and LLVM's llvm-as /
llvm-dis for the reader check (22.1.8 here). The host build also patches Mesa 25.0's
src/c11/threads.h, because glibc 2.43 and later declare C11's once_flag and call_once in
<stdlib.h> and Mesa defines them again; the iOS cross build has its own libc and does not.
7. What this decides for the runtime (2) and the kernel ABI (3)
Read this as intent, not as design. The runtime and the kernel ABI do not exist; nothing below
has been built against, and none of it is a measurement. What each item is is the consequence
that this frontend's choices force on whoever writes those parts — so an item can be wrong in two
ways: the consequence may be mis-stated, or the frontend may have changed since. Where the
frontend already does its half, the item says so; where it does not, the item is a promise nobody
has kept yet. The one thing here that has been exercised at all is item 2's buffer binding, and
only through air_run, which is not a Metal runtime and binds by the same indices the frontend
reports.
- Compilation happens at pipeline creation, not at
newLibraryWithData:. Function constants (newFunctionWithName:constantValues:), vertex/fragment varying linking and visible-function linking are only known then. The library step can translate eagerly and cache NIR; the ir3 step cannot run before the pipeline. (On real iOS the compile also leaves the process, forMTLCompilerService; whether this runtime does the same is a decision for (2), and NIR serialization —nir_serialize— makes either possible.) - Buffers are bound per stage by index, as UBOs (constant) and SSBOs (device).
(The frontend's half is done: it emits
load_ubo/load_ssboat Metal's own index and reports the kind per index inair_shader_info.air_runbinds by those indices and the results come back right, which is the only thing that has tested this.)setVertexBuffer:offset:atIndex:maps to the UBO or SSBO slotindexof that stage; the frontend reports which kind each index is. Adreno's UBO and SSBO base addresses have alignment rules Metal's offsets do not (Metal allows 4-byte offsets for constant buffers); the runtime must bind an aligned base and the frontend will take the remainder from a driver uniform when that becomes necessary. Argument buffers (pointers in memory) will need GPU virtual addresses of buffers stable across submissions — a requirement on (3) — and 64-bit global access. - One Metal texture index may be a sampler view or an image depending on the shader's access qualifier; the frontend reports which, the runtime binds accordingly.
- Threadgroup memory passed as an argument is sized at dispatch
(
setThreadgroupMemoryLength:atIndex:): the translator addresses it from base offsets in a driver uniform and Gallium'svariable_shared_memcarries the total (as rusticl does for OpenCL__localarguments). The threadgroup size is a dispatch argument too (dispatchThreadgroups:threadsPerThreadgroup:), soworkgroup_sizeis not fixed in NIR. - Vertex outputs and fragment inputs match by name (
"generated(8texcoordDv2_f)",user(...)), not by location: a pipeline-time link step assigns the sameVARYING_SLOT_VAR*to the same name on both sides. Visible functions (CoreImage's 135 stitchable kernels are all of this kind) are linked into the calling shader by name withnir_link_shader_functions. - (Half done: the frontend sets
origin_upper_lefton the fragment shader.clip_halfzis rasterizer state and nothing sets it —air_runnever needed it, because its quad is already in clip space.) Clip space and the framebuffer are Metal's: NDC depth is [0, 1] (clip_halfz), the framebuffer origin is the top-left; the frontend emits no flips, the runtime's viewport and rasterizer state must say this. - Inline samplers (
constexpr samplerin the source,!air.sampler_states) are created by the runtime and bound after the API's samplers. (Half done, and the half that is missing is the important one: the frontend assigns the index and passes the state word through, but it does not decode it — section 3.3. The earlier wording here said "from the state the frontend decodes", which was never true.)