Ir.AffineAffine legality queries over access index vectors (gh-ocannl-494, manifesto §6 waypoints 1-2).
Loop nests are generated from einsum projections, so every access in optimized Low_level.t code carries its affine index map (Indexing.axis_index array) natively. This module is the query engine over those maps: the procedural schedule-legality analyses (the default annotator's per-nest hazard agreement rule, the cross-nest alignment rule, C_syntax.parallel_grid_safe's shared rule, the covering checks) are conservative special cases of the decision procedures here, and are being re-derived as queries — one implementation of "is this access pattern race-free" instead of three.
Scope: linear-integer reasoning over box domains — per-axis linear Diophantine equations with gcd/interval infeasibility and forced-equality derivation via the mixed-radix injectivity criterion (the same criterion as Indexing.affine_injective). No full Presburger machinery: every query form needed so far is decided (or conservatively declined) at this level. The address queries read accesses through the coordinate view (coord): a coordinate the caller does not know (Unknown: a dynamic axis, a vector run, a Concat) contributes no information, which errs on the side of declining — soundness is preserved by construction.
gh-ocannl-1162. Indexing.Sub_axis has one meaning in the IR: the axis contributes zero to the row-major flat offset while keeping its stride, so a Sub_axis run followed by a component makes that component a flattened index over the run's whole extent (lowering's flat stores). What a caller does NOT know about an access — a dynamic access's data-dependent axis, a vector store's run — is a different fact, stated out of band to view rather than written into the map: "no information" is unsound for a flattened index, and folding a run into its following component is unsound for a placeholder, so the two must never share a spelling. The address queries take the view; Sub_axis never reaches them.
A coordinate folds span consecutive physical axes whose extents multiply to size: a Sub_axis run with the component after it is one Known coordinate indexed by that component, a trailing Sub_axis is a Known zero of its own axis, every other axis is its own coordinate, and a group containing the dynamic axis or a Concat is Unknown. Pair queries re-coarsen both sides to the coarsest grouping either needs (mixed radix: an un-flattened [h; e] over [H; E] becomes E·h + e; any Unknown part makes the merged coordinate Unknown). Every coordinate is assumed in bounds — the assumption the per-axis reading always made of ordinary components — so two addresses coincide exactly when every coordinate does.
type coord = private | Known of {span : int;size : int;terms : (int * Indexing.symbol) list;offset : int;}The index is the linear form Σ c·s + offset (coalesced), within 0 <= index < size.
| Unknown of {}val sexp_of_coord : coord -> Sexplib0.Sexp.ttype vec_view = | Run of intThe run length: the minor coordinate (the innermost non-unit axis with the unit axes after it and a flattened run before it) becomes Unknown — or the whole map, when the run cannot be proved to stay inside it.
| Blocks of intThe separation form: a minor index divisible by the run length is replaced by its aligned quotient (distinct quotients are disjoint runs) when the runs cannot spill into an outer coordinate; otherwise as Run.
A vector store's opacity (Set_from_vec: length consecutive FLAT cells from the base).
val sexp_of_vec_view : vec_view -> Sexplib0.Sexp.tval view :
?range:(Indexing.symbol -> (int * int) option) ->
?dyn_axis:int ->
?vec:vec_view ->
dims:int array ->
Indexing.axis_index array ->
coord arrayview ~dims idcs: the coordinates of the access map idcs into a node of physical dims (padding included: the buffer the map indexes). ?dyn_axis names the data-dependent axis, ?vec a vector store's run; ?range (loop bounds) lets a run be proved not to spill. A map whose rank differs from dims is one Unknown coordinate.
val coords_to_string : coord array -> stringThe spelling witnesses use for a view.
val axis_extents : dims:int array -> Indexing.axis_index array -> int arrayPer axis of a full-rank access, the extent of the coordinate its component indexes in the view: a component after a Sub_axis run is a flattened index over the run's whole extent product, so an edge guard or an output mask on it compares against that product, never against its own axis's dim (gh-ocannl-1165). Every other axis — the run's own, ordinary components — keeps its dim; a map of another rank is answered with dims.
Can two accesses touch a common cell from different "threads"? Thread identity is a tuple of parallel loop indices; the query works over two copies of the iteration space (the `Left and `Right access's enclosing loops), sharing the symbols that are equal across concurrently executing threads (static indices, loops enclosing the parallel region — a join separates their iterations).
type verdict = | DisjointThe two accesses never touch a common cell at all.
*)| Same_threadCommon cells occur only when every paired parallel symbol is equal — conflicts are confined to a single thread, where program order applies.
*)| Cross_thread of stringA cross-thread conflict is possible, or the engine cannot rule one out; the payload is the witness/explanation (the axis or symbol pair that failed).
*)val sexp_of_verdict : verdict -> Sexplib0.Sexp.tval pair_conflict :
range:(Indexing.symbol -> (int * int) option) ->
dup_left:(Indexing.symbol -> bool) ->
dup_right:(Indexing.symbol -> bool) ->
pairs:(Indexing.symbol * Indexing.symbol) list ->
left:coord array ->
right:coord array ->
verdictpair_conflict ~range ~dup_left ~dup_right ~pairs ~left ~right: verdict on whether the accesses viewed as left and right (over the same tensor node; re-coarsened to their common frame, declined when they span different axes) can touch a common cell from different threads. range s gives the inclusive iteration bounds of loop symbol s (None for static/unknown symbols). dup_left/dup_right select the symbols iterated independently by each side (its enclosing loops within the analyzed parallel region); other symbols are shared — equal across concurrently executing threads. pairs is the thread identity: the parallel symbols of the left copy paired with those of the right copy (for same-nest analyses, pairs of the form (p, p)).
Sound and conservative: Disjoint and Same_thread are proven; everything else is Cross_thread.
val separates :
range:(Indexing.symbol -> (int * int) option) ->
concurrent:(Indexing.symbol -> bool) ->
syms:Indexing.symbol list ->
coords:coord array ->
boolThe separation query: does an access's view tell apart the iterations of a set of loop symbols? Where pair_conflict asks whether two accesses of DIFFERENT program positions can collide, this asks the same engine about ONE access taken twice — the instance-vs-instance form of the question: two instances of the same statement, iterating concurrent symbols independently, can address a common cell of idcs only if they agree on every symbol of syms.
concurrent must cover every symbol whose value may differ between the two instances, not only those of syms: with idcs = acc[w1 + w2] and syms = [w1], holding w2 equal would "prove" that a common cell forces w1 equal, while instances (0, 1) and (1, 0) share acc[1]. syms is then the subset the caller needs told apart.
val axis_index_to_string : Indexing.axis_index -> stringThe rendering of one index component the engine's witnesses use, for messages that quote a cell beside a witness in one spelling.
val separation_failure :
range:(Indexing.symbol -> (int * int) option) ->
concurrent:(Indexing.symbol -> bool) ->
syms:Indexing.symbol list ->
coords:coord array ->
string optionseparates with the engine's witness: None where the vector separates syms, otherwise the explanation pair_conflict gives for the cross-thread conflict — the symbol a common cell does not force equal — for a refusal message that names what failed.
val within_box :
range:(Indexing.symbol -> (int * int) option) ->
coord array ->
boolwithin_box ~range coords: does the viewed access address a cell INSIDE its node — every coordinate within 0 <= index < size — for every valuation of its symbols within their ranges? The interval companion of covers_box, which asks about a bijection onto the box; this asks only that nothing leaves it.
Access validity, as distinct from the distinctness separates proves. A symbol with no range (a static index parameter) and an Unknown coordinate both answer false: an unknown value can be anywhere, and this query is only ever used to license moving an access to where a guard no longer covers it.
Whether a guard may join the levels that Low_level.peel_accum_nest peels down to the accumulation base — the one place the dead-level and lane-sharing hazards of hoisting the accumulated cell's open/close outside a guard are decided (gh-ocannl-722). The full hazard analysis, the Lane_private_if_separated escape and its required separates/within_box side conditions are documented in the implementation.
type peel_guard = | Confined_to_peelEvery symbol the guard mentions is peeled or bound outside every loop.
*)| Lane_private_if_separated of Indexing.symbol list| Not_peelable of stringWith the reason, for a decline log.
*)val peel_guard :
loop_bound:(Indexing.symbol -> bool) ->
peeled:(Indexing.symbol -> bool) ->
guard_syms:Indexing.symbol list ->
peel_guardval covers_box :
range:(Indexing.symbol -> (int * int) option) ->
coord array ->
boolcovers_box ~range coords: whether the viewed access, as its symbols range over their (loop) bounds, enumerates every cell of its node exactly once — a bijection onto the box. This is the write-dominance building block: a covering unguarded write rewrites the whole array. Requirements: each symbol used at most once across the view; per coordinate, a zero-based full-extent iterator, a mixed-radix affine combination of zero-based symbols whose radix chain exactly composes to the coordinate's size, or 0 on a unit-size coordinate. Generalizes (and is checked against) the procedural per-axis rule of C_syntax.first_access_standalone_covering.
val fiber_cardinality :
domain:(Indexing.symbol * int) list ->
Indexing.axis_index array ->
[ `Exact of int | `At_least of int ]fiber_cardinality ~domain idcs: how many points of the loop box domain (symbol, width pairs) map to one given cell in the image of the access map idcs — the per-cell visit count of a read access, and the recompute cost per read site of inlining a setter. Domain symbols absent from the map contribute the product of their widths; when the map is injective on its mentioned symbols (Indexing.affine_injective) that product is the exact fiber size of every image cell (cells outside the image have zero), otherwise it is a lower bound. Sub_axis is read in its IR meaning — it pins and mentions nothing, while the flattened component after it pins its own symbols — which is exact: a coordinate group has one component, and injective coordinates give an injective address.
val fiber_cardinality_ub :
domain:(Indexing.symbol * int) list ->
Indexing.axis_index array ->
[ `Exact of int | `At_most of int ]Upper-bound companion of fiber_cardinality: at most how many points of the loop box domain map to any single cell of the image of idcs. Domain symbols absent from the map contribute the product of their widths exactly; when the map is injective on its mentioned symbols that is the whole fiber and the bound is exact. Otherwise the mentioned symbols' contribution is bounded per component (see the implementation), and the smallest component-wise bound is taken.
Queries about the affine LHS map of a projection: is_surjective decides whether every LHS position is written — used to elide zero-initialization before assignments; is_injective whether no LHS position is written twice — used with is_surjective to elide initialization entirely.
val is_surjective : Indexing.projections -> boolProves exact dense coverage of the row-major LHS buffer from the product-component extents. Handles signed affine sums, shared symbols, Sub_axis strides and independent complete concatenations. Returns false for holes, unknown/runtime extents, unsupported concat-coordinate mixtures or arithmetic outside the proof's safe integer range. When RHS maps are supplied, every target block must have a viable producer (segment-choice proof capped at 1024 combinations); an empty RHS array asks only about the mathematical LHS map.
val is_injective : Indexing.projections -> boolThe extraction target for Low_level.affine_accesses (gh-494 waypoint 1): each tensor-node access as an explicit affine relation — the enclosing loop box, the index map into the node's cells, and the program placement. 'tn abstracts the tensor-node type to keep this module below Tnode in the dependency order.
type path_comp = | Stmt of intStatement index at one Seq nesting level.
| Arg of intA Local_scope occurrence's per-statement evaluation position: two scope bodies inlined into one statement's scalar tree extend distinct bases, so their interior components never interleave. Sibling positions are deliberately incomparable in the visibility rule: evaluation order among one statement's operands is not modeled, so no cross-operand ordering is claimed.
| CondInside an If statement's condition.
| BodyInside an If statement's guarded body.
| RhsInside a Set-family statement's right-hand side (or a Set_local's).
| WriteThe Set-family or Zero_out statement's own write.
gh-561: one component of an access's program position (a_path). Stmt components are per-Seq statement indices; the other constructors encode intra-statement order, which bare statement positions cannot express. Constructor order is execution order, so the derived compare makes lexicographic path comparison program order: a statement's Cond (If condition) evaluates before its Body, and a Set-family statement's Rhs (right-hand side and dynamic-index reads, including Local_scope bodies inlined there) executes before its Write. Every access path ends in Cond, Rhs or Write, and nothing extends a path past Write (writes have no interior), so a write's path is never a proper prefix of a read's.
val sexp_of_path_comp : path_comp -> Sexplib0.Sexp.tWhether two accesses sit in the same Set-family statement — their paths agree above their final component (the write's Write against the statement's own direct Rhs/Cond reads; reads nested deeper, e.g. in a Local_scope body's statements, have longer paths and do not match). This is the path-level counterpart of the a_stmt_write subordination.
val stmt_head : path_comp list -> intThe top-level statement index of a path, -1 when the whole routine is a single statement (its accesses' paths start with an intra-statement component).
type 'tn access = {a_tn : 'tn;a_map : Indexing.axis_index array;The affine map from the loop box into the node's cells. Empty and standing for every cell when a_whole.
a_write : bool;a_dyn_axis : int option;The data-dependent axis of a dynamic gather/scatter (Get_dynamic/Set_dynamic's dyn_axis); None for a static access. The map holds a placeholder there, so it is read through view with ~dyn_axis — that axis an Unknown coordinate, every other axis still known — never by interpreting the placeholder (gh-ocannl-1174).
a_whole : bool;A whole-node access (Zero_out).
a_vec_last : bool;A vectorized write (Set_from_vec): the map is the base of a run of a_vec_len consecutive flat cells along the minor coordinate, not a single cell — viewed through vec_view, never by writing a placeholder into the map.
a_vec_len : int;The run length of a vectorized write along the minor axis; 0 unless a_vec_last.
a_guarded : bool;Under an If guard: executes conditionally, never a definite write.
a_gated : bool;Under a SCALAR gate (gh-ocannl-1016): a read inside a Where arm or a gated operand (Ops.binop_conditionality Gated_second, Ops.ternop_conditionality Cond_and_one_arm) — evaluated only when the gate selects it, a conditionality inside one statement that a_guarded (the If statement) does not carry. A Local_scope body clears it: scope definitions are hoisted out of the expression and execute unconditionally. Always false for a write (statements are never gated).
a_rmw : bool;The statement also reads a_tn on its right-hand side (an accumulation): the write carries a reduction dependence — an order-sensitive legality dimension (the determinism contract): a loop carrying only reduction edges may be reassociated (vectorized) under an explicit license, but never parallelized.
a_val_syms : Indexing.symbol list;Writes only: loop symbols the written value depends on syntactically (index symbols of rhs reads, embedded indices, dynamic-index sub-expressions). Direct dependence only — a chain through another node's cells is not tracked.
*)a_stmt_write : Indexing.axis_index array option;Reads only: the index map of the enclosing Set/Set_from_vec/Set_dynamic statement's write when the read occurs in that statement's right-hand side; None elsewhere (If conditions and Local_scope inner statements carry their own statements' writes). The subject of the read-modify-write exemption (Low_level.rmw_exempt): matching by statement subordination rather than by program path, so a guarded body's write cannot alias its If condition's read (they share a path).
a_loops : (Indexing.symbol * (int * int)) list;Enclosing loops, outermost first, with inclusive iteration bounds.
*)a_path : path_comp list;Lexicographic program-order position: statement indices per Seq nesting level, interleaved with intra-statement components (path_comp) at each statement the traversal descends into.
}val sexp_of_access : ('tn -> Sexplib0.Sexp.t) -> 'tn access -> Sexplib0.Sexp.tgh-ocannl-1016: the sibling view of access, from the same walk (Low_level.affine_relations) — what a statement does that no tensor-node access carries. A "between two points, what else runs?" question is a query over the two lists: a tensor write is an access, everything else that can make code motion observable is a row here. A consumer classifies the kinds with an exhaustive match, so a new kind forces each consumer's decision.
A scope local's identity, as Low_level.scope_id spells it: the node naming the local and its scope id. Two locals are the same local only when both agree.
val sexp_of_local : ('tn -> Sexplib0.Sexp.t) -> 'tn local -> Sexplib0.Sexp.ttype 'tn effect_kind = | Local_write of 'tn localA Set_local, or a Scan_loop's carried-state initialization or per-iteration rotation: the scope local changes value.
| Local_read of 'tn localA Get_local, or a Scan_loop's per-iteration rotation reading the carried next (gh-ocannl-1050): the half of a scope body's inputs no tensor-node access carries.
| Local_declareA Declare_local: a statement-level local comes into scope.
| Scope_bodyA Local_scope occurrence: its body's statements run at this position, hoisted ahead of the statement whose expression holds it, and unconditionally (whatever scalar gate the occurrence sits under). The body's own accesses and effects are rows too, under the path this row names (in_scope_body).
| BarrierA Workgroup_barrier.
| StagedStaged_compilation: opaque code, whose accesses are not enumerated at all.
| MmaA Tile_mma: its fallback's accesses stand for its footprint, but the construct itself is emitted as one tensor-core operation — a code-motion barrier, not the loop nest the fallback spells.
| Merge_read of 'tnA read of the merge buffer sourced from the node (Get_merge_buffer): a separate, transient input — not an access of the node's own buffer, which is why it is no access.
val sexp_of_effect_kind :
('tn -> Sexplib0.Sexp.t) ->
'tn effect_kind ->
Sexplib0.Sexp.ttype 'tn statement_effect = {e_kind : 'tn effect_kind;e_loops : (Indexing.symbol * (int * int)) list;e_guarded : bool;e_gated : bool;Under a scalar gate, as a_gated: only a Merge_read or a Local_read can be; a Scope_body is not, its body being hoisted out of the gate.
e_path : path_comp list;The program position, in the components of a_path: a statement-shaped effect (every kind but Scope_body, Merge_read and Local_read) sits at its statement's Write — a Set_local's right-hand-side reads at the same statement's Rhs order before it; a Merge_read or Local_read at its reading expression's position, like a read access (a scan rotation's read of next at the rotation's Rhs); a Scope_body at the base its body's rows extend (the Arg component).
}val sexp_of_statement_effect :
('tn -> Sexplib0.Sexp.t) ->
'tn statement_effect ->
Sexplib0.Sexp.tval loops_live : (Indexing.symbol * (int * int)) list -> boolWhether every enclosing loop of an access or effect executes: a row under a dead loop (to_ < from_) never happens.
val in_scope_body : path_comp list -> boolWhether a path lies inside a Local_scope body (it crosses an Arg component).
within_statement ~write path: whether path lies inside the Set-family statement whose own write sits at write — its right-hand side, the Local_scope bodies inlined there, or the write itself. false when write is not a write position.
val may_touch_same_cell :
?static_range:(Indexing.symbol -> (int * int) option) ->
dims:int array ->
'tn access ->
'tn access ->
boolWhether two accesses (of the same node) can touch a common cell, each access taken over its whole loop box — the two sides' iterations paired independently, including iterations of loops the sides share (the accesses need not be simultaneous, so a shared loop's symbol varies independently between one side's visit and the other's). Symbols bound by neither side's loops (static indices) are shared parameters, equal on both sides, bounded by static_range when known. dims is the node's dims (the coordinate view's). Conservative: false only when pair_conflict proves disjointness. A dynamic access's data-dependent axis is an Unknown coordinate of its view, so its other coordinates still decide (gh-ocannl-1174); a vectorized access is viewed as a Run of its length (vec_view), its own loop bounds proving whether the run stays inside its minor coordinate; whole-node accesses count as overlapping.
val vec_runs_disjoint : dims:int array -> 'tn access -> boolWhether the runs of a vectorized access (a_vec_last) are pairwise disjoint in the node's flat cell space — the access then touches exactly base image * a_vec_len distinct cells (gh-ocannl-578). dims is the node's dims; the runs move along the minor coordinate of the view. Conservative: false when any of the sufficient conditions (documented in the implementation) is not proved.
val read_covered_before :
?thread:(Indexing.symbol -> bool) ->
?static_range:(Indexing.symbol -> (int * int) option) ->
dims:int array ->
read:'tn access ->
writes:'tn access list ->
unit ->
[ `Covered | `Unknown of string ]The containment query. read_covered_before ~read ~writes (): is every cell the read access can touch necessarily written before the read executes — the dominance side of dependence analysis, and the fifth decision procedure (gh-494 waypoint 2). Unlike the ∃-flavored pair_conflict (negated to prove disjointness), containment is a ∀∃ query — for every read instance there must exist a covering write instance. Visibility is same-common-iteration program order over path_comp paths; loop-carried coverage is declined, conservatively. An Unknown read coordinate (a dynamic gather's row, gh-ocannl-1174) ranges over its whole extent; an Unknown write coordinate (a scatter's row) declines that write. With ?thread naming the parallel (thread-identity) symbols, `Covered proves the cell side of the per-thread-copy transform. The full variable treatment, the cross-statement value side condition, and the union rule for partial covers are documented in the implementation.
Guarded writes are the caller's choice: include them to mirror guards-taken analyses (Low_level.trace_node_facts and the coverage queries take guards unconditionally), pre-filter a_guarded for execution-accurate coverage. writes must be accesses of the same node as read, whose dims are dims: each write is compared with the read in their views' common frame.
Config legality_crosscheck: when enabled, the call sites swapped onto the queries also run the legacy procedural analysis and compare. A query stricter than the procedural answer raises — either a query precision regression or a latent unsoundness of the procedural rule, both needing eyes. A query more permissive than the procedural answer is the expected precision gain, logged to stderr for review.