Notes on RISC-V Vector (RVV 1.0) support in SBCL =============================================== This file documents the RVV support added to the riscv64gc backend and what remains to be done for deeper integration. Status is current as of the completion of the core RVV work (specs 00-08 done, 09/10 partial) and the sb-simd contrib (full NEON API parity). What is implemented ------------------- 1. Assembler + disassembler for the complete ratified RVV 1.0 instruction set (src/compiler/riscv/rvv-insts.lisp, wired into src/cold/build-order.lisp-expr; printers live in src/compiler/riscv/target-insts.lisp): - vsetvli / vsetivli / vsetvl, with SEW/LMUL keywords (inst vsetvli rd rs1 :e32 :m1 t t) and a vtype decoder for the disassembler. - All OP-V arithmetic/logical/reduction/mask instructions in their .vv/.vi/.vx/.vf/.wv/.wi/.wx/.vs/.mm/.m forms, whole-register moves, merge/adc families, etc. Every mnemonic of RVV 1.0 as accepted by GNU as/LLVM's assembler. - All vector loads/stores: unit-stride, strided, indexed (ordered/ unordered), fault-only-first, segments (nf=2..8), whole-register and mask forms. - Masking: any instruction the ISA allows to be predicated takes an optional trailing MASKED argument; the disassembler prints ",v0.t". Every encoding was mechanically verified against binutils/LLVM: tests/rvv-assembler.pure.lisp re-assembles 570+ instructions and compares the emitted 32-bit words with the reference assemblers. Operand conventions: - Vector operands are TNs in the VECTOR-REGISTERS storage base (RISC-V V keeps the vector file separate from FP, so TN offsets are the v0-v31 encodings) or plain integers 0-31. - General-purpose and floating-point operands likewise accept TNs or integers. - Immediate operands are plain integers. 2. Chunk VOPs (src/compiler/riscv/vector.lisp): %-RVV-F32-{ADD,SUB, MUL,DIV}-CHUNK and %-RVV-U32-{ADD,MAX}-CHUNK perform one VL-limited load/compute/store operation on system-area-pointers and return the number of elements processed, plus RVV-F32-ADD / RVV-F32-MUL convenience wrappers over simple arrays. These VOPs use wired scratch vector registers (currently v24/v25) as VECTOR-REG temporaries in the dedicated VECTOR-REGISTERS storage base, which is independent of the FP file (see below). FP and vector register files are independent -------------------------------------------- Architectural note: unlike ARM SVE (whose vector registers overlay the FP/SIMD registers), RISC-V V 1.0 keeps the vector register file *separate* from the scalar FP register file. v0-v31 are distinct architectural state from f0-f31, with a separate `mstatus.VS` context-status field (analogous to but independent of `mstatus.FS`), and Linux saves/restores them separately (`__fpregs` for FP, the magic-tail `__riscv_v_ext_state` block for vector). There is no F/V aliasing: a float in f25 and a vector in v25 may be live at the same time. SBCL models this with two independent finite storage bases in src/compiler/riscv/vm.lisp: `FLOAT-REGISTERS` for the scalar FP SCs (single/double/complex-single/complex-double-reg) and `VECTOR-REGISTERS` for the vector SCs (`vector-reg`, `single/double/int-vector-reg`). The register allocator resolves conflicts per storage-base location index, so an FP TN at offset N and a vector TN at offset N no longer conflict: they are distinct physical registers. The chunk/block/unroll/reduce/strided/indexed VOPs express their wired scratch vector registers as `VECTOR-REG` temporaries in the `VECTOR-REGISTERS` base, so a live FP value need not be spilled across a vector VOP (and vice versa). The psABI F convention marks f8-f9 and f18-f27 callee-saved (SBCL's c-saved-float-registers matches this), while the vector convention is defined independently (see below). The current VOPs use v24-v31 as scratch; once the general-VLEN value lands, the scratch window will be re-pointed to the caller-saved v8-v23 pool, since spec 10 adopts v1-v7 + v24-v31 as the Lisp callee-saved vector set. Runtime / ABI status -------------------- - The C runtime never executes vector instructions, so the runtime needs no changes to *run* RVV code emitted by the compiler. - The Linux kernel saves/restores vector state in signal frames (appended to sigcontext) for threads that have touched the vector state; SBCL's interrupt machinery only reads/writes the fixed ucontext fields, so deferred signals do not corrupt vector registers. NOTE: code that uses RVV must be run on a kernel built with vector support (all current RISC-V Linux distros). - The RVV spec's calling-convention appendix (B, placeholder/not yet frozen) says all vector registers v0-v31 are caller-saved, along with the vl and vtype CSRs. Because lisp uses only v24-v31 as scratch and never keeps a live lisp value in a v register across a call, no vector register needs to be saved/restored anywhere yet. This would change as soon as lisp keeps live values across calls in v registers - see below. Note the contrast with the F convention, where f8-f9/f18-f27 are callee-saved: the same register can be "caller-saved" in its V role and "callee-saved" in its F role, which the allocator handles purely by location index. - *Vector-state accessor and VLENB probe (spec 10, partial).* The C runtime now has `os_context_vstate()` / `os_context_vector_register_addr()` (src/runtime/riscv-linux-os.c), which reach the kernel's magic-tail `__riscv_v_ext_state` block appended to the sigcontext (magic `0x53465457` at a fixed offset inside the `__fpregs` area). They return NULL when there is no vector state, so callers degrade gracefully on no-V kernels or threads that never touched the V extension. `riscv_vector_vlenb()` reads VLENB lazily via `csrr vlenb` (CSR 0xC22, encoded numerically so the runtime still builds without `-march=rv64gcv`) and is exposed to Lisp as `SB-VM:RVV-VLENB`. These are the foundations for the callee-saved register save/restore below and for the non-local-exit path. VLEN scaling and full RVV 1.0 coverage --------------------------------------- *Feature marker and build gating.* `make-config.sh` autodetects whether the toolchain can assemble RVV 1.0 (it compiles with `-march=rv64gcv` and checks for `__riscv_vector`) and, if so, adds `:riscv-vector` to the local target features; it is whitelisted in make-target-2-load.lisp so it survives warm init. Portable code can test `#+riscv-vector`. *Disable option.* `sh make.sh --without-riscv-vector` forces the feature off even on a V-capable toolchain, producing an RVA20-safe binary on an RVA23 host. The assembler/disassembler (`src/compiler/riscv/rvv-insts.lisp`) is compiled for every riscv build (`#+riscv`) because it only encodes/decodes 32-bit words and is useful for cross-disassembly; the VOPs (`vector.lisp`), their `DEFKNOWN`s, and the runtime stubs in `riscv-vm.lisp` are gated on `#+riscv-vector`. A no-V build therefore has no RVV entry points and cannot emit vector instructions. *VLEN independence.* The VOPs never assume a fixed VLEN. Each chunk calls `vsetvli` with the remaining element count and receives the actual VL, and the block VOPs read VLMAX from `vsetvli rd, x0, e32, m1` at run time, so the same image runs correctly at VLEN=128, 256, 512 and 1024. This was verified by running the same benchmark on the default cores (VLEN=256) and on the AI-accelerated cores (VLEN=1024): identical results, with VLMAX=e32 going from 8 to 32. There is no "wasted width" on VLEN=1024 in the correctness sense — a single e32/m1 vector already uses the full 1024-bit register (32 x 32-bit lanes). The remaining throughput gap versus gcc's auto-vectorizer at VLEN=1024 is an LMUL issue, not a VLEN issue (see rvv-todo.adoc). *Full RVV 1.0?* The *assembler and disassembler* cover the full ratified RVV 1.0 mnemonic set. The *compiler-level* support now spans the whole planned family: element-wise chunk/block VOPs at LMUL 1/2/4/8, scalar-out reductions, the mask/compare family (v0.t, vcpop.m, vfirst.m, predicated load/store), fused multiply-add (accumulate, overwrite-multiplicand, and widening), same-width float↔integer conversion, integer/float width change (vzext/vsext/vnsrl/vfwcvt/vfncvt) with the fractional-LMUL refinement, widening arithmetic (vwadd/vwsub/vwmul), and strided/indexed/segment memory (vlse32/vluxei32/vlseg2..8). See rvv-todo.adoc for the full status. *LMUL>1 register groups (wired groups).* The chunk/block VOPs are generated by two macros (`DEFINE-RVV-CHUNK-VOP`, `DEFINE-RVV-BLOCK-VOP`) that take an LMUL keyword. For LMUL>1 a vector operand spans a register group, so the macro declares the group's non-base registers as extra wired `VECTOR-REG` temporaries at consecutive, group-aligned offsets (LMUL=2 base 24/26; LMUL=4 base 24/28). Only the group base register is named by the emitted instructions; the extra wired temporaries exist purely to keep the allocator from putting a live float in the rest of the group. This is the "wired groups" approach from rvv-specs/02, chosen over a dedicated grouped storage class (deferred to spec 09). *N-way unroll.* The 4-way unrolled block loop was generalized into a `DEFINE-RVV-UNROLL-VOP` macro taking an `:unroll N` factor; N=4 reproduces `%RVV-F32-ADD-BLOCK4` and N=8 (`%RVV-F32-ADD-BLOCK8`) uses v16-v31. Measured on the VLEN=256 core, N=8 is *neutral*: it matches N=4 within noise (f32 add ~5.4x vs ~5.5x at n=65536) rather than improving it, because a single LMUL=1 vector already covers 8 lanes and the loop is already limited by the `vsetvli`/load/store stream, not by register-level ILP. This is the expected, measurement-driven outcome recorded in rvv-specs/03. *Scalar-out reductions.* `%RVV-F32-REDUCE-ADD`/`%RVV-F64-REDUCE-ADD` (`vfredosum.vs`) and `%RVV-U32-REDUCE-MAX` (`vredmaxu.vs`) reduce a pinned SAP into a single scalar. They strip-mine with `vsetvli`, fold one vector per iteration into element 0 of an accumulator register via the `.vs` form, and finally extract element 0 with `vfmv.f.s`/ `vmv.x.s`. Two correctness points worth recording: (a) the `vsetvli` must run before the accumulator init so `vmv.v.i` has a valid vtype; (b) `vmv.x.s` sign-extends a 32-bit element, so the u32 result is zero-extended with `slli`/`srli` before boxing. *Masks and compares.* `%RVV-U32-MAX-MASKED` computes an element-wise unsigned max with a mask round-trip: `vmsgtu.vv` produces the `a > b` mask into v0, then `vmerge.vvm` selects `a` or `b`. Note the `vmerge` operand convention: `vmerge.vvm vd, vs2, vs1` is `vd = vs1 if mask else vs2`, so the mask-selected ("then") source is vs1. `%RVV-U32-CMP-COUNT` does compare + `vcpop.m` and returns the number of lanes where `a > b`. v0 is declared as a wired offset-0 temporary so the allocator keeps f0 free while a mask is live (single-chunk mask lifetime only). *Fused multiply-add.* `%RVV-F32-FMA-CHUNK` (`vfmacc.vv`) and `%RVV-U32-MACC-CHUNK` (`vmacc.vv`) compute `dst = src1*src2 + src3` using the accumulate form (vd is loaded from src3 and then `vfmacc.vv`/`vmacc.vv` folds in src1*src2). Both same-width and widening (`vfwmacc`/`vwmacc`) forms are implemented. *Conversions.* `%RVV-U32->F32-CHUNK` (`vfcvt.f.xu.v`) and `%RVV-F32->U32-CHUNK` (`vfcvt.xu.f.v`) do same-width (SEW=e32) integer↔float conversion. The width-changing families (integer `vzext/vsext/vnsrl`, float `vfwcvt/vfncvt`) are implemented using the fractional-LMUL refinement: the widening extension VOPs run at LMUL=4/2 so the narrow source fills a whole register. *Known limitation: block VOPs and FUNCALL.* The chunk VOPs can be called through FUNCALL (their self-calling stubs compile cleanly); the block VOPs currently cannot be called through FUNCALL on this backend — doing so mis-loads the SAP arguments and faults. The block VOPs are whole-array entry points and are exercised by direct calls (the benchmark wrapper and the tests). The FUNCALL path for VOPs with `:from`/`:to` argument-aliasing temporaries should be fixed before first-class vector values land (spec 09), which will rely on exactly that path. What remains for full "first-class" vector support --------------------------------------------------- The items above give correct, tested assembler/disassembler support and a small but useful VOP API. Making vector values first-class citizens requires substantially more work: 1. Compiler-visible storage classes and value types. The `VECTOR-REG` scratch SC and the `single/double/int-vector-reg` value SCs now exist on a dedicated `VECTOR-REGISTERS` storage base (independent of `FLOAT-REGISTERS`). What remains for full first-class support is holding a *general VLEN-sized* vector value live across calls/basic-block regions (M2/M4/M8 register-group variants analogous to COMPLEX-DOUBLE-REG are still missing). The hard part is spilling: a vector register holds VLEN bits (128..1024 in deployed hardware), so a save/restore across calls needs runtime-sized stack slots, while SBCL's stack frame layout is fixed at compile time. Options: a) fix VLEN at build time (feature :riscv-vlen-128 etc.); b) reserve max-VLEN (Zvlmaxb) slots, wasteful on small cores; c) spill through memory using VL-aware VOPs with variable-size nfp frames - requires new machinery in alpha-ize/pack. None of these are implemented. 2. Vector register preservation across calls. Under the spec's current calling-convention appendix all of v0-v31 are caller-saved, so a future lisp ABI that keeps live vector values across calls must either (a) treat them all as caller-saved and never keep values across calls (the status quo), or (b) pick a callee-saved subset and update SAVE-C-REGISTERS / RESTORE-C-REGISTERS (src/assembly/riscv/assem-rtns.lisp) and C callbacks to preserve it. *Decision (spec 10, revised):* SBCL's Lisp convention adopts the callee-saved split of the RISC-V psABI *Standard Vector Calling Convention Variant* (riscv-elf-psabi-doc PR #389): v1-v7 and v24-v31 are callee-saved; v0 (mask) and v8-v23 (vector data / argument pool) are caller-saved. This replaces an earlier v8-v23-callee-saved draft, chosen under a mistaken assumption of F/V register aliasing (there is none; RISC-V V keeps vector and FP state separate) and with no runtime performance benefit. The FFI boundary is unaffected either way: alien/callback calls follow the standard C convention (all v0-v31 caller-saved) via c-saved-float-registers / c-unsaved-float-registers, so live vector values are spilled across them regardless. Refs: riscv-cc.adoc "Standard Vector Calling Convention Variant"; riscv-elf-psabi-doc PR #389; gcc.gnu.org/pipermail/gcc-patches/2023-August/628961.html; reviews.llvm.org/D154576. The frame size for a full save is dynamic (32 * VLENB bytes); `SB-VM:RVV-VLENB` (above) is the runtime VLENB that the SAVE-V-REGISTERS / RESTORE-V-REGISTERS assembly routines (src/assembly/riscv/assem-rtns.lisp) size against. The routines are written but not yet wired into call-into-c — no value is currently kept live in a v register across a call. 3. First-class vector value types (SIMD-PACK analogues). *Done (fixed-128-bit slice).* The `sb-simd-pack` feature is now enabled on riscv when the toolchain advertises `__riscv_vector`, and the x86-64/arm64 SIMD-PACK machinery is wired to RVV: a fixed 128-bit value (4x32 / 2x64 / int lanes) lives in a single vector register (VLEN >= 128) with a 2-word (16-byte) non-descriptor-stack spill slot, mirroring src/compiler/{x86-64,arm64}/simd-pack.lisp. See src/compiler/riscv/simd-pack.lisp (new) and src/compiler/generic/primtype.lisp (`(and sb-simd-pack riscv)`). The SCs (`single/double/int-vector-reg`) use the dedicated `VECTOR-REGISTERS` base, independent of the FP `FLOAT-REGISTERS` base (RISC-V V has no F/V aliasing), so a live FP value and a live fixed-128-bit vector value do not consume each other's registers. This only addresses the fixed-128-bit case; the general VLEN-sized value (variable-size spill) is still (1). Caveat: do not keep a vector value live across a call that may collect or clobber the vector CSRs until item (2) / spec 10 lands. 4. CPU feature detection. *Done.* The runtime now exposes riscv_vector_supported_p() (src/runtime/riscv-linux-os.c), which reads getauxval(AT_HWCAP) and tests HWCAP_ISA_V (the kernel's "can this thread actually execute vector code" hint), and lisp exposes it as the exported predicate SB-VM:RVV-SUPPORTED-P (src/code/riscv-vm.lisp). Assembler/disassembler support remains unconditional (encoding is always safe); the predicate gates *execution*, which is what the hardware requirement is about. Note this is deliberately a plain predicate, not the x86-64 DEF-CPU-FEATURE/DEF-VARIANT routine-dispatch machinery, which exists to swap whole function definitions per CPU and is more than a yes/no test needs. 5. Disassembler niceties not done: vector registers now disassemble against their own `VECTOR-REGISTERS` storage base (distinct from `FLOAT-REGISTERS`), so `maybe-note-associated-storage-ref` can distinguish f and v views of the same offset. Backtrace location annotations for vector values still rely on the tracked storage showing up via that base. Testing ------- - tests/rvv-assembler.pure.lisp: exhaustive encoding checks vs binutils/LLVM, plus masked forms. - tests/rvv.impure.lisp: functional smoke test of the chunk VOPs on hardware with the V extension (guarded by SB-VM:RVV-SUPPORTED-P; skipped elsewhere). - tests/rvv-bench.impure.lisp: scalar-vs-vector timing comparison, also guarded by SB-VM:RVV-SUPPORTED-P. Interpreter / evaluator caveat (fixed in SBCL 2.6.8) --------------------------------------------------- While benchmarking it was discovered that SBCL 2.6.6 (compiled from this tree, and identically the stock system SBCL 2.6.6 without any RVV changes) could miscompile top-level forms that are *evaluated* (the --script / load-as-source path): in one reproducible case a LET-bound variable was compiled as a function call; in others, operand moves for inlined VOP calls were dropped (e.g. the AVL operand of a chunk VOP read an unassigned register). Compiled code - COMPILE, COMPILE-FILE, or the COMPILE evaluator mode - was correct in all tested cases. This was an upstream SBCL compiler bug, not an RVV bug: local common subexpression elimination (LCSE) mishandled a lambda enclosed in a LET (and a FUNCTIONAL-KIND could be both :ZOMBIE and :LET), which could miscompile an evaluated LET form. It was fixed upstream in commits 9c53205f5 ("Skip common subexpr elim for lambda enclosed in a let") and 8b4b67101 ("functional-kind can't be zombie and let at the same time"), both of which landed in SBCL 2.6.8 (regression test in tests/lcse.impure.lisp). Re-verified against 2.6.8: the original LET/sap-ref-32 reproduction and the RVV chunk-VOP top-level driver evaluate correctly under both :interpret and :compile modes with no evaluator workarounds. The benchmark files still force :compile evaluator mode and recompile their drivers, but that is now purely defensive and harmless rather than required for correctness. Practical notes (retained for reference): - For interactive probing, (DECLARE (NOTINLINE ...)) forces a full call to the correctly-compiled definition instead of an inline VOP expansion; this remains a useful idiom. - tests/rvv-bench.impure.lisp demonstrates both the timing comparison (scalar vs RVV) and the safe probing pattern.