L3-L2 Orchestrator Communication¶
L3-L2 Orchestrator Communication lets an L3 Host Orchestrator exchange payload bytes and signal counters with a running L2 AICPU Orchestrator task.
This page documents the low-level region, payload, and counter primitives. For the ordered SPSC message queue wrapper built on these primitives, see l3-l2-message-queue.md.
The intended use case is in-flight interaction: L3 can write input payload, publish a data-ready counter, wait for L2/AICore completion, and read output payload without ending the L2 orchestration task. For where L3 and L2 sit in the runtime stack, see hierarchical-level-runtime.md. For dynamic cross-rank communication domains, see comm-domain.md.
1. API¶
L3 creates a GM communication region for one chip worker:
region = orch.create_l3_l2_region(
worker_id=0,
payload_bytes=payload_bytes,
counter_bytes=128,
)
data_ready = region.counter(0)
completion = region.counter(64)
l2_args = TaskArgs()
for value in region.descriptor_scalars():
l2_args.add_scalar(value)
l2_args.add_scalar(0) # data_ready counter offset
l2_args.add_scalar(64) # completion counter offset
orch.submit_next_level(l2_handle, l2_args, cfg, worker=0)
region.payload_write(input_offset, host_input)
data_ready.notify(seq, NotifyOp.Set)
completion.wait(seq, WaitCmp.GE, timeout=timeout_s)
region.payload_read(output_offset, host_output)
region.free()
The L3 handle exposes descriptor_scalars, payload_write, payload_read,
counter(offset), and free. Payload operations copy contiguous bytes between
the caller's Host buffer and the region payload range. The steady-state data
path runs in the L3 Host process on both simulation and onboard platforms.
Simulation maps a shared POSIX backing object. Onboard imports the child-owned
GM region through a VMM shareable handle and moves bytes with ACL copy
operations. Counter handles expose notify, test, and wait over
address-based int32_t counters.
On L2, orchestration code consumes the descriptor and builds an endpoint:
L3L2OrchEndpoint ep(desc);
uint64_t data_ready_addr = 0;
uint64_t completion_addr = 0;
ep.counter_addr(data_ready_offset, data_ready_addr);
ep.counter_addr(completion_offset, completion_addr);
int32_t observed = 0;
bool ok = ep.signal_wait(
data_ready_addr, seq, L3L2OrchWaitCmp::GE, timeout, observed) &&
ep.payload_read(input_offset, input_nbytes, input) &&
ep.payload_read(output_offset, output_nbytes, output);
// The wrapper combines gm_addr/nbytes with task-level dtype and shape.
launch_aicore(input, output);
wait_aicore_done();
ep.signal_notify(completion_addr, seq, L3L2OrchNotifyOp::Set);
The L2 endpoint signal_wait timeout argument is in nanoseconds. The Python
counter.wait(..., timeout=timeout_s) API takes seconds and converts them to
nanoseconds before issuing the operation.
payload_read on L2 returns a GM view. It does not copy tensor bytes. Large
outputs should be written directly by AICore into an output view in the region.
L2 payload_write is byte-oriented and intended for small metadata or status
payloads, not as the primary large-output path. Payload access is not PTO-ISA
TLOAD or TSTORE; typed tensor/tile operations remain wrapper or AICore
concerns.
2. Region Descriptor¶
The descriptor is byte-oriented and is encoded as six uint64_t scalar slots:
scalar[i + 0] = magic_version
scalar[i + 1] = region_id
scalar[i + 2] = payload_base
scalar[i + 3] = payload_bytes
scalar[i + 4] = counter_base
scalar[i + 5] = counter_bytes
| Field | Meaning |
|---|---|
magic_version |
Descriptor ABI marker and version. |
region_id |
Region identifier; 0 is reserved as invalid. |
payload_base |
Base GM address of the payload byte range. |
payload_bytes |
Size of the payload byte range. |
counter_base |
Base GM address of the signal counter range. |
counter_bytes |
Size of the signal counter range. |
The descriptor deliberately does not contain dtype, shape, stride, tensor rank, tile layout, stream header layout, queue layout, or semantic counter names. Wrappers pass those through task arguments or their own protocol fields.
The payload range is:
The counter range is:
counter_base is 64-byte aligned, and counter_bytes is a multiple of
sizeof(int32_t). Counter addresses must be 4-byte aligned and inside the
registered counter range. The payload and counter ranges do not overlap.
payload_base == 0 and counter_base == 0 are not validity sentinels; only
the range sizes, alignment, overflow, overlap, and reserved region_id == 0
checks determine descriptor validity.
3. Control Path¶
The lifecycle control path carries region create/release commands over the existing worker mailbox. Create returns the unchanged six-scalar L2 descriptor plus L3 Host-private transport metadata. Release is deferred until submitted L2 work has drained.
Steady-state payload_write, payload_read, SIGNAL_NOTIFY, SIGNAL_TEST,
and SIGNAL_WAIT run in the L3 Host against parent-private direct-access
metadata. The descriptor still contains L2-side payload and counter addresses
for the L2 AICPU endpoint; L3 Host operations do not dereference descriptor
addresses.
On onboard platforms, region create allocates one child-owned VMM GM range, exports it through a shareable handle, and returns the import metadata to the parent. The parent imports that region and closes the mapped VMM import before the child frees the physical allocation.
4. Signal Counters¶
Signal primitives are address-based int32_t counter operations. The bottom
layer does not assign directions or names to counters. A wrapper can choose
offsets such as 0 for data_ready and 64 for completion.
L3 counter handles expose:
counter.notify(value, NotifyOp.Set)
counter.notify(delta, NotifyOp.Add)
result = counter.test(cmp_value, WaitCmp.GE)
observed = counter.wait(cmp_value, WaitCmp.GE, timeout=timeout_s)
L2 endpoint methods expose the same primitive shape over explicit GM counter addresses:
ep.signal_notify(counter_addr, value, L3L2OrchNotifyOp::Set);
ep.signal_test(counter_addr, cmp_value, L3L2OrchWaitCmp::GE, result);
ep.signal_wait(
counter_addr, cmp_value, L3L2OrchWaitCmp::GE, timeout, observed);
NotifyOp supports:
Set: store the operand as the new counter value.Add: add the operand to the current counter value.
Add is a convenience read-modify-write operation, not an atomic operation.
On simulation, L3 counter reads and writes access the parent-side mapping
directly. On onboard, L3 counter reads and writes use 4-byte ACL copy
operations through the imported GM pointer; the parent does not CPU-dereference
imported GM counters. The primitive layer requires only 4-byte counter-address
alignment and does not force 64-byte counter offsets. This leaves room for one
writer to pack a batch of related counters into the same cache line when that
is the right protocol shape.
The ownership invariant is still single-writer per 64-byte cache line: all counters in one cache line must have the same writer. Counters written by different L3/L2 agents must not share one cache line. Wrappers should lay out high-frequency or cross-agent signals on separate 64-byte lines.
WaitCmp supports EQ, NE, GT, GE, LT, and LE.
counter.test(...) / signal_test is a non-blocking snapshot, not a
zero-timeout wait. The primitive reads the current counter once, evaluates the
comparison, and returns immediately. On L3, it returns
SignalTestResult(matched, observed). On L2, signal_test fills the endpoint
result with the same matched and observed counter fields.
For test, matched reports whether observed satisfies the requested
comparison. A mismatch is a normal polling result: it does not wait, does not
time out, and does not poison the region. Use wait(..., timeout=positive)
when the caller needs to block until the comparison matches or the timeout
expires.
At the abstract API level, signal_notify is the publish point for writes
ordered before the notify. A matched signal_test or signal_wait is the
observe point before reading protected data. A failed signal_test does not
establish observe semantics.
That contract is not provided by Host-side atomic_thread_fence calls alone.
Cross-agent payload and counter visibility comes from successful parent-side
direct transfers, backend DMA ordering, endpoint cache maintenance on onboard
builds, and wrapper-level sequencing that publishes only after prior writes are
complete.
Primitive signal code does not impose sequence monotonicity and does not treat
observed > cmp_value as a protocol error. Queue, stream, or channel wrappers
may still store sequence numbers in counters and enforce wrapper-level
protocol rules separately. For monotonic Add or sequence counters, wrappers
should normally wait with GE or GT; EQ is appropriate only when the
wrapper guarantees the target value cannot be skipped.
Counter reset is expressed as notify(0, NotifyOp.Set).
5. Ordering¶
A single stream-style round can follow this order:
L3:
payload_write(input_offset, host_input)
data_ready.notify(seq, Set)
L2:
signal_wait(data_ready_addr, seq, GE)
payload_read(input_offset, input_nbytes, input_view)
payload_read(output_offset, output_nbytes, output_view)
submit AICore(input_view, output_view)
wait for AICore completion
signal_notify(completion_addr, seq, Set)
L3:
completion.wait(seq, GE)
payload_read(output_offset, host_output)
The EQ versus GE choice is a wrapper decision. Prefer GE/GT for
monotonic sequence or Add counters, because EQ can miss a target if the
counter steps past it before the waiter observes the value. The primitive layer
only applies the requested comparison.
On onboard builds, correct payload/counter visibility depends on the common
aicpu/cache_maintenance.h helpers. The aarch64 path emits data-cache
maintenance instructions; sim and non-aarch64 paths are no-ops. See
hardware/cache-coherency.md.
All waits must use finite timeouts. Unbounded waits hide protocol deadlocks.
6. Lifetime Model¶
A region belongs to one L3 orchestration execution. It may be reused by multiple L2 orchestration runs submitted from that execution, but the handle is invalid after the L3 orchestration function returns.
The handle has three important user-visible states. A live handle allows
payload, counter, descriptor, and free operations. A released handle has seen
free() and rejects further payload, counter, or descriptor use. A poisoned
handle means progress is no longer trusted; only cleanup remains valid.
Physical GM release is deferred until submitted L2 work that may hold the descriptor has drained. This allows a region handle to become released in L3 without freeing memory still referenced by an in-flight L2 task.
If an L3 orchestration execution exits with live regions, runtime cleanup marks them released, drains submitted work, and then releases the physical resources.
7. Host Buffer Requirements¶
L3 payload buffers must be contiguous and large enough for the requested
transfer. On simulation platforms, the buffer only needs to be accessible from
the L3 Host orchestration code: runtime Host tensors, bytes, bytearray, and
writable contiguous memoryviews for reads are valid according to the
operation's read/write direction.
On onboard platforms, the L3 Host imports the child-owned region through ACL IPC and copies directly through ACL runtime primitives. Payload buffers must be ordinary contiguous L3 Host-accessible byte spans according to the operation's read/write direction.
Small wrapper metadata is payload too. A header such as {seq, DATA} or
{seq, STOP} is copied through payload_write; any staging needed for a
backend is owned by the primitive payload transfer, not by the queue protocol.
8. Error Handling¶
Primitive SIGNAL_TEST mismatch is success with matched = false and the
current observed counter value; it does not poison the primitive region.
Primitive SIGNAL_WAIT timeout reports the last observed counter value and
does not poison the primitive region by itself.
Failures that may corrupt payload or region progress poison the corresponding L3 Host region:
- DMA failure after a payload command is issued;
- signal notify failure;
- L3 Host transfer failure after operation issue;
- direct L3 Host mapped-region failure after a payload or counter primitive is issued;
- L2 endpoint fatal error reported with a valid
region_id; - explicit wrapper-level poison in future queue or stream abstractions.
Pre-command validation failures do not poison the region:
- malformed API arguments;
- invalid counter offset rejected by
region.counter(offset); - non-contiguous Host tensor;
- unsupported Host buffer detected before command issue;
- out-of-bounds payload offset detected before command issue;
- descriptor extraction after release or poison.
A poisoned region rejects payload_write, payload_read, counter operations,
and descriptor_scalars. free and orchestration cleanup remain valid.
L2 endpoint errors own structured fields inside L2, including region_id,
operation, counter address, operand, observed counter, kind, and message. The
current L3 Host poisoning path does not receive structured cross-level fatal
metadata; it depends on the wrapper's canonical fatal text format:
L3-L2 endpoint error ... region=<id>. When multiple live regions exist, the
L3 Host poisons only the region parsed from that text.
9. Platform Support¶
a2a3sim: full API and protocol support.a5sim: full API and protocol support.a2a3onboard: full API support with direct L3 Host VMM payload and counter operations.a5onboard: full API support with direct L3 Host VMM payload and counter operations.
Simulation backends preserve the same API, ordering, timeout, and error semantics as onboard backends.
10. Wrapper Example¶
The bottom layer does not define stream headers, opcodes, tensor schema, rings, or stop semantics. A streaming wrapper can reserve payload bytes for that protocol and reserve counter offsets for its own synchronization names.
For example, a wrapper can reserve payload offset 0..63 as a channel header,
followed by input and output tensor slices. It can use counter offset 0 for
data_ready and counter offset 64 for completion. One round writes
{seq, DATA}, publishes seq through data_ready, waits until
completion >= seq, and reads the output slice. Teardown can write
{seq + 1, STOP} and publish it through data_ready.
The related example lives in
examples/workers/l3/l3_l2_orch_comm_stream and is marked for a2a3sim,
a2a3, a5sim, and a5. It creates one region, submits one persistent L2
orchestration task, and drives three DATA
rounds from L3 while the L2 task stays in flight. Each round copies a
float32[128 * 128] input slice and a small channel header into the region;
L2 builds input/output GM tensor views from the descriptor, launches an AIV
kernel that adds a scalar to the input, publishes completion, and L3 reads back
and checks the output. The final STOP header lets L2 return, so Worker.run
drain acts as the acknowledgement without requiring a third counter.