synplexum/spec.md

35 KiB
Raw Permalink Blame History

Compiler Spec: L3 → L0

A four-layer compiler for ML training pipelines, from intent to bare metal.

Each layer is a closed DSL with finite forms. Each has its own IR suited to the decisions made at that level. Optimization happens between layers during lowering and within layers as IR→IR rewrites. Never at the DSL surface.

Lowering between layers is search: enumerate valid candidates at the target layer, score them, pick the best. If search is slow, the representation is wrong.

┌─────────────────────────────────────────────┐
│  L3 Spec          what you want             │
├───────────────── search ────────────────────┤
│  L2 Method        how, algorithmically      │
├───────────────── search ────────────────────┤
│  L1 Placement     where, on what            │
├───────────────── emit ──────────────────────┤
│  L0 Machine       explicit execution        │
├─────────────────────────────────────────────┤
│  Codegen          PTX / LLVM IR / x86 / ... │
└─────────────────────────────────────────────┘
        ↕ rewrites within each layer

Zen

  • Read little; write little
  • Correctness via DSL model, not assertions
  • Every stage as simple as possible; all mutations checkable
  • Unbounded execution = specification error
  • Optimize between stages, closer to machine; not at DSL level
  • Never sacrifice performance for correctness; change the representation
  • Specify via properties: liveness, safety, consistency, resource bounds

The last point is structural. Every verification table in this document classifies its checks under exactly these four property kinds. If a check doesn't fit one of them, it doesn't belong.

Property Definitions

Safety — references resolve, types match, nothing dangling. "You will not read garbage."

Consistency — declared constraints agree with each other and with structure. "Your spec doesn't contradict itself."

Liveness — every resource produced is consumed; every buffer freed; no dead allocations. "Nothing is wasted."

Resource bound — memory, compute, time, space are all finite and known. "You will not run forever or exhaust the machine."

Verification Without Solvers

The restricted DSL makes each check a specific algorithm. Conservation is a graph walk. Separation is set disjointness. Boundedness is constant arithmetic. If the DSL allowed recursion, unbounded loops, or dynamic allocation, we'd need SAT solvers. Because it doesn't, we don't.


L3: Spec

Purpose

Declare what you want. No algorithms, no hardware, no shapes beyond the data itself. This is a contract that all lower layers must satisfy.

DSL Forms

data <n> {
    <field> : <domain>
    ...
}

model <n> {
    in:         <data_ref>
    out:        <domain>
    properties: [<constraint>, ...]
}

loss <n> {
    <term_name> : <loss_kind>(<args>), weight: <float>
    ...
    combine: <combinator>
}

goal: minimize(<loss_ref>) | maximize(<ref>) | pareto([<refs>])

Domain Types

tensor(dtype, shape)        fixed shape
sequence(domain, max_len)   variable-length, bounded
table(col: domain, ...)     tabular
dist(n)                     probability distribution over n classes
scalar(dtype)               single value

Model Constraints

These are not implementations. They bound what L2 may produce.

depth(n)                    max composed stages
width(n)                    max hidden dimension
causal                      output_t depends only on input_≤t
autoregressive              output is fed back as input
equivariant(group)          symmetry constraint
param_budget(n)             max parameter count
latency_budget(ms)          max forward pass time (needs L1 info)

Loss Kinds

divergence(predicted, target)           ce, kl, etc. (choice is L2)
norm(target, p)                         Lp on params or activations
constraint(predicate)                   hard constraint → penalty
reconstruction(input, decoded)          autoencoders
contrastive(anchor, positive, negative) representation learning
custom(name, reads: [refs])             escape hatch, user-defined

IR

L3_IR = {
    data:   Map<Name, DataDecl>
    models: Map<Name, ModelDecl>
    losses: Map<Name, LossDecl>
    goal:   Goal
    hints:  Map<Path, EscapeHatch>
}

All refs resolved. All domains concrete. No optionals. A total description.

Verification

Property Check Catches
Safety Ref resolution Dangling references to data fields, model outputs
Consistency Domain compatibility Model input domain ≠ data field domain
Safety Loss signature Loss term reads something that doesn't exist
Consistency Constraint agreement depth(2) + autoregressive on feedforward output
Resource bound Finiteness Everything bounded, no recursion at this level

All checks decidable. All linear in program size.

Optimization

None. L3 is a spec. You don't optimize a spec. You may simplify it — merge redundant loss terms, canonicalize domains — but no semantic changes.

