Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 119 additions & 0 deletions docs/buffer-handle-abi.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
# BufferHandle / BufferRef — the L3+ memory model

At L3 and above, tasks name their data with typed, self-describing **buffer
handles and views**, not raw pointers. This replaces the legacy "raw pointer +
`child_memory` bool" mechanism with an ABI that carries a canonical identity, a
backend descriptor, and a strided view — so a buffer can be resolved exactly
across the L3→L2 (and L4→L3) boundaries without a side table.

The wire ABI is frozen in
[`.docs/L3-new/worker-memory-model/bufferhandle-abi.md`](../.docs/L3-new/worker-memory-model/bufferhandle-abi.md);
this page is the user-facing how-to.

## Three types

| Type | What it is | Where it lives |
| ---- | ---------- | -------------- |
| **`BufferHandle`** | An owned backing (POSIX shm / fork-COW / device malloc) with a canonical identity + lifecycle. Stays with the Worker that created it. | owner side (L3+) |
| **`BufferRef`** | A **self-describing view**: the full handle descriptor embedded + a strided view `(byte_offset, shapes, strides, dtype)`. The wire element of `TaskArgs`. Carries no materialized address. | on the wire (L3→L2, L4→L3) |
| **`Tensor`** | The materialized form (address + strided view). Exists **only** at the L2 device-runtime boundary. | L2 leaf |

**L3+ orch functions never touch a C++ `Tensor`.** They allocate handles, build
views, and submit refs. A `Tensor` only appears when a `BufferRef` is
materialized on the L2 chip child.

## Allocating buffers

```python
h = worker.create_buffer(nbytes) # kind3: explicit shared host buffer (POSIX shm)
h = worker.alloc_shared_tensor((M, N), dtype) # kind3: shape-sized create_buffer
# inside an orch fn, for device (chip-private) memory:
d = orch.alloc_child_tensor(worker, (M, N), dtype) # kind4: DEVICE_MALLOC on that chip
```

`create_buffer` returns a `BufferHandle` backed by a POSIX shm the consumer maps
lazily on first receipt of a ref over it (map-once, by identity);
`alloc_shared_tensor` returns a runtime-managed intermediate over a FORK_SHM ring
VA. `alloc_child_tensor` allocates device memory on a specific next-level worker
and wraps the pointer; its `.base` is the device pointer (the `orch.copy_to`
destination), and its ref must be dispatched only to that worker.

## Building views (the view algebra)

`handle.ref(...)` names a view; the view algebra mirrors the C++ `Tensor`
(pure metadata, returns a new `BufferRef`):

```python
v = h.ref(shapes=(M, N), dtype) # contiguous full view (row-major strides)
v.slice(dim, start, end, step=1) # any (incl. strided) view
v.transpose(x, y) # swap two dims
v.permute((1, 0)) # reorder dims
v.view(shapes, offsets) # sub-region
v.reshape(new_shapes) # contiguous only
```

`slice` / `transpose` / `permute` / `view` are unconstrained (strided views are
supported); `reshape` requires a contiguous view — exactly the `Tensor`
constraints. Strides are element strides (> 0); `byte_offset` is a byte offset.

## Submitting a task

```python
ta = TaskArgs()
ta.add_ref(a_h.ref((SIZE,), f32), ArgDirection.INPUT)
ta.add_ref(out_h.ref((SIZE,), f32), ArgDirection.OUTPUT_EXISTING)
orch.submit_next_level(chip_handle, ta, cfg, worker=0)
```

`TaskArgs` carries `BufferRef`s. Tags drive dependency inference, which keys on
the **canonical identity** (buffer granularity, the successor of the former
buffer-address key); byte-range overlap between same-buffer views is refined by
the L2 OverlapMap on the materialized tensors, not by the L3 key.

## Reading and writing data — torch only at the boundaries

The orch fn is a pure DAG builder: computing on data there would be invisible to
dependency inference. So **torch is used only outside `run()`** (fill inputs,
read outputs) or **inside a Python sub-worker** (a compute leaf):

```python
h = worker.create_buffer(n * 4)
torch.frombuffer(h.shm.buf, dtype=torch.float32, count=n).fill_(5.0) # before run()
worker.run(my_orch, ...) # orch names refs only
result = torch.frombuffer(out_h.shm.buf, dtype=torch.float32, count=n) # after run()
```

## How a ref reaches its consumer (three-way split)

A `BufferRef` on the wire is materialized differently by each consumer:

