Resident pipelines on XDNA¶
A resident pipeline keeps data moving between independently progressing strands inside one device invocation. DMA can fill an input while a compute worker processes another record. Channels govern when each record becomes readable and when its storage can be reused; the host does not dispatch every stage.
This walkthrough follows a BF16 gate/up projection from authored Loom to a native XDNA executable. The same source is a maintained compiler workload. It exercises ordinary functions, owned channels, adjacent tile memory, DMA, and specialization. It computes one projection pair and a quadratic gate, not a complete FFN or an optimized matrix multiplication.
The current native realization requires explicit singleton worker selections, specialized storage sizes, and channel protocols supported by the target. Each worker image must fit its tile's instruction budget. GPU and CPU strands need their own execution and progress mechanisms; selecting a different target does not yet turn this example into a GPU or CPU executable.
Compile a complete program¶
Save gated-projection.loom.
It includes all helper functions and the public
@ffn_gate_up_quadratic_bf16 pipeline. With an installed Loom toolchain that
includes XDNA, compile for Strix Halo NPU5:
loom-format --check gated-projection.loom
loom-compile gated-projection.loom \
--root=@ffn_gate_up_quadratic_bf16 \
--target=amd.xdna.aie2p:amd.xdna.strix_halo.17f0_11 \
--config=ffn_gate_up.input_size=512 \
--format=xdna --output=projection.xdna
For Strix NPU4, use amd.xdna.aie2p:amd.xdna.strix.17f0_10.
Compilation needs no attached NPU. Execution requires a device matching the
exact profile; these images are not interchangeable.
The caller supplies three buffers in source argument order:
| Argument | Contents at width K | Access |
|---|---|---|
| Input | One row of K BF16 values. | Read. |
| Weights | Two rows of K BF16 values, gate then up. | Read. |
| Output | One F32 value carrying the rounded BF16 result. | Write. |
All three bindings satisfy the source's 64-byte base-alignment contract. K is
a compile-time configuration from 512 through 4096, divisible by 32. Dot
products accumulate in F32. The epilogue computes
gate * (0.5 + 0.25 * gate) * up, rounding each step to BF16. This explicitly
chosen quadratic gate is different from SiLU or SwiGLU.
Construct storage and independently progressing strands¶
Here is the complete pipeline body from that source. Its helper definitions are in the downloadable file; the transfer helpers appear below.
pipeline.def<kernel> public retain target(@npu) @ffn_gate_up_quadratic_bf16() run(%input_arg: buffer, %weights_arg: buffer, %output_arg: buffer) {
%input_size = config.get @ffn_gate_up.input_size : index
%record_count = index.constant 1 : index
%input, %weights, %output = buffer.assume.alignment %input_arg, %weights_arg, %output_arg {minimum_alignment = 64} : buffer, buffer, buffer
%tile_memory = pipeline.memory<workgroup>[0, 3] : pool
%origin = index.constant 0 : offset
%single = index.constant 1 : index
%double_buffer = index.constant 2 : index
%element_bytes = index.constant 2 : index
%pair_elements = index.mul %double_buffer, %input_size : index
%pair_byte_count = index.mul %pair_elements, %element_bytes : index
%pair_bytes = index.cast %pair_byte_count : index to offset
%input_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %pair_bytes : buffer
%input_slots = buffer.view %input_storage[%origin] : buffer -> view<2x[%input_size]xbf16>
%inputs = channel.bind<fifo> %input_slots, %double_buffer : view<2x[%input_size]xbf16>, index -> channel<tile<[%input_size]xbf16>>
%weight_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %pair_bytes : buffer
%weight_slots = buffer.view %weight_storage[%origin] : buffer -> view<1x2x[%input_size]xbf16>
%resident_weights = channel.bind<fifo> %weight_slots, %single : view<1x2x[%input_size]xbf16>, index -> channel<tile<2x[%input_size]xbf16>>
%output_bytes = index.constant 8 : offset
%output_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %output_bytes : buffer
%output_slots = buffer.view %output_storage[%origin] : buffer -> view<2x1xf32>
%outputs = channel.bind<fifo> %output_slots, %double_buffer : view<2x1xf32>, index -> channel<tile<1xf32>>
pipeline.strand workers([0, 2], [1, 1], [1, 1]) {
%source_weights = buffer.view %weights[%origin] : buffer -> view<2x[%input_size]xbf16>
%weight_write, %weight_destination = channel.reserve %resident_weights : channel<tile<2x[%input_size]xbf16>> -> (write<tile<2x[%input_size]xbf16>>, view<2x[%input_size]xbf16>)
%weight_copy = kernel.async.copy %source_weights to %weight_destination {cache_scope = workgroup, cache_temporal = regular, direction = global_to_workgroup} : view<2x[%input_size]xbf16> to view<2x[%input_size]xbf16> -> kernel.async.token
%weight_pending = kernel.async.group %weight_copy : kernel.async.token -> kernel.async.group
kernel.async.wait %weight_pending {newer_groups = 0} : kernel.async.group
channel.publish %weight_write : write<tile<2x[%input_size]xbf16>>
%begin = index.constant 0 : index
%step = index.constant 1 : index
%prefetch_count = index.min %double_buffer, %record_count : index
scf.for %record = [%begin to %prefetch_count step %step] {
func.call @fill_input(%input_size, %record, %input, %inputs) : (index, index, buffer, channel<tile<[%input_size]xbf16>>)
}
scf.for %record = [%begin to %record_count step %step] {
func.call @drain_output(%record, %outputs, %output) : (index, channel<tile<1xf32>>, buffer)
%next_record = index.add %record, %prefetch_count : index
%has_next = index.cmp ult, %next_record, %record_count : index
scf.if %has_next {
func.call @fill_input(%input_size, %next_record, %input, %inputs) : (index, index, buffer, channel<tile<[%input_size]xbf16>>)
}
}
}
pipeline.strand workers([0, 3], [1, 1], [1, 1]) {
%weight_read, %weight_values = channel.acquire %resident_weights : channel<tile<2x[%input_size]xbf16>> -> (read<tile<2x[%input_size]xbf16>>, view<2x[%input_size]xbf16>)
%begin = index.constant 0 : index
%step = index.constant 1 : index
scf.for %record = [%begin to %record_count step %step] {
%input_read, %input_values = channel.acquire %inputs : channel<tile<[%input_size]xbf16>> -> (read<tile<[%input_size]xbf16>>, view<[%input_size]xbf16>)
%output_write, %output_values = channel.reserve %outputs : channel<tile<1xf32>> -> (write<tile<1xf32>>, view<1xf32>)
func.call @gate_up_tile(%input_size, %input_values, %weight_values, %output_values) : (index, view<[%input_size]xbf16>, view<2x[%input_size]xbf16>, view<1xf32>)
channel.publish %output_write : write<tile<1xf32>>
channel.release %input_read : read<tile<[%input_size]xbf16>>
}
channel.release %weight_read : read<tile<2x[%input_size]xbf16>>
}
}
pipeline.def<kernel> requires one directly loadable artifact. Its leading
parentheses contain specialization arguments, empty here; run declares the
values supplied for each invocation. The selected target provides hardware
facts. The target symbol is not a live device or a shared mutable execution
instance.
A pipeline.strand declares work rather than executing inline at its position
in the construction region. Each selected worker enters its strand once.
Ordinary scf.for loops express repetition. Reaching the closing brace completes
that strand instance; it does not implicitly restart it whenever input arrives.
Strands capture lexical SSA values, including buffers and channel identities.
The three coordinate lists are origins, counts, and strides. On this target,
workers([0, 2], [1, 1], [1, 1]) selects column 0, row 2, and
workers([0, 3], [1, 1], [1, 1]) selects its adjacent compute tile. Coordinate
i within a dimension maps to origin + i * stride.
A strand need not consume a compute core. After specialization, this example's transfer strand consists entirely of fixed DMA transfers and channel actions. The device invocation command stream issues its copies and preserves its waits and publications. Only the arithmetic strand needs tile instructions. A transfer strand and a compute strand can also select the same tile when communication has this realization.
The command stream is one sequential engine, so the compiler assigns at most one strand to it. This realization requires an acyclic unconditional sequence, fixed transfer endpoints, and at most one admission at each channel endpoint. Bounded ingress loops have a separate realization: the DMA engine itself can reserve a free channel slot, copy the next record, and publish it. Independent DMA streams keep separate engines and routes rather than sharing the command sequencer's execution order. Communication that needs instruction execution still runs on its selected core.
pipeline.memory<workgroup>[0, 3] selects the invocation's tile-local pool.
It allocates nothing and introduces no synchronization. The three buffer.alloca
operations create distinct allocation roots in that pool. Their buffer.view
operations describe how slots and payload elements occupy those allocations.
Compute accesses and DMA routes must have legal mappings to that memory.
Adjacency permits direct shared-memory access between these selected tiles.
The leading view dimension is the number of storage slots. For example,
view<2x[%input_size]xbf16> supplies two K-element records to
channel<tile<[%input_size]xbf16>>. The channel type describes one record, while the bound
view and capacity describe its storage. Binding creates a fresh protocol
identity: equal addresses do not imply equal channels or control dependencies.
At K=512, the authored channel storage totals 4104 bytes: 2048 for input slots, 2048 for retained weights, and 8 for output slots. The projection also uses a 64-byte private reduction buffer. Alignment and transport/control storage are additional physical requirements accounted for during lowering. The compiler checks the complete placement against the device's available memory.
Publication and reuse surround the actual transfer¶
The ingress helper reserves writable storage, launches a copy into it, waits for that copy, then publishes the initialized record:
func.def inline @fill_input(%input_size: index, %record: index, %input: buffer, %inputs: channel<tile<[%input_size]xbf16>>) {
%element_bytes = index.constant 2 : index
%record_elements = index.mul %record, %input_size : index
%byte_index = index.mul %record_elements, %element_bytes : index
%byte_offset = index.cast %byte_index : index to offset
%source = buffer.view %input[%byte_offset] : buffer -> view<[%input_size]xbf16>
%write, %destination = channel.reserve %inputs : channel<tile<[%input_size]xbf16>> -> (write<tile<[%input_size]xbf16>>, view<[%input_size]xbf16>)
%copy = kernel.async.copy %source to %destination {cache_scope = workgroup, cache_temporal = regular, direction = global_to_workgroup} : view<[%input_size]xbf16> to view<[%input_size]xbf16> -> kernel.async.token
%pending = kernel.async.group %copy : kernel.async.token -> kernel.async.group
kernel.async.wait %pending {newer_groups = 0} : kernel.async.group
channel.publish %write : write<tile<[%input_size]xbf16>>
func.return
}
write<tile<[%input_size]xbf16>> owns the reservation. The view is a borrowed way to access
its payload, so ordinary vector operations and functions can use it.
Publishing consumes the write obligation. Publishing before the DMA completes
would let the consumer read incomplete data.
The output helper follows the other side of the lifetime:
func.def inline @drain_output(%record: index, %outputs: channel<tile<1xf32>>, %output: buffer) {
%element_bytes = index.constant 4 : index
%byte_index = index.mul %record, %element_bytes : index
%byte_offset = index.cast %byte_index : index to offset
%destination = buffer.view %output[%byte_offset] : buffer -> view<1xf32>
%read, %source = channel.acquire %outputs : channel<tile<1xf32>> -> (read<tile<1xf32>>, view<1xf32>)
%copy = kernel.async.copy %source to %destination {cache_scope = workgroup, cache_temporal = regular, direction = workgroup_to_global} : view<1xf32> to view<1xf32> -> kernel.async.token
%pending = kernel.async.group %copy : kernel.async.token -> kernel.async.group
kernel.async.wait %pending {newer_groups = 0} : kernel.async.group
channel.release %read : read<tile<1xf32>>
func.return
}
channel.acquire returns an owned read plus a ready payload view. The read
stays owned through the outgoing DMA; only channel.release permits recycling
that record's storage. Waiting for the DMA is a storage-lifetime edge, not a
global barrier across the pipeline.
The compute strand holds its weight read across its entire loop. Each input and output record has a shorter lifetime inside that loop. More records can reuse the same weights and slots without a new host submission. This maintained workload sets the record count to one; its two-slot storage and prefill/drain loops express the streaming schedule even when specialization removes that repetition. A larger stream also needs input and output bindings sized for its record count.
This schedule preloads at most two inputs and then drains an output before refilling an input. That order matters: a service worker that blocks filling an input while the compute worker blocks on a full output can deadlock. Buffer capacity and control flow are part of the author's schedule in this native cut; channel ownership alone is not a proof that every authored network makes progress.
Repeating ingress without a feeder core¶
A regular input stream can move its entire reserve/copy/wait/publish loop into
DMA descriptors. For example, eight external records can cycle through two
tile-local slots while the compute strand retains its weights. Each DMA
iteration acquires a free credit and publishes a ready credit only after the
local write completes. The consumer returns free credit through its ordinary
channel.release. A full input channel stalls that DMA stream without making a
compute core poll for space.
This realization preserves supported strided record layouts. Descriptor iteration advances the external record address and rotates the local slot; it does not gather each record into another transient buffer. On the current profiles, one task supports up to 256 executions, with up to 64 distinct external record offsets and 64 local slots. A repeatedly read fixed external record can use all 256 executions. Memory, descriptor, route, and lock limits still apply.
Read-ahead needs an ownership proof. An external DMA can fetch bytes before the
corresponding local slot becomes free. The compiler therefore requires disjoint
caller-buffer facts and verifies that the complete pipeline cannot write the
source while those reads are in flight. Distinct buffer arguments alone do not
establish disjointness; an author's buffer.assume.noalias is a caller contract.
Imported channels or opaque effects that prevent this proof keep instruction
execution. The compiler also requires the consumer to drain the complete
produced prefix before invocation completion, so no task still owns its backing
storage when the caller reuses it.
Output lifetime remains separate. A compute strand can drain each result with the output helper above, holding its read until the external write completes. Ingress offload does not authorize releasing that output after only a local source read. Configuration starts autonomous input streams before potentially blocking command-stream work, and resets the local iteration cursor between invocations when the record count ends partway around the slot ring.
The resulting compile report counts instruction images and channel storage separately, so eliminating a feeder image cannot hide its storage cost. Successful lowering proves that the program fits this realization; device execution and timing remain separate qualification steps.
Inspect the real compilation boundaries¶
Add IR tracing to the same successful compile when investigating placement or generated worker code:
loom-compile gated-projection.loom \
--root=@ffn_gate_up_quadratic_bf16 \
--target=amd.xdna.aie2p:amd.xdna.strix_halo.17f0_11 \
--config=ffn_gate_up.input_size=512 --format=xdna \
--dump-ir-after=outline-pipeline-strands \
--dump-ir-after=aie2p-lower-pipeline \
--dump-ir-output=trajectory/ \
--compile-report=summary --compile-report-output=projection.report.json \
--output=projection.xdna
The trace directory contains complete modules, with a trace index identifying their boundaries. The generated examples below come from those compiler outputs, with trace comments removed from the downloadable modules.
Explicit callable boundaries¶
The outlined module still contains the pipeline's storage and placement. Each strand calls an ordinary function with explicit captured arguments. The public three-buffer invocation ABI has not acquired those internal channel arguments.
Complete compiler-produced outlined module
aie2p.target<array> @npu
pipeline.def<kernel> public retain target(@__loom_target_context_0_0) @ffn_gate_up_quadratic_bf16() run(%input_arg: buffer, %weights_arg: buffer, %output_arg: buffer) {
%input_size = index.constant 512 : index
%input, %weights, %output = buffer.assume.alignment %input_arg, %weights_arg, %output_arg {minimum_alignment = 64} : buffer, buffer, buffer
%tile_memory = pipeline.memory<workgroup>[0, 3] : pool
%origin = index.constant 0 : offset
%single = index.constant 1 : index
%double_buffer = index.constant 2 : index
%pair_bytes = index.constant 2048 : offset
%input_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %pair_bytes : buffer
%input_slots = buffer.view %input_storage[%origin] : buffer -> view<2x[%input_size]xbf16>
%inputs = channel.bind<fifo> %input_slots, %double_buffer : view<2x[%input_size]xbf16>, index -> channel<tile<[%input_size]xbf16>>
%weight_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %pair_bytes : buffer
%weight_slots = buffer.view %weight_storage[%origin] : buffer -> view<1x2x[%input_size]xbf16>
%resident_weights = channel.bind<fifo> %weight_slots, %single : view<1x2x[%input_size]xbf16>, index -> channel<tile<2x[%input_size]xbf16>>
%output_bytes = index.constant 8 : offset
%output_storage = buffer.alloca<workgroup> pool(%tile_memory) align(64) %output_bytes : buffer
%output_slots = buffer.view %output_storage[%origin] : buffer -> view<2x1xf32>
%outputs = channel.bind<fifo> %output_slots, %double_buffer : view<2x1xf32>, index -> channel<tile<1xf32>>
pipeline.strand workers([0, 2], [1, 1], [1, 1]) {
func.call @__pipeline_strand_1(%input, %output, %weights, %outputs, %inputs, %input_size, %resident_weights, %origin) : (buffer, buffer, buffer, channel<tile<1xf32>>, channel<tile<[%input_size]xbf16>>, index, channel<tile<2x[%input_size]xbf16>>, offset)
}
pipeline.strand workers([0, 3], [1, 1], [1, 1]) {
func.call @__pipeline_strand_0(%inputs, %resident_weights, %input_size, %outputs) : (channel<tile<[%input_size]xbf16>>, channel<tile<2x[%input_size]xbf16>>, index, channel<tile<1xf32>>)
}
}
aie2p.target<array> @__loom_target_context_0_0 {device_profile = "amd.xdna.strix_halo.17f0_11"}
func.def inline target(@__loom_target_context_1_0) @__pipeline_strand_0(%inputs: channel<tile<[%input_size]xbf16>>, %resident_weights: channel<tile<2x[%input_size]xbf16>>, %input_size: index, %outputs: channel<tile<1xf32>>) {
cfg.br ^_bb1
^_bb1:
%weight_read, %weight_values = channel.acquire %resident_weights : channel<tile<2x[%input_size]xbf16>> -> (read<tile<2x[%input_size]xbf16>>, view<2x[%input_size]xbf16>)
%input_read, %input_values = channel.acquire %inputs : channel<tile<[%input_size]xbf16>> -> (read<tile<[%input_size]xbf16>>, view<[%input_size]xbf16>)
%output_write, %output_values = channel.reserve %outputs : channel<tile<1xf32>> -> (write<tile<1xf32>>, view<1xf32>)
%base = index.constant 0 : offset
%c0$33 = index.constant 0 : index
%step = index.constant 32 : index
%initial = vector.constant 0.0 : vector<16xf32>
%scratch_bytes = index.constant 64 : offset
%scratch = buffer.alloca<private> align(64) %scratch_bytes : buffer
%scratch_view = buffer.view %scratch[%base] : buffer -> view<16xf32>
%lanes = index.constant 16 : index
%c1 = index.constant 1 : index
%c2 = index.constant 2 : index
%fzero = scalar.constant 0.0 : f32
cfg.br ^_bb2(%c0$33: index, %fzero: f32, %fzero: f32)
^_bb2(%43: index, %gate_iter: f32, %up_iter: f32):
%46 = index.cmp slt, %43, %c2 : index
cfg.cond_br %46, ^_bb3, ^_bb10
^_bb3:
%row = index.assume %43 [range(%43, 0, 1)] : index
cfg.br ^_bb4(%c0$33: index, %initial: vector<16xf32>)
^_bb4(%48: index, %accumulator: vector<16xf32>):
%50 = index.cmp slt, %48, %input_size : index
cfg.cond_br %50, ^_bb5, ^_bb6
^_bb5:
%position = index.assume %48 [range(%48, 0, 480), multiple_of(%48, 32)] : index
%x = vector.load %input_values[%position] : view<[%input_size]xbf16> -> vector<32xbf16>
%w = vector.load %weight_values[%row, %position] : view<2x[%input_size]xbf16> -> vector<32xbf16>
%next = vector.dot2f %x, %w, %accumulator : vector<32xbf16>, vector<32xbf16>, vector<16xf32>
%55 = index.add %position, %step : index
cfg.br ^_bb4(%55: index, %next: vector<16xf32>)
^_bb6:
vector.store %accumulator, %scratch_view[0] : vector<16xf32>, view<16xf32>
cfg.br ^_bb7(%c0$33: index, %fzero: f32)
^_bb7(%56: index, %sum_iter: f32):
%58 = index.cmp slt, %56, %lanes : index
cfg.cond_br %58, ^_bb8, ^_bb9
^_bb8:
%lane = index.assume %56 [range(%56, 0, 15)] : index
%part = view.load %scratch_view[%lane] : view<16xf32> -> f32
%next_sum = scalar.addf %sum_iter, %part : f32
%62 = index.add %lane, %c1 : index
cfg.br ^_bb7(%62: index, %next_sum: f32)
^_bb9:
%is_gate = index.cmp eq, %row, %c0$33 : index
%gate_next = scf.select %is_gate, %sum_iter, %gate_iter : f32
%up_next = scf.select %is_gate, %up_iter, %sum_iter : f32
%66 = index.add %row, %c1 : index
cfg.br ^_bb2(%66: index, %gate_next: f32, %up_next: f32)
^_bb10:
%67 = scalar.fptrunc %gate_iter : f32 to bf16
%gate = vector.splat %67 : vector<16xbf16>
%69 = scalar.fptrunc %up_iter : f32 to bf16
%up = vector.splat %69 : vector<16xbf16>
%quarters = vector.constant 0.25 : vector<16xbf16>
%halves = vector.constant 0.5 : vector<16xf32>
%c0$73 = vector.constant 0.0 : vector<16xbf16>
%left$74 = vector.interleave<0> %gate, %c0$73 : vector<16xbf16>, vector<16xbf16> -> vector<32xbf16>
%right$75 = vector.interleave<0> %quarters, %c0$73 : vector<16xbf16>, vector<16xbf16> -> vector<32xbf16>
%product$76 = vector.dot2f %left$74, %right$75, %initial : vector<32xbf16>, vector<32xbf16>, vector<16xf32>
%result$77 = vector.fptrunc %product$76 : vector<16xf32> to vector<16xbf16>
%linear_f32 = vector.extf %result$77 : vector<16xbf16> to vector<16xf32>
%scale_f32 = vector.addf %halves, %linear_f32 : vector<16xf32>
%scale = vector.fptrunc %scale_f32 : vector<16xf32> to vector<16xbf16>
%right$81 = vector.interleave<0> %scale, %c0$73 : vector<16xbf16>, vector<16xbf16> -> vector<32xbf16>
%product$82 = vector.dot2f %left$74, %right$81, %initial : vector<32xbf16>, vector<32xbf16>, vector<16xf32>
%result$83 = vector.fptrunc %product$82 : vector<16xf32> to vector<16xbf16>
%left$84 = vector.interleave<0> %result$83, %c0$73 : vector<16xbf16>, vector<16xbf16> -> vector<32xbf16>
%right$85 = vector.interleave<0> %up, %c0$73 : vector<16xbf16>, vector<16xbf16> -> vector<32xbf16>
%product$86 = vector.dot2f %left$84, %right$85, %initial : vector<32xbf16>, vector<32xbf16>, vector<16xf32>
%result$87 = vector.fptrunc %product$86 : vector<16xf32> to vector<16xbf16>
%result$88 = vector.extract %result$87[0] : vector<16xbf16> -> bf16
%result_f32 = scalar.extf %result$88 : bf16 to f32
view.store %result_f32, %output_values[0] : f32, view<1xf32>
channel.publish %output_write : write<tile<1xf32>>
channel.release %input_read : read<tile<[%input_size]xbf16>>
channel.release %weight_read : read<tile<2x[%input_size]xbf16>>
func.return
}
func.def inline target(@__loom_target_context_1_1) @__pipeline_strand_1(%input: buffer, %output: buffer, %weights: buffer, %outputs: channel<tile<1xf32>>, %inputs: channel<tile<[%input_size]xbf16>>, %input_size: index, %resident_weights: channel<tile<2x[%input_size]xbf16>>, %origin: offset) {
cfg.br ^_bb1
^_bb1:
%source_weights = buffer.view %weights[%origin] : buffer -> view<2x[%input_size]xbf16>
%weight_write, %weight_destination = channel.reserve %resident_weights : channel<tile<2x[%input_size]xbf16>> -> (write<tile<2x[%input_size]xbf16>>, view<2x[%input_size]xbf16>)
%weight_copy = kernel.async.copy %source_weights to %weight_destination {cache_scope = workgroup, cache_temporal = regular, direction = global_to_workgroup} : view<2x[%input_size]xbf16> to view<2x[%input_size]xbf16> -> kernel.async.token
%weight_pending = kernel.async.group %weight_copy : kernel.async.token -> kernel.async.group
kernel.async.wait %weight_pending {newer_groups = 0} : kernel.async.group
channel.publish %weight_write : write<tile<2x[%input_size]xbf16>>
%byte_offset = index.constant 0 : offset
%source$104 = buffer.view %input[%byte_offset] : buffer -> view<[%input_size]xbf16>
%write, %destination$106 = channel.reserve %inputs : channel<tile<[%input_size]xbf16>> -> (write<tile<[%input_size]xbf16>>, view<[%input_size]xbf16>)
%copy$107 = kernel.async.copy %source$104 to %destination$106 {cache_scope = workgroup, cache_temporal = regular, direction = global_to_workgroup} : view<[%input_size]xbf16> to view<[%input_size]xbf16> -> kernel.async.token
%pending$108 = kernel.async.group %copy$107 : kernel.async.token -> kernel.async.group
kernel.async.wait %pending$108 {newer_groups = 0} : kernel.async.group
channel.publish %write : write<tile<[%input_size]xbf16>>
%destination$109 = buffer.view %output[%byte_offset] : buffer -> view<1xf32>
%read, %source$111 = channel.acquire %outputs : channel<tile<1xf32>> -> (read<tile<1xf32>>, view<1xf32>)
%copy$112 = kernel.async.copy %source$111 to %destination$109 {cache_scope = workgroup, cache_temporal = regular, direction = workgroup_to_global} : view<1xf32> to view<1xf32> -> kernel.async.token
%pending$113 = kernel.async.group %copy$112 : kernel.async.token -> kernel.async.group
kernel.async.wait %pending$113 {newer_groups = 0} : kernel.async.group
channel.release %read : read<tile<1xf32>>
func.return
}
aie2p.target<core> @__loom_target_context_1_0 {device_profile = "amd.xdna.strix_halo.17f0_11"}
aie2p.target<core> @__loom_target_context_1_1 {device_profile = "amd.xdna.strix_halo.17f0_11"}
Shapes and target environments are visible in this IR. The normal compiler has also simplified the single-record loop and lowered remaining structured loops to CFG. A helper that accepts only borrowed views still performs ordinary memory computation. Channel-aware helpers expose ownership effects through their definitions before worker realization.
Tile code and configuration code¶
The native realization module contains the arithmetic worker and configuration functions. The worker implements its channel credits and arithmetic. Configuration code establishes routes, storage and bindings, loads the worker image, and starts execution. It also implements the finite transfer strand's ordered DMA and channel actions before joining the final completion obligations. No transfer image is loaded.
An input publication still follows its DMA completion. An output acquisition can become a descriptor's credit acquire when its copy and wait immediately follow it; otherwise it remains an explicit ordered wait. The final command waits for the external output write before returning. Channel storage remains allocated even when communication requires no tile instructions, and the compile report accounts for storage and worker images separately.
Both are ordinary target IR. In particular, program.load is an AIE2P
configuration instruction, inside a Low function targeting
amd.xdna.aie2p.configuration. It selects
the column, row, and tile-code symbol to load. It is not a generic pipeline
operation, a host callback between records, or a separate executable category.
| Term | Meaning in this example |
|---|---|
| Role | The logical transfer or projection computation authored by functions and control flow. |
| Strand | One independently progressing source body, entered once per selected worker instance. |
| Worker program | Tile instructions for the computation and communication assigned to a core. A DMA-realized strand needs no worker program. |
| Tile image | Linked instructions and data admitted against one tile's physical budgets. |
| Exported entry | The named invocation ABI in the containing ELF. |
The .xdna ELF can contain multiple exports and code contributions. A tile
program is not the ELF itself. Sharing immutable code does not merge invocation
storage or channel identities. This lowering emits resident images that fit
the instruction budget; it does not yet insert in-invocation image replacement.
That requires a target realization which preserves live storage and ownership
while transferring instruction residency. Splitting a large program into
functions alone does not remove the 16 KiB tile instruction limit.
Resume compilation from text or bytecode¶
Save either complete generated module, for example the native realization as
native.loom, and compile it without the original source or analysis process:
loom-format native.loom --to=bc --output=native.loombc
loom-compile native.loombc --root=@ffn_gate_up_quadratic_bf16 \
--format=xdna --output=resumed.xdna
The IR already contains its specialized target and program semantics. Analysis is reconstructed from that module; no saved compiler-side planning object is required. The walkthrough exercises both boundaries on both profiles and compares the native artifacts produced by their text and bytecode forms.
Use the compile-report workflow to inspect the native artifact's code, allocation and scheduling evidence. Successful compilation establishes target admission; it does not establish numerical correctness or measured device throughput.
The caller owns the invocation lifetime¶
The normal loom-test path can compile and execute buffer-bound XDNA
pipelines through the
XDNA HAL:
The HAL selects the exact compiler profile from immutable device facts, loads the emitted ELF, stages the scenario buffers, submits the entry, and preserves their lifetimes through completion and readback. The current image ABI accepts buffer launch bindings. Leading specialization values must already be bound into compiler SSA before image emission; runtime scalar bindings and invocation results require a future image ABI.
The HAL queries each entry's backing and binding requirements and prepares its establishing command. libamdf submits the prepared instruction range. It does not interpret channels, invent a work schedule, or patch each pipeline record.
The establishing command loads/configures the workers once for that invocation. All record processing and input/output handoffs occur inside it. The emitted completion path waits for the program's worker and transfer obligations before the caller can reclaim their resources. A separate device consumer of the output still needs its own synchronization and lifetime edge.
The current time-sliced native context does not guarantee tile state survives between independent submissions. Reusing the same context or immutable command allocation does not establish that guarantee. Each independent invocation uses the establishing command; retaining state within one invocation is a different contract from retaining it between submissions.
This is the boundary a complete pipeline launcher must own: materialize transient allocations, bind the caller's I/O, submit work, and track completion. The XDNA HAL implements those steps for finite buffer-bound pipelines. General heterogeneous launch lowering, GPU participation/progress, and code-image replacement each need a consuming realization before they become executable capabilities of the source model.