Skip to content

Placement is an effect: kernels, buffers, and a handler that decides where they run - #95

Merged
ecto merged 26 commits into
mainfrom
claude/research-paper-discussion-b16446
Aug 19, 2026
Merged

Placement is an effect: kernels, buffers, and a handler that decides where they run#95
ecto merged 26 commits into
mainfrom
claude/research-paper-discussion-b16446

Conversation

@ecto

@ecto ecto commented Aug 19, 2026

Copy link
Copy Markdown
Owner

Reading of GPU Offload in Rust: Portable, Safe, and Fast (Drehwald et al., arXiv 2608.13759), and what it suggested for Loon.

The paper reads data direction off &T/&mut T, gets safe kernels via an unsafe trait for partitioning strategies, and marks sync points with Preload/PreloadMut. What it's candid about not solving: its convenient interface is up to 400x slower than explicit transfers, closing that needs a transfer-hoisting pass inside LLVM that's still a prototype, and the "Energy" benchmark (6 kernels sharing ~15 arrays) needs a heuristic they didn't ship.

Those two facts a compiler works hardest to recover — what does this launch touch and when does the host actually want to look — are both events. Loon has a way to spell that.

What this adds

[kernel saxpy [i a x y out]
  [put out i [+ [* a [at x i]] [at y i]]]]