| Consumer | What it does |
| -------- | ------------ |
| **Chip leaf (L2 runtime)** | Materialize each ref to a `Tensor` (map-once, keyed by identity), including **strided** views; hand the Tensor blob to `run_from_blob`. |
| **Python sub-worker** (compute) | Map each ref into a `MappedArg`; the callable computes with `torch.frombuffer(arg.buffer, ...)`. No `Tensor`. |
| **Nested L4→L3 orch** (forwarding) | **Re-export** each backing to a handle `H'` that keeps the source's canonical identity — no pass-through, no map on the forwarding hop. |

**Re-export (no pass-through).** An upper-level ref is forwarded on receipt as a
handle `H'` that keeps the source's **canonical identity unchanged** (invariant
across every edge — an L4 buffer forwarded L4→L3→L2 carries one identity at all
three layers; only role / materialized VA / view change), per-backing and without
mapping. A downstream compute leaf maps lazily, so pure forwarding carries no map
cost. Dependency inference keys on the invariant identity, so an alias /
retain-release does not split across layers.

## Backends

| Backend | Materializes to | Used for |
| ------- | --------------- | -------- |
| `POSIX_SHM` | a named shm mapped into the consumer | `create_buffer` / `alloc_shared_tensor` |
| `FORK_SHM` | the same VA (copy-on-write inherited), no map | a pre-fork host buffer, zero-copy |
| `DEVICE_MALLOC` | the device pointer, no map (chip-local) | `alloc_child_tensor` |
| `REMOTE_SIDECAR` | (P2) resolved via the remote transport | an arg to a remote L3; the descriptor rides in the sidecar |

## Scope / status

Single-machine (host + device) L3→L2 and L4→L3→L2 dispatch is implemented and
verified in `a2a3sim` and onboard `a2a3`. The remote **receive** side and the
buffer lifecycle robustness (`release_buffer`, in-flight retain / deferred-free)
are later phases (P2); see [`.docs/L3/P1.md`](../.docs/L3/P1.md) for the phase
breakdown.
98 changes: 43 additions & 55 deletions docs/comm-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,11 @@ allocation: if `sum(b.nbytes) > window_size`, `allocate_domain` raises
| `device_ctx` | pointer to the device-side `CommContext` (pass as a kernel scalar) |
| `local_window_base` | base device address of this rank's window |
| `actual_window_size` | window size actually allocated |
| `buffer_ptrs` | `{buffer_name: device_ptr}` for each `CommBufferSpec` |
| `buffers` | `{buffer_name: BufferHandle}` for each `CommBufferSpec` (device `VMM_WINDOW`, owned by this chip) |

Kernels read peer windows through `device_ctx` (which holds every rank's
window base, local + imported peer); `buffer_ptrs[name]` is the local slice.
window base, local + imported peer); `buffers[name]` is the local slice —
name it in a task arg with `buffers[name].ref(shapes, dtype)`.

---

Expand All @@ -64,7 +65,7 @@ The handle is a context manager. Its lifecycle has **two distinct states**:
This split exists because `submit_next_level()` only *enqueues* DAG work;
`Worker.run()` does not wait for completion until the orch function returns.
If `release()` freed memory immediately on `with`-exit, a still-queued task that
captured the domain's `device_ctx` / `buffer_ptrs` would read freed memory. So
captured the domain's `device_ctx` / `buffers` would read freed memory. So
**release is deferred**: `release()` flips `released` and queues the backend
free; the real free runs after the run fence, when every task that could
reference the window has completed.
Expand Down Expand Up @@ -151,14 +152,15 @@ To preload host data (rather than have a kernel write the window), use
`orch.copy_to`:

```python
orch.copy_to(chip_idx, dst=handle[chip_idx].buffer_ptrs["input"], src=tensor.data_ptr(), size=n)
orch.copy_to(handle[chip_idx].buffers["input"], tensor)
```

`copy_to` is **synchronous** (control-mailbox round-trip + synchronous
`rtMemcpy` H2D): when it returns, the bytes are in that rank's window. `src`
must be device-visible from the forked chip child — e.g. a `torch` tensor moved
to shared memory with `.share_memory_()` **before** `Worker.init()` forks the
chips.
`copy_to(dst_handle, src)` is **synchronous** (control-mailbox round-trip +
synchronous `rtMemcpy` H2D): when it returns, the bytes are in that rank's
window. `dst` is the window's `VMM_WINDOW` BufferHandle; `src` (a `torch` tensor
or writable buffer) must be device-visible from the forked chip child — e.g. a
tensor moved to shared memory with `.share_memory_()` **before** `Worker.init()`
forks the chips.

