297 lines
12 KiB
Markdown
297 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.
|