Trace — MNIST at L3

data mnist {
    images : tensor(f32, [28, 28])
    labels : tensor(u8, [1])
}

model classify {
    in:  mnist.images
    out: dist(10)
    properties: [depth(3), width(512), param_budget(500K)]
}

loss objective {
    ce:     divergence(classify.out, mnist.labels), weight: 1.0
    sparse: norm(classify.params, p=1),             weight: 1e-4
    combine: weighted_sum
}

goal: minimize(objective)

This is the entire program. Everything below exists to make it run.


L3 → L2 Lowering

Given an L3 spec, enumerate valid L2 programs and pick the best.

The search space is combinatorial over (sample) × (architecture) × (backward) × (update). Architecture is the big one — it's a DAG construction problem. The search is structured, not brute force:

From depth(3) + sequential: 3-layer chain. Width search over powers-of-2 up to width(n). If causal, only causal attention. If autoregressive, add feedback edges. Backward filtered by architecture compatibility. Update mostly independent. Sample constrained by data domain.

Cost model scores without lowering to L1: param count, FLOPs per step, memory high-water mark, known convergence priors.

If search produces >10K candidates: representation too loose. Add constraints at L3 or tighten the enumeration grammar.


L2: Method

Purpose

Choose how to achieve the spec. Algorithms, architecture, training strategy. Still no hardware, no memory, no scheduling.

DSL Forms

methods for <model_ref> {
    sample:   <sample_method>
    infer:    <infer_method>
    backward: <backward_method>
    update:   <update_method>
}

Sample Methods

epoch_shuffle(batch: n, drop_last: bool)
stream_window(window: n, stride: n)
curriculum([{ filter: <pred>, duration: n_steps }, ...])
replay(buffer: n, priority: <kind>)
distributed_shard(shards: n, method: <inner>)

Infer Methods (Architecture)

sequential([<layer>, ...])
residual([<block>, ...])
diffusion_chain(steps: n, noise: <schedule>, denoiser: <infer>)
recurrent(cell: <infer>, steps: n | until(<pred>))
graph(nodes: <infer>, edges: <adjacency>)
mixture(experts: [<infer>], gate: <infer>)

Layer Primitives

linear(in, out)
conv(in_ch, out_ch, kernel, stride, padding)
attn(heads, dim, causal: bool)
norm(kind: layer | group(n) | rms)
act(fn: relu | gelu | silu | swish)
embed(vocab, dim)
pool(kind: avg | max, kernel)
dropout(p)                                training only

Backward Methods

autodiff(mode: reverse | forward, checkpoint: [<layer_ref>])
evolution(pop: n, sigma: f, selection: <kind>)
dfa(feedback_dim: n)
reinforce(baseline: <kind>)
straight_through(estimator: <kind>)
zeroth_order(samples: n, sigma: f)
hybrid(primary: <backward>, aux: <backward>, blend: <schedule>)

autodiff is an L2 declaration. L2→L1 lowering expands it into an explicit backward DAG in the tensor graph. At L1 and below, forward and backward are both explicit — no compiler magic.

Update Methods

sgd(lr)
adam(lr, beta1, beta2, eps)
muon(lr, ns_steps)
lion(lr, beta1, beta2)
schedule(warmup: n, decay: <kind>, inner: <update>)
gradient_clip(max_norm: f, inner: <update>)
ema(decay: f, inner: <update>)

IR

L2_IR = {
    sample:   SampleNode
    infer:    InferGraph              DAG of layer nodes
    backward: BackwardDecl
    update:   UpdateChain
    shapes:   Map<NodeId, Shape>     propagated during verification
    hints:    Map<Path, EscapeHatch>
}

The InferGraph is a DAG, not a sequence. sequential and residual are sugar that lower into the DAG form. This makes fusion and rewrite uniform.

Key principle: recurrent(... steps: n) is finite. recurrent(... until(pred)) is a specification error unless pred is proven terminating with a maximum bound. We do not allow unbounded execution.

Verification

Property Check Catches
Consistency Shape propagation Mismatched shapes on infer DAG edges
Consistency Spec constraint check depth, width, param_budget violated
Consistency Backward compatibility Backward method can't handle infer graph structure
Consistency Update shape match Update produces params of wrong shape
Consistency Sample/data match Batch shape inconsistent with data domains
Resource bound Finiteness DAG is acyclic (recurrent unrolled to fixed depth)
Resource bound Resource estimation Rough FLOPs, memory — feeds L1 search cost model