**Cross-rank ordering:** when a kernel reads a *peer's* staged window, stage
**all** ranks' windows before submitting any kernel — `copy_to` is synchronous
Expand All @@ -168,7 +170,7 @@ rank's producer run before another rank has finished staging:
```python
with orch.allocate_domain(...) as handle:
for chip_idx in handle.workers: # stage all first
orch.copy_to(chip_idx, dst=handle[chip_idx].buffer_ptrs["input"], src=..., size=n)
orch.copy_to(handle[chip_idx].buffers["input"], tensor)
for chip_idx in handle.workers: # then submit
orch.submit_next_level(chip_handle, args, cfg, worker=chip_idx)
```
Expand All @@ -177,77 +179,63 @@ with orch.allocate_domain(...) as handle:

## 6. Host tensor visibility for `worker.run`

A host tensor passed to `worker.run(...)` / `orch.submit_next_level(...)` /
`orch.submit_sub(...)` is ultimately dereferenced from a forked local L3 child,
not the parent, so its memory must be backed by pages mapped into that child.
Fork-inherited MAP_SHARED mappings retain their virtual address, while post-fork
worker-allocated buffers may map at a different address and have their pointers
rewritten before decoding. Two sources are legal:
A host tensor named in a task arg is ultimately dereferenced from a forked local
L3 child, not the parent, so its backing must reach that child. Under the
BufferRef ABI an arg is a `BufferRef` carrying a self-describing descriptor; the
child materializes it lazily on first receipt (map-once, keyed by canonical
identity) — there is no eager broadcast and no host-pointer rewrite. Two sources
are legal:

| Source | How | Why it works |
| ------ | --- | ------------ |
| **fork-inherited** | `tensor.share_memory_()` **before `Worker.init()`** (before the local L3 children are forked) | the child inherits the MAP_SHARED page at fork |
| **worker-allocated post-fork** | `worker.create_host_buffer(nbytes)` after the children exist | born-shared memory attached into every local child, **zero-copy** |
| **fork-inherited** | `tensor.share_memory_()` **before `Worker.init()`**, named with `worker.make_ref_arg(t, shapes, dtype)` (FORK_SHM) | the child inherits the MAP_SHARED page at the fork; the ref resolves to that same VA |
| **worker-allocated post-fork** | `worker.create_buffer(nbytes)` after the children exist, named with `handle.ref(shapes, dtype)` (POSIX_SHM) | the child maps the shm by identity on first receipt of the ref, **zero-copy** |

The local L3 children are forked eagerly in `Worker.init()`. A host tensor
created after that — the natural dynamic-shape serving pattern — is invisible to
the children unless it lives in a `create_host_buffer` buffer:
created after that — the natural dynamic-shape serving pattern — reaches the
children by naming a `create_buffer` handle as a `BufferRef`:

```python
worker = Worker(level=3, ...); worker.register(chip); worker.init() # forks the chips

buf_h = worker.create_host_buffer(tokens * hidden_size * 4) # born-shared, post-fork
buf_o = worker.create_host_buffer(batch * vocab * 4)
buf_h = worker.create_buffer(tokens * hidden_size * 4) # POSIX shm, post-fork
buf_o = worker.create_buffer(batch * vocab * 4)
try:
hidden = torch.frombuffer(buf_h.buffer, dtype=torch.float32).view(tokens, hidden_size)
out = torch.frombuffer(buf_o.buffer, dtype=torch.float32).view(batch, vocab)
hidden = torch.frombuffer(buf_h.shm.buf, dtype=torch.float32, count=tokens * hidden_size)
out = torch.frombuffer(buf_o.shm.buf, dtype=torch.float32, count=batch * vocab)
for step in batches:
fill(hidden); worker.run(orch, ...) # no per-run copy — child reads/writes the same pages
fill(hidden)
# name each buffer as a ref in the task args; the child maps it once
worker.run(orch, ...) # no per-run copy — child reads/writes the same pages
use(out)
del hidden, out # drop views before free
del hidden, out # drop views before close
finally:
worker.free_host_buffer(buf_h)
worker.free_host_buffer(buf_o)
buf_h.close()
buf_o.close()
```

**Create once, reuse many runs.** `create_host_buffer` maps a shm into each
child and keeps it mapped; the child reads and writes the same physical pages
the parent sees, so there is **no per-run copy**. Build the tensor over
`buf.buffer` (buffer protocol — `torch.frombuffer` / `np.frombuffer`) once and
reuse it; a sub-view (slice) that fits inside the buffer is resolved
automatically. simpler stays framework-free — torch/numpy appear only on the
caller's side.

