Sarek_interp.Sarek_ir_interp_valuemodule F32 = Sarek_float32type value = Sarek_value.value = Re-export value type from Sarek_value for convenience
Used for synchronizing threads at barriers. Each thread is suspended when it hits a barrier, and all threads are resumed together.
type env = {vars : (int, value) Stdlib.Hashtbl.t;var_id -> value
*)vars_by_name : (string, value) Stdlib.Hashtbl.t;var_name -> value (fallback)
*)arrays : (string, value array) Stdlib.Hashtbl.t;array_name -> data
*)funcs : (string, Sarek_ir_types.helper_func) Stdlib.Hashtbl.t;helper functions
*)coopmats : (string, value array) Stdlib.Hashtbl.t;Cooperative-matrix fragments, by fragment name — backlog-62 slice 3.
A SEPARATE table from vars and arrays because fragment names are a separate namespace in the IR: a fragment is not a variable and not an array, and merging it into either would make a name collision between a fragment and a variable silently resolve to one of them.
The model. A subgroup-scope fragment is held collectively by the whole subgroup, with each invocation holding a few components at an implementation-defined position. The interpreter has no subgroup, so it holds the WHOLE matrix redundantly in every invocation and has every invocation perform the whole operation. That is observationally equivalent to the device — a coopMatStore then writes the same value to the same location once per invocation instead of once per subgroup — precisely BECAUSE GL_KHR_cooperative_matrix requires the buffer, index and stride arguments to be dynamically uniform across the scope. The redundancy is not an approximation; it is the same function computed the only way a scalar interpreter can compute it.
}val create_env : unit -> envEnvironment for a HELPER CALL: the callee's own scope, not a copy of the caller's.
copy_env duplicates vars/vars_by_name, which is right for a nested block — a block sees its enclosing locals — and wrong for a function call: carrying the caller's bindings in makes the callee's lookups depend on ids and names not in its scope, and since lookup_var resolves by id before name, a caller binding can answer a callee reference.
"A helper sees only its parameters" would be the clean statement and it is NOT true of this language: a module-level constant (MConst) is declared before the helpers and IS lexically visible in their bodies, yet lowering emits it as an SLet at the head of the KERNEL body — into vars, the one table this does not alias. Such a kernel is broken on both sides of this change today (it fails on the base revision too, for an unrelated positional-id reason), so nothing regresses here — but the scope model below is narrower than the surface language, and the two must be reconciled BEFORE the hf_params follow-up lands: that fix would turn a masked failure into a hard Unbound_variable which copy_env was accidentally covering.
No test distinguishes this from copy semantics: reverting it leaves the whole suite green, measured by two independent review passes. That is not because it is decoration — it is the PRECONDITION that makes get_array's new precedence sound. That lookup consults vars_by_name BEFORE arrays so a helper's formal shadows a kernel array of the same name; under copy semantics vars_by_name also holds the CALLER's locals, so the same precedence would let a caller binding answer a lookup that should reach the kernel's array. The two changes are one change and land together.
No test separates them because a caller local holding an array value is not expressible today: kernel vectors live in arrays, and a let rec in the kernel body — which could close over one — is rejected at parse time. So the dependency is argued, not measured, and this says which of the two it is.
coopmats is fresh where copy_env copied it. A fragment belongs to the block that declared it, which is right for one the helper declares itself; if coopmat ops in helper bodies ever become expressible against a KERNEL-scope fragment, this must alias coopmats too.
arrays, shared and funcs stay ALIASED, deliberately: kernel buffers, block-shared memory and the helper table are genuinely global to the invocation, and arrays in particular is shared across threads by design, so it must not be copied.
Detach a record value from whatever container it was read out of, so that a LOCAL holding it cannot write back through it.
A VRecord carries a MUTABLE value array, and reading an element out of a vector hands back that same array rather than a copy — which is precisely what makes v.(i).f <- e land in storage (Sarek_ir_interp_eval.assign_lvalue's LRecordField arm). A local binding must NOT inherit that sharing: on every other backend
let e = v.(tid) in e.p <- 42.0
stores into a copy (the C-family emits a struct-copy local, Native marshals a fresh record out through Vector.get) and leaves vector storage alone. Measured with 4 elements: Native / OpenCL x2 / Vulkan x2 / CUDA-PTX x2 all read back 0 1 2 3, while the sharing interpreter read back 42 42 42 42. Pinned by test_record_local_alias_agreement.ml.
The copy is DEEP through records — a shallow one leaves e.sub.p <- 42.0 writing into an inner record still shared with storage — and through variant payloads, for the same reason.
It deliberately stops at VArray. An array binding must keep aliasing: a kernel buffer is shared across threads by design and block-shared memory is shared within the block, so copying one would break both. Arrays are reference-like on every backend; records are values.
Depth bound. The recursion is bounded by detach_max_depth and raises past it. Two things are true at once here and the guard exists because of the gap between them.
A cyclic value is not constructible through the DSL. A back-edge needs a type whose field (or variant payload) type is the type itself, and [@@sarek.type] refuses that at declaration:
type rec_r = {here : float32; next : rec_r} [@@sarek.type]
type rec_v = A | B of rec_v [@@sarek.type]both fail to compile with "sarek: unknown alignment for field type 'rec_r' - register it with %ktype before using it as a record/variant field", because a field type must already be registered and registration happens at the end of the very declaration that would close the loop. Neither arm of the recursion below has a source-level way in. (Verified by compiling both.)
But that argument is a property of the PPX, in another module, and this function's parameter is a bare value. The interpreter is dynamically typed over value, and the in-place field store this whole file exists for writes fields.(i) <- e with no check that e's shape is the field's declared type; a future emitter change, a relaxed layout rule, or any other producer of value could close a loop that no declaration did. So the argument is recorded rather than relied on.
What the guard buys, measured rather than asserted — and re-measured, since the two previous numbers written here were both wrong. It is not "an infinite loop" (round 4's claim), and it is not "about a second" (the correction to it, off by roughly 36x). The recursion is not tail recursive, so an unguarded cyclic value does not hang; it dies with Stack_overflow, but slowly, because every level runs the Array.map below and so ALLOCATES an array per frame. The cost is dominated by that allocation and the resulting GC work, it is not a fast stack walk.
Measured on one host — Linux x86-64, OCaml 5.3.0, ulimit -s 8192, OCAMLRUNPARAM unset — by replacing the depth > detach_max_depth test below with false && depth > detach_max_depth:
VRecord (the value array is mutable, so a field can hold its own record) and calling detach_record on it raised Stack_overflow after 36.6s of wall clock: 36.80s, 36.48s, 36.60s over three runs, CPU time within 2% of wall, about 84M minor words allocated on the way down.exception Stack overflow, in 62.4s / 63.5s / 71.4s over three runs — noisier than the probe because it makes two such descents and the machine was not quiet. With the guard restored the same suite exits 0 in 0.003s.Only that one configuration was measured, and the seconds are a property of it, not of the code: a larger ulimit -s buys more levels before the overflow and therefore more time. So the load-bearing claim here is the DIRECTION, not the figure — tens of seconds of allocation and GC thrash ending in an untyped crash, rather than a hang, and equally not a fast failure someone could shrug at. It is worth replacing on either reading. A Stack_overflow escaping through the interpreter is an untyped crash naming neither the value nor the binding; this raises Unsupported_operation with the operation and the bound in it. A diagnosable error is better than a crash.
The bound is on DEPTH, not on visited identity, because pointer-identity tracking would cost an allocation per bind on this hot path, while a legitimate nesting depth is small — the deepest nesting any @@sarek.type declaration in this repository reaches is two levels (colored_point over point in tests/e2e/test_nested_types.ml, l2 over l1 in the record-field-store tests).
"Small" there is a claim about plausible types, not a limit the compiler imposes, and the wording that used to sit here — 64 is "far above anything expressible", the nesting being one "which the layout rules already bound" — asserted the second. Checked, and it is false. A chain of 65 DISTINCT declarations,
type float32 = float type t64 =
: float32} [@@sarek.type]
type t63 = {f : t64} [@@sarek.type]
(* ... one per level, down to ... *)
type t0 = {f : t1} [@@sarek.type]
compiles and links clean through the ppx (built as an executable under
tests/e2e/ and run). Nothing caps chain length: each link's size and
alignment are resolved by one lookup of the already-registered field type in
the ppx's size/alignment tables, so a link costs the same whether it is the
2nd or the 65th. The unreachability argument above is about a SELF-referential
field type, and a finite chain of DISTINCT types is a different thing — it
registers cleanly, innermost first, in declaration order.
Bound to a LOCAL, that [t0] is 65 nested [VRecord]s (the ppx's marshaller
emits one per link rather than flattening), so [t64] is reached at depth 64
and its [float32] at depth 65, which trips the test below. A legal type,
refused. Two things narrow it: the chain must be RECORDS, since the layout
rules refuse a variant nested below the top level, and 64 links pass — 65 is
the first that does not.
What was and was not observed, because the paragraph above is prose and
NOTHING IN THE TREE GOES RED IF IT STOPS BEING TRUE. The 65-link chain was
generated, compiled and run once by hand, in round 5; the probe was reverted
rather than committed, so no test covers it. That the refusal then FIRES at
depth 65 is weaker still — it is read off the marshaller and [bind_var], not
observed, since no kernel binding a [t0] local was written.
Be precise about which way that fails, because the two halves fail
differently. If the ppx later grows a chain-length cap, or the marshaller
starts flattening, then the CONCLUSION drawn below stays safe: the guard would
merely be conservative about a false positive that can no longer happen. But
the FACTUAL sentences above ("compiles and links clean", "nothing caps chain
length") would simply be false — stale, and stated with more confidence than
an unchecked claim earns. That is a documentation rot risk, not a correctness
risk, and it is the honest characterisation. It is the same shape as the
defect this round exists to fix, one order weaker.
Left uncovered on purpose. A committed fixture here would pin the ABSENCE of a
ppx limitation, so it would fire at anyone who later adds a chain-length cap —
a change nobody has argued against — and read as a promise that deep chains
are supported, which is the opposite of this note's point. If coverage is ever
wanted, pin the boundary on THIS side instead, where the behaviour actually
matters and the ppx is not involved: [nest 64] is copied and [nest 65] is
refused, six lines in test_detach_record_depth.ml, no ppx compile.
So the honest bound is: 64 is far above anything PLAUSIBLE, not above
anything expressible, and far below a stack overflow. The trade is kept
deliberately. The false positive needs a hand-written 65-link chain of
distinct types, and it fails LOUDLY — the refusal below names the operation
and the bound, so whoever wrote that chain is told what to raise — whereas
identity tracking would tax every record bind in every kernel. If such a type
ever shows up in earnest, raise [detach_max_depth]; do not read this comment
as a promise that it cannot.The interpreter's variant tag for a constructor NAME.
The definition for everything INSIDE the interpreter, and the whole of it: Sarek_ir_interp_eval's EVariant arm and both of its matchers (EMatch, SMatch) call this rather than repeating Hashtbl.hash _ mod 256, so a value built here and an arm selected there cannot drift apart. They were three separate copies of the expression, which is how a fourth copy — a literal 0 — went unnoticed.
ONE copy is left outside: Sarek_ppx's Ptype_variant helper emits Hashtbl.hash "C" mod 256 as a RUNTIME expression into generated user code, so a @@sarek.type variant round-trips to the same tag. It could be routed here — the generated code already reaches Sarek.Sarek_value by a forwarding alias — and is not, so it is a duplication rather than a boundary. If the encoding here changes, that emitter is the second place to change, and it says so at its own site.
mod 256 means the encoding is NOT injective: two constructors of the same type whose names collide modulo 256 select each other's arms. That is a pre-existing property of the interpreter's variant representation, not something introduced here, and it is left alone; naming the function at least gives it one place to be fixed.
val default_value_of_elttype : Sarek_ir_types.elttype -> valueArray allocation — backlog-206
Zero value of an element type, used to fill a freshly declared kernel array (EArrayCreate / DShared).
Records and variants used to fall through to VUnit here, which is why let%shared (s : tri) = 4l followed by s.(i).a <- e raised "assignment target of .a (got unit)" on the Interpreter while Native accepted the same store — the divergence backlog-206 is about. A record element type now gets a VRecord whose fields are themselves zeroed, and a variant with at least one constructor gets a real constructor with zeroed payloads. A TVariant carrying an EMPTY constructor list still gives VUnit: there is no constructor to pick, the PPX produces no such type, and inventing a tag for it would be worse than the VUnit.
WHICH constructor, and WHAT TAG, are both load-bearing, and the first version of this got the tag wrong.
The tag is not a positional index in this interpreter. EVariant encodes Hashtbl.hash ctor_name mod 256 (Sarek_ir_interp_eval), and EMatch/SMatch select an arm with exactly that predicate. A literal 0 therefore matches the first constructor only if its name happens to hash to zero — so a default slot matched NO arm and the interpreter raised "Pattern match failure in SMatch". Measured on a registered type c2 = A of float32 | B whose unwritten shared slot was read: Interpreter x2 RAISED where Native answered B. The tag now goes through variant_tag_of_ctor, the same encoding the evaluator uses, so a default slot decodes to the constructor this function chose.
The constructor is the first NULLARY one if there is one, else the first constructor with zeroed payloads. The nullary preference is not an aesthetic choice: it is what Sarek_native_helpers.default_value_for_type does (List.find_opt over the constructors for one with no argument), so for a variant that HAS a nullary constructor the two CPU backends put the same constructor in the slot — and a CPU-backend disagreement about the contents of a freshly declared shared array is the exact shape of divergence backlog-206 was filed as. Measured for type c2 = A of float32 | B: Interpreter x2 and Native all answer B.
The FALLBACK is where they stop agreeing, and it is one-sided rather than divergent. With no nullary constructor Native takes the first one and recurses on its argument type, which has no arm for a TUPLE argument — so C of float32 * float32 as the only shape reaches failwith "Cannot create default value for this type" and the kernel does not initialise at all on Native, where this function zeroes each payload component and carries on. The interpreter is strictly more defined there; that is not two answers to one question, and it is NOT asserted as agreement. Nothing here changes the Native side.
None of this is a promise that reading an unwritten slot is DEFINED. __local/shared memory is uninitialised storage on every device, so any value read before a write is arbitrary there; what these two paragraphs buy is that the two backends which do have to put something in the slot put the same thing, and that the something is a value the interpreter can match.
TUint8 gets VInt32 0l, and that is a GUESS rather than a convention: TUint8 appears nowhere else in this interpreter, so unlike TFloat16 — whose VFloat32 carrier is established by Sarek_ir_interp_eval and Sarek_ir_interp — nothing here fixes its representation. It exists only as a cooperative-matrix operand element type, which does not reach a kernel array declaration, so the arm is unreachable in practice; it is written this way so that the match is exhaustive rather than because a zero of that type has been agreed. If TUint8 ever becomes a real element type, decide its carrier before trusting this.
TArray and TVec elements stay VUnit: an array of arrays is not expressible as a kernel array element type on any backend, and inventing a nested VArray of an unknown length here would be a guess.
Plain recursion, no depth guard, unlike detach_record. The argument is different in kind: this walks an elttype, a finite tree the PPX builds bottom-up, where detach_record walks a value, which the interpreter's in-place field store can shape at run time.
val alloc_kernel_array : Sarek_ir_types.elttype -> int -> value arrayAllocate a kernel array of size elements of type ty.
Array.init, never Array.make: for a boxed element type (a record, or a variant with a payload) Array.make stores ONE value in every slot, so s.(0).f <- e is visible through every index. That is exactly the Native half of backlog-206, whose CPU-runtime allocator had the same Array.make size default shape. Slots must be independent allocations.
val bind_var : env -> Sarek_ir_types.var -> value -> unitBind a variable in the environment (both by id and name).
Records are detached on the way in (see detach_record): binding is what creates a LOCAL, and a local record is a value, not a window onto the container it was read from. Every binding site goes through here — SLet, SLetMut, the SFor loop variable, helper parameters, and a whole-variable assignment — so the rule holds uniformly rather than at whichever site someone remembered.
NOT covered: the variant-pattern binders in SMatch/EMatch, which write vars_by_name directly. Their payloads come from a scrutinee that is itself already a local in every shape expressible today, so there is no container to write back into; if a variant read straight out of a vector ever becomes a match scrutinee, those two sites need this too.
val lookup_var : env -> Sarek_ir_types.var -> valueLook up a variable (try id first, then name as fallback)
val to_int32 : value -> int32val to_int64 : value -> int64val to_int : value -> intval to_float32 : value -> floatval to_float64 : value -> floatval to_bool : value -> boolval eval_binop : Sarek_ir_types.binop -> value -> value -> valueval eval_unop : Sarek_ir_types.unop -> value -> value