Optimization (IR→IR rewrites)

Semantics-preserving transformations on L2 IR: layer fusion (linear → act becomes fused_linear_act), redundant norm elimination, checkpoint selection for memory/compute tradeoff, architecture search pruning of dominated candidates.

Trace — MNIST at L2

methods for classify {
    sample:   epoch_shuffle(batch: 128)

    infer: sequential [
        linear(784, 256), norm(layer), gelu,
        linear(256, 256), norm(layer), gelu,
        linear(256, 10),  softmax
    ]

    backward: autodiff(mode: reverse)
    update:   schedule(warmup: 1000, decay: cosine,
                       inner: adam(lr: 3e-4))
}

Search chose this from L3 constraints: 3-layer sequential (from depth(3)), widths 256 (under width(512)), ~200K params (under param_budget(500K)). softmax output satisfies dist(10).


L2 → L1 Lowering

Given an L2 method and a hardware description, produce a valid L1 placement. Search decides: which compute goes on which node, how to partition across accelerators, pipeline depth, where to stage data, whether to fuse or split stages.

Cost model uses hardware specs: transfer time = data_size / bandwidth, compute time = FLOPs / throughput, memory pressure = peak live tensors, pipeline bubble ratio = (depth − 1) / (depth × stages).

If placement search explodes, either the hardware description is too ambiguous or the L2 method has too many equivalent decompositions.

Three things happen during this lowering that don't happen at other boundaries:

  1. Backward expansion. If L2 declares autodiff(mode: reverse), the lowering derives the backward pass and writes it as explicit tensor ops in the L1 tensor graph. At L1, no operation is implicit.

  2. Update materialization. L2's adam(lr, beta1, beta2, eps) becomes an explicit actor handler with tensor arithmetic at L1.

  3. Data binding. L3's abstract data declarations gain physical storage locations (@ ssd_0, path: "...").


L1: Placement

Purpose

Map the L2 method onto specific hardware. This is the layer where the systems-level DSL lives. It answers two questions: where does each computation live (topology + placement), and when does it execute (schedule + dataflow).

Six declaration forms: system, data, actor, stage, tensor_graph, pipeline. Plus a schedule block.

DSL Forms — Topology

system <n> {
    node <id> { kind: <storage|memory|compute>, <properties> }
    ...
    edges {
        <id> -> <id>  : bandwidth <rate>      // unidirectional
        <id> <-> <id> : bandwidth <rate>      // bidirectional
    }
}

Node kinds:

  • storage — persistent (SSD, disk). Source of data.
  • memory — volatile (RAM, HBM). Has capacity.
  • compute — processing (CPU, GPU). Has isa, and optionally vram, cores, sm, etc.

DSL Forms — Data Sources

data <n> {
    <field> : <domain> @ <storage_node>, path: <string>
    ...
}

Binds L3's abstract data declarations to physical storage. The @ annotation names the topology node where the data lives at rest.

DSL Forms — Actors

Actors own mutable state. They process messages one at a time — serialized, no concurrent access to their state.

actor <n> on <node> {
    state {
        <field> : <type>, <init>
        ...
    }

    window <n> : (<fields>)
        readers(<stage_or_graph_refs>)

    on <MessageType>(<args>) {
        <update logic>
    }
}

Windows grant read-only access to actor state from stages or tensor graphs. The actor's message handler is the only writer. Safety requires that readers and writers never execute concurrently — enforced by data dependency chains within a step and actor serialization across steps.

DSL Forms — Stages

Stages are stateless dataflow processors. They receive buffers, do work, and forward results. Every buffer received must be consumed exactly once — forwarded or freed. No exceptions.

stage <n> on <node> {
    reads: <refs>                          // optional window reads

    fn process(<args>) -> forward(<target>, <outputs>)
                        | Drop(<buf>) {
        <body>
    }
}

Buffer types carry region provenance: buf<ram_0>, buf<gpu_0>. Every forwarding site is checked: target exists, arity matches, types match, region provenance matches the target's placement.

DSL Forms — Tensor Graphs

Lazy DAGs of primitive tensor ops. Run on accelerators. No control flow, no mutation, no side effects — pure compute.