**Unregistered post-fork tensors are forwarded unvalidated.** A tensor that is
neither fork-inherited nor from `create_host_buffer` is passed through as-is:
the fork-inherited case is the common legitimate one, so it must keep working.
An anonymous post-fork tensor forwarded this way reads stale/unmapped memory in
the child — allocate it with `create_host_buffer` instead.
**Create once, reuse many runs.** The first ref over a `create_buffer` handle
maps its shm into the child (map-once, cached by identity); later runs reuse the
mapping, so there is **no per-run copy**. Build the tensor over `handle.shm.buf`
(buffer protocol — `torch.frombuffer` / `np.frombuffer`) once and reuse it.
simpler stays framework-free — torch/numpy appear only on the caller's side.

### Contract / limits

- **Zero-copy is a live shared medium.** The buffer's pages are shared with the
child; during a `run` the child is reading/writing them, so the parent must
not read or write the buffer until `run` returns (same contract as a
fork-inherited `.share_memory_()` tensor). In-run cross-task ordering (a
producer task's output read by a consumer task) is still enforced by the
runtime's OverlapMap, keyed on the host address — no host-side copy involved.
- **Shape varies within `nbytes`.** A tensor built over the buffer may take any
shape whose bytes fit; a view that runs past the buffer raises before dispatch
(`overruns its host buffer`). To grow beyond `nbytes`, free and re-create a
larger buffer. Do not free a buffer while a `run` using it is in flight, and
drop every tensor/`memoryview` over `buffer.buffer` before `free_host_buffer`
(or `close()`) so the shm can be released promptly.
- **`orch.copy_to` is the unmanaged low-level path.** `create_host_buffer`
covers the `run` / `submit_next_level` host-tensor args. The explicit
`orch.copy_to(src=tensor.data_ptr())` staging path (§5) is *not* validated —
its `src` must be fork-inherited (`.share_memory_()` before `init()`)
or a `create_host_buffer` buffer.
producer task's output read by a consumer task) is enforced by the runtime's
dependency inference, keyed on the ref's canonical identity — no host-side copy.
- **`orch.copy_to` is the low-level device path.** It stages host bytes into a
domain window (`copy_to(dst_handle, src)`, §5); its `src` must be
fork-inherited (`.share_memory_()` before `init()`) or a `create_buffer` shm.
- **Fork-inherited anonymous memory is copy-on-write, hence stale.** Even a
tensor the child legitimately inherited is only useful as a *live* input if it
is MAP_SHARED: anonymous (non-`share_memory_`) pages are COW, so writes the
parent makes *after* fork do not reach the child. A live input must be
file-backed (`.share_memory_()` before `init()`) or a `create_host_buffer` one.
file-backed (`.share_memory_()` before `init()`) or a `create_buffer` one.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,14 @@
CoreCallable,
DataType,
TaskArgs,
Tensor,
TensorArgType,
)
from simpler.worker import Worker

from simpler_setup.elf_parser import extract_text_section
from simpler_setup.kernel_compiler import KernelCompiler
from simpler_setup.pto_isa import ensure_pto_isa_root
from simpler_setup.torch_interop import make_tensor_arg
from simpler_setup.torch_interop import make_tensor_ref

HERE = os.path.dirname(os.path.abspath(__file__))
N = 128 * 128
Expand Down Expand Up @@ -129,18 +128,10 @@ def orch_fn(orch, _args, cfg):
for rank in range(nranks):
domain = handle[rank]
args = TaskArgs()
args.add_tensor(make_tensor_arg(inp[rank]), TensorArgType.INPUT)
args.add_tensor(make_tensor_arg(out[rank]), TensorArgType.OUTPUT_EXISTING)
args.add_tensor(make_tensor_arg(result[rank]), TensorArgType.OUTPUT_EXISTING)
args.add_tensor(
Tensor.make(
data=domain.buffer_ptrs["notify_counter"],
shapes=(1,),
dtype=DataType.INT32,
child_memory=True,
),
TensorArgType.INPUT,
)
args.add_ref(make_tensor_ref(worker, inp[rank]), TensorArgType.INPUT)
args.add_ref(make_tensor_ref(worker, out[rank]), TensorArgType.OUTPUT_EXISTING)
args.add_ref(make_tensor_ref(worker, result[rank]), TensorArgType.OUTPUT_EXISTING)
args.add_ref(domain.buffers["notify_counter"].ref((1,), DataType.INT32.value), TensorArgType.INPUT)
args.add_scalar(domain.device_ctx)
orch.submit_next_level(chip_handle, args, cfg, worker=rank)

Expand Down
Loading
Loading