Functions and structured control flow¶
Example files: loom/docs/examples/elementwise-transform/
Functions are typed callables that can execute on the host or device. Structured control flow keeps conditions, iteration domains, and carried values visible to verification, specialization, unrolling, distribution, and lowering.
In this chapter, you will learn:
- how definitions, declarations, exact calls, and returns compose;
- how function modifiers state visibility, purity, placement, and inline policy;
- how
scf.ifandscf.forproduce SSA values; - why loop-carried state is explicit; and
- when to request an implementation contract with
func.applyinstead of naming one function.
Define a typed callable¶
A func.def owns a symbol, signature,
and body. Every exit uses func.return
with the declared result types:
func.def pure @affine(%value: f32, %scale: f32, %bias: f32) -> (f32) {
%scaled = scalar.mulf %value, %scale : f32
%result = scalar.addf %scaled, %bias : f32
func.return %result : f32
}
Function bodies do not capture module-local SSA values. Arguments, globals, configuration, and target queries make every input source explicit. That isolation lets a linker move a function with its dependency closure and lets a specializer reason about one callable without hidden lexical state.
Functions may return several values. Results remain ordinary SSA values at the call site:
Name exact dependencies¶
func.call asks for one exact symbol.
The current module contains either its definition or a compatible
func.decl:
func.decl pure @affine(%value: f32, %scale: f32, %bias: f32) -> (f32)
func.def @apply_affine(%value: f32, %scale: f32, %bias: f32) -> (f32) {
%result = func.call @affine(%value, %scale, %bias) : (f32, f32, f32) -> (f32)
func.return %result : f32
}
The declaration records what this module requires. A linker can satisfy it from an explicitly supplied source or bytecode library. A missing declaration is a source verification error; a library search is never allowed to invent the dependency after seeing an unresolved call.
Exact calls are appropriate when identity is part of the algorithm: one bit decoder, one reference function, or one helper whose precise implementation the caller selected.
Function modifiers state contracts¶
Modifiers sit on the definition or declaration whose behavior they constrain:
| Modifier | Contract |
|---|---|
public |
The symbol is visible outside the module. Absence means private. |
pure |
The function is deterministic and has no memory effects. |
host / device |
The calling convention and intended execution domain. |
inline |
The current IR stage must remove the callable boundary. |
noinline |
The current IR stage preserves the callable boundary. |
hot / cold |
Execution-temperature evidence for downstream policy. |
These are semantic inputs, not compiler folklore in comments. inline is not
needed merely because a helper is small; it is used when the source contract
requires the boundary to disappear at the current stage. Without an explicit
policy, the consuming pass decides from target, call graph, and cost evidence.
Conditionals can return values¶
scf.if consumes an i1 condition. A
result-producing conditional has an else region, and each region yields the
declared results:
%below_floor = scalar.cmpf olt, %value, %floor : f32
%clamped = scf.if %below_floor -> (f32) {
scf.yield %floor : f32
} else {
scf.yield %value : f32
}
There is no mutable temporary shared by both branches. The yielded value is the
definition of %clamped, so use-def analysis and specialization see exactly
which alternatives can reach each consumer.
A resultless scf.if can omit the textual scf.yield; the parser still
materializes its terminator in memory. This keeps the in-memory region contract
uniform without cluttering the common source form.
Counted loops carry state explicitly¶
scf.for uses a half-open logical index
range: the lower bound is inclusive, the upper bound is exclusive, and the step
is positive. Parenthesized bindings initialize loop-carried values, and
scf.yield forwards the next iteration state:
%result = scf.for %iteration = [%begin to %end step %step](%current = %initial : f32) -> (f32) {
%next = func.call @affine(%current, %scale, %bias) : (f32, f32, f32) -> (f32)
scf.yield %next : f32
}
The induction variable is an index. Every carried value has the same type as
its corresponding result. Multiple accumulators are ordinary parallel
bindings:
%sum, %sum_of_squares = scf.for %column = [%begin to %column_count step %step](
%sum_acc = %zero : f32,
%square_acc = %zero : f32
) -> (f32, f32) {
%value = view.load %input[%column] : view<[%column_count]xf32> -> f32
%square = scalar.mulf %value, %value : f32
%next_sum = scalar.addf %sum_acc, %value : f32
%next_square = scalar.addf %square_acc, %square : f32
scf.yield %next_sum, %next_square : f32, f32
}
This spelling preserves recurrence edges. A scheduler can distinguish the two independent recurrence chains, an unroller can interleave their producers, and reports can attribute pressure to the values that actually stay live.
Unrolling is a loop policy¶
Keep the source loop when the algorithm is a loop. Request full local unrolling on the loop instead of pasting body copies:
%sum = scf.for %part = [%begin to %part_count step %step](%acc = %zero : f32) -> (f32) unroll {
%next = func.call @accumulate_part(%acc, %part) : (f32, index) -> (f32)
scf.yield %next : f32
}
The unroll transform proves and materializes the trip structure. If source facts are insufficient, the request fails with the unresolved bounds rather than silently leaving a loop that the author required to be unrolled. More specific unroll schedules can express linear, interleaved, or recurrence-aware body ordering without changing the logical loop.
Select among whole values¶
Not every choice needs a region. scf.select
chooses between two whole values under one scalar condition. scf.lookup
selects a value tuple from a keyed table, and scf.switch owns multi-region
control flow when cases perform different work.
Use the smallest construct that preserves the program distinction:
scf.selectfor two already-computed values;scf.lookupfor a static keyed value table;scf.ifwhen alternatives execute different operations;scf.switchfor several operation regions;scf.forfor bounded iteration;scf.whilewhen continuation is computed by the loop itself.
The structured form is part of the optimization input. Lowering to branches is a target decision, not source authoring work.
Request a contract when identity is not fixed¶
An exact call says which symbol. A
func.apply says which
implementation contract and lets specialization select an eligible provider.
The composition example's motif contains a concrete helper and two providers for one contract:
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
}
func.template<guide.elementwise_transform> requires [#target.subgroup.size<32>] priority(20) @wave32_elementwise_transform(%value: f32) -> (f32) {
%result = func.call @double(%value) : (f32) -> (f32)
func.return %result : f32
}
func.template<guide.elementwise_transform> priority(1) @portable_elementwise_transform(%value: f32) -> (f32) {
%result = func.call @double(%value) : (f32) -> (f32)
func.return %result : f32
}
The kernel requests that contract without naming either provider:
Provider selection uses the contract key, exact signature, available facts, requirements, and explicit priority. File order and module path do not break ties. The wave32 provider is eligible only when the application-site target facts prove a subgroup size of 32; the portable provider remains the fallback.
After selection, the apply becomes an exact callable edge and ordinary inlining can erase the boundary. Templates therefore express library variability at specialization time without turning every device invocation into a runtime branch.
The next guide boundary is buffers, views, and structured compute: functions will continue to own reusable algorithms while storage identity and memory access become explicit.