tensor_graph <n> on <node> {
    reads: <window_refs>

    fn compute(<inputs>) -> (<outputs>) {
        let t0 = tensor_matmul(x, w)
        let t1 = tensor_add(t0, tensor_broadcast(b, shape))
        ...
        return (loss, gw, gb)
    }
}

The reads clause grants access to actor state via a window — same mechanism as stages. The body is a sequence of let bindings over primitive tensor ops. No autodiff — both forward and backward are explicit. Movement ops (reshape, permute, broadcast) are free — metadata only.

Primitive tensor ops:

tensor_matmul(a, b)              matrix multiply
tensor_add(a, b)                 elementwise add
tensor_sub(a, b)                 elementwise subtract
tensor_mul(a, b)                 elementwise multiply
tensor_scale(a, scalar)          scalar multiply
tensor_broadcast(a, shape)       expand dimensions
tensor_transpose(a)              transpose
tensor_reduce_mean(a)            reduce mean (all axes)
tensor_reduce_sum(a, axis: n)    reduce sum on axis
tensor_const(v)                  scalar constant

DSL Forms — Pipelines

Ordered chains of stages and tensor graphs.

pipeline <n> {
    <stage_or_graph> -> <stage_or_graph> -> ...
}

DSL Forms — Schedule

schedule {
    pipelines: [<refs>]
    actors:    [<refs>]
    pipeline_depth: <n>
}

No explicit phases. Execution order is inferred from forward() edges — the dependency DAG. pipeline_depth controls overlap: at depth 2, step N+1's early stages can overlap with step N's late stages. The actor is the serialization fence between steps.

IR

L1_IR = {
    topology:      Graph<NodeId, Edge>           hardware graph
    data_sources:  Map<Name, DataDecl>
    placement:     Map<L2_NodeId, HW_NodeId>     where each computation lives
    actors:        Map<Name, ActorDecl>
    stages:        Map<Name, StageDecl>
    tensor_graphs: Map<Name, TensorDAG>          lazy compute DAGs
    pipelines:     [Pipeline]                    ordered stage/graph chains
    schedule:      ScheduleDecl
    hints:         Map<Path, EscapeHatch>
}

Every cross-reference is resolved during parsing. demux reading connection_manager.routes becomes a pointer within the tree. If a name is unresolved, the parser rejects. If a syntax rule is violated, the parser rejects. Semantic validity is the verifier's job.

Verification — Structural (Phase 1)

Reads the IR. Does not transform it. If it also rewrote things, we'd need to verify the verifier's transformations. Keeping it read-only keeps it auditable.

Property Check Catches
Safety Topology consistency Edges reference nonexistent nodes; no path for transfers
Safety Ref resolution Dangling window reads, missing forward targets
Safety Forwarding signatures Arity mismatch, type mismatch, region provenance mismatch
Safety Window separation Reader and writer may execute concurrently
Consistency Tensor shape propagation Mismatched shapes in tensor graph DAG
Consistency Region provenance buf<ram_0> forwarded to a gpu_0 tensor graph
Liveness Buffer conservation Buffer allocated but never freed or forwarded
Liveness Tensor reachability DAG node neither consumed nor named as output
Resource bound Persistent memory bounded Actor state exceeds node capacity
Resource bound DAG finiteness Tensor graph or pipeline contains a cycle

Conservation check per stage: build the control flow graph, walk every path tracking live Buffer resources. Every path must end with an empty live set. If any path ends with a live buffer, the verifier rejects with the specific path and buffer name.

Separation check: within a step, the data dependency chain ensures the window reader (tensor graph) completes before the writer (actor handler) fires. At pipeline_depth > 1, cross-step safety requires that step N's actor update completes before step N+1's reader begins — the actor serializes this.

Optimization (IR→IR rewrites)

Stage fusion (merge adjacent stages on same node), pipeline rebalancing, tensor graph rewriting (fuse ops, eliminate copies), communication optimization (overlap compute and transfer), parallelism mapping (data/tensor/pipeline parallel as topology + placement rewrites).

Trace — MNIST at L1 (abbreviated)

system single_gpu {
    node ssd_0  { kind: storage }
    node ram_0  { kind: memory, capacity: 32GB }
    node cpu_0  { kind: compute, isa: x86_64, cores: 8 }
    node gpu_0  { kind: compute, isa: cuda, vram: 24GB, sm: 84 }

    edges {
        ssd_0 -> ram_0  : bandwidth 2GB/s
        ram_0 <-> cpu_0 : bandwidth 50GB/s
        cpu_0 <-> gpu_0 : bandwidth 32GB/s
    }
}

actor model_state on gpu_0 {
    state {
        w : tensor(f32, [784, 256]), init: kaiming
        b : tensor(f32, [256]),      init: zeros
    }

    window weights : (w, b)
        readers(forward_backward)

    on GradUpdate(gw, gb) {
        let lr = 3e-4
        w = tensor_sub(w, tensor_scale(gw, lr))
        b = tensor_sub(b, tensor_scale(gb, lr))
    }
}

stage load_data on cpu_0 { ... }
stage transfer_to_gpu on cpu_0 { ... }
tensor_graph forward_backward on gpu_0 {
    reads: model_state.weights
    fn compute(x, y) -> (loss, gw, gb) { ... }
}
stage transfer_grads on cpu_0 { ... }

pipeline train { load_data -> transfer_to_gpu -> forward_backward }
pipeline grad_return { transfer_grads }

schedule {
    pipelines: [train, grad_return]
    actors: [model_state]
    pipeline_depth: 2
}

L1 → L0 Lowering

Not search — emission. The verified L1 IR is lowered into L0 through three compiler passes, then split into two code paths.

Pass 1: Memory Planning

Compiler-derived, not user-declared. Three strategies selected by lifetime analysis:

Strategy Use Lifetime
Persistent Actor state (w, b) Init → program end
Arena Tensor intermediates Graph start → graph end, bulk reset
Pool Transfer staging buffers alloc → free, freelist

Pass 2: Stream Inference

Each step's GPU work gets a stream. At pipeline_depth: N, N streams. Cross-stream dependencies become sync events.

stream_0: step N   → K0, K1, ..., sgd_kernel
stream_1: step N+1 → K0, K1, ..., sgd_kernel

sync: event recorded after sgd_kernel on stream_0
      waited before K0 on stream_1

Pass 3: Kernel Scheduling & Fusion

Tensor graph DAG nodes are grouped into kernels. Fusion rules: elementwise ops fuse freely; reduction forces a kernel boundary; different iteration spaces cannot fuse.

Dual Emission

The verified L1 IR splits into two code paths:

Path A — Control. Stages and actor handlers emit to Control IR (Machine IR). This is the CPU-side orchestration: data loading, transfers, message dispatch, pipeline coordination.

Path B — Compute. Tensor graphs emit to Compute IR (UOps). This is the accelerator-side math: matmuls, reductions, elementwise, fused kernels.

Both paths feed into the Phase 2 (concrete) verifier before codegen.


L0: Machine

Purpose

Fully explicit execution plan. Every buffer, every kernel, every stream, every byte. No decisions left. This is what actually runs.

Control IR

Ten operations over named memory regions. SSA registers. No flat address space — every load and store names its region, so the backend knows all aliasing without analysis.

load.{sz}  rgn[off]           read from named region
store.{sz} rgn[off], val      write to region (rw only)
hash val → reg                deterministic hash for map indexing
arith {op} a, b → reg         add, sub, mul, and, or, shift
cmp {op} a, b → reg           eq, neq, lt, gt → 1-bit result
branch reg → blk, blk         conditional jump
jump → blk                    unconditional jump
queue_push rgn, val            push descriptor to queue
queue_pop rgn → reg            pop descriptor from queue
terminate                      end of execution unit

Regions are named, sized, access-controlled memory. Not a flat address space — specific bounded things. This preserves aliasing knowledge all the way to codegen. A flat address space throws this away and forces the backend to re-derive it.

Why not target C? C implies abstractions we don't use: a call stack (we don't recurse), a general pointer model (we have named regions), function boundaries (we inline everything). The C compiler then has to re-discover structure we already know. It may fail and generate worse code. More importantly, C is a black box: we lose visibility into what actually executes.

Compute IR (UOps)

Atomic operations inside a kernel. Each is a typed node (op, dtype, src[], arg) forming a DAG rooted at STORE nodes.

Category Ops
Memory LOAD STORE INDEX
Arithmetic ADD MUL MULACC NEG RECIPROCAL MAX MOD
Compare CMPLT CMPNE WHERE
Transcendent EXP2 LOG2 SIN SQRT
Control RANGE END IF ENDIF BARRIER
Type CONST CAST BITCAST VECTORIZE GEP
Special WMMA REDUCE CUSTOM

~25 ops. Complete for any dense numeric kernel. CUSTOM covers sparse, scatter, and target-specific intrinsics.

IR

L0_IR = {
    memory_plan:   Map<BufferId, MemorySlot>
    kernel_plan:   [KernelDecl]
    stream_plan:   Map<StreamId, [KernelId]>
    sync_plan:     [SyncPoint]
    transfer_plan: [Transfer]
    init_plan:     [InitOp]
    control_blocks: [Block]              Machine IR blocks
}

KernelDecl = {
    id:         KernelId
    inputs:     [BufferId]
    outputs:    [BufferId]
    ops:        [UOp]                    the kernel body
    grid:       (x, y, z)
    block:      (x, y, z)
    shared_mem: bytes
}

MemorySlot = {
    pool:       PoolId                   which physical memory
    offset:     bytes
    size:       bytes
    lifetime:   (first_use, last_use)
}

Verification — Concrete (Phase 2)

After memory planning, stream inference, and kernel scheduling. Pure read-only checks on the concrete plan.

Property Check Catches
Safety Kernel correctness UOp sequences don't type-check, out-of-bounds access
Safety Buffer lifetime safety Overlapping writes to same memory slot
Safety Stream hazard check Shared mutable region + different streams, no sync
Liveness Sync completeness Cross-stream dependency missing a barrier
Resource bound Memory capacity Sum of live slots > pool capacity at any time t
Resource bound Pool exhaustion No headroom for transient allocations at pipeline depth

Optimization (IR→IR rewrites)

Closest to the metal. Most aggressive. Kernel fusion (merge adjacent kernels sharing buffers), memory packing (minimize peak by reordering lifetimes), launch config tuning (grid/block for occupancy), instruction selection (UOp → target ISA), register pressure management, prefetch insertion.


Codegen

L0 IR → target code. This is the only target-dependent phase in the entire compiler.

Control Path

Machine IR → target backend. The 10 ops translate mechanically.

Backend Characteristics
x86-64 Each op → 1–3 instructions. ~5 live values, no spills. Regions → register pointers.
LLVM Regions become pointer arguments with !noalias metadata. LLVM gets perfect aliasing.
eBPF Machine IR's restrictions (no loops, bounded access, linear resources) already satisfy the eBPF verifier. Nearly 1:1.
FPGA Each block → combinational pipeline stage. Regions → BRAM ports. queue_push → FIFO write-enable. terminate → valid signal.

Compute Path

UOps → target ISA. Three passes:

  1. Instruction selection — maps UOps to target ISA. MULACC → fma.rn.f32 on PTX, vfmadd on x86.
  2. Register allocation — handles spill/fill and manages pressure per-target. GPU has thousands of registers (occupancy-sensitive), CPU has 16 GPRs (spill-heavy).
  3. ISA emission — produces final text or binary. Warp divergence on GPU means IF/ENDIF UOps have different cost profiles than on CPU. Vectorization width is target-dependent.

Bounded Complexity

Codegen is deferred complexity, not buried complexity. The contract is clear: L0 IR is target-independent. Codegen is the only target-dependent phase. The UOp set is small enough that adding a new backend means writing ~25 emission rules plus register allocation for that target. This is a bounded, mechanical task — not an open research problem. The Machine IR is similarly small: 10 ops, each mapping to a handful of target instructions.


Linking & Runtime

Binary Structure

One binary. CPU object + accelerator blobs + metadata.

.text     schedule_loop, stage_pipeline, actor_dispatch, platform_primitives
.rodata   kernel cubins, memory_layout_table, stream_descriptors, schedule_descriptor
.data     pool_metadata, arena_metadata
.bss      gpu_context, stream_handles, event_handles

Platform Primitives

Hand-written per platform. The generated code calls into these. The emitter produces control flow + kernels. The runtime provides the operations.

Primitive Linux + CUDA
region_alloc Pool: atomic pop. Arena: bump.
region_free Pool: atomic push. Arena: no-op.
dma_ssd_to_ram io_uring / mmap+prefault
pcie_dma_h2d cuMemcpyHtoDAsync
pcie_dma_d2h cuMemcpyDtoHAsync
gpu_dispatch cuLaunchKernel
gpu_sync cuStreamSynchronize
gpu_event_* cuEventRecord / cuStreamWaitEvent
queue_push/pop Ring buffer in shared memory

Machine IR and kernel descriptors are target-independent. Only backends and primitive implementations change per platform.

Runtime Error Policy

