- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets — stories, in progress, done, pending, discarded, learnings. It covered only the Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs repointed; `Kanban:` banners renamed to `Status:`. - New discarded.md (settled rejections with reasons: inheritance, `abstract`, Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong- output, malloc-path ASan trick, deferred checks that never reach the VM). - RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk, never committed (gitignored path), cited by five docs incl. principle 12. Root cause was broader: all seven forward-roadmap plans in docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc. - Repaired broken links across docs/, 270 -> 36: fixes a regression from the earlier reference/ -> .dev/reference/ move (relative paths at ../../ and deeper were skipped), plus depth and reorg drift. The 36 residual point at content that does not exist and need decisions, not paths. - New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md, applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw, abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins. Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by- design. types.ml header drops its false satisfaction-set claim. 00-code- review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
7.1 KiB
02 — The writeonce stance on assembly
Policy: no custom assembly in crates/rt. Use Rust stdlib + libc for every category Go's runtime solves with .s files. If a future profiling pass proves an asm optimisation is necessary, confine it to crates/rt/src/runtime/asm/ with one file per use case and an #[cfg(target_arch = "...")] cover per file (the Go asm_amd64.s pattern, one-per-architecture).
Why the policy works
Each Go asm category from 01-go-runtime-asm.md maps to a Rust-stdlib equivalent that is already correct on every supported architecture:
| Go asm need | What writeonce uses | Why it covers the gap |
|---|---|---|
Scheduler stack switching (gogo, mcall, systemstack) |
— nothing — | Single-threaded event loop through phases 02–08 (see Phase 2 Concurrency Model). Phase 09 introduces thread-per-core scaling for the 10k-user ecommerce workload — but still no Go-style stack switching: each thread runs its own event loop, connections are pinned for their lifetime, and cross-thread work is message-passing, not scheduler-stealing. No goroutines, no g0, even at scale. |
Preemption (asyncPreempt) |
— nothing — | No preemption through phases 02–08. Phase 09's thread-per-core model keeps this property: handlers run to completion on whichever thread owns their connection. |
Atomic operations (Load, Store, Cas, Xadd, ...) |
std::sync::atomic |
The compiler emits the right instruction per target — LOCK CMPXCHG on x86, LDXR/STXR on ARM, LR.W/SC.W on RISC-V. Ordering is in the type signature (Ordering::Acquire, Release, SeqCst). |
Memory barriers (MFENCE etc.) |
std::sync::atomic::fence(Ordering) |
One call, one fence, arch-neutral. |
memmove / memequal / memclr |
core::ptr::copy, <[T]>::copy_from_slice, ==, [T]::fill(0) |
LLVM emits the same vectorised code Go's asm does, often better because it knows alignment statically. |
| Syscall trampolines | libc::write(...), libc::read(...), libc::syscall(libc::SYS_io_uring_enter, ...) |
No scheduler means no "park the M across this syscall" concern. Plain libc is the right abstraction. |
Signal handler entry (sigtramp) |
signalfd (see ../linux/04-signalfd.md) |
Signals become fd reads on the epoll loop. No user-side handler runs; no trampoline needed. |
| Cgo boundary | — nothing — | Rust is the only language. extern "C" handles libc in the compiler's own ABI pass; no asm shim. |
nanotime (vDSO) |
std::time::Instant::now() |
Already goes through the vDSO via glibc. No added value in rolling our own. |
Net result: the assembly layer that accounts for Go's runtime complexity collapses into Rust's standard library and a handful of libc::* calls. Zero hand-written .s files in the target architecture.
Edge cases where asm might come up
Three situations where a future developer might reach for std::arch::asm! — and the first-line answer for each, which is always "try this Rust-stdlib path first."
1. io_uring ring atomics
The SQ and CQ rings need specific memory ordering around the head/tail indices: producer writes release-ordered, consumer reads acquire-ordered. The kernel and userland form a lockless SPSC queue across the mmap'd region.
- Rust answer:
AtomicU32at the ring-index offsets with explicitOrdering::Acquire/Ordering::Release.fence(Ordering::SeqCst)where the kernel ABI demands a full barrier. This is howio-uring(the community crate) implements it — in safe Rust. - When to escalate: never, realistically. Kernel + Rust stdlib agree on x86-TSO / ARM-AcRel semantics. An asm deviation would be a bug.
2. SIMD in the phase-05 JSON parser
simd-json and serde-json-core both show that JSON parsing benefits significantly from SIMD byte scans (whitespace skip, quote/escape detection).
- Rust answer:
core::arch::x86_64::*intrinsics (_mm256_cmpeq_epi8,_mm_movemask_epi8) inside#[cfg(target_feature = "avx2")]blocks, with a scalar fallback. All safe-enough Rust — noasm!block needed.std::simd(nightly) is even cleaner when it stabilises. - When to escalate: if a specific instruction sequence outperforms the compiler's codegen by a meaningful margin under measurement, wrap it in
asm!inside a single#[cfg(target_arch = "x86_64")]helper. Today the compiler is almost always as good as handwritten for well-understood patterns.
3. WAL group-commit sub-µs timing
Phase 3's WAL loop batches commits and fsyncs them in one io_uring submission. If we ever need to shave nanoseconds off the per-commit path — spin-waiting on the io_uring CQE ring, say — the tightest loop might want specific instructions (PAUSE on x86, WFE on ARM).
- Rust answer:
std::hint::spin_loop(). The compiler emitsPAUSE/WFEper target. Usecore::hint::black_boxto prevent compiler over-optimisation of measurement. - When to escalate: when benchmarks show a specific hotspot the compiler is provably wrong about. Not before.
The escape hatch
If all three defences above are exhausted — benchmarked, documented, reviewed — assembly goes in:
crates/rt/src/runtime/asm/
├── mod.rs # re-exports; arch gating
├── memcpy_avx2_x86_64.rs # #[cfg(all(target_arch = "x86_64", target_feature = "avx2"))]
└── spin_pause_x86_64.rs # #[cfg(target_arch = "x86_64")]
Rules:
- One concern per file. No
asm.rscatch-all. - Arch-gated at the file level.
#[cfg(target_arch = "...")]at the top of the file covering the whole module. A non-x86_64 build sees an empty module, not missing symbols. - Scalar fallback in
mod.rs. Every asm function has a pure-Rust sibling gated with the negation. Hosts that fail thetarget_featurecheck get the fallback transparently. - Comment every instruction with the intent.
lock xaddis opaque without "// atomic RMW for SQ head advance." - Benchmark in the commit message. Numbers before and after, the compiler version that produced the delta, and the specific CPU model.
No asm has been written under this policy yet. The expectation is it stays that way for the foreseeable future — the docs/plan/02-08 sequence reaches feature parity with Go's runtime using exclusively the Rust-stdlib + libc path.
Cross-references
00-overview.md— why runtimes ever need asm at all (three categories).01-go-runtime-asm.md— Go's asm inventory, by file.../linux/04-signalfd.md— the specific primitive that obviates Go'ssigtrampasm.../02-event-loop-epoll.md— phase 02, where theruntime/module actually lands.