Define the spawn-time handoff a job process consumes before any application code runs: two inherited descriptors named by SWACTOR_ARENA_FD and SWACTOR_WAKE_FD, and a fixed 48-byte little-endian header at arena offset 0 naming one control-ring region. - Canonical header layout, shared parser, and a host writer that leases the header region and control ring disjointly under the arena's own placement law, zeroes the ring, stamps its generation, and returns the handoff (env map, inheritable arena and wake descriptors, host wake eventfd) only after every write completes. - Python binding gains swactor.run(main): map the arena read-only, take ground truth from fstat, validate through the shared parser, arm FD_CLOEXEC on the wake descriptor, close the arena descriptor after mapping, and drive main on asyncio with Context.data carrying the resolved state. Fail-fast BootstrapError before main on any defect. - Rewrite jobs/tiny_linear_inference.py against the approved path API and record the slice, invariants, and remaining bridge work in the handoff doc. Verified with 12 new data-plane bootstrap guarantee tests (parser defect table, fuzzed pages, writer round-trip), 29 binding tests across in-process and exec boundaries, and full data-plane and iroh-driver suites with no regressions.
296 lines
12 KiB
Markdown
296 lines
12 KiB
Markdown
# Data-plane path API handoff
|
|
|
|
## Purpose
|
|
|
|
Pause-point for the first Myelin GPU job flow. We proved the current end-to-end path, then identified that its temporary Python/data-plane boundary is the wrong long-term abstraction.
|
|
|
|
Resume by working backward from the approved namespace/path API and designing fundamental path resolution before changing the binding again.
|
|
|
|
## What was accomplished
|
|
|
|
### Working manual GPU flow
|
|
|
|
The repository now has a manually exercised path through:
|
|
|
|
1. Myelin orchestrator startup.
|
|
2. Local Docker GPU-node provisioning from the Provision panel.
|
|
3. Runtime-image selection during provisioning.
|
|
4. Fleet-panel GPU-job submission.
|
|
5. Model-weight transfer over the data-plane edge transport.
|
|
6. Tinygrad CUDA inference in the worker job image.
|
|
7. Result transfer back to the orchestrator and bare Fleet display.
|
|
|
|
The observed deterministic result was:
|
|
|
|
```text
|
|
CUDA -> [2.75, -8.75]
|
|
```
|
|
|
|
The flow was repeated after worker teardown/reprovisioning and produced the same result.
|
|
|
|
### Runtime and deterministic fixture
|
|
|
|
- `apps/myelin/node-image/Dockerfile.job-tinygrad` builds the small Tinygrad CUDA job image.
|
|
- The selected image is carried through the existing provisioning request/spec and shown by the existing UI.
|
|
- `apps/myelin/jobs/tiny_linear.weights` is a 24-byte, six-`f32` fixture:
|
|
|
|
```text
|
|
matrix = [[1.5, -2.0],
|
|
[0.5, 4.0]]
|
|
bias = [0.25, -0.75]
|
|
input = [2.0, -1.0]
|
|
output = [2.75, -8.75]
|
|
```
|
|
|
|
- `apps/myelin/jobs/tiny_linear_inference.py` performs GPU-only inference and rejects non-CUDA Tinygrad selection.
|
|
|
|
### Job/data-plane integration
|
|
|
|
The current implementation connects job-runner lifecycle to real Iroh edge streams and the Myelin Fleet probe. Work completed during this pass included:
|
|
|
|
- model ingress and result egress over `EDGE_ALPN` rather than actor-message payloads;
|
|
- reverse-route assignment acknowledgement so a fast job-exit event is not lost;
|
|
- edge-stream completion acknowledgement before sender teardown;
|
|
- explicit stream-fault propagation into Fleet job failure;
|
|
- result framing/parsing and deterministic Fleet result display;
|
|
- job-runner assignment/lifecycle coverage for the acknowledgement ordering.
|
|
|
|
### Verification already performed
|
|
|
|
At the completed checkpoint:
|
|
|
|
- `swactor-job-runner`: 12 focused tests passed;
|
|
- `iroh-driver`: 14 tests passed;
|
|
- Python binding built successfully with stable-ABI forward compatibility enabled for the workstation Python version;
|
|
- Myelin test targets compiled;
|
|
- final Docker node and Tinygrad job images built;
|
|
- the UI-driven local Docker GPU scenario returned `[2.75, -8.75]`;
|
|
- temporary worker containers, orchestrators, state directories, and debug sessions were removed; built images were intentionally retained.
|
|
|
|
## The approved Python API direction
|
|
|
|
Python names the data it wants. Swactor does not declare application ports or application data types.
|
|
|
|
```python
|
|
async def main(ctx: swactor.Context) -> None:
|
|
weights = await ctx.data.read_blob(
|
|
"/models/tiny-linear/weights",
|
|
)
|
|
|
|
async with ctx.data.write_stream(
|
|
"/runs/self/results/inference",
|
|
) as results:
|
|
await results.write(b"opaque application bytes")
|
|
|
|
|
|
swactor.run(main)
|
|
```
|
|
|
|
The intended binding primitives are:
|
|
|
|
```python
|
|
ctx.data.read_blob(path) # finite immutable bytes
|
|
ctx.data.write_blob(path) # finite output
|
|
ctx.data.read_stream(path) # incrementally readable bytes
|
|
ctx.data.write_stream(path) # incrementally writable bytes
|
|
```
|
|
|
|
Public concepts should remain limited to:
|
|
|
|
```text
|
|
Context
|
|
DataPlane
|
|
Blob
|
|
ArenaView
|
|
StreamReader
|
|
StreamWriter
|
|
```
|
|
|
|
### Boundary
|
|
|
|
Swactor may understand:
|
|
|
|
- logical names and paths;
|
|
- blob versus stream;
|
|
- byte length and optional content digest;
|
|
- stream ordering and termination;
|
|
- arena leases, alignment, generations, cursors, and backpressure;
|
|
- authorization, placement, routing, and lifecycle.
|
|
|
|
Swactor must not understand:
|
|
|
|
- tensors;
|
|
- dtypes or shapes;
|
|
- byte order as application schema;
|
|
- JSON or JSON schemas;
|
|
- model formats;
|
|
- inference-result semantics.
|
|
|
|
Therefore there should be no `swactor.Tensor`, `swactor.Json`, typed job-port manifest, or orchestrator-provided application port declaration.
|
|
|
|
Application code interprets bytes. For the fixed model:
|
|
|
|
```python
|
|
weights_blob = await ctx.data.read_blob("/models/tiny-linear/weights")
|
|
|
|
expected_bytes = struct.calcsize("<6f")
|
|
if weights_blob.length != expected_bytes:
|
|
raise ValueError(
|
|
f"expected {expected_bytes} model bytes, "
|
|
f"received {weights_blob.length}"
|
|
)
|
|
|
|
with weights_blob.map() as mapped:
|
|
weights = struct.unpack_from("<6f", mapped)
|
|
```
|
|
|
|
Tinygrad owns tensor construction and the host-to-GPU upload. JSON encoding of results likewise remains Python logic:
|
|
|
|
```python
|
|
encoded = json.dumps(
|
|
{"device": device, "output": output},
|
|
separators=(",", ":"),
|
|
sort_keys=True,
|
|
).encode("utf-8")
|
|
|
|
async with ctx.data.write_stream(
|
|
"/runs/self/results/inference"
|
|
) as results:
|
|
await results.write(encoded)
|
|
```
|
|
|
|
A stream is opaque ordered bytes. If the application wants records, JSON Lines, protobuf, or another framing scheme, the application supplies it.
|
|
|
|
## Shared-memory direction
|
|
|
|
Unix sockets are rejected for the Python/data-plane boundary.
|
|
|
|
The data-plane already has the correct foundation:
|
|
|
|
- `ArenaManager::arena_fd()` exposes Linux shared backing;
|
|
- arena leases are offset-based rather than process-pointer-based;
|
|
- ring identity, generation, reservation, commit, consume, and wake contracts already exist;
|
|
- `EdgeRuntime` already composes network edges, arena leases, and worker loading.
|
|
|
|
The intended local flow is:
|
|
|
|
```text
|
|
remote edge -> ingress arena ring -> Python mapped view
|
|
Python bytes -> egress arena ring -> remote edge
|
|
```
|
|
|
|
Python should inherit and map the arena backing. Async operations wait on shared ring state plus a notification primitive; payloads remain in shared memory.
|
|
|
|
### Current implementation gaps
|
|
|
|
These are implementation gaps, not reasons to add another transport:
|
|
|
|
- `ArenaBacking` exposes `read_at`/`write_at`, but the binding does not yet expose an `mmap` view.
|
|
- `RingHelperHarness` still uses a process-local `Vec<u8>`; production ring headers and atomic cursors must live in arena memory.
|
|
- `LayoutPointer::NoProcessPointer` is not yet resolved into a mapped process-local view.
|
|
- Python process bootstrap does not yet receive the arena and wake descriptors plus its initial control-ring layouts.
|
|
- The Python binding currently contains Tokio `UnixStream` input/output classes. They are temporary and should be removed.
|
|
- `EmbeddedJobDataPlane` currently bridges through local Unix sockets. That is also temporary.
|
|
- The Tinygrad test bridge currently reads arena data as a file and uses line-delimited JSON control. The final binding should map the arena directly.
|
|
- The first honest GPU path will have one arena-to-CUDA upload performed by Tinygrad. Direct device-handle integration is separate from path resolution and is not required to design the namespace API.
|
|
## Bootstrap slice (implemented)
|
|
|
|
The first unknown down the approved API chain — what `swactor.run(main)`
|
|
consumes before `main(ctx)` can exist — is now implemented and tested.
|
|
|
|
Contract:
|
|
|
|
- The host execs the job process with two inherited descriptors named by
|
|
`SWACTOR_ARENA_FD` and `SWACTOR_WAKE_FD`; nothing else crosses the
|
|
boundary at spawn.
|
|
- Arena offset 0 holds a fixed 48-byte little-endian bootstrap header
|
|
(`SWBS` magic, version, reserved-zero fields, arena size, control-ring
|
|
offset/capacity/generation). Canonical definition:
|
|
`crates/data-plane/src/bootstrap.rs` — header layout, shared parser,
|
|
`write_bootstrap` host writer (header region and control ring are
|
|
disjoint leases under the arena's own placement law; all writes complete
|
|
before the handoff is returned).
|
|
- The binding's `run()` maps the arena read-only, takes ground truth from
|
|
`fstat` (never the header's size claim), validates via the shared
|
|
parser, arms `FD_CLOEXEC` on the wake descriptor, closes the arena
|
|
descriptor after mapping, builds `Context`/`DataPlane`, and drives
|
|
`main` on asyncio. Fail-fast: any defect raises `swactor.BootstrapError`
|
|
(`swactor.SwactorError` subclass) before `main` is invoked.
|
|
|
|
Removed with this slice: the binding's Tokio `UnixStream`
|
|
`DataPlaneInput`/`DataPlaneOutput` classes and the
|
|
`SWACTOR_DATA_PLANE_INPUT/OUTPUT` environment variables.
|
|
|
|
Verification (all passing):
|
|
|
|
- `data-plane`: 12 bootstrap guarantee tests (parser defect table, fuzzed
|
|
pages, writer round-trip, ring zero/stamp, env ABI, fd shape).
|
|
- Python binding: 29 tests (`crates/bindings/python/tests/`) covering the
|
|
defect table in-process and across real exec boundaries, wake opacity,
|
|
CLOEXEC grandchild isolation, exit-code contract, and surface leakage.
|
|
- `iroh-driver`, `myelin`, `swactor-job-runner` still compile; all
|
|
pre-existing suites pass.
|
|
|
|
Deliberately unchanged: `EmbeddedJobDataPlane` still bridges weights/results
|
|
over local Unix sockets, and its `configure()` still emits the legacy socket
|
|
environment — the binding no longer reads it. That bridge is replaced when
|
|
the arena carries real path data (next slice: control-ring framing for
|
|
`read_blob`), so the proven GPU scenario is not left half-cut-over.
|
|
|
|
|
|
## Where design resumes: fundamental path resolution
|
|
|
|
The next work starts with path semantics and resolution, not binding implementation.
|
|
|
|
Two layers must remain distinct:
|
|
|
|
```text
|
|
logical path
|
|
-> blob owner or matched stream participants
|
|
-> actor addresses
|
|
-> current nodes
|
|
-> live transport route
|
|
-> provisioned edge and arena rings
|
|
```
|
|
|
|
The candidate direction discussed, but not yet finalized, is:
|
|
|
|
1. A data-directory authority resolves canonical paths.
|
|
2. Blob paths resolve to a current owner and pinned generation.
|
|
3. Stream paths rendezvous one egress participant with one ingress participant for v1.
|
|
4. Existing swactor actor-directory state resolves participant actors to their current nodes.
|
|
5. The Iroh driver resolves nodes to live direct/relay transport routes.
|
|
6. Edge provisioning allocates an `EdgeId`, installs ingress/egress arena rings, and only then opens the byte stream.
|
|
7. Python sees none of the actor, node, endpoint, edge, or ring identities.
|
|
|
|
The concrete scenario to use while settling the design:
|
|
|
|
```text
|
|
/models/tiny-linear/weights
|
|
blob read by the worker job
|
|
|
|
/runs/self/results/inference
|
|
live stream written by the worker job
|
|
`self` scoped to the current run
|
|
```
|
|
|
|
Questions to settle first when work resumes:
|
|
|
|
- Path grammar, normalization, and scoped aliases such as `self`.
|
|
- Who owns the root namespace and whether authority delegates by prefix.
|
|
- How a blob publisher binds, updates, and tombstones a path generation.
|
|
- How stream readers and writers register, wait, match, cancel, and close.
|
|
- How path capabilities are attached to the job context and enforced.
|
|
- Which actor owns edge-plan creation and the ordering between ring installation and transport connection.
|
|
- Failure behavior when a path is absent, an owner moves, a participant exits, or a generation changes during resolution.
|
|
|
|
Do not redesign the UI, job specification, model format, or inference logic while resolving these fundamentals. Work backward from the approved Python API and preserve the already-proven manual GPU scenario as the integration target.
|
|
|
|
## Explicitly rejected directions
|
|
|
|
- Orchestration declaring named application ports for Python.
|
|
- Static job-port manifests.
|
|
- Actor IDs, edge IDs, peers, or socket paths exposed to Python.
|
|
- Per-port Unix sockets or a separate local control socket.
|
|
- Swactor-owned tensor, JSON, model, or inference schemas.
|
|
- Embedding application semantics into the data-plane.
|