All correctness errors caught at compile time by the two-phase verifier. The binary assumes all static proofs hold. Hardware faults crash the process — outside scope of generated code.


Escape Hatches

At any layer, annotate with #[<target_layer>::<directive>]:

// At L3: force a method choice
model classify {
    ...
    #[l2::backward(evolution(pop: 50))]
}

// At L2: force a placement
infer: sequential [
    linear(784, 256),
    #[l1::on(gpu_1)]
    linear(256, 10)
]

// At L2: force a kernel
infer: sequential [
    linear(784, 256),
    #[l0::kernel("fused_gelu.ptx")]
    ...
]

The escape hatch must pass verification at its target layer. You can inline PTX, but it must have the right buffer types. The compiler checks the boundary. If it fails, the error points at the annotation — the user broke the contract.

An escape hatch is opaque to optimization at layers above its target. #[l0::kernel(...)] cannot be fused with adjacent ops at L1. It's a black box with a typed boundary. This is the price of escaping.


Search Architecture

Every lowering boundary uses the same pattern:

lower(source_ir, target_constraints) -> Result<target_ir, SearchFailure>

    1. enumerate(source_ir, target_grammar) -> [candidate]
    2. filter(candidates, verify_target)    -> [valid]
    3. score(valid, cost_model_target)      -> [(candidate, cost)]
    4. select(scored)                       -> target_ir | SearchFailure

SearchFailure =
    NoCandidates(reason)        constraints too tight
  | TooManyCandidates(count)    representation too loose
  | Timeout(budget_exhausted)   problem is genuinely hard

SearchFailure is a diagnostic, not an error. It tells you what to fix: tighten constraints, loosen them, or redesign the layer boundary.


Data Ingestion

Data flows from storage to device memory before any kernel touches it:

Storage → decode (CPU) → Host Memory → batch → Staging → DMA → Device

The contract boundaries are clean: L2 says what to sample (epoch shuffle, curriculum, etc.), L1 says where to stage it (which memory pool, which node), L0 says how bytes move (DMA ops, transfer plan). Format-specific decoders (JPEG, video, custom binary) are pluggable at the L1/L0 boundary — they're CUSTOM UOps or host-side init ops.


Open Questions

1. Recurrence and variable-length. Current stance: everything finite, until(pred) is spec error without a proven bound. But transformers process variable-length sequences. Is sequence(domain, max_len) at L3 enough? Or do we need a richer notion?

2. Multi-model composition. GAN = generator + discriminator. Distillation = teacher + student. RL = policy + value + environment. L3 currently has one model per spec. Do we want multiple models with relationships, or a separate composition layer?

3. Training loop structure. L3 says goal: minimize(loss). Real training has phases — pretrain, finetune, RLHF. Epochs, checkpointing, evaluation. Probably L2 as a schedule, but it needs design.

4. Cost model grounding. L2 cost model is heuristic. L1 uses hardware specs. Neither is calibrated. Do we want empirical calibration — run a few kernels, measure, update?

5. Incremental compilation. If you change one loss term at L3, do you re-search all of L2? Probably: hash each IR node, re-lower only the subgraph whose inputs changed.

6. The host language. Rust for the compiler (algebraic types = IR correctness for free). But the DSL parser, the REPL, the interactive experience — Python bindings? LSP?


What Is Resolved

  • Layer count and purpose (4 layers, clear separation of concerns)
  • L1 is the systems-level DSL: actors, stages, pipelines, windows, tensor graphs, topology
  • Dual emission from L1: stages/actors → Control IR, tensor graphs → Compute IR (UOps)
  • Two-phase verification: structural (pre-scheduling, at L1) and concrete (post-scheduling, at L0)
  • Verification at every layer, classified by four property kinds
  • Named memory regions in Control IR — aliasing known to codegen without analysis
  • Memory planning as a compiler pass (persistent, arena, pool), not user-declared
  • Stream inference from pipeline_depth + placement
  • Escape hatch semantics (typed, opaque, verified at target)
  • Codegen contract (target-independent L0 IR, target-dependent emission only)
  • Data ingestion contract (L2 what, L1 where, L0 how)
  • Search architecture (uniform enumerate → filter → score → select at every boundary)
  • Platform primitives as the narrow runtime interface
  • No autodiff at L1 — backward is explicit; autodiff is an L2 method expanded during lowering
  • Dependency-inferred scheduling — no user-specified phases