[Place.run saxpy n #[3.0 x y out]]
[Place.read out]

Place.run and Place.read are effect operations, so a handler sees every launch and every synchronization point. Residency is then library code, not a compiler pass — os/place.oo:

[fn place/resident [thunk]
  [handle [thunk]
    [Place.run k n args] [do [Place.pin args] [resume [Place.run k n args]]]
    [Place.read b]       [do [let v [Place.read b]] [Place.unpin b] [resume v]]]]

On an Apple M4 Max via Metal, identical program in both columns:

launches no policy place/resident speedup
8 29.3 ms 8.4 ms 3.5x
32 94.8 ms 11.2 ms 8.4x
128 358.8 ms 18.1 ms 19.9x

Direction is inferred, not annotated: Loon already computed per-parameter ownership modes and threw them away. at is a read, put a write-through, so saxpy's shader binds inputs read and the output read_write with no &mut written anywhere.

Kernels run on CPU, across cores, on a GPU via wgpu (Metal/Vulkan/DX12), and in a browser tab — all agreeing on the answer. They're recordable: a run on the GPU replays on a build with no GPU compiled in.

Bugs found, several pre-existing on main

The tests kept earning their keep. All of these produced wrong answers, not crashes:

  • loon run --native silently returned () for any program with a main function.
  • Closures called from map/filter clobbered the caller's register 0. [let n 8] [map [fn [v] [* 2 v]] [range 0 n]] left n as 14.
  • WASM heap-allocated None, so it would have tested truthy there and falsy on the VM.
  • sum filtered vectors to integers — a vector of floats summed to 0, which looks like an empty sum rather than a failure. The checker also typed it Vec Int → Int with the comment "(approximate)", contradicting the registry's documented Vec Num → Num.
  • [len buf] returned 0 through a catch-all.
  • Three in my own WGSL emitter, each caught by naga validation rather than shipping: an inference fixpoint that stopped before loop-carried types settled, branch targets resolved across every function in the module (BlockId is per-function), and a unit value reaching a float slot with no conversion.

Deliberate restrictions

  • A kernel writes at its own index and nowhere else (E0602). Reading anywhere is fine. This is what lets the parallel executor hand each thread a split_at_mut slice — disjointness the borrow checker enforces rather than an unsafe impl promises.
  • No 64-bit on a GPU. WGSL core has no f64, so such a launch is refused by name rather than silently narrowed.
  • Reductions need no new feature: each work item sums its own chunk into its own slot, partials combine on the host.

What is not claimed

  • No comparison against hand-written CUDA/HIP/Metal. Not attempted, not implied.
  • The benchmark CPU column is Loon's own typed executor — a fair floor, not optimized C.
  • Notably, parallel CPU beats the GPU at 1M elements on this machine. That's the point rather than a disappointment: you find it by changing one word, because the program doesn't know where it runs.

For the reviewer

  • docs/blog/2026-08-18-placement-is-an-effect.md is the argument; docs/plans/2026-08-18-placement-remaining.md is the honest boundary.
  • Two open questions I deliberately did not decide, both language semantics: set! and push! are documented in four places and exist on neither the default backend; and on the interpreter push! does not mutate despite its name, so the guide's own example is true of no backend. Both options are written up rather than guessed at.
  • Browser GPU currently uses a Web Worker blocking on Atomics.wait (needs COOP/COEP). A second path needing no headers is built and verified — a handler that parks the continuation — but the demo page still uses the worker.
  • 766 tests pass with the gpu feature, 32 suites without; cargo fmt --check clean; the whole stack compiles for wasm32-unknown-unknown.

Try it:

loon run os/demo-place.oo                     # one program, four handlers
loon run os/demo-residency.oo --place device  # the transfer gap
loon run samples/place/reduce.oo --place gpu  # reductions on real hardware

🤖 Generated with Claude Code

ecto and others added 24 commits August 18, 2026 10:14
Groundwork for treating placement as an effect. Two things the compiler
already knew but threw away are now carried through to the backends, and
the encoding every backend depends on is written down once.

`eir/layout.rs` holds the NaN-boxing constants that `value64.rs`,
`wasm.rs`, and `native.rs` each used to redeclare under a comment asking
the next person to keep them in sync. It also defines `DType` and
`BufferHeader` for the dense buffers kernels will exchange. A new
`abi_conformance` test compiles the same literals on all three backends
and compares raw bits.

That test immediately found three real divergences:

  - `loon run --native` silently returned `()` for any program with a
    `main` function. The entry point reaches `main` through Close +
    Invoke, both unimplemented, and both emitted a unit placeholder
    instead of failing. Operations that *use* an unrepresentable value
    now error, as `End::TailInvoke` already did; ones that merely
    construct one stay inert, since lowering emits a dead Close for
    every named function.
  - The WASM backend heap-allocated the nullary `None` instead of using
    the immediate singleton. `value64.rs` documents that bit equality is
    `None` equality and truthiness is a pure bit test, so a heap `None`
    would have tested truthy there and falsy on the VM.
  - The native backend encoded `None` as `()`, making the two
    indistinguishable.

Ownership modes (Borrow/MutBorrow/Move) were inferred and then dropped
with the checker. They now ride on `Checker::fn_param_modes` and land on
`Func::param_modes` as In/InOut/Owned — the same directional information
Rust makes you write as `&T`/`&mut T`, inferred instead of annotated.
Mode inference also iterates to a fixed point: a single source-order
pass guessed `Move` for callees it had not reached yet, so the answer
depended on the order two functions happened to be written in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A kernel is an ordinary function that promised to stay small:

  [kernel saxpy [i a x y out]
    [put out i [+ [* a [at x i]] [at y i]]]]

`[kernel ...]` desugars to `fn` before anything else looks at it, so kernels
infer, lower, and run through exactly the same path as any other function.
What the keyword buys is a promise, checked in check/kernel.rs: no closures,
no allocation, no strings, no effects. That restriction is the safety
argument. The Rust offload work needs an `unsafe trait` for its partitioning
strategies because a kernel there can index a slice however it likes; here
the unsafe program cannot be written down.

Buffers (Obj::Buffer) are dense, unboxed, fixed-length arrays — the
representation that can leave the process, which persistent trees of
NaN-boxed words cannot. `at` reads, `put` writes in place.

`Place.run`, `Place.read`, `Place.pin`, `Place.stats` are effect operations.
Unhandled, they run serially right here, so a program that never mentions
placement still works. Handled, everything changes without the program
moving: os/place.oo has tracing, dry-run, and residency handlers in a few
lines each, and os/demo-place.oo runs one function under four of them to the
same answer. Because `Place.read` is an operation rather than an accessor, a
handler sees every synchronization point — which is what `Preload`/
`PreloadMut` exist to reconstruct in a language where it cannot.

Direction is inferred, not annotated: `at` is a borrow and `put` a mutable
borrow, so saxpy's modes come out In/In/In/In/InOut with no `&mut` anywhere.

Two bugs found on the way, both pre-existing and both silent:

  - Closures called from higher-order builtins clobbered the caller's
    register 0. `[let n 8] [map [fn [v] [* 2 v]] [range 0 n]]` left `n` as
    14, the last value map computed. The VM re-enters itself for these calls
    and named register 0 as the destination for a result that belongs to
    Rust; there is now a RET_DISCARD sentinel and a parity regression test.
  - `[len buf]` returned 0 through a catch-all rather than the length.

Also: effect operations still move their arguments, so "responding twice
with the same value is a compile error" survives. Place is the exception,
because placement reads its inputs and writes through its outputs — it never
takes ownership, and `[Place.read out]` afterwards is the entire point.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`--place device` gives buffers a memory of their own. The arithmetic still
happens on the host — this is not a GPU yet — but the bookkeeping is real: a
buffer must be uploaded before a kernel can use it, results must come back
before the host can read them, and nothing survives a launch unless somebody
says it should. That last part is what makes the default honest. A device
that has not been told a buffer will be wanted again does not keep it.

Saying so is `Place.pin`, and that is the entire vocabulary a residency
policy needs:

  [fn place/resident [thunk]
    [handle [thunk]
      [Place.run k n args] [do [Place.pin args] [resume [Place.run k n args]]]
      [Place.read b]       [do [let v [Place.read b]] [Place.unpin b] [resume v]]]]

Eight launches over one buffer, measured by os/demo-residency.oo:

  no policy:       uploads 8, resident hits 0, bytes in 128
  place/resident:  uploads 1, resident hits 7, bytes in 16

Same program, same answer. The Rust offload paper measures this same gap at
up to 400x and closes it with `Preload`/`PreloadMut` types at every call site
plus a transfer-hoisting pass inside LLVM that is still a prototype. The
information that pass tries to recover is already in the program here:
`Place.run` says what a launch touches and `Place.read` says when the host
wants an answer, so a handler can act on it.

Pinning deliberately does not mark a buffer resident. It says "keep this once
it is here", not "it is here already", so the first launch still pays for the
upload — otherwise a policy would look free in the accounting by declaring
itself so, which flatters a design instead of testing it.

Also: --place-stats prints the transfer table, and place/resident-only takes
the buffers to keep as an argument, for the case the paper flags as needing a
heuristic it did not ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An EIR kernel becomes a @compute entry point: buffers are storage bindings,
scalars are uniform fields, and the work index is global_invocation_id.x.
saxpy comes out as

  @group(0) @binding(1) var<storage, read> b1: array<f32>;
  @group(0) @binding(3) var<storage, read_write> b3: array<f32>;
  ...
  r5 = b1[u32(r0)];
  r6 = (params.s0 * r5);
  b3[u32(r0)] = r8;

with `read` versus `read_write` decided by whether the body indexes the
argument with `at` or writes it with `put` — the same fact that decides which
way bytes move at runtime. `infer_arg_kinds` reads a launch signature off the
body, so nothing has to be declared: an argument that is indexed is a buffer,
one that is computed with is a scalar.

Two decisions worth naming. Kernels are specialized per launch rather than
emitted once for all uses, which is how a language with inferred types still
produces a shader whose every binding has a definite type. And there is no
relooper: WGSL has no goto, so the emitter wraps a switch on a block index in
a loop, exactly as the WASM backend already does. A single-block kernel skips
the dispatcher entirely and reads like the source did.

Every kernel in the repo is parsed and type-checked by naga in CI, on a
machine with no GPU. That is the automated cross-target validation the Rust
offload paper reports as still missing — they found their host/device slice
divergence by hand.

It earned its keep immediately. Emitting the repo's own saxpy produced a
shader referencing an undeclared identifier, because a buffer argument used
as a number has no WGSL expression to become. That is now refused by argument
number instead of emitted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wgpu turns the emitted WGSL into a compute pipeline and dispatches it. On
this machine that is Metal on an Apple M4 Max; the same code path reaches
Vulkan on Linux, DX12 on Windows, and WebGPU in a browser tab. That last one
is the entry a compiler emitting PTX and AMDGCN cannot reach at all, and it
comes from having picked a portable shading language rather than a vendor's.

The end-to-end test is the one worth having: the same Loon kernel run through
the interpreter and run on the GPU, compared. Not "close to a formula written
separately in Rust" — the same program, both ways. A GPU that computes
something different quickly is not an optimization.

  saxpy over 256 elements, f32 within 1e-4
  a branching kernel, exercising the block dispatcher in the shader
  10,000 elements across many workgroups, every one written

`run` returns the bytes read back from each writable buffer rather than
mutating host buffers in place, which keeps it free of any opinion about
where the host's copy lives and makes the transfer something a test can look
at directly. Only buffers declared writable come back — the same distinction
the ownership pass draws, arriving at the hardware.

Behind the `gpu` feature, off by default: it is a large dependency and every
other placement mode works without it. Absence of a GPU is not a failure —
tests print SKIPPED and pass, so nothing in the suite depends on hardware
being present. It only gets checked on hardware when hardware is there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`Place.run` now dispatches to the GPU when the mode says so. The sample that
has been running on the CPU since M1 runs unchanged:

  loon run samples/place/saxpy.oo --place cpu     → #[0 5 10 15 20 25 30 35]
  loon run samples/place/saxpy.oo --place device  → #[0 5 10 15 20 25 30 35]
  loon run samples/place/saxpy.oo --place gpu     → #[0 5 10 15 20 25 30 35]

Not one character of the program differs between those, and the residency
handler written in M2 against no hardware at all now decides what an Apple M4
Max actually copies: eight launches over one buffer cost eight uploads with
no policy and one upload under place/resident, on the GPU, same answer.

Residency accounting is shared between the simulated device and the real one,
because "how many times did these bytes cross the boundary" is a property of
placement rather than of any particular device. That is why the policy did
not need rewriting when real hardware arrived.

The refusal test found a bug in this integration worth keeping: a buffer
passed to a parameter the kernel multiplies by was silently becoming 0.0,
because scalars and buffers were drawn from separate lists without checking
that each argument matched the shape the kernel body implies. It now says
which argument disagrees. A confident wrong answer is the exact failure this
design exists to prevent, and it very nearly shipped inside it.

Without the `gpu` feature, asking for `--place gpu` says the build has no GPU
support rather than quietly running on the CPU.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
  loon run samples/place/saxpy.oo --place gpu --record trace.oo
  loon replay trace.oo samples/place/saxpy.oo     # no GPU feature compiled in

Both print #[0 5 10 15 20 25 30 35].

Placement joins the recorded effects, so from the program's point of view a
kernel launch is an operation that returned nothing and a read is one that
produced some numbers. Recording those reproduces the run exactly on a
machine with no GPU, in CI, or after the hardware it was written for stopped
existing. Nothing about the recording is GPU-specific — a trace made against
the simulated device replays the same way.

`Place.stats` is deliberately excluded. It reports on the run currently
happening, and a replayed run really did move no bytes; feeding back the
original transfer counts would be a recording that lies about the execution
it is part of. The replayed run reports zero launches, because it performed
zero launches.

Buffers now print as `#buf<f32 x 3>` rather than `<obj:3>`. A heap slot in
output is unhelpful, and it would have made every recorded trace differ from
the last for no reason.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The residency accounting was describing a model, not the hardware. Every
launch was creating fresh GPU buffers, uploading, dispatching, and copying
results back — so `uploads 1` in the stats sat next to four real uploads on
the device. Fixed by giving the GPU a residency map keyed by the host's heap
slot: `Place.pin` keeps an allocation alive, a launch uploads only what is
not already there, and `Place.read` is the only thing that brings bytes home.

An upload counted is now an upload that happened, which makes the benchmark
worth reading. A chain of launches over one 4096-element buffer, Apple M4 Max
via Metal, identical program in both columns:

    launches   no policy   place/resident   speedup
           8     28.9 ms           9.3 ms      3.1x
          32     93.1 ms          15.4 ms      6.0x
         128    356.7 ms          18.3 ms     19.5x

Two bugs fixed on the way, both of the kind that produce wrong answers rather
than slow ones:

  - Evicting a dirty buffer discarded what the kernel had written. A cache
    that drops dirty data is not a cache. Eviction now writes back first,
    which is exactly the cost a policy of keeping nothing resident is
    choosing to pay.
  - `Place.read` collected values from the host copy *before* downloading, so
    it returned stale data and reported a transfer that changed nothing. The
    transfer now happens first, which is the whole content of the word
    "synchronization".

Compiled shaders are cached, so a loop launching the same kernel does not
recompile it every iteration.

BENCHMARKS.md records all of this with the framing it needs: the CPU column
is Loon's own interpreter, the slowest reasonable baseline, and the ratio says
what there is to gain by leaving it — not how a generated shader compares to
a hand-written kernel. That comparison is not attempted and not claimed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/blog/2026-08-18-placement-is-an-effect.md and the changelog entry.

The post's argument, in short: the two facts a GPU offload compiler works
hardest to recover — what does this launch touch, and when does the host
actually want to look — are both *events*, and we already have a way to spell
those. Making Place.run and Place.read effect operations turns the
transfer-hoisting pass into a nine-line handler, and turns the "heuristic we
did not ship" for the Energy benchmark into an argument the user passes.

It is explicit about what is not claimed. No comparison against hand-written
CUDA is attempted. The CPU column in the benchmarks is Loon's own
interpreter, so the GPU ratio says what there is to gain by leaving the
interpreter, not anything about generated code quality. Reductions and
atomics are outside the kernel subset, and f64 narrows to f32 on a device
that has no 64-bit scalar.

And the honest summary of the comparison: they built a compiler and this
moved a seam. Those are different kinds of work. The seam is worth writing
about because it makes residency, prefetch, eviction, tracing, simulation,
and replay stop being compiler features somebody has to ship for you and
start being code you can write on a Tuesday.

Third in the arc: v0.7 effects end to end, v0.8 the kernel is a handler,
v0.9 the GPU is a handler in the middle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Kernels were running through the general interpreter: every value NaN-boxed,
every buffer access through the heap table, every arithmetic operation
checking what it was handed. Right for Loon, wrong for a loop doing the same
three floating-point operations a million times.

eir/kernel_exec.rs runs the numeric subset against raw slices instead. A
kernel outside the subset falls back to the VM, so nothing a kernel was
allowed to be has been narrowed — `supported()` is a question asked once
before a launch, not a requirement.

That makes the CPU baseline fair, which matters because "the GPU beats our
interpreter" was never an interesting claim. And because the executor works
over an index range on plain slices, `--place par` follows: split the range,
give each thread a disjoint piece of the output via split_at_mut, and let the
borrow checker be the thing that guarantees they do not overlap. The Rust
offload paper needs an `unsafe impl` of a partitioning strategy to promise
the same property.

A kernel that writes outside its own range fails with a message saying so,
rather than racing another thread.

The same kernel across four placements (Apple M4 Max):

    elements         cpu         par         gpu
        1024      447 µs      566 µs     10.3 ms
      262144      8.9 ms      3.3 ms     12.4 ms
     1048576     36.4 ms     11.4 ms     19.2 ms

The last row is the interesting one: every core beats the GPU here. A machine
with this many fast cores, and a launch that pays submission and transfer
before computing anything, land that way. That is not a disappointing result
— you found it out by changing one word, because the program does not know
where it runs, and where the crossover sits is a property of the machine
rather than of the code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two corrections and a finding.

The finding: a GPU dispatch submits to the queue and returns. Nothing waits
until the host asks for data, and `Place.read` is the only way to ask — so a
handler that defers reads defers every synchronization point in the program.
64 launches take 19.9 ms against 6.9 ms for one; if each blocked on the
previous, that would be nearer 440 ms. The offload paper prototypes async
transfers as a separate LLVM optimization. Here it is what happens when the
synchronization point is something the program says out loud.

The correction that matters: the post implied you can run one of these
programs in a browser today. You cannot. WGSL is WebGPU's shading language
and naga validates every kernel we emit, so the shader half really is
portable — but Loon's wasm build still embeds the old tree-walking
interpreter rather than the EIR VM, so there is nowhere for the program to
run. That gap predates this work and has nothing to do with placement. The
post now says the architecture points at a target the alternatives cannot
reach, and does not claim to have arrived.

Also corrected: Vulkan and DX12 are the same code path through wgpu, but I
have not run either, so they are "should" rather than "does".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/plans/2026-08-18-placement-remaining.md, so the next person does not
have to infer it from the code: what shipped, and the four things that did
not — the browser (blocked on the wasm build embedding the old interpreter,
not on anything about placement), reductions, 64-bit on a GPU, and
compile-time rejection of scatter kernels.

Also records that the placement stack itself compiles for
wasm32-unknown-unknown today, which is the checkable half of the
portability claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`DType::gpu_ok` existed, was tested, and was never called. So `--place gpu`
silently narrowed an f64 buffer to f32 while the docs said the narrowing was
"reported rather than hidden". It is now refused:

  error: argument 1 is a f64 buffer, and a GPU has no 64-bit number; run this
  with --place cpu or --place par, or build the buffer with `buf` instead of
  `buf-f64`

Handing back numbers of a precision the program never asked for, and has no
way to notice, is the confident-wrong-answer failure this design exists to
prevent. The same program runs on the CPU, which does have 64-bit numbers.

And the disjointness rule is now enforced where it belongs. A kernel may write
at its own index and nowhere else — that is what lets the parallel executor
hand each thread a slice and what lets a GPU run every work item at once. The
parallel executor caught violations at runtime; the checker now rejects them
at compile time (E0602), naming the offending index:

  error: [E0602] kernel 'reverse' writes at an index other than its own
    why: every work item runs at once, so each may only write element 'i';
         writing elsewhere means two of them can reach the same element and
         the result would depend on which got there first
    fix: write at 'i', and read whatever else this element needs with `at`

Reading anywhere is still fine — gather is safe, scatter is not. The index
parameter can be called whatever the kernel calls it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A kernel may write at its own index and nowhere else, which sounds like it
rules out summing an array. It doesn't: a reduction is a map followed by a much
smaller reduction. Give each work item its own chunk of the input and its own
slot in a partials buffer and every item is still writing exactly one element —
its own. Combine the sixty-four partials on the host, where sending them to a
device would cost more than adding them.

So there is no `Place.reduce`, no workgroup-shared memory, and no exception
carved out of the disjointness rule. samples/place/reduce.oo sums 0..4096 to
8386560 on cpu, par, device, and gpu alike.

Getting there needed `loop` added to the kernel forms — `recur` was already
allowed, which is meaningless without the form that introduces it — and turned
up four bugs.

Two in `sum`, both pre-existing. The registry has always declared it
`Vec Num → Num` and the interpreter has always implemented that, but the
checker said `Vec Int → Int` with the comment "(approximate)", so summing
floats did not type check; and the EIR VM filtered the vector to integers and
returned an i64, so a vector of floats summed to 0. That is the worst shape of
wrong answer, because it looks like an empty sum rather than a failure.

Three in the WGSL emitter, each of which produced a shader that failed
validation — the good outcome, but only because the emitter is checked:

  - The type-inference fixpoint stopped when no *new* register had a type, so
    a loop-carried value kept whichever type its first predecessor gave it.
    Types now widen monotonically and iterate until nothing changes.
  - Branch targets were resolved across every function in the module. `BlockId`
    is per-function, so a jump found whichever other function happened to have
    a block with the same number and copied arguments into its parameters.
  - A unit value reaching a float slot had no conversion, so the emitter wrote
    a bare i32 register where WGSL wanted an f32.

The reduction kernel is now in the naga validation corpus, so all three stay
fixed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`crates/loon-wasm` gains an `eval_placed` export that runs on the EIR VM, and
`web/public/place.html` exercises it: pick a placement in a tab, see what
crossed the boundary.

    placed on device: 4 launches over 32 work items;
                      9 uploads (288 B), 2 downloads (64 B), 3 resident hits

That is the residency handler — written in M2 against no hardware at all —
deciding what a browser copies. Verified in a real tab, not inferred from the
fact that it compiles.

Kernels and buffers exist only on the EIR VM, which is why a new export was
needed rather than a migration. The DOM-driving exports stay on the legacy
interpreter: its bridge is written against `Value`/`InterpError` rather than
the EIR's `Val`/`VmResult`, and the guide's examples use builtins such as
`push!` that the EIR VM does not implement. Adding an entry point beats
breaking documented pages.

WebGPU remains the missing half, and now for a precise reason rather than a
vague one: wgpu's device setup returns futures a browser resolves on its event
loop, and `eir::gpu` blocks on them with pollster, which cannot block on wasm.
`--place gpu` in a tab refuses and says why. The shaders are validated by naga
in CI, so both ends are known-good; the wire between them is what is absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`run_parallel` handled exactly one output buffer and quietly ran everything
else sequentially. Asking for `--place par` and getting `--place cpu` was
indistinguishable from getting what you asked for, which is the kind of silence
this codebase spends most of its error messages avoiding.

Every output is now carved into per-thread slices. `iter_mut` yields disjoint
`&mut`s, so pieces taken from different buffers coexist without anyone
promising they don't overlap — `split_at_mut` returns that, and the borrow
checker enforces it.

A kernel splitting one input into two outputs now really does run across cores:

  [kernel split [i src lo hi]
    [let v [at src i]]
    [put lo i [if [< v 0.0] v 0.0]]
    [put hi i [if [< v 0.0] 0.0 v]]]

cpu, par, device, and gpu agree on the answer at 1000 elements.

Arguments are now passed to the executor already separated into scalars,
inputs, and outputs rather than pre-assembled, which is what lets the parallel
driver carve the outputs while sharing the inputs whole. `run_sequential`
takes the same shape so a caller does not assemble arguments two ways.

An output shorter than the launch is reported rather than silently covering
fewer elements.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`place.html` was only reachable because I was serving `web/public` directly.
The build copies a fixed list of static assets into `dist`, and the page was
not on it — so on the deployed site it would have 404'd.

Also removed the stray `loon_wasm*` copies I had left in `public/` root. The
build says plainly not to put them there (`public/pkg/` is the single source of
truth, and its contents are copied to the dist root), and they are gitignored,
so they were local clutter that happened to make my test pass for the wrong
reason.

Verified against the built site rather than the source tree: device placement
reports 9 uploads and 3 resident hits, and `--place gpu` refuses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remaining-work doc had drifted: reductions, the browser, scatter rejection,
and the 64-bit refusal were all filed under "not done" after being done.

Restructured into three sections rather than two, because the middle one was
the part being mis-shelved. "Decided, and deliberately restrictive" holds the
rules the design rests on — write at your own index, no 64-bit on a GPU, no
allocation or effects in a kernel. Those are not gaps waiting to be closed;
they are the reason the parallel executor can hand out slices and the reason
the unsafe program is unwriteable rather than merely rejected.

What is left is genuinely left: WebGPU behind the browser needs either an
asynchronous effect path in the VM or a worker blocking on Atomics.wait, and
neither is really about placement; atomics would need a second kind of kernel
with its own safety argument; and there is still no comparison against
hand-written CUDA, which we have not measured and do not claim.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`eir::device::Device` is the six things a placement backend has to do:
name itself, make a buffer resident, say whether one is, dispatch, download,
evict. wgpu implements it on a desktop. A browser will implement it by
proxying to JavaScript, because WebGPU is reachable only through promises and
this VM is synchronous.

The VM now holds an `Rc<dyn Device>` and does not know which it has. A host
can install one; failing that, the `gpu` feature opens wgpu; failing that,
asking for `--place gpu` says there is nowhere to run rather than quietly
running somewhere else.

The refactor immediately turned up a real waste: every argument was marked
dirty after a launch, so saxpy — two buffers read, one written — copied all
three home and reported 96 bytes when 32 had changed. Only written buffers
are dirty now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Loon kernel now runs on the GPU in a browser tab:

    placed on gpu: 4 launches over 32 work items;
                   9 uploads (288 B), 3 downloads (96 B), 3 resident hits

Same WGSL a desktop build hands to wgpu, same answer as every other placement,
with the residency handler deciding what gets copied.

The obstacle was that WebGPU is asynchronous — a device comes from a promise,
a readback goes through mapAsync — and Loon's VM is synchronous all the way
down: `Place.read` is an effect operation that returns a value, not a future.
Both facts cannot hold on one thread.

So they hold on two. The VM runs in a Web Worker; every device call posts a
request to the main thread and blocks on `Atomics.wait` until the reply lands
in a SharedArrayBuffer. The main thread, where the promises live, does the
WebGPU work and wakes the worker. The blocking is the trick, not a workaround:
it lets an asynchronous API sit under a synchronous language without either
pretending to be the other.

None of that reached the VM, because the device went behind `eir::device::
Device` first — name, ensure-resident, is-resident, dispatch, download, evict.
wgpu implements it on a laptop, a JS bridge implements it in a tab, and the VM
does not know which it has. That is the move placement makes at the language
level, one floor down.

  crates/loon-wasm/src/gpu_bridge.rs   the Rust side of the bridge
  web/public/place-gpu.js              WebGPU on the main thread
  web/public/place-worker.js           the VM, blocking
  vercel.json                          COOP/COEP so SharedArrayBuffer exists

Verified in a real tab against the built site, not the source tree. Without
cross-origin isolation the page says so and the other placements still work.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Looking into what it would take to move the DOM exports onto the EIR VM turned
up something bigger than the DOM.

`set!` and `push!` are documented in four places — the collections guide, the
ownership guide, the builtin reference, and DESIGN.md — and neither exists on
the EIR VM, which is the default backend. `loon run` on a program from the
guide says "unbound symbol 'set!'".

On the interpreter they do exist, and `push!` does not mutate:

    [let mut v #[1 2 3]]
    [push! v 4]
    [println v]            ; #[1 2 3] — unchanged
    [println [push! v 9]]  ; #[1 2 3 9]

The guide's own example claims the opposite, and prints `#[]` on the
interpreter while failing to type check on the EIR VM. The documented
behaviour is currently true of no backend at all.

Not fixed here, and deliberately. Porting the interpreter's behaviour verbatim
would give a second backend a builtin whose name promises mutation and whose
implementation returns a copy; making it mutate is a language decision with a
real question attached (what a closure that captured the old value sees).
Written up in the remaining-work doc with both options rather than guessed at.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
I claimed that letting a browser answer `Place.read` asynchronously — and so
dropping the cross-origin isolation requirement — would need an asynchronous
effect path in the VM. Then I tried it. The VM already has one.

A handler clause does not have to call `resume`. If it hands `resume`
somewhere else and returns, the handled computation unwinds without ending,
and the continuation stays live in whoever caught it. Calling it later picks
the program up mid-expression:

    work: starting
    host: computation parked; the rest of it is mine now
    host: ...doing something slow...
    work: continued with 21
    host: finished with 42

os/demo-park.oo, with tests pinning the ordering — "work: continued" appears
after the host has printed, which is only possible if the computation really
unwound and was restarted from outside. No VM support was added for any of
this. It is what reified escaping continuations already are.

Two things needed correcting as a result. The remaining-work doc said this was
"a real project rather than a small change"; the semantics were never the
problem. What is actually left is plumbing — a Vm that outlives one call,
since the continuation lives in its heap, and an export for the page to resume
through — and the post now says so.

I had assumed the hard part was the language and the easy part was the wiring.
It was the other way round, which is what comes of writing down what I thought
was true instead of trying it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plumbing the last commit said was left. `eir::vm::Session` prepares a
program, runs it until it finishes or parks, and can be resumed later with the
value the host went off to fetch:

    let mut s = Session::new(src, dir, Mode::Cpu)?;
    let Step::Parked { .. } = s.start()? else { ... };
    let k = s.pending().unwrap();
    s.resume(k, s.vec_of_floats(&bytes_from_the_gpu))?;

`Host.park` is the effect a suspending handler performs; the VM keeps the
continuation and the request, and the handled computation unwinds. `Vm::
call_value` invokes a continuation or closure from Rust. The VM stays alive
across all of it, which is the one real requirement: a parked continuation is
a heap object, so dropping the VM would drop the rest of the program.

Writing the tests turned up the one genuinely surprising thing about parking,
now pinned by a test named after it: unwinding means the code *after* the
handle runs immediately, with whatever the clause returned, and only the
suspended part waits. So a deferring handler belongs outermost with nothing
meaningful after it, and the computation's value arrives through `resume`
rather than from the call that parked. That is written down in os/demo-park.oo
where somebody about to write such a handler will read it.

Also fixed: `Session::take_output` returned nothing, because `Vm::run` hands
its output out in the result and the session was discarding it between steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`place_start` and `place_resume` expose the session to a page. A program that
parks returns `{done: false}`; the page does whatever it could not do
synchronously and calls back with the numbers.

Confirmed in a browser with crossOriginIsolated === false — no worker, no
SharedArrayBuffer, no COOP/COEP:

    start:  done=false                              ← parked at Place.read
    resume: done=true out="read #[2 4 6]" value=12  ← finished with host data

So the browser GPU has two possible paths now. The worker one, which is what
the demo page uses and which is verified on real hardware, needs isolation
headers. This one needs nothing, and only the readback ever has to wait —
`writeBuffer` and `submit` are already synchronous.

Pointing the page at it is what remains, and it is a page change rather than a
capability one: the sample has to be written with the deferring handler
outermost, because parking unwinds to the `handle` and anything after it runs
immediately with the placeholder.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
loon Ready Ready Preview Aug 19, 2026 2:25am

Request Review

main split the OS layer out to ecto/fxos, which deletes `os/` from this repo.
The placement handlers and demos were living there and are not part of the OS,
so they move to where the other placement samples already are:

  os/place.oo           -> samples/place/lib.oo
  os/demo-place.oo      -> samples/place/demo-handlers.oo
  os/demo-residency.oo  -> samples/place/demo-residency.oo
  os/demo-park.oo       -> samples/place/demo-park.oo

`crates/loon-lang/tests/loon_os.rs` went with the split. The six placement
tests it had gained are extracted to `tests/place_samples.rs`, resolved against
the new directory; all six still pass.

Two content conflicts, both the same shape — upstream and this branch each
added a parameter to `run_file`, and each added modules to `eir`. Merged rather
than picked, so `--unchecked` and `--place` compose:

  loon run samples/place/saxpy.oo --unchecked --place device

33 suites green, formatting clean, and every relocated sample runs. The
residency demo still reports 8 uploads with no policy and 1 under
place/resident.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Clippy's approx_constant is deny-by-default and caught two values in the
golden f32 buffer written as 3.14159265 and -2.71828. They were never meant to
be pi and e — the list is deliberately awkward bit patterns (signed zeros, a
denormal, the extremes, a NaN with a payload) and those two were there as
ordinary values with a full mantissa, to catch a backend that round-trips the
special cases and mangles the mundane ones.

Written as truncated constants they suggested they meant something. Replaced
with values that are plainly arbitrary, and the comment now says what they are
for.

My miss: I ran fmt and the tests locally but never clippy, which is what CI
runs. Checked now across the workspace and with the gpu feature; both clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@ecto
ecto merged commit c12ef56 into main Aug 19, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant