Ll_buildersPure IR builders shared by both packages (gh-ocannl-954). This library depends only on arrayjit.ir and base; execution and optimization helpers stay in Ll_test.
module LL = Ir.Low_levelmodule Tn = Ir.Tnodemodule Ops = Ir.Opsmodule Idx = Ir.Indexingval single : Ops.precval node_factory :
?prec:??? ->
first_id:Base.Int.t ->
dims:Base__Int.t Base.Array.t ->
unit ->
?dims:??? ->
Base.String.t ->
Tn.tnode_factory ~first_id ~dims () returns a maker of fresh single-precision tensor nodes with consecutive ids above first_id and default dimensions dims (overridable per node). Each test executable picks an id range of its own, so nodes stay distinguishable in debug output.
val materialize : Tn.t -> unitDeclares tn materialized and observable: the executed legs seed and read back exactly these nodes, and observability is also what forbids the buffer-aliasing planner from handing their bytes to another node. Both are declared intent, settled before optimization, so neither perturbs a structural pin — see virtualize for what "declared intent" reaches.
val virtualize : Tn.t -> unitDeclares tn virtual — the standing of the scope-local scalars a virtualizer-emitted Local_scope owns.
This and materialize write the tnode's DECLARED INTENT (Tn.update_memory_mode, the memory_mode_intent field), not a lineage decision. Placement decisions live on the optimize_ctx's placements table, and Tn.Placements.get falls back to the declared intent for a node the lineage has not decided — which is the whole reason a test can hand optimize a node that is ALREADY virtual (or already materialized) before the analyses run, and the reason the passes read it back as such.
val sym : unit -> Idx.symbolval iter : Idx.symbol -> Idx.axis_indexval fixed : Base.int -> Idx.axis_indexval aff : (Base.int * Idx.symbol) Base.list -> Base.int -> Idx.axis_indexaff terms offset is the affine index sum (coeff * symbol) + offset.
val set :
?debug:??? ->
Ir.Tnode.t ->
Ir.Indexing.axis_index Base.array ->
LL.scalar_t ->
LL.tval get : Ir.Tnode.t -> Ir.Indexing.axis_index Base.array -> LL.scalar_tval zero : Ir.Tnode.t -> LL.tval if_ : LL.scalar_t -> LL.t -> LL.tif_ cond body guards body on cond being nonzero (Ir.Low_level.t.If). The condition is read at index precision only when it is an index expression; a value read (the usual flag tensor) keeps the node's precision, which is what single is here.
val loop :
?from_:??? ->
?axis:??? ->
upto:Base.int ->
Ir.Indexing.symbol ->
LL.t ->
LL.tloop ~upto s body iterates s over 0 .. upto INCLUSIVE, mirroring Ir.Low_level.t.For_loop's own bounds; upto < 0 is a dead loop, which is a case worth building. ~axis declares the loop's hardware axis (Ir.Low_level.axis_type.Serial by default): the tests that judge a binding — a Grid block loop, a Workgroup lane, a Workgroup_reduce accumulation — name it here rather than spelling the record.
val loop_n : ?axis:??? -> Ir.Indexing.symbol -> Base__Int.t -> LL.t -> LL.tloop_n s n body iterates s over a range of WIDTH n, i.e. 0 .. n-1.
val set_at : Ir.Tnode.t -> Ir.Indexing.axis_index -> LL.scalar_t -> LL.tset_at tn idx llsc writes the single-axis cell idx — set over a one-element index array, which is the shape of every hand-built one-dimensional case.
Ir.Low_level.t.Scan_loop (gh-ocannl-696): a loop with declared loop-carried scalar state. A carried scalar is a pair of scope ids over one VIRTUAL node -- the state's name and precision, never a buffer -- read as prev and written as next inside the body, rotated prev := next after every iteration. The builders below mint the pair from a node the test declares virtualized, so a case cannot spell the two ids over different nodes or forget the declaration.
val carry : init:LL.scalar_t -> Ir.Tnode.t -> LL.carriedcarry ~init tn is one carried scalar over the state node tn, starting at init (a scalar that may read tensor nodes but no carried state).
val prev : LL.carried -> LL.scalar_tprev cr reads the carried scalar's value from the previous iteration (its init on the first).
val next : LL.carried -> LL.scalar_tnext cr reads the value the CURRENT iteration already assigned with set_next: the rotation is phi-style, so old and new values coexist inside one body.
val set_next : LL.carried -> LL.scalar_t -> LL.tset_next cr v assigns the carried scalar's next value -- exactly once per carried scalar, as a top-level statement of the body, which is the contract Ir.Low_level.validate_scan_loops enforces.
val scan :
?from_:??? ->
?direction:??? ->
upto:Base.int ->
Ir.Indexing.symbol ->
carried:LL.carried Base.list ->
LL.t ->
LL.tscan ~upto s ~carried body iterates s over from_ .. upto INCLUSIVE like loop, carrying carried across iterations; ~direction:Backward counts down instead.
The gather/scatter pair (Ir.Low_level.scalar_t.Get_dynamic / Ir.Low_level.t.Set_dynamic): a read or write whose row along ONE axis is a runtime value rather than an index expression. The ordinary pipeline never hands these to optimize — Assignments lowering emits neither, and the ones the pipeline does mint come from rewrite_one_hot_reductions, which runs after both virtualization arms — so hand-built IR is the only way to put one in front of the analyses (gh-ocannl-734).
Their idcs array is static everywhere except dyn_axis, where the type's contract asks for a Fixed_idx 0 placeholder standing in for the runtime row. The builders below PLANT that placeholder themselves: pass the static indices of the other axes at full array width (whatever sits at dyn_axis is overwritten) and a slot cannot be spelled wrong, nor a dyn_axis pointed outside the array.
val dyn_idcs :
idcs:Idx.axis_index Base.Array.t ->
dyn_axis:Base.Int.t ->
Idx.axis_index Base.Array.tval gather :
tn:Ir.Tnode.t ->
idcs:Idx.axis_index Base.Array.t ->
dyn_axis:Base.Int.t ->
dyn_value:LL.scalar_arg ->
LL.scalar_tgather ~tn ~idcs ~dyn_axis ~dyn_value reads tn at idcs with the dyn_axis row taken from the runtime value dyn_value (an index-valued scalar paired with the precision it is read at — iprec for an index computation, the node's own precision for a row number stored in a tensor). Counts as a read of tn, like get.
val scatter :
tn:Ir.Tnode.t ->
idcs:Idx.axis_index Base.Array.t ->
dyn_axis:Base.Int.t ->
dyn_value:LL.scalar_arg ->
LL.scalar_t ->
LL.tscatter ~tn ~idcs ~dyn_axis ~dyn_value llsc writes llsc into that same cell: set with the dyn_axis row supplied at runtime. Loops whose index reaches dyn_value carry a cross-iteration write dependency, so schedule analyses must treat the write as statically unknown — which is much of what makes this shape worth building by hand.
val scatter_add :
tn:Tn.t ->
idcs:Idx.axis_index Base.Array.t ->
dyn_axis:Base.Int.t ->
dyn_value:LL.scalar_arg ->
LL.scalar_arg ->
LL.tscatter_add ~tn ~idcs ~dyn_axis ~dyn_value addend is the accumulating form tn[.., dyn_value, ..] += addend — the shape rewrite_one_hot_reductions actually mints for the embedding-table gradient. The read-back is an explicit gather of the written cell at the node's storage precision, which is what makes the accumulation visible to read-tracking and to has_accumulation; addend carries its OWN precision, as the matched gradient argument does there — a mixed-precision accumulation (an f32 gradient into a bf16 table) is a shape worth building, and relabelling the addend with the target's precision would build different IR from the one the pipeline mints.
val tile_mma :
?ta:??? ->
?tb:??? ->
?m:??? ->
?n:??? ->
?k:??? ->
?ldd:??? ->
?lda:??? ->
?ldb:??? ->
?tile:??? ->
?lane:??? ->
d:(Tn.t * Idx.axis_index Base.array) ->
a:(Tn.t * Idx.axis_index Base.array) ->
b:(Tn.t * Idx.axis_index Base.array) ->
LL.t ->
LL.tCooperative tile multiply-accumulate
Ir.Low_level.t.Tile_mma: d[i,j] += Σ_{l<k} a[i,l] * b[l,j] over a block of the declared extents, executed jointly by the threads of a Workgroup lane axis (tensor cores / simdgroup_matrix / the register-tiled CPU GEBP kernel), carrying a scalar micro-kernel fallback the renderer falls back to when it declines the block. Hand-built IR is the only way to put one in front of a pass: schedule transforms mint Tile_mma AFTER the optimization pipeline, and Ir.Low_level.optimize rejects one outright — so a test that wants a tile in a routine builds the scalar twin, optimizes THAT, and substitutes the tile into the result.
ldd/lda/ldb default to the declared extents read as a contiguous row-major block — ldd = n, lda = if ta then m else k, ldb = if tb then k else n — a purely syntactic default off the tile's own geometry, NOT a read of the operands' dimensions: an operand whose tile is a window into a wider array, or whose tile major axis sits outside its minor two (a batched site, gh-ocannl-528), passes its stride explicitly. lane defaults to a fresh symbol and tile to None, the renderer's own choice of C-tile geometry (gh-ocannl-619).
val c : Base.float -> LL.scalar_tval embed : Idx.symbol -> LL.scalar_tval binop : Ir.Ops.binop -> LL.scalar_t -> LL.scalar_t -> LL.scalar_tval add : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval mul : LL.scalar_t -> LL.scalar_t -> LL.scalar_tThe builders above are single-precision, which is what a value computation is. A GUARD is not: a comparison and its conjunctions are read at index precision, the same as an Ir.Low_level.t.If's condition and a Where's selector, and building one at single misstates what the pass under test sees. Index precision is read at build time rather than captured once, because it is a configured setting.
val iprec : unit -> Ops.precval embed_idx : Ir.Indexing.axis_index -> LL.scalar_tembed_idx idx embeds an arbitrary index expression, where embed takes a symbol.
val ic : int -> LL.scalar_tic n is the integer constant n as a scalar.
val cmp : Ir.Ops.binop -> LL.scalar_t -> LL.scalar_t -> LL.scalar_tcmp op a b applies an index-precision binary operator — a comparison (Cmplt, Cmple, Cmpeq, Cmpne) or a connective (And, Or).
val lt : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval le : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval eq : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval ne : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval conj : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval disj : LL.scalar_t -> LL.scalar_t -> LL.scalar_tval where_ : LL.scalar_t -> LL.scalar_t -> LL.scalar_t -> LL.scalar_twhere_ cond then_ else_ is the Where ternop, its condition read at index precision and its arms at single — the shape a zero-fringe guard renders as.
val if_idx : LL.scalar_t -> LL.t -> LL.tif_idx cond body is if_ with the condition read at INDEX precision: the standing of a launch-extent or fringe guard, whose condition is an index expression rather than a value read.