Module Ll_builders

Pure 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_level
module Tn = Ir.Tnode
module Ops = Ir.Ops
module Idx = Ir.Indexing
val single : Ops.prec

Tensor nodes

val node_factory : ?prec:??? -> first_id:Base.Int.t -> dims:Base__Int.t Base.Array.t -> unit -> ?dims:??? -> Base.String.t -> Tn.t

node_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 -> unit

Declares 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 -> unit

Declares 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.

Index and statement builders

val sym : unit -> Idx.symbol
val fixed : Base.int -> Idx.axis_index
val aff : (Base.int * Idx.symbol) Base.list -> Base.int -> Idx.axis_index

aff 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.t
val get : Ir.Tnode.t -> Ir.Indexing.axis_index Base.array -> LL.scalar_t
val zero : Ir.Tnode.t -> LL.t
val seq : LL.t -> LL.t -> LL.t
val if_ : LL.scalar_t -> LL.t -> LL.t

if_ 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.t

loop ~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.t

loop_n s n body iterates s over a range of WIDTH n, i.e. 0 .. n-1.

set_at tn idx llsc writes the single-axis cell idxset over a one-element index array, which is the shape of every hand-built one-dimensional case.

Scan loops

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.carried

carry ~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_t

prev cr reads the carried scalar's value from the previous iteration (its init on the first).

val next : LL.carried -> LL.scalar_t

next 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.t

set_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.t

scan ~upto s ~carried body iterates s over from_ .. upto INCLUSIVE like loop, carrying carried across iterations; ~direction:Backward counts down instead.

Dynamic indexing

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 optimizeAssignments 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.t
val gather : tn:Ir.Tnode.t -> idcs:Idx.axis_index Base.Array.t -> dyn_axis:Base.Int.t -> dyn_value:LL.scalar_arg -> LL.scalar_t

gather ~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.t

scatter ~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.t

scatter_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.t

Cooperative 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).

Scalar builders

val c : Base.float -> LL.scalar_t
val embed : Idx.symbol -> LL.scalar_t

Index-precision scalars

The 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.prec

embed_idx idx embeds an arbitrary index expression, where embed takes a symbol.

val ic : int -> LL.scalar_t

ic n is the integer constant n as a scalar.

cmp op a b applies an index-precision binary operator — a comparison (Cmplt, Cmple, Cmpeq, Cmpne) or a connective (And, Or).

where_ 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.t

if_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.