writeonce/docs/plan/assembly/02-writeonce-stance.md
shoney.arickathil 9279175f26 scaffold sibling crates, multi-app ecommerce, REST + concurrency docs
- scaffold 14 placeholder crates (app, db, engine, gen, http, logic,
  policy, ql, service, sub, txn, ui, value, wal) — empty Cargo.toml +
  src/lib.rs to receive code phase-by-phase from `rt`
- restructure docs/examples/ecommerce into multi-app layout: apps/admin
  and apps/storefront, with shared/ types/logic/components, per-app
  app.wo + wo.toml, and reusable .htmlx components (layout, money,
  order-row)
- add reference/rest/{blog,ecommerce}.rest — VS Code/JetBrains HTTP
  request files driving the running prototype, including 501/404/405
  expectations for stubbed endpoints
- add docs/plan/09-concurrency-scaleout.md and docs/plan/ui/00-overview.md;
  refine docs/plan/assembly/02-writeonce-stance.md
- refresh templates (about, article, header/footer, home, layout, styles)
  and add static favicon/logo
- add infra/sync.sh and tighten .gitignore for reference/ symlinks
2026-05-04 13:39:58 +02:00

7.1 KiB
Raw Blame History

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: AtomicU32 at the ring-index offsets with explicit Ordering::Acquire / Ordering::Release. fence(Ordering::SeqCst) where the kernel ABI demands a full barrier. This is how io-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 — no asm! 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 emits PAUSE / WFE per target. Use core::hint::black_box to 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:

  1. One concern per file. No asm.rs catch-all.
  2. 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.
  3. Scalar fallback in mod.rs. Every asm function has a pure-Rust sibling gated with the negation. Hosts that fail the target_feature check get the fallback transparently.
  4. Comment every instruction with the intent. lock xadd is opaque without "// atomic RMW for SQ head advance."
  5. 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