Facts and specialization¶
Example files: loom/docs/examples/elementwise-transform/ and loom/docs/examples/guide/command-programs/
Loom specializes from facts already present in the program. Constants, dynamic shape bindings, configuration contracts, control-flow conditions, storage assumptions, and target profiles all contribute knowledge to the same program instead of disappearing into separate graph, kernel, and backend layers.
In this chapter, you will learn:
- where facts originate and how they follow SSA values;
- when to use configuration, workload arguments, assumptions, or executable assertions;
- how value predicates and target requirements select template providers;
- why unknown facts are different from false requirements; and
- how specialization removes alternatives without creating runtime dispatch.
Facts are inputs to compilation¶
A fact is a compiler-visible statement about a value, storage root, execution scope, or target version. Common fact categories include:
| Category | Examples |
|---|---|
| Scalar value | Exact constant, signed range, nonzero, power of two, known divisor, finite floating point. |
| Shape and representation | Dynamic dimension binding, element type, vector footprint, encoding, address layout. |
| Storage | Root identity, byte extent, alignment, memory space, alias scope. |
| Execution | Lane-varying or uniform value, workitem and workgroup topology domains. |
| Target | Subgroup size, resource limits, supported operations, ABI and artifact capabilities. |
Operations publish facts for their results and transfer facts from operands. An exact constant stays exact through arithmetic that can be folded. Ranges and divisibility propagate through index calculations. A view preserves its buffer root and layout facts. Kernel coordinate queries carry their topology and uniformity domains.
This analysis is always on. Authors add facts at boundaries where the compiler cannot derive them; they do not manually annotate every intermediate result.
Compose runtime sizes from facts¶
Runtime sizes can retain useful guarantees through arithmetic without becoming constants. For example, this group width remains a power of two after capping and scaling, and divides an extent whose caller promises a multiple of sixteen:
%extent = index.assume %extent_input [range(%extent_input, 16, 4096), multiple_of(%extent_input, 16)] : index
%shift = index.assume %shift_input [range(%shift_input, 0, 4)] : index
%unit = index.constant 1 : index
%cap = index.constant 8 : index
%scale = index.constant 2 : index
%requested_width = index.shli %unit, %shift : index
%capped_width = index.min %requested_width, %cap : index
%group_width = index.mul %capped_width, %scale : index
%group_count = index.div %extent, %group_width : index
The caller contracts belong on %extent_input and %shift_input. The derived
width needs no repeated power_of_two assumption. The same guarantees survive
index.max, selection between qualifying alternatives, and positive quotients
of powers of two. These facts let bounds analysis relate a channel's group
coordinate to a dynamically sized group view.
Carrier semantics still matter. Unsigned widening preserves a nonzero bit
pattern; truncation may turn it into zero. A wrapping multiplication may lose
its power-of-two guarantee, and a quotient that can be zero is not a positive
power of two. Casts remain explicit. index.cast maps Booleans to 0 or 1;
other narrow integer payloads enter index as signed values and offset as
unsigned bit patterns.
The executable integer lookup examples show grouped and remainder-addressed views with runtime policy choices, an independent oracle, and guarded input/output storage.
Declare compile-time configuration¶
config.decl names a value that a
composition root must eventually supply. Its where clause constrains the
value even before an exact definition is available:
config.decl @model.layer_count : %value: index where [range(%value, 1, 256)]
config.decl @model.prefill_batch_size : %value: index where [range(%value, 64, 512), multiple_of(%value, 64), power_of_two(%value)]
config.decl @model.weight_encoding : encoding<schema>
range(%value, lo, hi) is inclusive. multiple_of(%value, 64) records
divisibility without performing multiplication. power_of_two records a
power-of-two contract. Relational predicates such as lt, le, eq, and
ne may relate a value to a literal or another SSA value.
config.get makes the dependency an
ordinary symbol edge and exposes its typed facts in executable IR:
%layer_count = config.get @model.layer_count : index
%prefill_batch_size = config.get @model.prefill_batch_size : index
%weight_encoding = config.get @model.weight_encoding : encoding<schema>
A source config.def, a --config
binding, a JSON/JSONC configuration object, or an embedding's loomc options
can provide the exact value. Materialization replaces the declaration with a
definition, validates its predicates, and lets ordinary canonicalization fold
dependent code. Encoding-valued configuration can select a complete physical
schema such as #ggml.q4_0 without reducing that choice to an integer enum.
Partial links may preserve reachable declarations. A final executable
compilation requires every reachable config.get to resolve to exactly one
definition. Bindings for keys that a selected root does not use are ignored,
so one model configuration bag can serve several smaller compiled programs.
The linking workflow shows schema inspection, individual bindings, and resolved-config enforcement.
Keep per-launch values out of global configuration¶
Configuration describes a compiled artifact version. A kernel workload describes one launch. The distinction matters when the same kernel appears several times in one command program or accepts many shapes without recompilation.
| Value changes when... | Source boundary |
|---|---|
| Building or selecting an artifact version | config.decl / config.get. |
| Evaluating one kernel launch | Kernel workload argument. |
| Issuing one device dispatch | Kernel launch argument. |
| Specializing one reusable command program | Command specialization argument. |
| Binding weights, cache, input, or output storage | Command buffer binding. |
A maximum supported row count can be configuration while the actual row count remains a workload and device argument. This preserves one reusable artifact and still gives the compiler a finite capacity for launch planning and bounds:
%row_capacity = config.get @model.row_capacity : index
%bounded_rows = index.assume %row_count [range(%row_count, 1, 65535), le(%row_count, %row_capacity)] : index
Moving every dynamic value into configuration creates global variants and prevents one program from launching the same kernel with different workloads. Keep a value dynamic unless changing it genuinely defines a new artifact or specialization boundary.
Refine the value that carries a proven contract¶
index.assume returns identity
aliases with stronger predicates. Use it when a fact comes from a caller or
storage producer and is not visible in the current control flow:
%expert_id_i32 = view.load %routing[%token] : view<[%token_count]xi32> -> i32
%expert_id = index.cast %expert_id_i32 : i32 to index
%bounded_expert_id = index.assume %expert_id [range(%expert_id, 0, 127)] : index
The result equals the operand, but only %bounded_expert_id carries the new fact
edge. Memory accesses, casts, loop transforms, and provider selection that need
the proof consume the refined value.
Range endpoints may be SSA values when one dynamic quantity is bounded by another. Each endpoint contributes the interval facts already known for that value, so the count below is non-negative and no greater than the maximum possible row count without repeating that maximum:
%rows = index.assume %rows0 [range(%rows0, 1, 709)] : index
%count = index.assume %count0 [range(%count0, 0, %rows)] : index
This form keeps the relationship to %rows as well as the finite interval. It
is useful for routed expert counts, page-table lengths, ragged tile extents, and
other loaded metadata whose capacity is already represented by an SSA value.
scalar.assume provides the same
contract for fixed-width integer and floating-point payloads:
%finite_scale = scalar.assume %scale [finite(%scale)] : f32
%aligned_length = scalar.assume %length [multiple_of(%length, 16)] : i64
An assumption is not an executable check. It records a fact established by the
surrounding program, caller contract, or dominating condition. If runtime input
is untrusted, sanitizer.assert.value
checks the predicates, aborts the execution path on failure, and returns
refined aliases on success:
%checked_length = sanitizer.assert.value %length [range(%length, 0, 4096), multiple_of(%length, 16)] : index
The distinction is semantic: assume makes a promise to the compiler;
sanitizer.assert.value enforces a promise at runtime.
Control flow contributes path facts¶
A dominating structured condition can establish facts without repeating an assumption. Template selection and later analysis evaluate an application site in its control-flow context:
%tile_size_sixteen = index.constant 16 : index
%is_tile_size_sixteen = index.cmp eq, %tile_size, %tile_size_sixteen : index
%result = scf.if %is_tile_size_sixteen -> (i32) {
%specialized = template.apply<@guide.scale>(%tile_size, %value) : (index, i32) -> (i32)
scf.yield %specialized : i32
} else {
%fallback = template.apply<@guide.scale>(%tile_size, %value) : (index, i32) -> (i32)
scf.yield %fallback : i32
}
Inside the first region, %tile_size == 16; inside the second, it does not.
The condition controls runtime execution and simultaneously gives each
compile-time application site a stronger context. This lets a structured
program preserve the reason a specialization is valid instead of cloning a
function under an opaque generated name.
The same path facts feed bounds proofs and explicit loop schedules. For
example, a true %remaining >= %tile_size edge makes
index.min %remaining, %tile_size exact inside that region, so an enclosed
fixed-tile pipeline or full unroll needs no duplicate index.assume. The false
region retains the complementary relation but still has a dynamic tail length.
A template family selects an implementation¶
template.decl defines the
stable family signature. template.apply
requests an implementation of that family, and
template.def provides one
candidate whose signature and predicates may satisfy that demand:
template.decl @guide.scale(%tile_size: index, %value: i32) -> (i32)
template.def<@guide.scale> priority(20) @tile16_scale(%tile_size: index, %value: i32) -> (i32) where [eq(%tile_size, 16)] {
%result = scalar.addi %value, %value : i32
template.return %result : i32
}
template.def<@guide.scale> priority(1) @portable_scale(%tile_size: index, %value: i32) -> (i32) {
template.return %value : i32
}
An explicitly supplied library makes providers visible. For every reachable apply, specialization evaluates candidates in this order:
- The family must match. Ordinary types, shaped carrier kinds, element types, ranks, and view alignment stay exact; declared input dimensions and complete encodings must be proven. Result types follow the family contract exactly.
- Calling context and any explicit target identity must be compatible.
- Value predicates in
wheremust be proven at the application site. - Typed target requirements in
requiresmust be proven by the active function-version target facts. - The highest-priority proven provider wins.
Selection rewrites the semantic demand to an inline exact call. Normal inlining and dead-code elimination then remove the selected boundary and every unreachable alternative. No per-invocation provider switch remains in the device program.
Distinct providers tied at the best priority are ambiguous. File order, library order, and symbol spelling never become accidental tie breakers.
Input shapes are requirements¶
A provider can specialize the shape declared by its family. Here @last16
requires a sixteen-element view; the generic provider accepts the family's
input extent:
template.decl @last(%n: index, %input: view<[%n]xf32>) -> (f32)
template.def<@last> priority(20) @last16(%n: index, %input: view<16xf32>) -> (f32) {
%value = view.load %input[15] : view<16xf32> -> f32
template.return %value : f32
}
template.def<@last> priority(1) @last_dynamic(%n: index, %input: view<[%n]xf32>) -> (f32) {
%one = index.constant 1 : index
%position = index.sub %n, %one : index
%value = view.load %input[%position] : view<[%n]xf32> -> f32
template.return %value : f32
}
func.def public @read_last(%input: view<16xf32>) -> (f32) {
%n = index.constant 16 : index
%value = template.apply<@last>(%n, %input) : (index, view<16xf32>) -> (f32)
func.return %value : f32
}
The application selects @last16. A dynamic view can select the same provider
when its extent is proven to be sixteen, including inside a branch that
establishes that equality. The family requirement still applies: %n must
agree with the actual view's extent before any provider is eligible. A fallback
cannot repair an invalid family application.
This rule applies to group, tensor, tile, vector, and view inputs.
Selection makes any necessary shape change explicit with the corresponding
*.refine operation before an exactly typed call. A refinement preserves the
value and its storage; it does not allocate, copy, or reshape data. Ordinary
func.call typing remains exact.
Complete encodings select storage-specific implementations¶
An encoding-bearing family can accept several complete representations while each provider names the representation it implements:
template.decl @first(%layout: encoding<layout>, %input: view<16xf32, %layout>) -> (f32)
template.def<@first> priority(20) @first_dense(%layout: encoding<layout>, %input: view<16xf32>) -> (f32) {
%value = view.load %input[0] : view<16xf32> -> f32
template.return %value : f32
}
template.def<@first> priority(1) @first_encoded(%layout: encoding<layout>, %input: view<16xf32, %layout>) -> (f32) {
%value = view.load %input[0] : view<16xf32, %layout> -> f32
template.return %value : f32
}
func.def public @read_dense(%input: view<16xf32>) -> (f32) {
%layout = encoding.define #encoding.layout.dense : encoding<layout>
%value = template.apply<@first>(%layout, %input) : (encoding<layout>, view<16xf32>) -> (f32)
func.return %value : f32
}
The omitted encoding on @first_dense means native dense representation.
A provider can instead name a complete static encoding, including a strided
layout, a GGML schema, or a storage composition such as
#encoding.storage<layout=#encoding.layout.dense, schema=#ggml.q4_0>.
Matching preserves the complete identity: a one-dimensional strided layout
with stride one has the same addresses as dense storage but remains a distinct
encoding. A custom encoding exposing dense geometry likewise retains its own
identity. Converting representations requires explicit operations.
Input requirements determine applicability. The requested result type never selects a provider, and a provider's results must satisfy its family's exact result contract. Dependent results and separate extent/encoding co-results retain their ordinary binding rules.
Staged inputs reach provider predicates¶
A kernel-scoped pipeline's leading specialization arguments become known from the launching command. They participate before final provider selection, including through ordinary computations in the kernel source:
%width_limit = index.constant 6 : index
%supported = index.cmp slt, %tile_width, %width_limit : index
%result = template.apply<@reduce_tile>(%supported, %columns, %input) : (i1, index, view<32xi32>) -> (i32)
Here %tile_width is a leading specialization argument of the enclosing
pipeline.def<kernel>. A provider with where [eq(%supported, 1)] is applicable
when a command launches that pipeline with a tile width of four or five. The
family does not need a fallback just because the pipeline was originally
declared with an unbound width. A launch with width eight fails selection if
the family has no other applicable provider.
Staged schemas, layouts, storage encodings, vectors, and buffer literals follow the same rule: binding exposes their facts before evaluating the declared provider constraints. This does not change the types of ordinary calls or choose a provider from the requested result type. The family contract and explicit input requirements determine selection.
Requirements filter; they do not manufacture facts¶
A target requirement states when a provider is valid:
template.def<@guide.elementwise_transform> requires [#target.subgroup.size<32>] priority(20) @wave32_elementwise_transform(%value: f32) -> (f32) {
%result = func.call @double(%value) : (f32) -> (f32)
template.return %result : f32
}
requires [#target.subgroup.size<32>] does not force wave32, select AMDGPU, or
teach the caller that subgroup size is 32. It filters this provider against the
facts of the function version containing the template.apply.
Use an exact target(@symbol) provider only when target identity itself is an
applicability requirement. Most reusable providers stay targetless and name the
narrow normalized capability they need. The same requirement can then match
AMDGPU, SPIR-V, or another target family that establishes it.
The composition example's motif contains both the wave32 provider and its portable fallback:
Source: loom/docs/examples/elementwise-transform/motif.loom
// These targetless helpers and providers deliberately omit `public`. An
// explicitly linked library can satisfy reachable dependencies from them
// without adding their implementation symbols to the public module interface.
func.def inline @double(%value: f32) -> (f32) {
%doubled = scalar.addf %value, %value : f32
func.return %doubled : f32
}
template.decl @guide.elementwise_transform(%value: f32) -> (f32)
template.def<@guide.elementwise_transform> requires [#target.subgroup.size<32>] priority(20) @wave32_elementwise_transform(%value: f32) -> (f32) {
%result = func.call @double(%value) : (f32) -> (f32)
template.return %result : f32
}
template.def<@guide.elementwise_transform> priority(1) @portable_elementwise_transform(%value: f32) -> (f32) {
%result = func.call @double(%value) : (f32) -> (f32)
template.return %result : f32
}
Unknown is not false¶
Target profiles may be exact, generic, partial, saved, synthetic, or projected from a live device. A requirement can therefore be proven true, proven false, or remain unknown.
| Candidate state | Early specialization | Final selection |
|---|---|---|
| Requirement proven true | Candidate can be selected. | Candidate competes by priority. |
| Requirement proven false | Candidate is rejected. | Candidate is absent from the choice set. |
| Requirement unknown | A potentially better candidate remains live. | The best proven fallback may be selected; otherwise compilation diagnoses missing facts. |
This prevents an early generic link from prematurely erasing a provider that a later device profile could prove valid. It also prevents final executable lowering from carrying an unresolved semantic demand into target code.
Target facts attach to function versions rather than becoming one mutable
module-global mode. A compile invocation can specialize different entries for
different profiles while sharing the same linked libraries and targetless
helpers. The public loomc target API
defines target environments, immutable profiles, and per-function
specialization rows.
Diagnose the missing fact, not the selected assembly¶
Provider selection emits structured report rows for selected, fallback, rejected, ambiguous, and missing-fact outcomes. A blocked selection names the contract, candidate, and unresolved value or target requirement. That is a better repair boundary than inspecting final assembly and guessing why a specialized implementation disappeared.
Typical mistakes expose the missing ownership decision:
| Symptom | Actual problem | Repair |
|---|---|---|
| A specialized provider never matches | Its where predicate is not proven at the apply site. |
Carry the relevant value or refined alias to the apply. |
| A target provider remains unknown | The active function version lacks the required target fact. | Supply a more exact profile or a portable fallback. |
| Two providers are ambiguous | They have equal priority and distinct implementations. | State the intended priority or make their predicates disjoint. |
| A fallback disappears during partial linking | The provider library was not an explicit input. | Declare and supply the library dependency. |
| The program recompiles for every shape | Per-launch workload was modeled as artifact configuration. | Move the value back to workload and launch signatures. |
| An assumption fixes a runtime failure | The source asserted an unverified promise. | Establish the fact with control flow or an executable assertion. |
Read compile reports explains how to capture details, inspect row-level JSON paths, and compare selection and emitted-code evidence across configurations or targets.
Continue with Checks and benchmarks: executable cases provide runtime values, configuration bindings, expected results, and named performance rows without creating a second representation of the program.