From source to artifacts¶
Example files: loom/docs/examples/elementwise-transform/
Loom keeps a program understandable while it moves from reusable source to a target-specific artifact. Linking, specialization, launch configuration, correctness cases, benchmark workloads, and compiler evidence operate on the same program instead of meeting only after code has become opaque.
The useful unit is therefore larger than an instruction stream and smaller than a framework. A Loom module can contain reusable computation, alternative implementations, dispatchable kernels, executable checks, and command programs. An application chooses which roots and facts matter, then asks Loom to link and specialize only that reachable program.
The canonical elementwise-transform example maps a selectable doubling motif
over f32 buffers, then follows that program through linking, target
specialization, native kernel emission, and command-program lowering.
One program, several forms¶
.loom is the canonical, human-readable source form. .loombc preserves the
same linkable program in bytecode. Neither form implies one target or one
deployment strategy.
motif.loom + kernel.loom + model.loom + configuration + target profile
│
link and specialize
│
lower the selected executable roots
│
native code + launch contracts + compiler evidence
The same source can be compiled ahead of time, linked and specialized when a model is loaded, or JIT-compiled for a concrete invocation. Those choices change when facts become available; they do not require different kernel languages.
Modules are ordinary composition boundaries. Public symbols can become roots for another module or an embedding, private symbols remain implementation details, and unreachable helpers and providers can disappear after selection. The linker resolves declared symbol relationships; merely placing a module in a library does not make its entire contents live.
Compose explicit modules¶
Every .loom file verifies independently. An exact call into another module
therefore needs a local declaration that states the complete contract. The
link invocation or embedding supplies the libraries that may satisfy it:
Source: loom/docs/examples/module-composition/root.loom
// The declaration states the exact callable contract required from a library.
func.decl @project_layer(%value: i32) -> (i32)
func.def public @entry(%value: i32) -> (i32) {
%result = func.call @project_layer(%value) : (i32) -> (i32)
func.return %result : i32
}
The layer has its own declared dependency on @scale. The checked workflow
links only the available layer, writes a standalone partial .loombc, reloads
it, and then supplies the kernel library:
| Source contract | Selection behavior |
|---|---|
| Exact declaration | Resolve the compatible direct definition or unique exported library definition from the explicitly supplied source universe. |
template.decl plus template.apply |
Select an eligible family implementation from the supplied libraries using facts, requirements, and priority. |
Runtime import attributes on func.decl |
Preserve an external ABI contract for runtime resolution instead of satisfying it from Loom libraries. |
Library paths and insertion order do not participate in symbol identity. The application or build graph chooses the source universe; Loom declarations state what that program requires. A partial link may preserve reachable declarations for a later boundary, while a closed link rejects any exact requirement that remains unresolved.
The linking workflow follows partial and transitive composition in detail. The source-module chapter defines the language contract.
Exact calls and selectable implementations¶
Loom separates naming one implementation from requesting an implementation that satisfies a contract.
| Source construct | Meaning |
|---|---|
func.def and func.call |
Define and call an exact helper by symbol. |
template.decl |
Declare a stable compile-time family contract in the module that uses or implements it. |
template.def |
Provide one selectable implementation of that family. |
template.apply |
Request an implementation of that family at compile time. |
template.call |
Call one named template implementation without candidate ranking. |
An exact bit-manipulation helper is naturally a func.call. An operation whose
best implementation depends on element format, shape facts, subgroup size, or
target capabilities is naturally a template.apply. During specialization, Loom
matches the reachable providers, their predicates and requirements, and the
known facts. The selected provider becomes an ordinary callable boundary that
normal inlining and optimization can remove.
This is the foundation of a scalable library: callers name semantic demands;
libraries provide reusable implementations; target selection remains a fact of
the final compilation rather than a global property baked into every helper.
This example defines an exact helper, a wave32-specific provider, and a
portable provider for the same guide.elementwise_transform 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
}
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
}
All three definitions omit public and are therefore private. Supplying the
motif as an explicit library makes those implementations available while Loom
forms the requested program, but it does not add them to the module's public
interface. Provider selection and normal reachability can then remove the
implementations the selected root does not need.
The wave32 provider is eligible only when the selected target establishes a subgroup size of 32. The fallback stays targetless, so the motif remains useful to targets that know nothing about AMDGPU.
The complete operation inventories live in the generated
func and
template dialect references.
A kernel owns two contracts¶
A kernel.def contains a launch
configuration region and a device body. They answer different questions.
The launch configuration region receives workload values and computes the physical workgroup grid, required workgroup size, and optional cluster size. The body receives the arguments carried through the device launch ABI and uses workgroup, workitem, subgroup, and memory operations to perform the work.
This distinction keeps a workload such as “process 1009 elements” separate from the physical decision “launch four workgroups of 256 invocations.” An embedding evaluates the compiled launch configuration for the workload; it does not reverse-engineer grid dimensions from a kernel name or duplicate the kernel's scheduling arithmetic.
Workload arguments and device arguments are explicit even when the same value
appears in both contracts. That explicit boundary lets Loom specialize launch
geometry when facts are known while preserving a correct dynamic path when
they are not. The generated kernel dialect
reference defines the complete launch,
execution, collective, and synchronization vocabulary.
The kernel below accepts its element count as a workload and device value, uses a portable 64-thread workgroup, and applies the motif contract without naming either provider. The target profile still selects the eligible motif implementation independently of that launch geometry:
Source: loom/docs/examples/elementwise-transform/kernel.loom
// This reusable source kernel owns portable physical launch geometry and
// applies the transform contract without naming one provider.
template.decl @guide.elementwise_transform(%value: f32) -> (f32)
kernel.def export("elementwise_transform_f32") @elementwise_transform_f32(%element_count: index) {
%unit = index.constant 1 : index
%workgroup_size = index.constant 64 : index
%rounding = index.sub %workgroup_size, %unit : index
%rounded_count = index.add %element_count, %rounding : index
%workgroups = index.div %rounded_count, %workgroup_size : index
kernel.launch.config workgroups(%workgroups, %unit, %unit) workgroup_size(%workgroup_size, %unit, %unit) : index
} launch(%element_count: index, %input: buffer, %output: buffer) {
%workgroup = kernel.workgroup.id<x> : index
%lane = kernel.workitem.id<x> : index
%workgroup_size = kernel.workgroup.size<x> : index
%element_index = index.madd %workgroup, %workgroup_size, %lane : index
%input_noalias, %output_noalias = buffer.assume.noalias %input, %output : buffer, buffer
%base = index.constant 0 : offset
%input_view = buffer.view %input_noalias[%base] : buffer -> view<[%element_count]xf32>
%output_view = buffer.view %output_noalias[%base] : buffer -> view<[%element_count]xf32>
%in_bounds = index.cmp ult, %element_index, %element_count : index
scf.if %in_bounds {
%value = view.load %input_view[%element_index] : view<[%element_count]xf32> -> f32
%result = template.apply<@guide.elementwise_transform>(%value) : (f32) -> (f32)
view.store %result, %output_view[%element_index] : f32, view<[%element_count]xf32>
}
kernel.return
}
Ask when a value becomes known¶
Most confusion about specialization disappears once every value has a clear binding stage.
| Value category | Binding stage | Role |
|---|---|---|
| Configuration values | Link or compile time | Resolve config.decl through a matching definition and seed facts used by specialization. |
| Target facts | Link or compile product boundary | Describe capabilities such as subgroup size, supported types, and resource limits for the selected executable root. |
| Kernel workload values | Launch-configuration evaluation | Compute the physical launch for one workload without pretending the values were compile-time constants. |
| Kernel launch arguments | Device issue | Carry scalars and buffers through the selected target ABI into the kernel body. |
| Command specialization arguments | Command-program specialization | Shape source control flow and launch computation; direct counts and scalar command data must become exact before portable preparation. |
| Command buffer bindings | Command-program issue | Attach concrete parameter, transient, input, and output storage without recompiling the schedule. |
Configuration is not a hidden global flag. A module declares artifact-level choices such as a model's layer count or weight encoding, another input defines them, and ordinary symbol dependency analysis keeps their effect visible:
config.decl @model.layer_count : %value: index where [range(%value, 1, 256)]
config.decl @model.weight_encoding : encoding<schema>
Per-launch element counts stay workload values instead of becoming global configuration. Likewise, target requirements are compile-time selection constraints rather than runtime branches. Reusable code generally stays targetless; a provider names a target requirement only when its algorithm actually depends on one.
Loom can preserve only information the program supplies. The ranges and
divisibility predicates on a
config.decl constrain a value even
before a composition root binds it to one constant. Control flow also supplies
path facts: inside the guarded region in kernel.loom, Loom knows that
%element_index < %element_count without an extra assumption. An
index.assume is for information
the compiler cannot derive, such as a range guaranteed by the producer of a
loaded routing index. The buffer.assume.* family carries root alignment,
memory-space, aliasing, and identity facts; the example's
buffer.assume.noalias
makes the input and output independence explicit.
These are optimization inputs, not documentation comments. Shapes, ranges, alignment, aliasing, and target requirements survive linking and specialization so that each later stage can make a stronger decision without rediscovering information that the author or embedding already knew.
The generated config and
target references define those two
fact sources.
Correctness and measurement stay beside the program¶
A check.case is an executable SSA
program that creates inputs, launches code, obtains expected values, and states
the comparison policy. A
check.benchmark selects named
assignments from a case for timing. The benchmark row does not copy the setup or
invent a second workload description.
Checks are test-only symbols, not deployment entry points. Keeping them in the
same source gives formatters, verifiers, test runners, benchmark runners, and
agents one authoritative statement of the workload while allowing published
libraries to exclude the harness. The generated
check dialect reference covers input
generation, fixtures, requirements, expectations, and benchmark records.
Command programs move the same model upward¶
A command.program.def
composes kernel launches, parameter views, resources, and serial or concurrent
schedule regions into a reusable subgraph. Its leading specialization
arguments participate in staged specialization and pure launch-count
computation; its buffer bindings remain replaceable when the materialized
program is issued. Exact counts become direct commands, while intentionally
dynamic counts use an explicit view<3xi32> produced before an indirect
dispatch.
That separation allows one program to specialize around model structure and target facts while still accepting different weights, cache storage, inputs, and outputs. It is the same idea as a specialized kernel launch, applied to a larger ownership boundary rather than hidden behind a separate graph compiler.
The outer module declares the exact kernel dependency and turns one concrete 1009-element invocation into a reusable command program. Its portable workgroup geometry makes the physical count exact without loading the kernel implementation. The kernel itself retains its dynamic workload contract and can be launched with other counts by other roots:
Source: loom/docs/examples/elementwise-transform/model.loom
// The declaration makes the exact dependency explicit in this source module.
kernel.decl @elementwise_transform_f32(%workload_element_count: index) launch(%device_element_count: index, %input: buffer, %output: buffer)
// The command program owns the reusable schedule while buffers remain
// replaceable at issue time.
command.program.def public @elementwise_transform() launch(%input: buffer, %output: buffer) {
%element_count = index.constant 1009 : index
kernel.launch @elementwise_transform_f32[%element_count](%element_count, %input, %output) : [index](index, buffer, buffer)
command.return
}
Command deployment products
loom-compile --format=loom-command materializes each selected command
root as a portable .loomcmd and writes the shared executable-entry
manifest. The target-specific kernel executable remains a separate
artifact so an embedding can cache, replace, or prebuild it independently.
The generated command dialect
reference documents the source
constructs that exist today.
Follow one composition to Low¶
The three source listings above are repository .loom files, not prose copies.
With the Loom tools on PATH, this command formats them, links the selected
program against the explicit provider universe, specializes that closure for
the generic GFX11 profile, and emits both kernel and command products. It
prints every Loom command it runs:
loom/docs/examples/elementwise-transform/run.sh \
gfx11-generic build/elementwise-transform/gfx11-generic
The resulting directory contains the closed target-specialized
elementwise-transform.loom module, the GFX11 HSACO, a command manifest, one
portable .loomcmd, and the captured target Low IR. The
--target=amdgpu:gfx11-generic link argument makes subgroup facts
available before template-provider pruning, so the wave32 implementation is
selected without pulling the portable alternative into the product. The
documentation build invokes the same script and regenerates the views below;
neither output is checked-in source.
low.kernel.def retain target<amdgpu.gfx11.generic.core>(@__loom_target_context_0_0) abi_layout({constant_count = 2, direct_arg_count = 1, direct_arg_names = {arg0 = "element_count"}, direct_arg_offsets = [0], direct_arg_parameter_indices = [0], direct_arg_sizes = [8], parameter_count = 3, resource_count = 2, resource_offsets = [8, 16], resource_parameter_indices = [1, 2], uses_kernarg_segment_ptr = true}) workgroup_size(64, 1, 1) @elementwise_transform_f32() asm {
%kernarg = live_in<amdgpu.kernarg_segment_ptr> : reg<amdgpu.sgpr x2>
%workgroup = live_in<amdgpu.workgroup_id.x> : reg<amdgpu.sgpr>
%lane = live_in<amdgpu.workitem_id.x> : reg<amdgpu.vgpr>
%element_count = s_load_dwordx2_offset_only %kernarg
%4 = s_load_dwordx4_offset_only %kernarg {offset = 8}
%element_index = v_lshl_add_u32_shift_imm %workgroup, %lane, 6
%6 = v_mov_b32 0
%7 = slice %element_count[0] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%8 = v_mov_b32_copy %7
%9 = slice %element_count[1] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%10 = v_mov_b32_copy %9
%11 = v_cmp_lt_u32_src0_inline %10 {lhs = 0}
%12 = v_cmp_lt_u32 %element_index, %8
%13 = v_cmp_eq_i32_src0_inline %10 {lhs = 0}
%14 = s_and_b64 %13, %12
%in_bounds = s_or_b64 %11, %14
%16, %17 = s_and_saveexec_b64 %in_bounds
low.cond_br %17, ^_bb1, ^_bb2 : reg<amdgpu.scc>
^_bb1:
%18 = v_lshlrev_b32_src0_inline %lane, 2
%19 = s_mov_b32 0
%20 = concat(%workgroup, %19) : (reg<amdgpu.sgpr>, reg<amdgpu.sgpr>) -> reg<amdgpu.sgpr x2>
%21 = s_mov_b32 8
%22 = s_lshl_b64 %20, %21
%23 = slice %4[0] : reg<amdgpu.sgpr x4> -> reg<amdgpu.sgpr>
%24 = slice %4[1] : reg<amdgpu.sgpr x4> -> reg<amdgpu.sgpr>
%25 = slice %22[0] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%26 = slice %22[1] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%27 = s_add_u32 %23, %25
%28 = s_addc_u32 %24, %26
%29 = concat(%27, %28) : (reg<amdgpu.sgpr>, reg<amdgpu.sgpr>) -> reg<amdgpu.sgpr x2>
%value = global_load_b32_saddr %18, %29
%doubled = v_add_f32 %value, %value
%32 = concat(%workgroup, %19) : (reg<amdgpu.sgpr>, reg<amdgpu.sgpr>) -> reg<amdgpu.sgpr x2>
%33 = s_lshl_b64 %32, %21
%34 = slice %4[2] : reg<amdgpu.sgpr x4> -> reg<amdgpu.sgpr>
%35 = slice %4[3] : reg<amdgpu.sgpr x4> -> reg<amdgpu.sgpr>
%36 = slice %33[0] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%37 = slice %33[1] : reg<amdgpu.sgpr x2> -> reg<amdgpu.sgpr>
%38 = s_add_u32 %34, %36
%39 = s_addc_u32 %35, %37
%40 = concat(%38, %39) : (reg<amdgpu.sgpr>, reg<amdgpu.sgpr>) -> reg<amdgpu.sgpr x2>
global_store_b32_saddr %18, %doubled, %40
low.br ^_bb2
^_bb2:
s_mov_b64_exec %16
return
}
amdgpu.target<gfx11-generic> @__loom_target_context_0_0
low.func.def public retain target<cmd.core> abi(command_program) abi_layout({entry_count = 1, executable_count = 1, fixed_buffer_count = 0, rebindable_binding_count = 2}) @elementwise_transform() asm {
%23 = resource<command_input> {index = 0, source_type = buffer} : reg<cmd.binding>
%24 = resource<command_input> {index = 1, source_type = buffer} : reg<cmd.binding>
%25 = resource<command_input> {index = 0, source_type = index} : reg<cmd.executable>
%26 = resource<command_input> {index = 0, source_type = index} : reg<cmd.entry>
%27 = cmd.constant.u64 0
%28 = cmd.constant.u64 -1
%29 = cmd.constant.u32 16
%30 = cmd.constant.u32 1
%31 = cmd.constant.b64 1009
cmd.dispatch.direct<%25, %26>[%29, %30, %30](%31, %23, %27, %28, %24, %27, %28)
return
}
The GFX11 tab comes directly from the installed-tool workflow above. The
command-program tab is the readable Low view of the .loomcmd produced from
the same closed program: launch configuration is projected as pure caller IR,
folded to an exact direct count, and lowered while the device implementation
remains unopened. The deployment artifact is binary and target-neutral; the
HSACO independently supplies its logical kernel entry.
Embedding chooses the deployment policy¶
Loom produces modules, target artifacts, launch contracts, diagnostics, and structured compiler reports. The embedding decides when compilation happens, where artifacts are cached, how executable code is loaded, and how work is issued to a device runtime.
The public loomc C API exposes the in-memory
source, link, specialization, compilation, emission, and launch-configuration
boundaries needed to build those policies. Executable embedding examples in
this guide are included only from checked programs that build against that API;
the generated header reference remains authoritative for ownership, lifetime,
threading, and failure contracts.
Embed kernel JIT compilation follows one of those checked programs from targetless source through launch evaluation and an in-memory native executable.
Working rules¶
- Keep reusable algorithms targetless until a real implementation requirement needs target facts.
- Use an exact call for an exact helper and a contract application when the implementation is intentionally substitutable.
- Use configuration for compile-time product choices, workload arguments for per-launch shape, and launch arguments for the device ABI.
- Put correctness policy in
check.case; make benchmarks select those checked workloads instead of reconstructing them. - Keep motifs as functions and templates. Add a kernel ABI where the program actually becomes dispatchable, and a command-program ABI where a reusable subgraph becomes materializable.
Continue with Source modules and canonical
text to learn the source and composition contract,
or browse the generated language reference for exact
syntax and operation contracts. If the Loom tools are not yet on PATH,
Acquiring Loom describes the current installation status.