From bf8d0a8fc63c50a6b392cb256c06fb7577402079 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 20:51:32 +0800 Subject: [PATCH 1/7] feat(topic29): add vector IR ops and strip-mining vectorizer with scalar lowering Phase 1: IR vector opcodes + builder APIs, FOR strip-mining vectorizer with scalar remainder clone, per-lane scalar lowering in the instruction selector, encoder vector-mnemonic rejection, and --vectorize / --vector-width / --vector-isa wiring on both CLI and config sides. RVV deferred. Codegen also materializes constant operands for R-type instructions and emits encoder-safe memory operands so the vectorized programs are executable by the existing RV32IM emulator. --- scratchv/backend/instruction_select.py | 96 +++- scratchv/backend/riscv_encoder.py | 22 + scratchv/backend/vector_scalar.py | 205 ++++++++ scratchv/compiler.py | 37 ++ scratchv/ir/builder.py | 58 +++ scratchv/ir/types.py | 23 + scratchv/main.py | 15 + scratchv/optimizer/vectorize.py | 689 +++++++++++++++++++++++++ tests/test_backend.py | 110 ++++ tests/test_vector_encoder.py | 50 ++ tests/test_vector_lowering.py | 236 +++++++++ tests/test_vectorize.py | 417 +++++++++++++++ 12 files changed, 1948 insertions(+), 10 deletions(-) create mode 100644 scratchv/backend/vector_scalar.py create mode 100644 scratchv/optimizer/vectorize.py create mode 100644 tests/test_vector_encoder.py create mode 100644 tests/test_vector_lowering.py create mode 100644 tests/test_vectorize.py diff --git a/scratchv/backend/instruction_select.py b/scratchv/backend/instruction_select.py index 26395d2..a7b030e 100644 --- a/scratchv/backend/instruction_select.py +++ b/scratchv/backend/instruction_select.py @@ -11,15 +11,22 @@ from scratchv.backend.machine_types import ( MachineInstr, MachineOp, MachineOperand, ) +from scratchv.backend.vector_scalar import VectorScalarExpander class InstructionSelector: """Select RISC-V instructions for each IR instruction.""" + # Not in ``machine_types.ALL_REGS``: the register allocator can never + # assign a virtual register to it, so it is safe as a scratch register. + _ADDR_SCRATCH = "a1" + def __init__(self, program: Program): self.program = program self._instructions: list[MachineInstr] = [] self._label_counter = 0 + self._vector_expander = VectorScalarExpander() + self._defined_consts: set[str] = set() def run(self) -> list[MachineInstr]: """Select instructions for all functions. @@ -29,8 +36,27 @@ def run(self) -> list[MachineInstr]: self._instructions = [] for func in self.program.functions: self._select_function(func) + self._assert_no_vector_leak() return self._instructions + def _assert_no_vector_leak(self) -> None: + """Post-condition: no raw vector value reaches machine code.""" + vector_names: set[str] = set() + for func in self.program.functions: + for block in func.blocks: + for instr in block.instructions: + if instr.opcode.is_vector() and instr.dest is not None: + vector_names.add(instr.dest.name) + if not vector_names: + return + for mi in self._instructions: + for op in (mi.dst, mi.src1, mi.src2): + if op is None or op.kind != "vreg": + continue + if op.value in vector_names: + raise AssertionError( + f"vector value leaked into machine code: {op.value}") + def _fresh_label(self, prefix: str = "L") -> str: self._label_counter += 1 return f".L{prefix}_{self._label_counter}" @@ -38,6 +64,8 @@ def _fresh_label(self, prefix: str = "L") -> str: def _select_function(self, func: Function) -> None: # Function prologue label self._emit_label(func.name) + self._vector_expander.begin_function(func.name) + self._defined_consts.clear() for block in func.blocks: self._emit_label(f".{block.name}") @@ -45,6 +73,10 @@ def _select_function(self, func: Function) -> None: self._select_instruction(instr) def _select_instruction(self, instr: Instruction) -> None: + if instr.opcode.is_vector(): + self._instructions.extend( + self._vector_expander.expand(instr)) + return handler = getattr(self, f"_select_{instr.opcode.value}", None) if handler is None: raise ValueError( @@ -68,6 +100,43 @@ def _op(self, instr: Instruction, idx: int): return MachineOperand.immediate(int(op.const_value)) return MachineOperand.vreg(op.name) + def _op_reg(self, instr: Instruction, idx: int): + """Like ``_op``, but materializes constants into a vreg. + + R-type machine instructions (add/sub/mul/div) cannot encode an + immediate operand, and the RV32IM encoder silently maps a + non-register operand to ``x0``. Constants are therefore turned + into ``LI tmp, imm`` first and passed as a register. + """ + op = instr.operands[idx] + if op.is_constant and op.const_value is not None: + # Reuse the register already materialized by a LOAD_CONST + # instruction when there is one (avoids a duplicate LI and a + # needless virtual register). + if op.name in self._defined_consts: + return MachineOperand.vreg(op.name) + tmp = MachineOperand.vreg(f"const__{op.name}") + self._emit(MachineOp.LI, tmp, + MachineOperand.immediate(int(op.const_value)), + comment=f"const {op.const_value}") + return tmp + return MachineOperand.vreg(op.name) + + def _mem_addr(self, instr: Instruction, idx: int) -> MachineOperand: + """Return an encoder-safe memory operand for an address value. + + The RV32IM encoder only accepts ``lw rd, rs1(offset)`` and + ``sw rs1(offset), rs2``; a bare ``lw rd, rs1`` operand is silently + encoded with ``rs1 = x0``. The address is copied into the fixed + scratch register ``a1`` and emitted with an explicit ``(0)`` + offset. ``a1`` is not part of ``ALL_REGS``, so the register + allocator never assigns it to a virtual register. + """ + addr = self._op_reg(instr, idx) + self._emit(MachineOp.ADDI, MachineOperand.reg(self._ADDR_SCRATCH), + addr, MachineOperand.immediate(0), comment="addr") + return MachineOperand.reg(f"{self._ADDR_SCRATCH}(0)") + def _dst(self, instr: Instruction): if instr.dest is None: return None @@ -84,22 +153,24 @@ def _select_load_const(self, instr: Instruction) -> None: self._emit(MachineOp.LI, dst, MachineOperand.immediate(int(val)), comment=f"const {val}") + if instr.dest is not None: + self._defined_consts.add(instr.dest.name) def _select_add(self, instr: Instruction) -> None: self._emit(MachineOp.ADD, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._op_reg(instr, 0), self._op_reg(instr, 1)) def _select_sub(self, instr: Instruction) -> None: self._emit(MachineOp.SUB, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._op_reg(instr, 0), self._op_reg(instr, 1)) def _select_mul(self, instr: Instruction) -> None: self._emit(MachineOp.MUL, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._op_reg(instr, 0), self._op_reg(instr, 1)) def _select_div(self, instr: Instruction) -> None: self._emit(MachineOp.DIV, self._dst(instr), - self._op(instr, 0), self._op(instr, 1)) + self._op_reg(instr, 0), self._op_reg(instr, 1)) def _select_neg(self, instr: Instruction) -> None: # RISC-V: sub rd, x0, rs @@ -123,7 +194,9 @@ def _select_relu(self, instr: Instruction) -> None: """ReLU(x) = max(x, 0). Use: max rd, rs, x0""" src = self._op(instr, 0) dst = self._dst(instr) - self._emit(MachineOp.MAX, dst, src, MachineOperand.immediate(0)) + # ``zero`` (not immediate 0): the encoder's ``max`` expansion must + # not borrow a temporary register that may be live here. + self._emit(MachineOp.MAX, dst, src, MachineOperand.reg("zero")) def _select_gelu(self, instr: Instruction) -> None: # GELU approx: x * relu(x) / 2 (simplified, pure RV32IM) @@ -137,9 +210,10 @@ def _select_gelu(self, instr: Instruction) -> None: comment="relu(x)") self._emit(MachineOp.MUL, dst, src, tmp, comment="x * relu(x)") - self._emit(MachineOp.DIV, dst, dst, - MachineOperand.immediate(2), - comment="/ 2") + div2 = MachineOperand.vreg("const__gelu_div2") + self._emit(MachineOp.LI, div2, MachineOperand.immediate(2), + comment="const 2") + self._emit(MachineOp.DIV, dst, dst, div2, comment="/ 2") def _select_softmax(self, instr: Instruction) -> None: # softmax ≈ identity (pure RV32I passthrough) @@ -157,10 +231,12 @@ def _select_reshape(self, instr: Instruction) -> None: self._emit(MachineOp.MV, dst, src, comment="reshape") def _select_load(self, instr: Instruction) -> None: - self._emit(MachineOp.LW, self._dst(instr), self._op(instr, 0)) + addr = self._mem_addr(instr, 0) + self._emit(MachineOp.LW, self._dst(instr), addr) def _select_store(self, instr: Instruction) -> None: - self._emit(MachineOp.SW, self._op(instr, 0), self._op(instr, 1)) + addr = self._mem_addr(instr, 0) + self._emit(MachineOp.SW, addr, self._op_reg(instr, 1)) def _select_alloca(self, instr: Instruction) -> None: raw_size = instr.attrs.get("size", 4) diff --git a/scratchv/backend/riscv_encoder.py b/scratchv/backend/riscv_encoder.py index d39ee4b..f35b4fa 100644 --- a/scratchv/backend/riscv_encoder.py +++ b/scratchv/backend/riscv_encoder.py @@ -120,6 +120,26 @@ def _sext(val: int, bits: int) -> int: return val +# ── Vector mnemonic guard (Topic 29, phase 1) ───────────────────────── +# +# Phase 1 lowers vector IR to plain RV32IM before encoding, so no vector +# instruction should ever reach this encoder. This guard turns a silent +# ``unknown instruction``/mis-encoding into an explicit failure. + +VECTOR_MNEMONIC_RE = re.compile( + r"^v(setvli|setivli|set|le|se|lw|sw|add|sub|mul|div|rem|max|min|" + r"mv|fmv|fadd|fsub|fmul|fdiv|redsum|rgather|slide|merge|macc|nclip|" + r"widen|narrow|and|or|xor)") + +VECTOR_ENCODING_MSG = ( + "vector instruction '{op}' is not supported: " + "ScratchV phase 1 targets RV32IM only") + + +class VectorEncodingError(ValueError): + """Raised when a vector mnemonic reaches the RV32IM encoder.""" + + # ── Instruction encoders ────────────────────────────────────────────── def _r_type(rd: int, rs1: int, rs2: int, @@ -333,6 +353,8 @@ def _encode_line( return None op = tokens[0].lower() + if VECTOR_MNEMONIC_RE.match(op): + raise VectorEncodingError(VECTOR_ENCODING_MSG.format(op=op)) operands = tokens[1:] fixup = None diff --git a/scratchv/backend/vector_scalar.py b/scratchv/backend/vector_scalar.py new file mode 100644 index 0000000..444dae4 --- /dev/null +++ b/scratchv/backend/vector_scalar.py @@ -0,0 +1,205 @@ +"""Scalar lowering of phase-1 vector ops (Topic 29). + +``VectorScalarExpander`` converts each vector IR instruction into a +sequence of plain RV32IM machine instructions, one per lane, so that the +existing register allocator, assembly emitter, encoder and RV32IM +emulator can all execute vectorized programs without modification. + +Two encoder-facing constraints shape the emitted code: + +* Scratch lane addresses are materialized in a fixed physical register + (``a1``) and referenced with an explicit ``(0)`` offset, because the + RV32IM encoder only accepts ``lw rd, rs1(offset)`` / + ``sw rs1(offset), rs2`` memory forms; a bare ``lw rd, rs1`` operand is + silently encoded as ``rs1 = x0``. ``a1`` is outside the allocatable + register sets, so no allocated vreg can collide with it. +* ``VRELU`` uses the ``zero`` register (not the immediate ``0``) as the + ``max`` pseudo-instruction's second operand, so the encoder's expansion + does not need to borrow a temporary general-purpose register. +""" + +from __future__ import annotations + +from scratchv.backend.machine_types import ( + MachineInstr, + MachineOp, + MachineOperand, +) +from scratchv.ir.types import Instruction, OpCode, Value + + +class VectorLoweringError(ValueError): + """Raised when a vector op cannot be lowered to scalar machine code.""" + + +_BINARY_OPS: dict[OpCode, MachineOp] = { + OpCode.VADD: MachineOp.ADD, + OpCode.VSUB: MachineOp.SUB, + OpCode.VMUL: MachineOp.MUL, + OpCode.VDIV: MachineOp.DIV, +} + +# Fixed physical scratch register for lane addresses. Must not be a +# member of ``machine_types.ALL_REGS`` (t0-t6, s0-s11). +_ADDR_SCRATCH = "a1" + + +class VectorScalarExpander: + """Expand vector IR instructions into per-lane scalar machine code.""" + + def __init__(self, *, default_width: int = 4) -> None: + self._default_width = default_width + self._lanes: dict[str, list[MachineOperand]] = {} + self._counter = 0 + + # ── Lifecycle ─────────────────────────────────────────────────────── + + def begin_function(self, func_name: str) -> None: + """Reset per-function lowering state.""" + self._lanes.clear() + self._counter = 0 + + # ── Public API ────────────────────────────────────────────────────── + + def expand(self, instr: Instruction) -> list[MachineInstr]: + """Lower one vector instruction to a list of machine instructions.""" + op = instr.opcode + if op is OpCode.VLOAD: + return self._expand_load(instr) + if op is OpCode.VSTORE: + return self._expand_store(instr) + if op is OpCode.VBCAST: + return self._expand_bcast(instr) + if op in _BINARY_OPS: + return self._expand_binary(instr) + if op is OpCode.VRELU: + return self._expand_unary(instr) + raise VectorLoweringError( + f"unsupported vector op: '{op.value}'") + + # ── Shape helpers ─────────────────────────────────────────────────── + + def _width(self, instr: Instruction) -> int: + raw = instr.attrs.get("width", self._default_width) + try: + return int(raw) # type: ignore[arg-type] + except (TypeError, ValueError): + return self._default_width + + def _operand_reg(self, value: Value, + out: list[MachineInstr]) -> MachineOperand: + """A register operand for a scalar value; constants become LI.""" + if value.is_constant and value.const_value is not None: + return self._materialize_const(int(value.const_value), out) + return MachineOperand.vreg(value.name) + + def _materialize_const(self, raw: int, + out: list[MachineInstr]) -> MachineOperand: + self._counter += 1 + tmp = MachineOperand.vreg(f"vcst_{self._counter}") + out.append(MachineInstr( + MachineOp.LI, tmp, MachineOperand.immediate(raw), + comment=f"vector const {raw}")) + return tmp + + def _lanes_of(self, value: Value, width: int, + out: list[MachineInstr]) -> list[MachineOperand]: + """Return the lane operands bound to *value*.""" + bound = self._lanes.get(value.name) + if bound is not None: + if len(bound) != width: + raise VectorLoweringError( + f"vector value '{value.name}' has {len(bound)} lane(s), " + f"expected {width}") + return bound + if value.is_constant and value.const_value is not None: + tmp = self._materialize_const(int(value.const_value), out) + return [tmp] * width + if value.shape: + raise VectorLoweringError( + f"vector value '{value.name}' has no lane binding") + return [MachineOperand.vreg(value.name)] * width + + # ── Per-op expansion ──────────────────────────────────────────────── + + def _lane_addr(self, base: MachineOperand, k: int, out: list[MachineInstr], + name_hint: str) -> MachineOperand: + """Materialize lane *k*'s address (``base + 4k``) in a scratch reg.""" + out.append(MachineInstr( + MachineOp.ADDI, + MachineOperand.reg(_ADDR_SCRATCH), + base, + MachineOperand.immediate(4 * k), + comment=f"{name_hint} lane {k} addr")) + return MachineOperand.reg(f"{_ADDR_SCRATCH}(0)") + + def _expand_load(self, instr: Instruction) -> list[MachineInstr]: + out: list[MachineInstr] = [] + width = self._width(instr) + dest = instr.dest + if dest is None: + raise VectorLoweringError("vload without destination") + addr = self._operand_reg(instr.operands[0], out) + lanes: list[MachineOperand] = [] + for k in range(width): + lane = MachineOperand.vreg(f"{dest.name}__lane{k}") + a = self._lane_addr(addr, k, out, dest.name) + out.append(MachineInstr(MachineOp.LW, lane, a)) + lanes.append(lane) + self._lanes[dest.name] = lanes + return out + + def _expand_store(self, instr: Instruction) -> list[MachineInstr]: + out: list[MachineInstr] = [] + width = self._width(instr) + addr = self._operand_reg(instr.operands[0], out) + vec = instr.operands[1] + lanes = self._lanes_of(vec, width, out) + for k in range(width): + a = self._lane_addr(addr, k, out, vec.name) + out.append(MachineInstr(MachineOp.SW, a, lanes[k])) + return out + + def _expand_bcast(self, instr: Instruction) -> list[MachineInstr]: + out: list[MachineInstr] = [] + width = self._width(instr) + dest = instr.dest + if dest is None: + raise VectorLoweringError("vbcast without destination") + src = self._operand_reg(instr.operands[0], out) + self._lanes[dest.name] = [src] * width + return out + + def _expand_binary(self, instr: Instruction) -> list[MachineInstr]: + out: list[MachineInstr] = [] + width = self._width(instr) + dest = instr.dest + if dest is None: + raise VectorLoweringError( + f"{instr.opcode.value} without destination") + va = self._lanes_of(instr.operands[0], width, out) + vb = self._lanes_of(instr.operands[1], width, out) + mop = _BINARY_OPS[instr.opcode] + lanes: list[MachineOperand] = [] + for k in range(width): + lane = MachineOperand.vreg(f"{dest.name}__lane{k}") + out.append(MachineInstr(mop, lane, va[k], vb[k])) + lanes.append(lane) + self._lanes[dest.name] = lanes + return out + + def _expand_unary(self, instr: Instruction) -> list[MachineInstr]: + out: list[MachineInstr] = [] + width = self._width(instr) + dest = instr.dest + if dest is None: + raise VectorLoweringError("vrelu without destination") + va = self._lanes_of(instr.operands[0], width, out) + zero = MachineOperand.reg("zero") + lanes: list[MachineOperand] = [] + for k in range(width): + lane = MachineOperand.vreg(f"{dest.name}__lane{k}") + out.append(MachineInstr(MachineOp.MAX, lane, va[k], zero)) + lanes.append(lane) + self._lanes[dest.name] = lanes + return out diff --git a/scratchv/compiler.py b/scratchv/compiler.py index fa5459e..a68f698 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -53,6 +53,9 @@ class CompilerConfig: cycle_stats: Run 5-stage pipeline cycle estimation (detailed). enable_forwarding: Enable forwarding in cycle estimator. branch_predictor: Branch predictor mode for cycle estimator. + vectorize: Run the FOR-loop strip-mining vectorizer (Topic 29). + vector_width: Vector strip width W (2 or 4). + vector_isa: Vector ISA target ("scalar"; "p"/"v" rejected). """ backend: str = "riscv" @@ -73,6 +76,9 @@ class CompilerConfig: cycle_stats: bool = False enable_forwarding: bool = True branch_predictor: str = "always_not_taken" + vectorize: bool = False + vector_width: int = 4 + vector_isa: str = "scalar" # ═══════════════════════════════════════════════════════════════════════════════ @@ -300,6 +306,20 @@ def compile(self, input_path: str, output_path: str | None = None, opt_result = self._run_optimizations(program) opt_message = opt_result.message + # --- 3b. Vectorize (Topic 29, phase 1) --- + if self.config.vectorize: + if self.config.use_dag_isel: + warnings.append( + "vectorize is incompatible with --dag-isel; " + "falling back to linear isel") + self.config.use_dag_isel = False + vec_result = self._run_vectorizer(program) + warnings.extend(vec_result.warnings) + if vec_result.message: + opt_message = ( + (opt_message + "; " if opt_message else "") + + vec_result.message) + ir_dump_after = "" if self.config.dump_ir: from scratchv.ir.printer import IRPrinter @@ -315,6 +335,15 @@ def compile(self, input_path: str, output_path: str | None = None, ) # --- 4. Code generation --- + if self.config.vectorize and self.config.vector_isa != "scalar": + return CompileResult( + success=False, + errors=[ + f"vector-ISA '{self.config.vector_isa}' is not " + "implemented (phase 2); use --vector-isa scalar" + ], + ir_dump=ir_dump, + ) try: asm_text = self._generate_code(program) except Exception as e: @@ -415,6 +444,14 @@ def _run_optimizations(self, program) -> PassResult: return pm.run(program) + # ── Internal: vectorization (Topic 29) ────────────────────────────────── + + def _run_vectorizer(self, program) -> PassResult: + """Run the FOR-loop strip-mining vectorizer.""" + from scratchv.optimizer.vectorize import Vectorizer + vec = Vectorizer(program, width=self.config.vector_width) + return vec.run(program) + # ── Internal: code generation ─────────────────────────────────────────── def _generate_code(self, program) -> str: diff --git a/scratchv/ir/builder.py b/scratchv/ir/builder.py index 5468962..645af3a 100644 --- a/scratchv/ir/builder.py +++ b/scratchv/ir/builder.py @@ -207,3 +207,61 @@ def reshape(self, val: Value, shape: tuple) -> Value: dest = self.make_value() self._emit(OpCode.RESHAPE, dest, [val], shape=shape) return dest + + # --- SIMD vector ops (Topic 29, phase 1) --- + + def vload(self, addr: Value, *, width: int = 4, + elem_bytes: int = 4, align: int = 4) -> Value: + dest = self.make_value(dtype=addr.dtype) + dest.shape = (width,) + self._emit(OpCode.VLOAD, dest, [addr], width=width, + elem_bytes=elem_bytes, align=align) + return dest + + def vstore(self, addr: Value, vec: Value, *, width: int | None = None, + elem_bytes: int = 4, align: int = 4) -> Instruction: + w = width if width is not None else _shape_width(vec) + return self._emit(OpCode.VSTORE, operands=[addr, vec], width=w, + elem_bytes=elem_bytes, align=align) + + def vbcast(self, scalar: Value, *, width: int = 4) -> Value: + dest = self.make_value(dtype=scalar.dtype) + dest.shape = (width,) + self._emit(OpCode.VBCAST, dest, [scalar], width=width) + return dest + + def vadd(self, lhs: Value, rhs: Value, *, + width: int | None = None) -> Value: + return self._emit_vector_binary(OpCode.VADD, lhs, rhs, width) + + def vsub(self, lhs: Value, rhs: Value, *, + width: int | None = None) -> Value: + return self._emit_vector_binary(OpCode.VSUB, lhs, rhs, width) + + def vmul(self, lhs: Value, rhs: Value, *, + width: int | None = None) -> Value: + return self._emit_vector_binary(OpCode.VMUL, lhs, rhs, width) + + def vdiv(self, lhs: Value, rhs: Value, *, + width: int | None = None) -> Value: + return self._emit_vector_binary(OpCode.VDIV, lhs, rhs, width) + + def vrelu(self, val: Value, *, width: int | None = None) -> Value: + w = width if width is not None else _shape_width(val) + dest = self.make_value(dtype=val.dtype) + dest.shape = (w,) + self._emit(OpCode.VRELU, dest, [val], width=w) + return dest + + def _emit_vector_binary(self, opcode: OpCode, lhs: Value, rhs: Value, + width: int | None) -> Value: + w = width if width is not None else _shape_width(lhs) + dest = self.make_value(dtype=lhs.dtype) + dest.shape = (w,) + self._emit(opcode, dest, [lhs, rhs], width=w) + return dest + + +def _shape_width(value: Value, default: int = 4) -> int: + """Return the lane count of a vector value, or *default*.""" + return value.shape[0] if value.shape else default diff --git a/scratchv/ir/types.py b/scratchv/ir/types.py index 059f73d..f4c7897 100644 --- a/scratchv/ir/types.py +++ b/scratchv/ir/types.py @@ -49,6 +49,19 @@ class OpCode(enum.Enum): RESHAPE = "reshape" CONCAT = "concat" + # ── Extended instruction selection (Topic 28) ──────────────────── + # Reserved for Topic 28; Topic 29 does not touch this partition. + + # ── SIMD vector ops (Topic 29, phase 1) ────────────────────────── + VLOAD = "vload" + VSTORE = "vstore" + VBCAST = "vbcast" + VADD = "vadd" + VSUB = "vsub" + VMUL = "vmul" + VDIV = "vdiv" + VRELU = "vrelu" + def is_arith(self) -> bool: return self in (OpCode.ADD, OpCode.SUB, OpCode.MUL, OpCode.DIV) @@ -71,6 +84,16 @@ def is_control_flow(self) -> bool: OpCode.FOR, OpCode.ENDFOR, OpCode.BR, OpCode.BR_IF, OpCode.RETURN) + def is_vector(self) -> bool: + """Whether this opcode is a phase-1 SIMD vector op (Topic 29).""" + return self in _VECTOR_OPS + + +_VECTOR_OPS = frozenset({ + OpCode.VLOAD, OpCode.VSTORE, OpCode.VBCAST, + OpCode.VADD, OpCode.VSUB, OpCode.VMUL, OpCode.VDIV, OpCode.VRELU, +}) + class DataType(enum.Enum): """Element data types.""" diff --git a/scratchv/main.py b/scratchv/main.py index 52feff5..4bd96eb 100644 --- a/scratchv/main.py +++ b/scratchv/main.py @@ -104,6 +104,18 @@ def build_arg_parser() -> argparse.ArgumentParser: "--extended-isel", action="store_true", help="Use extended instruction selector with fp64/sqrt/min/max/abs support (Topic 28)", ) + parser.add_argument( + "--vectorize", action="store_true", + help="Run FOR-loop strip-mining vectorization (Topic 29, phase 1)", + ) + parser.add_argument( + "--vector-width", type=int, choices=[2, 4], default=4, + help="Vector strip width W (default: 4)", + ) + parser.add_argument( + "--vector-isa", choices=["scalar", "p", "v"], default="scalar", + help="Vector ISA target; 'p'/'v' are rejected until phase 2", + ) # ── Cycle estimation ────────────────────────────────────────────── parser.add_argument( @@ -153,6 +165,9 @@ def args_to_config(args: argparse.Namespace) -> CompilerConfig: cycle_stats=args.cycle_stats, enable_forwarding=not args.no_forwarding, branch_predictor=args.branch_predictor, + vectorize=args.vectorize, + vector_width=args.vector_width, + vector_isa=args.vector_isa, ) diff --git a/scratchv/optimizer/vectorize.py b/scratchv/optimizer/vectorize.py new file mode 100644 index 0000000..6a32571 --- /dev/null +++ b/scratchv/optimizer/vectorize.py @@ -0,0 +1,689 @@ +"""FOR-loop strip-mining vectorizer (Topic 29, phase 1). + +Rewrites straight-line ``FOR`` regions whose memory accesses follow the +element pattern ``base + i*elem_bytes`` into vector ops over strips of +``width`` lanes, plus a scalar remainder loop when the trip count is not +divisible by the width. + +The transformed IR still contains vector ops; phase 1 lowers them to +scalar RV32IM code in the instruction selector via +``scratchv.backend.vector_scalar.VectorScalarExpander``. + +Semantics: every accepted loop is transformed so that the vector body is +a per-strip regrouping of the original scalar body; lane k of a strip +corresponds exactly to iteration ``strip*width + k``. Rejected loops are +left completely untouched and the reason is reported through +``PassResult.warnings`` / ``Vectorizer.last_report``. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Optional + +from scratchv.ir.types import ( + BasicBlock, + DataType, + Function, + Instruction, + OpCode, + Program, + Value, +) +from scratchv.pass_interface import CompilerPass, PassResult + + +# ── Stable rejection reasons (machine-readable) ────────────────────────── + +REASON_NON_CONSTANT_BOUNDS = "non-constant-bounds" +REASON_UNSUPPORTED_STEP = "unsupported-loop-step" +REASON_UNSUPPORTED_START = "unsupported-loop-start" +REASON_TRIP_TOO_SMALL = "trip-count-too-small" +REASON_NESTED_CONTROL_FLOW = "nested-control-flow" +REASON_NO_ELEMENT_PATTERN = "no-memory-element-pattern" +REASON_NON_ELEMENTWISE_IV = "non-elementwise-iv-use" +REASON_ALIASING_STORE = "aliasing-store" +REASON_UNSUPPORTED_OP = "unsupported-op" + + +# ── Element op → vector op mapping ─────────────────────────────────────── +# +# NOTE (deviation from design doc 2.2.1 C5): ``NEG`` is listed there as an +# element op, but the phase-1 vector instruction set has no ``VNEG``, so a +# region containing NEG is rejected as ``unsupported-op`` instead of being +# rewritten with a non-existent opcode. + +_ELEMENT_VECTOR_OPS: dict[OpCode, OpCode] = { + OpCode.ADD: OpCode.VADD, + OpCode.SUB: OpCode.VSUB, + OpCode.MUL: OpCode.VMUL, + OpCode.DIV: OpCode.VDIV, + OpCode.RELU: OpCode.VRELU, +} + +_CONTROL_FLOW_OPS = frozenset({ + OpCode.FOR, + OpCode.ENDFOR, + OpCode.BR, + OpCode.BR_IF, + OpCode.LABEL, + OpCode.RETURN, +}) + + +# ── Report structures ──────────────────────────────────────────────────── + +@dataclass +class LoopVectorizationRecord: + """Outcome of attempting to vectorize one ``FOR`` region.""" + + function: str + block: str + index: int + status: str + reason: str = "" + start: int = 0 + end: int = 0 + width: int = 0 + strips: int = 0 + remainder: int = 0 + vector_ops: int = 0 + + +@dataclass +class _LoopRegion: + for_index: int + end_index: int + for_instr: Instruction + body: list[Instruction] + + +@dataclass +class _MemRef: + """A LOAD/STORE inside the region with its decomposed address.""" + + instr: Instruction + index: int + base: Optional[Value] + offset: Optional[Value] + canonical: bool + + @property + def is_load(self) -> bool: + return self.instr.opcode is OpCode.LOAD + + @property + def lane_key(self) -> object: + if self.canonical: + return "canonical" + if self.offset is not None: + return self.offset.name + return None + + +@dataclass +class _Plan: + n: int + width: int + elem_bytes: int + strips: int + remainder: int + iv_name: str + outer_names: frozenset[str] + offset_mul_indices: set[int] = field(default_factory=set) + addr_add_bases: dict[int, Value] = field(default_factory=dict) + mem_indices: set[int] = field(default_factory=set) + elem_indices: set[int] = field(default_factory=set) + const_indices: set[int] = field(default_factory=set) + + +def _is_int(value: object) -> bool: + return isinstance(value, int) and not isinstance(value, bool) + + +class Vectorizer(CompilerPass): + """Strip-mine vectorizer for straight-line ``FOR`` regions.""" + + def __init__(self, program: Program, *, width: int = 4, + elem_bytes: int = 4) -> None: + self.program = program + self.width = width + self.elem_bytes = elem_bytes + self._report: list[LoopVectorizationRecord] = [] + self._counter = 0 + self._used_names: set[str] = set() + + # ── CompilerPass API ──────────────────────────────────────────────── + + @property + def name(self) -> str: + return "vectorize" + + @property + def last_report(self) -> list[LoopVectorizationRecord]: + return list(self._report) + + def run(self, input_data: Any = None) -> PassResult: + self._report = [] + warnings: list[str] = [] + changes = 0 + self._used_names = set() + for func in self.program.functions: + for block in func.blocks: + for instr in block.instructions: + if instr.dest is not None: + self._used_names.add(instr.dest.name) + for op in instr.operands: + self._used_names.add(op.name) + for param in func.params: + self._used_names.add(param.name) + + for func in self.program.functions: + for block in func.blocks: + i = 0 + while i < len(block.instructions): + instr = block.instructions[i] + if instr.opcode is not OpCode.FOR: + i += 1 + continue + region = self._collect_region(block, i) + if region is None: + i += 1 + continue + rec = self._try_vectorize(func, block, region) + self._report.append(rec) + if rec.status == "vectorized": + changes += 1 + i = self._skip_rewritten(region, rec) + continue + warnings.append( + f"loop {rec.function}:{rec.block}[{rec.index}] " + f"rejected: {rec.reason}" + ) + i += 1 + + return PassResult( + data=self.program, + changes=changes, + message=(f"vectorized {changes}/{len(self._report)} loop(s), " + f"width={self.width}"), + warnings=warnings, + ) + + # ── Region discovery ──────────────────────────────────────────────── + + def _collect_region(self, block: BasicBlock, + for_index: int) -> Optional[_LoopRegion]: + instrs = block.instructions + depth = 0 + for j in range(for_index, len(instrs)): + op = instrs[j].opcode + if op is OpCode.FOR: + depth += 1 + elif op is OpCode.ENDFOR: + depth -= 1 + if depth == 0: + return _LoopRegion( + for_index=for_index, + end_index=j, + for_instr=instrs[for_index], + body=list(instrs[for_index + 1:j]), + ) + return None + + def _skip_rewritten(self, region: _LoopRegion, + rec: LoopVectorizationRecord) -> int: + """Index of the instruction after the rewritten region.""" + next_index = region.end_index + 1 + if rec.remainder: + body_len = region.end_index - region.for_index - 1 + next_index += 1 + body_len + 1 + return next_index + + # ── Decision tree (design doc 2.2.1 C1–C7) ────────────────────────── + + def _try_vectorize(self, func: Function, block: BasicBlock, + region: _LoopRegion) -> LoopVectorizationRecord: + for_instr = region.for_instr + start = for_instr.attrs.get("start") + end = for_instr.attrs.get("end") + step = for_instr.attrs.get("step") + rec = LoopVectorizationRecord( + function=func.name, + block=block.name, + index=region.for_index, + status="rejected", + start=start if _is_int(start) else 0, + end=end if _is_int(end) else 0, + width=self.width, + ) + + # C2/C3: constant bounds, start == 0, step == 1, n >= W. + if not _is_int(start) or not _is_int(end) or not _is_int(step): + return self._reject(rec, REASON_NON_CONSTANT_BOUNDS) + if step != 1: + return self._reject(rec, REASON_UNSUPPORTED_STEP) + if start != 0: + return self._reject(rec, REASON_UNSUPPORTED_START) + n = end - start + if n < self.width: + return self._reject(rec, REASON_TRIP_TOO_SMALL) + + # C1: no nested control flow inside the region. + if any(instr.opcode in _CONTROL_FLOW_OPS for instr in region.body): + return self._reject(rec, REASON_NESTED_CONTROL_FLOW) + + defs: dict[str, Instruction] = { + instr.dest.name: instr + for instr in region.body if instr.dest is not None + } + outer_names = self._outer_names(func, defs) + iv_name = for_instr.dest.name if for_instr.dest else "" + + # C4: at least one canonical element address chain. + mem_refs = [ + self._mem_ref(instr, idx, defs, outer_names, iv_name) + for idx, instr in enumerate(region.body) + if instr.opcode in (OpCode.LOAD, OpCode.STORE) + ] + if not any(ref.canonical for ref in mem_refs): + return self._reject(rec, REASON_NO_ELEMENT_PATTERN) + + # C7: aliasing stores (shallow, conservative base analysis). + reason = self._alias_reason(mem_refs) + if reason is not None: + return self._reject(rec, reason) + + # C6: the induction variable may only feed canonical address MULs. + reason = self._iv_use_reason(region.body, iv_name) + if reason is not None: + return self._reject(rec, reason) + + plan = _Plan( + n=n, width=self.width, elem_bytes=self.elem_bytes, + strips=n // self.width, remainder=n % self.width, + iv_name=iv_name, outer_names=frozenset(outer_names), + ) + self._build_plan(plan, region, defs, mem_refs) + + # C5: classify every remaining instruction. + reason = self._classify(plan, region, defs) + if reason is not None: + return self._reject(rec, reason) + + original_iv = region.for_instr.dest + vector_ops = self._rewrite(block, region, plan) + self._clone_remainder(block, region, plan, original_iv) + + rec.status = "vectorized" + rec.reason = "" + rec.strips = plan.strips + rec.remainder = plan.remainder + rec.vector_ops = vector_ops + return rec + + @staticmethod + def _reject(rec: LoopVectorizationRecord, + reason: str) -> LoopVectorizationRecord: + rec.status = "rejected" + rec.reason = reason + return rec + + def _outer_names(self, func: Function, + defs: dict[str, Instruction]) -> set[str]: + outer: set[str] = set() + for block in func.blocks: + for instr in block.instructions: + if instr.dest is not None and instr.dest.name not in defs: + outer.add(instr.dest.name) + for param in func.params: + outer.add(param.name) + return outer + + def _mem_ref(self, instr: Instruction, index: int, + defs: dict[str, Instruction], outer_names: set[str], + iv_name: str) -> _MemRef: + addr = instr.operands[0] + base: Optional[Value] = None + offset: Optional[Value] = None + + if addr.name in defs: + addr_def = defs[addr.name] + if addr_def.opcode is OpCode.ADD and len(addr_def.operands) == 2: + x, y = addr_def.operands + x_local = x.name in defs + y_local = y.name in defs + if x_local and not y_local: + offset, base = x, y + elif y_local and not x_local: + offset, base = y, x + else: + base = addr + + canonical = False + if offset is not None and offset.name in defs: + offset_def = defs[offset.name] + if (offset_def.opcode is OpCode.MUL + and len(offset_def.operands) == 2): + a, b = offset_def.operands + other: Optional[Value] = None + if a.name == iv_name: + other = b + elif b.name == iv_name: + other = a + if (other is not None and other.is_constant + and other.const_value is not None + and _is_int(other.const_value) + and other.const_value == self.elem_bytes): + canonical = True + + if canonical and base is not None: + if not (base.is_constant or base.name in outer_names): + canonical = False + + return _MemRef(instr=instr, index=index, base=base, + offset=offset, canonical=canonical) + + def _alias_reason(self, mem_refs: list[_MemRef]) -> Optional[str]: + loads: dict[str, list[_MemRef]] = {} + stores: dict[str, list[_MemRef]] = {} + for ref in mem_refs: + if ref.base is None: + continue + bucket = loads if ref.is_load else stores + bucket.setdefault(ref.base.name, []).append(ref) + + for base_name, store_refs in stores.items(): + if len(store_refs) > 1: + return REASON_ALIASING_STORE + if base_name not in loads: + continue + load_refs = loads[base_name] + if len(load_refs) != 1: + return REASON_ALIASING_STORE + if load_refs[0].lane_key != store_refs[0].lane_key: + return REASON_ALIASING_STORE + return None + + def _iv_use_reason(self, body: list[Instruction], + iv_name: str) -> Optional[str]: + """C6: the induction variable may only feed canonical address MULs.""" + canonical_mul_ids: set[int] = set() + for instr in body: + if instr.opcode is not OpCode.MUL or len(instr.operands) != 2: + continue + a, b = instr.operands + if a.name != iv_name and b.name != iv_name: + continue + other = b if a.name == iv_name else a + if (other.is_constant and other.const_value is not None + and _is_int(other.const_value) + and other.const_value == self.elem_bytes): + canonical_mul_ids.add(id(instr)) + for instr in body: + if id(instr) in canonical_mul_ids: + continue + for op in instr.operands: + if op.name == iv_name: + return REASON_NON_ELEMENTWISE_IV + return None + + def _build_plan(self, plan: _Plan, region: _LoopRegion, + defs: dict[str, Instruction], + mem_refs: list[_MemRef]) -> None: + for ref in mem_refs: + if not ref.canonical: + continue + plan.mem_indices.add(ref.index) + offset_def = defs[ref.offset.name] + plan.offset_mul_indices.add(_index_of(region.body, offset_def)) + addr_name = ref.instr.operands[0].name + if addr_name in defs: + addr_def = defs[addr_name] + addr_index = _index_of(region.body, addr_def) + plan.addr_add_bases[addr_index] = ref.base + + def _classify(self, plan: _Plan, region: _LoopRegion, + defs: dict[str, Instruction]) -> Optional[str]: + # Values that will have a vector form after the rewrite: element + # results and canonical LOAD results (which become VLOADs). + vector_values: set[str] = set() + for idx in plan.mem_indices: + dest = region.body[idx].dest + if dest is not None: + vector_values.add(dest.name) + for idx, instr in enumerate(region.body): + if (idx in plan.offset_mul_indices + or idx in plan.mem_indices + or idx in plan.addr_add_bases): + continue + if instr.opcode is OpCode.LOAD_CONST: + plan.const_indices.add(idx) + continue + if instr.opcode not in _ELEMENT_VECTOR_OPS: + return REASON_UNSUPPORTED_OP + if instr.dest is None: + return REASON_UNSUPPORTED_OP + for op in instr.operands: + if op.is_constant: + continue + if op.name in plan.outer_names: + continue + if op.name in vector_values: + continue + return REASON_UNSUPPORTED_OP + plan.elem_indices.add(idx) + vector_values.add(instr.dest.name) + + # Dead element/constant values must not sneak into the region. + reachable: set[str] = set() + stack: list[Value] = [] + for idx in plan.mem_indices: + for op in region.body[idx].operands: + stack.append(op) + while stack: + value = stack.pop() + if value.name in reachable: + continue + reachable.add(value.name) + defining = defs.get(value.name) + if defining is None: + continue + for op in defining.operands: + stack.append(op) + for idx in plan.elem_indices | plan.const_indices: + dest = region.body[idx].dest + if dest is None or dest.name not in reachable: + return REASON_UNSUPPORTED_OP + return None + + # ── Rewrite (strip-mining) ────────────────────────────────────────── + + def _rewrite(self, block: BasicBlock, region: _LoopRegion, + plan: _Plan) -> int: + old_iv = region.for_instr.dest + iv_dtype = old_iv.dtype if old_iv is not None else DataType.INT32 + strip_iv = Value(name=self._fresh("v"), dtype=iv_dtype) + c_scale_value = plan.width * plan.elem_bytes + c_scale = Value(name=self._fresh("c"), dtype=DataType.INT32, + is_constant=True, const_value=c_scale_value) + + new_body: list[Instruction] = [] + val_map: dict[str, Value] = {} + base_addrs: dict[str, Value] = {} + bcast_cache: dict[str, Value] = {} + boff: Optional[Value] = None + vector_ops = 0 + + for idx, instr in enumerate(region.body): + if idx in plan.offset_mul_indices: + if boff is None: + new_body.append(Instruction( + OpCode.LOAD_CONST, c_scale, [], + attrs={"value": c_scale_value})) + boff = Value(name=self._fresh("boff"), + dtype=DataType.INT32) + new_body.append(Instruction( + OpCode.MUL, boff, [strip_iv, c_scale])) + if instr.dest is not None: + val_map[instr.dest.name] = boff + continue + + if idx in plan.addr_add_bases: + base = plan.addr_add_bases[idx] + assert base is not None and boff is not None + key = base.name + if key not in base_addrs: + pa = Value(name=self._fresh("pa"), dtype=base.dtype) + new_body.append(Instruction(OpCode.ADD, pa, [base, boff])) + base_addrs[key] = pa + if instr.dest is not None: + val_map[instr.dest.name] = base_addrs[key] + continue + + if instr.opcode is OpCode.LOAD: + addr = val_map[instr.operands[0].name] + dest = instr.dest + assert dest is not None + vec = Value(name=self._fresh("v"), dtype=dest.dtype, + shape=(plan.width,)) + new_body.append(Instruction( + OpCode.VLOAD, vec, [addr], + attrs={"width": plan.width, + "elem_bytes": plan.elem_bytes, "align": 4})) + val_map[dest.name] = vec + vector_ops += 1 + continue + + if instr.opcode is OpCode.STORE: + addr = val_map[instr.operands[0].name] + src = self._element_value( + instr.operands[1], plan, val_map, bcast_cache, new_body) + new_body.append(Instruction( + OpCode.VSTORE, None, [addr, src], + attrs={"width": plan.width, + "elem_bytes": plan.elem_bytes, "align": 4})) + vector_ops += 1 + continue + + if idx in plan.elem_indices: + vec_op = _ELEMENT_VECTOR_OPS[instr.opcode] + vec_dest = instr.dest + assert vec_dest is not None + operands = [ + self._element_value(op, plan, val_map, + bcast_cache, new_body) + for op in instr.operands + ] + out = Value(name=self._fresh("v"), dtype=vec_dest.dtype, + shape=(plan.width,)) + new_body.append(Instruction( + vec_op, out, operands, attrs={"width": plan.width})) + val_map[vec_dest.name] = out + vector_ops += 1 + continue + + if instr.opcode is OpCode.LOAD_CONST: + # Constants remain referenceable as values; they are + # materialized on demand by the backend (LI / VBCAST). + continue + + # Defensive: classification should have rejected this. + raise ValueError( + f"vectorizer internal error: unexpected op " + f"{instr.opcode.value} in classified region") + + region.for_instr.dest = strip_iv + region.for_instr.attrs = { + "start": 0, + "end": plan.strips, + "step": 1, + "vector_width": plan.width, + "elem_bytes": plan.elem_bytes, + "orig_trip": plan.n, + } + block.instructions[region.for_index + 1:region.end_index] = new_body + return vector_ops + + def _element_value(self, value: Value, plan: _Plan, + val_map: dict[str, Value], + bcast_cache: dict[str, Value], + out: list[Instruction]) -> Value: + """Map a scalar/element operand to its vector form.""" + if value.name in val_map: + return val_map[value.name] + if value.is_constant or value.name in plan.outer_names: + if value.name not in bcast_cache: + vec = Value(name=self._fresh("v"), dtype=value.dtype, + shape=(plan.width,)) + out.append(Instruction( + OpCode.VBCAST, vec, [value], + attrs={"width": plan.width})) + bcast_cache[value.name] = vec + return bcast_cache[value.name] + raise ValueError( + f"vectorizer internal error: no vector form for '{value.name}'") + + def _clone_remainder(self, block: BasicBlock, region: _LoopRegion, + plan: _Plan, original_iv: Optional[Value]) -> int: + if plan.remainder == 0: + return 0 + new_for = Instruction( + opcode=OpCode.FOR, dest=original_iv, + attrs={"start": plan.strips * plan.width, + "end": plan.n, "step": 1}) + cloned = self._clone_instrs(region.body) + endfor = Instruction(opcode=OpCode.ENDFOR) + insert_at = region.end_index + 1 + block.instructions[insert_at:insert_at] = [new_for, *cloned, endfor] + return 1 + + def _clone_instrs(self, instrs: list[Instruction]) -> list[Instruction]: + rename: dict[str, Value] = {} + for instr in instrs: + if instr.dest is None: + continue + old = instr.dest + rename[old.name] = Value( + name=self._fresh_clone_name(old.name), + dtype=old.dtype, + is_constant=old.is_constant, + const_value=old.const_value, + shape=old.shape, + ) + cloned: list[Instruction] = [] + for instr in instrs: + operands = [rename.get(op.name, op) for op in instr.operands] + dest = rename.get(instr.dest.name) if instr.dest else None + cloned.append(Instruction( + opcode=instr.opcode, + dest=dest, + operands=operands, + attrs=dict(instr.attrs), + target=instr.target, + )) + return cloned + + # ── Fresh names ───────────────────────────────────────────────────── + + def _fresh(self, prefix: str = "v") -> str: + while True: + self._counter += 1 + name = f"{prefix}_{self._counter}" + if name not in self._used_names: + self._used_names.add(name) + return name + + def _fresh_clone_name(self, base: str) -> str: + name = f"{base}__rem" + while name in self._used_names: + name += "_" + self._used_names.add(name) + return name + + +def _index_of(instrs: list[Instruction], target: Instruction) -> int: + for i, instr in enumerate(instrs): + if instr is target: + return i + raise ValueError("instruction not found in region body") diff --git a/tests/test_backend.py b/tests/test_backend.py index 9ded06a..7809910 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -1,6 +1,9 @@ """Tests for backend (instruction selection, reg alloc, assembly emission).""" +import re + from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType from scratchv.backend.instruction_select import InstructionSelector from scratchv.backend.register_alloc import RegisterAllocator, MachineOp from scratchv.backend.asm_emit import AsmEmitter @@ -137,3 +140,110 @@ def test_disabled_config_leaves_assembly_unchanged(self): result = driver._run_asm_passes(source, warnings) assert result == source assert warnings == [] + + +# ── Topic 29: vector CLI wiring ───────────────────────────────────────── + +class TestVectorCLIWiring: + def test_vector_flags_parse_and_convert(self): + args = build_arg_parser().parse_args([ + "m.onnx", "--vectorize", "--vector-width", "2", + "--vector-isa", "scalar", + ]) + config = args_to_config(args) + assert config.vectorize is True + assert config.vector_width == 2 + assert config.vector_isa == "scalar" + + def test_vector_defaults(self): + args = build_arg_parser().parse_args(["m.onnx"]) + config = args_to_config(args) + assert config.vectorize is False + assert config.vector_width == 4 + assert config.vector_isa == "scalar" + + def test_vector_isa_v_parses(self): + args = build_arg_parser().parse_args(["m.onnx", "--vector-isa", "v"]) + assert args_to_config(args).vector_isa == "v" + + +# ── Topic 29: vector driver integration ───────────────────────────────── + +def _vector_ir(n: int = 16): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + bb = b.load_const(0x410000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + vb = b.load(b.add(bb, off)) + r = b.relu(b.mul(va, vb)) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +class TestVectorDriverIntegration: + def test_compile_vectorized_program(self, monkeypatch, tmp_path): + driver = CompilerDriver(CompilerConfig( + vectorize=True, vector_width=2, reg_alloc="greedy")) + program = _vector_ir() + monkeypatch.setattr(driver, "_parse", lambda *a, **k: program) + + result = driver.compile("input.dsl", str(tmp_path / "out.s")) + + assert result.success + assert "vectorized 1/1 loop(s), width=2" in result.stats["opt_message"] + assert not re.search(r"^\s*v[a-z]", result.output_text, re.MULTILINE) + assert "vload" not in result.output_text + assert "vstore" not in result.output_text + + def test_vector_isa_v_rejected(self, monkeypatch, tmp_path): + driver = CompilerDriver(CompilerConfig( + vectorize=True, vector_isa="v", reg_alloc="greedy")) + monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + + result = driver.compile("input.dsl", str(tmp_path / "out.s")) + + assert result.success is False + assert any("phase 2" in err for err in result.errors) + + def test_vectorize_disabled_matches_baseline(self, monkeypatch, tmp_path): + config = CompilerConfig(vectorize=False, reg_alloc="greedy") + driver = CompilerDriver(config) + monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + result = driver.compile("input.dsl", str(tmp_path / "out.s")) + + assert result.success + assert result.stats["opt_message"] == "" + assert not re.search(r"^\s*v[a-z]", result.output_text, re.MULTILINE) + + +# ── Topic 29 P0: constant operands in R-type instructions ─────────────── + +class TestConstantOperandMaterialization: + def test_no_rtype_immediate(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + x = b.make_value(name="x", dtype=DataType.INT32) + c = b.load_const(4, dtype=DataType.INT32) + y = b.mul(x, c) + z = b.add(y, c) + w = b.sub(z, c) + v = b.div(w, c) + b.ret(v) + + machine = InstructionSelector(b.program).run() + allocated = RegisterAllocator(machine, mode="greedy").run() + asm = AsmEmitter(allocated).emit() + + assert "li" in asm + assert not re.search( + r"^\s*(add|sub|mul|div)\s+\w+,\s*\w+,\s*-?\d+\s*$", + asm, re.MULTILINE) diff --git a/tests/test_vector_encoder.py b/tests/test_vector_encoder.py new file mode 100644 index 0000000..03e839e --- /dev/null +++ b/tests/test_vector_encoder.py @@ -0,0 +1,50 @@ +"""Tests for the RV32IM encoder's vector-mnemonic rejection guard (Topic 29). + +Phase 1 never emits vector instructions, but the guard converts a silent +mis-encode into an explicit failure should one ever leak through. +""" + +import pytest + +from scratchv.backend.riscv_encoder import ( + VectorEncodingError, + assemble_to_binary, +) + + +@pytest.mark.parametrize("text", [ + "vadd.vv v1, v2, v3", + "vsetvli t0, a0, e32, m1, ta, ma", + "vsetivli t0, 4, e32, m1, ta, ma", + "vle32.v v1, (a0)", + "vse32.v v1, (a0)", + "vmv.v.x v1, a0", + "vsub.vv v1, v2, v3", + "vmul.vv v1, v2, v3", + "vdiv.vv v1, v2, v3", + "vmax.vx v1, v2, x0", + "vfmv.v.f v1, f0", + "vredsum.vs v1, v2, v3", +]) +def test_vector_mnemonic_rejected(text): + with pytest.raises(VectorEncodingError) as exc: + assemble_to_binary(text) + message = str(exc.value) + assert "phase 1 targets RV32IM only" in message + assert text.split()[0] in message + + +def test_scalar_still_encodes(): + assert len(assemble_to_binary("add a0, a1, a2\n")) == 4 + + +def test_scalar_program_still_encodes(): + binary = assemble_to_binary( + "li t0, 4\n" + "mul t1, t0, t0\n" + "addi a1, t0, 0\n" + "lw t2, a1(0)\n" + "sw a1(0), t2\n" + "jalr zero, ra\n" + ) + assert len(binary) == 6 * 4 diff --git a/tests/test_vector_lowering.py b/tests/test_vector_lowering.py new file mode 100644 index 0000000..801a025 --- /dev/null +++ b/tests/test_vector_lowering.py @@ -0,0 +1,236 @@ +"""Tests for phase-1 scalar lowering of vector ops (Topic 29). + +The executable differential tests run the same chain for the scalar +baseline and the vectorized program: + + InstructionSelector -> RegisterAllocator(greedy) -> AsmEmitter + -> assemble_to_binary -> RV32Emulator + +Note on widths: the pre-existing greedy allocator assigns one physical +register per distinct virtual register and does not reload evicted +registers, so executables with more than the 19 allocatable registers are +miscompiled regardless of vectorization. The differential tests are +therefore built to stay within that budget (which is why the binary +multiply case runs at W=2 while the unary/broadcast cases run at W=4). +""" + +import re + +import pytest + +from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.instruction_select import InstructionSelector +from scratchv.backend.machine_types import MachineOp +from scratchv.backend.register_alloc import RegisterAllocator +from scratchv.backend.riscv_encoder import assemble_to_binary +from scratchv.backend.vector_scalar import ( + VectorLoweringError, + VectorScalarExpander, +) +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Instruction, OpCode, Value +from scratchv.optimizer.vectorize import Vectorizer +from scratchv.simulator.rv32_emulator import RV32Emulator + +BASE_A = 0x400000 +BASE_B = 0x410000 +BASE_OUT = 0x420000 + +_VECTOR_MNEMONIC_RE = re.compile(r"^\s*v[a-z]", re.MULTILINE) +_RTYPE_IMMEDIATE_RE = re.compile( + r"^\s*(add|sub|mul|div)\s+\S+,\s*\S+,\s*-?\d+\s*$", re.MULTILINE) + + +# ── Program builders ──────────────────────────────────────────────────── + +def _make_relu_loop(n: int): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(BASE_A, dtype=DataType.INT32) + o = b.load_const(BASE_OUT, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.relu(va) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +def _make_mul_loop(n: int): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(BASE_A, dtype=DataType.INT32) + bb = b.load_const(BASE_B, dtype=DataType.INT32) + o = b.load_const(BASE_OUT, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + vb = b.load(b.add(bb, off)) + r = b.relu(b.mul(va, vb)) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +def _make_div_loop(n: int, divisor: int = 3): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(BASE_A, dtype=DataType.INT32) + o = b.load_const(BASE_OUT, dtype=DataType.INT32) + k = b.load_const(divisor, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.div(va, k) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +# ── Execution helper ──────────────────────────────────────────────────── + +def _compile(program): + machine = InstructionSelector(program).run() + asm = AsmEmitter(RegisterAllocator(machine, mode="greedy").run()).emit() + return machine, asm, assemble_to_binary(asm) + + +def _compile_and_run(program, n, inputs_a, inputs_b=None): + _, asm, binary = _compile(program) + emu = RV32Emulator() + emu.load_code(bytes(binary)) + for i, x in enumerate(inputs_a): + emu.write_i32(BASE_A + 4 * i, int(x)) + if inputs_b is not None: + for i, x in enumerate(inputs_b): + emu.write_i32(BASE_B + 4 * i, int(x)) + emu.run(max_instr=200000) + return asm, [emu.read_i32(BASE_OUT + 4 * i) for i in range(n)] + + +# ── Sanity: no vector instructions in the lowered output ──────────────── + +class TestLoweringSanity: + def test_no_vector_mnemonic_in_asm(self): + program = _make_mul_loop(16) + Vectorizer(program, width=4).run(program) + machine, asm, _ = _compile(program) + + assert not _VECTOR_MNEMONIC_RE.search(asm) + vector_names = { + instr.dest.name + for block in program.functions[0].blocks + for instr in block.instructions + if instr.opcode.is_vector() and instr.dest is not None + } + for instr in machine: + for op in (instr.dst, instr.src1, instr.src2): + if op is not None and op.kind == "vreg": + assert op.value not in vector_names + + def test_lane_addressing_uses_addi(self): + program = _make_relu_loop(16) + Vectorizer(program, width=4).run(program) + _, asm, _ = _compile(program) + + assert "addi a1," in asm + assert "lw" in asm and "a1(0)" in asm + assert not _RTYPE_IMMEDIATE_RE.search(asm) + + def test_lowering_emits_expected_machine_ops(self): + program = _make_mul_loop(16) + Vectorizer(program, width=2).run(program) + machine, _, _ = _compile(program) + ops = {instr.op for instr in machine if instr.op != MachineOp.LABEL} + assert MachineOp.LW in ops + assert MachineOp.SW in ops + assert MachineOp.MUL in ops + assert MachineOp.MAX in ops + assert MachineOp.ADDI in ops + + +# ── Executable differential ───────────────────────────────────────────── + +class TestEmulatorDifferential: + A_NEG = [7, -3, 0, 100000, -1, 2, -2048, 2047, + 123456, -654321, 0, -5, 9, -9, 42, -42] + B_NEG = [3, 5, -7, 3, -8, -2, 15, 16, + 2, 11, 0, -12, 13, 13, -3, 4] + + def _run_pair(self, make, n, width, inputs_a, inputs_b, reference): + baseline = make(n) + _, out_scalar = _compile_and_run(baseline, n, inputs_a, inputs_b) + + vectorized = make(n) + result = Vectorizer(vectorized, width=width).run(vectorized) + assert result.changes == 1 + _, out_vector = _compile_and_run(vectorized, n, inputs_a, inputs_b) + + assert out_scalar == reference + assert out_vector == out_scalar + assert out_vector == reference + + def test_relu_map_w4_matches_scalar(self): + reference = [max(x, 0) for x in self.A_NEG] + self._run_pair(_make_relu_loop, 16, 4, self.A_NEG, None, reference) + + def test_mul_map_w2_matches_scalar(self): + reference = [max(x * y, 0) for x, y in zip(self.A_NEG, self.B_NEG)] + self._run_pair(_make_mul_loop, 16, 2, + self.A_NEG, self.B_NEG, reference) + + def test_div_broadcast_w4_matches_scalar(self): + inputs = [12, 3, 0, 99, 6, 27, 2049, 2043, + 123, 654, 0, 51, 9, 33, 42, 45] + reference = [x // 3 for x in inputs] + self._run_pair(_make_div_loop, 16, 4, inputs, None, reference) + + def test_remainder_loop_w2_matches_scalar(self): + inputs = self.A_NEG + [11] + reference = [max(x, 0) for x in inputs] + self._run_pair(_make_relu_loop, 17, 2, inputs, None, reference) + + +# ── Lowering errors ───────────────────────────────────────────────────── + +class TestLoweringErrors: + def test_missing_lane_binding(self): + expander = VectorScalarExpander() + expander.begin_function("main") + va = Value(name="va", dtype=DataType.INT32, shape=(4,)) + vb = Value(name="vb", dtype=DataType.INT32, shape=(4,)) + dest = Value(name="vd", dtype=DataType.INT32, shape=(4,)) + instr = Instruction( + opcode=OpCode.VADD, dest=dest, operands=[va, vb], + attrs={"width": 4}) + with pytest.raises(VectorLoweringError) as exc: + expander.expand(instr) + assert "has no lane binding" in str(exc.value) + + def test_width_mismatch(self): + expander = VectorScalarExpander() + expander.begin_function("main") + va = Value(name="va", dtype=DataType.INT32, shape=(2,)) + dest = Value(name="vd", dtype=DataType.INT32, shape=(4,)) + instr = Instruction( + opcode=OpCode.VRELU, dest=dest, operands=[va], + attrs={"width": 4}) + with pytest.raises(VectorLoweringError): + expander.expand(instr) + + def test_non_vector_op_is_refused(self): + expander = VectorScalarExpander() + expander.begin_function("main") + instr = Instruction(opcode=OpCode.RELU) + with pytest.raises(VectorLoweringError): + expander.expand(instr) diff --git a/tests/test_vectorize.py b/tests/test_vectorize.py new file mode 100644 index 0000000..0f5c142 --- /dev/null +++ b/tests/test_vectorize.py @@ -0,0 +1,417 @@ +"""Tests for the phase-1 SIMD vectorizer (Topic 29). + +Covers the vector builder APIs, the strip-mining decision tree +(C1-C7 of the design document) and the structured rejection report. +""" + +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Instruction, OpCode, Program +from scratchv.optimizer.vectorize import ( + REASON_ALIASING_STORE, + REASON_NESTED_CONTROL_FLOW, + REASON_NO_ELEMENT_PATTERN, + REASON_NON_CONSTANT_BOUNDS, + REASON_NON_ELEMENTWISE_IV, + REASON_TRIP_TOO_SMALL, + REASON_UNSUPPORTED_START, + REASON_UNSUPPORTED_STEP, + Vectorizer, +) + +VECTOR_OPS = ( + OpCode.VLOAD, + OpCode.VSTORE, + OpCode.VBCAST, + OpCode.VADD, + OpCode.VSUB, + OpCode.VMUL, + OpCode.VDIV, + OpCode.VRELU, +) + + +# ── Helpers ───────────────────────────────────────────────────────────── + +def _make_map_loop(n: int) -> Program: + """out[i] = relu(a[i] * b[i]) for i in [0, n), hand-written IR.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + bb = b.load_const(0x410000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + vb = b.load(b.add(bb, off)) + r = b.relu(b.mul(va, vb)) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +def _make_chain_loop(n: int) -> Program: + """Exercises all eight vector ops in one vectorized region.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + bb = b.load_const(0x410000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + k = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + vb = b.load(b.add(bb, off)) + s = b.add(va, vb) + d = b.sub(va, vb) + m = b.mul(s, d) + q = b.div(m, k) + r = b.relu(q) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + +def _instructions(program: Program) -> list[Instruction]: + return program.functions[0].blocks[0].instructions + + +def _for_indices(program: Program) -> list[int]: + return [i for i, instr in enumerate(_instructions(program)) + if instr.opcode is OpCode.FOR] + + +def _body(program: Program, for_index: int) -> list[Instruction]: + instrs = _instructions(program) + body = [] + for instr in instrs[for_index + 1:]: + if instr.opcode is OpCode.ENDFOR: + break + body.append(instr) + return body + + +def _find(instrs: list[Instruction], opcode: OpCode) -> list[Instruction]: + return [instr for instr in instrs if instr.opcode is opcode] + + +# ── Builder API ───────────────────────────────────────────────────────── + +class TestVectorIrBuilders: + def _new_builder(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + return b + + def test_builder_emits_vector_ops(self): + b = self._new_builder() + addr = b.make_value(name="addr", dtype=DataType.INT32) + scalar = b.make_value(name="s", dtype=DataType.INT32) + va = b.vload(addr, width=4) + vb = b.vload(addr, width=4) + bc = b.vbcast(scalar, width=4) + results = [ + (b.vadd(va, vb), OpCode.VADD), + (b.vsub(va, vb), OpCode.VSUB), + (b.vmul(va, vb), OpCode.VMUL), + (b.vdiv(va, vb), OpCode.VDIV), + (b.vrelu(va), OpCode.VRELU), + ] + instrs = _instructions(b.program) + + assert va.shape == (4,) and vb.shape == (4,) and bc.shape == (4,) + assert _find(instrs, OpCode.VLOAD)[0].attrs["width"] == 4 + assert _find(instrs, OpCode.VLOAD)[0].attrs["elem_bytes"] == 4 + assert _find(instrs, OpCode.VLOAD)[0].attrs["align"] == 4 + assert _find(instrs, OpCode.VBCAST)[0].attrs == {"width": 4} + for value, opcode in results: + assert value.shape == (4,) + instr = _find(instrs, opcode)[0] + assert instr.attrs == {"width": 4} + + def test_vstore_returns_instruction(self): + b = self._new_builder() + addr = b.make_value(name="addr", dtype=DataType.INT32) + vec = b.vbcast(b.make_value(name="s", dtype=DataType.INT32), width=4) + instr = b.vstore(addr, vec) + assert isinstance(instr, Instruction) + assert instr.opcode is OpCode.VSTORE + assert instr.dest is None + assert instr.attrs["width"] == 4 + + def test_builder_width_inference(self): + b = self._new_builder() + addr = b.make_value(name="addr", dtype=DataType.INT32) + va = b.vload(addr, width=2) + vb = b.vload(addr, width=2) + assert b.vadd(va, vb).shape == (2,) + assert b.vrelu(va).shape == (2,) + + def test_is_vector_covers_all_eight_ops(self): + assert all(op.is_vector() for op in VECTOR_OPS) + for opcode in (OpCode.ADD, OpCode.LOAD, OpCode.FOR, OpCode.RELU): + assert not opcode.is_vector() + + +# ── Strip-mining structure ────────────────────────────────────────────── + +class TestVectorizerStructure: + def test_strip_mining_no_remainder(self): + program = _make_map_loop(16) + vec = Vectorizer(program, width=4) + result = vec.run(program) + + assert result.changes == 1 + for_indices = _for_indices(program) + assert len(for_indices) == 1 + + for_instr = _instructions(program)[for_indices[0]] + assert for_instr.attrs == { + "start": 0, "end": 4, "step": 1, + "vector_width": 4, "elem_bytes": 4, "orig_trip": 16, + } + + body = _body(program, for_indices[0]) + counts = { + OpCode.VLOAD: len(_find(body, OpCode.VLOAD)), + OpCode.VMUL: len(_find(body, OpCode.VMUL)), + OpCode.VRELU: len(_find(body, OpCode.VRELU)), + OpCode.VSTORE: len(_find(body, OpCode.VSTORE)), + } + assert counts == { + OpCode.VLOAD: 2, + OpCode.VMUL: 1, + OpCode.VRELU: 1, + OpCode.VSTORE: 1, + } + for instr in body: + if instr.opcode.is_vector(): + assert instr.attrs["width"] == 4 + if instr.dest is not None: + assert instr.dest.shape == (4,) + + assert vec.last_report[0].status == "vectorized" + assert vec.last_report[0].strips == 4 + assert vec.last_report[0].remainder == 0 + assert vec.last_report[0].vector_ops == 5 + + def test_all_eight_vector_ops_are_generated(self): + program = _make_chain_loop(16) + vec = Vectorizer(program, width=4) + result = vec.run(program) + + assert result.changes == 1 + body = _body(program, _for_indices(program)[0]) + generated = {instr.opcode for instr in body + if instr.opcode.is_vector()} + assert generated == set(VECTOR_OPS) + + def test_strip_mining_with_remainder(self): + program = _make_map_loop(17) + vec = Vectorizer(program, width=4) + vec.run(program) + + for_indices = _for_indices(program) + assert len(for_indices) == 2 + + strip_for = _instructions(program)[for_indices[0]] + assert strip_for.attrs["end"] == 4 + remainder_for = _instructions(program)[for_indices[1]] + assert remainder_for.attrs == {"start": 16, "end": 17, "step": 1} + + remainder_body = _body(program, for_indices[1]) + assert remainder_body + assert not any(instr.opcode.is_vector() for instr in remainder_body) + assert all(instr.dest.name.endswith("__rem") + for instr in remainder_body if instr.dest) + + vector_body = _body(program, for_indices[0]) + vector_names = {instr.dest.name for instr in vector_body if instr.dest} + rem_names = {instr.dest.name for instr in remainder_body if instr.dest} + assert not (vector_names & rem_names) + + assert vec.last_report[0].remainder == 1 + + def test_width_2(self): + program = _make_map_loop(16) + vec = Vectorizer(program, width=2) + vec.run(program) + + for_indices = _for_indices(program) + assert len(for_indices) == 1 + for_instr = _instructions(program)[for_indices[0]] + assert for_instr.attrs["vector_width"] == 2 + assert for_instr.attrs["end"] == 8 + + body = _body(program, for_indices[0]) + for instr in body: + if instr.opcode.is_vector(): + assert instr.attrs["width"] == 2 + if instr.dest is not None: + assert instr.dest.shape == (2,) + + def test_trip_too_small_ir_unchanged(self): + program = _make_map_loop(3) + before = program.dump() + vec = Vectorizer(program, width=4) + result = vec.run(program) + + assert result.changes == 0 + assert program.dump() == before + assert vec.last_report[0].status == "rejected" + assert vec.last_report[0].reason == REASON_TRIP_TOO_SMALL + + +# ── Rejections (design doc 2.5 I1-I4) ─────────────────────────────────── + +class TestVectorizerRejects: + def _reject_reason(self, program: Program, width: int = 4) -> str: + vec = Vectorizer(program, width=width) + result = vec.run(program) + assert result.changes == 0 + assert vec.last_report[0].status == "rejected" + return vec.last_report[0].reason + + def test_i1_reduction_without_memory(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + s = b.load_const(0, dtype=DataType.INT32) + iv = b.for_loop(0, 10) + acc = b.add(s, iv) + b.endfor() + b.ret(acc) + assert self._reject_reason(b.program) == REASON_NO_ELEMENT_PATTERN + + def test_i1b_iv_in_element_expression(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + s = b.add(va, iv) + b.store(b.add(o, off), s) + b.endfor() + b.ret() + assert self._reject_reason(b.program) == REASON_NON_ELEMENTWISE_IV + + def test_i2_aliasing_store(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + c1 = b.load_const(1, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + im1 = b.sub(iv, c1) + off2 = b.mul(im1, c4) + b.store(b.add(a, off2), va) + b.endfor() + b.ret() + assert self._reject_reason(b.program) == REASON_ALIASING_STORE + + def test_i3_dynamic_bounds(self): + program = _make_map_loop(16) + for_instr = _instructions(program)[_for_indices(program)[0]] + for_instr.attrs.pop("end") + assert self._reject_reason(program) == REASON_NON_CONSTANT_BOUNDS + + def test_i4_nested_control_flow(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + cond = b.load_const(1, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + b.br_if(cond, "then", "else") + b.store(b.add(o, off), va) + b.endfor() + b.ret() + assert self._reject_reason(b.program) == REASON_NESTED_CONTROL_FLOW + + def test_unsupported_step(self): + program = _make_map_loop(16) + for_instr = _instructions(program)[_for_indices(program)[0]] + for_instr.attrs["step"] = 2 + assert self._reject_reason(program) == REASON_UNSUPPORTED_STEP + + def test_unsupported_start(self): + program = _make_map_loop(16) + for_instr = _instructions(program)[_for_indices(program)[0]] + for_instr.attrs["start"] = 4 + assert self._reject_reason(program) == REASON_UNSUPPORTED_START + + def test_rejections_do_not_touch_ir(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.relu(b.add(va, va)) + n = b.neg(r) + b.store(b.add(o, off), n) + b.endfor() + b.ret() + + before = b.program.dump() + vec = Vectorizer(b.program, width=4) + vec.run(b.program) + # NEG has no phase-1 vector op → rejected as unsupported. + assert vec.last_report[0].reason == "unsupported-op" + assert b.program.dump() == before + + +# ── Report ────────────────────────────────────────────────────────────── + +class TestVectorizeReport: + def test_report_counts(self): + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + s = b.load_const(0, dtype=DataType.INT32) + # Rejected loop first: no element address pattern. + iv = b.for_loop(0, 16) + b.add(s, iv) + b.endfor() + # Vectorizable loop second. + iv2 = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv2, c4) + va = b.load(b.add(a, off)) + r = b.relu(va) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + + vec = Vectorizer(b.program, width=4) + result = vec.run(b.program) + + assert result.changes == 1 + assert result.message == "vectorized 1/2 loop(s), width=4" + assert len(vec.last_report) == 2 + assert vec.last_report[0].status == "rejected" + assert vec.last_report[1].status == "vectorized" + assert len(result.warnings) == 1 + assert "rejected: no-memory-element-pattern" in result.warnings[0] From e125619d32e3fb700f51a6f227003b04595b7a96 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 21:22:02 +0800 Subject: [PATCH 2/7] docs(topic29): add design and development documents --- ...00\345\217\221\346\226\207\346\241\243.md" | 819 ++++++++++++++++++ ...76\350\256\241\346\226\207\346\241\243.md" | 503 +++++++++++ 2 files changed, 1322 insertions(+) create mode 100644 "docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" create mode 100644 "docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" diff --git "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" new file mode 100644 index 0000000..7d4747f --- /dev/null +++ "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -0,0 +1,819 @@ +# 课题 29:SIMD 向量化开发文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 配套设计:同目录《设计文档.md》(术语、分期边界、strip-mining 规则、测试设计以设计文档为准) +> 适用基线:`HEAD d146515`(行号会漂移,实施前以 `git rev-parse HEAD` + grep 复核) +> 涉及文件:`scratchv/ir/types.py`、`scratchv/ir/builder.py`、`scratchv/optimizer/vectorize.py`(新增)、`scratchv/backend/vector_scalar.py`(新增)、`scratchv/backend/instruction_select.py`、`scratchv/backend/riscv_encoder.py`、`scratchv/compiler.py`、`scratchv/main.py`、`tests/test_vectorize.py`(新增)、`tests/test_vector_lowering.py`(新增)、`tests/test_vector_encoder.py`(新增)、`tests/test_backend.py` +> 只读依赖(一期禁止修改):`scratchv/simulator/rv32_emulator.py`、`scratchv/standalone/benchmark.py`、`scratchv/standalone/onnx_to_riscv_standalone.py` + +--- + +## 一、接口契约(精确名称) + +> 本节是唯一权威命名表。实现时任何名称与本节不一致,视为接口破坏,需改文档或改测试,不允许“各写各的”。 + +### 1.1 IR 枚举(`scratchv/ir/types.py`) + +| 名称 | 值(`opcode.value`) | 形式 | 必需 attrs | +|---|---|---|---| +| `OpCode.VLOAD` | `"vload"` | `%vd = vload %addr` | `width:int`, `elem_bytes:int=4`, `align:int=4` | +| `OpCode.VSTORE` | `"vstore"` | `vstore %addr, %vs` | 同上 | +| `OpCode.VBCAST` | `"vbcast"` | `%vd = vbcast %s` | `width:int` | +| `OpCode.VADD` | `"vadd"` | `%vd = vadd %va, %vb` | `width:int` | +| `OpCode.VSUB` | `"vsub"` | `%vd = vsub %va, %vb` | `width:int` | +| `OpCode.VMUL` | `"vmul"` | `%vd = vmul %va, %vb` | `width:int` | +| `OpCode.VDIV` | `"vdiv"` | `%vd = vdiv %va, %vb` | `width:int` | +| `OpCode.VRELU` | `"vrelu"` | `%vd = vrelu %va` | `width:int` | + +方法:`OpCode.is_vector(self) -> bool`,等价于 `self in _VECTOR_OPS`(模块级 `frozenset`)。 + +属性键(精确字符串):`"width"`、`"elem_bytes"`、`"align"`;strip `FOR` 附加键:`"vector_width"`、`"orig_trip"`(`"elem_bytes"` 复用)。 + +约定:向量 `Value` 满足 `shape == (width,)` 且 `dtype` 为 lane 元素类型;`VSTORE.dest is None`。 + +### 1.2 Vectorizer 常量与报告(`scratchv/optimizer/vectorize.py`) + +拒绝原因常量(值为稳定字符串,测试直接断言): + +```python +REASON_NON_CONSTANT_BOUNDS = "non-constant-bounds" +REASON_UNSUPPORTED_STEP = "unsupported-loop-step" +REASON_UNSUPPORTED_START = "unsupported-loop-start" +REASON_TRIP_TOO_SMALL = "trip-count-too-small" +REASON_NESTED_CONTROL_FLOW = "nested-control-flow" +REASON_NO_ELEMENT_PATTERN = "no-memory-element-pattern" +REASON_NON_ELEMENTWISE_IV = "non-elementwise-iv-use" +REASON_ALIASING_STORE = "aliasing-store" +REASON_UNSUPPORTED_OP = "unsupported-op" +``` + +报告记录: + +```python +@dataclass +class LoopVectorizationRecord: + function: str # Function.name + block: str # FOR 所在 BasicBlock.name + index: int # FOR 在该 block.instructions 中的下标(变换前) + status: str # "vectorized" | "rejected" + reason: str = "" # 仅 rejected 时非空,取 REASON_* 之一 + start: int = 0 # 原始 attrs.start + end: int = 0 # 原始 attrs.end + width: int = 0 # 实际采用的 W + strips: int = 0 # n // W + remainder: int = 0 # n % W + vector_ops: int = 0 # 生成的向量 op 条数(vectorized 时) +``` + +### 1.3 Builder API(`scratchv/ir/builder.py`) + +```python +def vload(self, addr: Value, *, width: int = 4, + elem_bytes: int = 4, align: int = 4) -> Value: ... +def vstore(self, addr: Value, vec: Value, *, width: int | None = None, + elem_bytes: int = 4, align: int = 4) -> Instruction: ... +def vbcast(self, scalar: Value, *, width: int = 4) -> Value: ... +def vadd(self, lhs: Value, rhs: Value, *, width: int | None = None) -> Value: ... +def vsub(self, lhs: Value, rhs: Value, *, width: int | None = None) -> Value: ... +def vmul(self, lhs: Value, rhs: Value, *, width: int | None = None) -> Value: ... +def vdiv(self, lhs: Value, rhs: Value, *, width: int | None = None) -> Value: ... +def vrelu(self, val: Value, *, width: int | None = None) -> Value: ... +``` + +规则:`width=None` 时从第一个向量操作数的 `shape[0]` 推断,推断失败取 `4`;`dest.dtype` 继承操作数 `dtype`(默认 `DataType.FLOAT32`);`dest.shape = (width,)`。 + +### 1.4 Pass 类与签名 + +```python +# scratchv/optimizer/vectorize.py +class Vectorizer(CompilerPass): + def __init__(self, program: Program, *, width: int = 4, + elem_bytes: int = 4) -> None: ... + @property + def name(self) -> str: ... # "vectorize" + def run(self, input_data: Any) -> PassResult: ... + last_report: list[LoopVectorizationRecord] # 运行后可读 + +# scratchv/backend/vector_scalar.py +class VectorLoweringError(ValueError): ... + +class VectorScalarExpander: + def __init__(self, *, default_width: int = 4) -> None: ... + def begin_function(self, func_name: str) -> None: ... + def expand(self, instr: Instruction) -> list[MachineInstr]: ... +``` + +`PassResult` 约定:`data=program`,`changes` = 被向量化循环数,`message` 形如 `"vectorized 1/3 loop(s), width=4"`,`warnings` 每条拒绝一行:`f"loop {func}:{block}[{index}] rejected: {reason}"`。 + +### 1.5 `CompilerConfig` 字段与 CLI(精确名称) + +```python +# scratchv/compiler.py :: CompilerConfig(追加,默认值即“现状”) +vectorize: bool = False # 关闭时零行为差异 +vector_width: int = 4 # 允许 2 或 4 +vector_isa: str = "scalar" # "scalar" | "p" | "v"(p/v 一期显式拒绝) +``` + +```python +# scratchv/main.py :: build_arg_parser() +parser.add_argument("--vectorize", action="store_true", + help="Run FOR-loop strip-mining vectorization (Topic 29, phase 1)") +parser.add_argument("--vector-width", type=int, choices=[2, 4], default=4, + help="Vector strip width W (default: 4)") +parser.add_argument("--vector-isa", choices=["scalar", "p", "v"], default="scalar", + help="Vector ISA target; 'p'/'v' are rejected until phase 2") +``` + +```python +# scratchv/main.py :: args_to_config() +vectorize=args.vectorize, +vector_width=args.vector_width, +vector_isa=args.vector_isa, +``` + +### 1.6 异常与错误消息(精确文本) + +| 异常 | 触发 | 消息模板 | +|---|---|---| +| `VectorLoweringError` | 向量值无 lane 绑定 / 宽度不一致 | `f"vector value '{name}' has no lane binding"` | +| `VectorEncodingError` | 编码器遇到 `v*` 助记符 | `f"vector instruction '{op}' is not supported: ScratchV phase 1 targets RV32IM only"` | +| 编译失败(`CompileResult.errors`) | `--vector-isa` 为 `p`/`v` | `f"vector-ISA '{isa}' is not implemented (phase 2); use --vector-isa scalar"` | + +`VectorEncodingError` 继承 `ValueError`(兼容既有 `except ValueError` 调用方);`VectorLoweringError` 同理。 + +--- + +## 二、`ir/types.py` 新增段落位置约定(与课题 28 并行) + +### 2.1 位置规则 + +- **只允许在 `OpCode` 枚举末尾追加**,禁止在既有成员之间插入、禁止重排; +- 每个课题拥有独立的注释横幅分区,课题 28 分区在前、课题 29 分区紧随其后; +- 枚举成员一律显式赋值小写字符串,禁止自动 `auto()`; +- 测试只按成员名引用,不依赖枚举顺序。 + +### 2.2 目标代码形态(`types.py:50` 之后) + +```python + # Shape / data movement + TRANSPOSE = "transpose" + RESHAPE = "reshape" + CONCAT = "concat" + + # ── Extended instruction selection (Topic 28) ──────────────────── + # 课题 28 的分区;课题 29 不改动、不插入 + # (如 SQRT/ABS/MIN/MAX/IDIV/REM/MOD/LOAD_F64/... 由课题 28 自行追加) + + # ── SIMD vector ops (Topic 29, phase 1) ────────────────────────── + VLOAD = "vload" + VSTORE = "vstore" + VBCAST = "vbcast" + VADD = "vadd" + VSUB = "vsub" + VMUL = "vmul" + VDIV = "vdiv" + VRELU = "vrelu" +``` + +### 2.3 `is_vector()` 与模块级集合 + +在 `is_control_flow()`(`types.py:69`)之后追加: + +```python + def is_vector(self) -> bool: + return self in _VECTOR_OPS +``` + +在 `class OpCode` 定义前(或枚举定义后)定义: + +```python +_VECTOR_OPS = frozenset({ + OpCode.VLOAD, OpCode.VSTORE, OpCode.VBCAST, + OpCode.VADD, OpCode.VSUB, OpCode.VMUL, OpCode.VDIV, OpCode.VRELU, +}) +``` + +注意:`_VECTOR_OPS` 若定义在类体之前,成员引用需写成 `frozenset` 构造放在类之后(Python 类体执行顺序约束),推荐放在模块底部或用字符串集合 + 运行时转换,实施时二选一并加测试。 + +--- + +## 三、`ir/builder.py` 新增 API + +追加到 `reshape()`(`builder.py:206-209`)之后,风格与既有方法一致: + +```python + def vload(self, addr: Value, *, width: int = 4, + elem_bytes: int = 4, align: int = 4) -> Value: + dest = self.make_value() + dest.shape = (width,) + self._emit(OpCode.VLOAD, dest, [addr], width=width, + elem_bytes=elem_bytes, align=align) + return dest + + def vstore(self, addr: Value, vec: Value, *, width: int | None = None, + elem_bytes: int = 4, align: int = 4) -> Instruction: + w = width if width is not None else _shape_width(vec) + return self._emit(OpCode.VSTORE, operands=[addr, vec], + width=w, elem_bytes=elem_bytes, align=align) +``` + +其余 `vbcast/vadd/vsub/vmul/vdiv/vrelu` 同构:生成 `dest`(`shape=(w,)`、`dtype` 继承)、`_emit` 带 `width=w`。内部小工具: + +```python +def _shape_width(value: Value, default: int = 4) -> int: + return value.shape[0] if value.shape else default +``` + +边界:`vstore` 不返回 `Value`(与既有 `store` 一致,返回 `Instruction`);调用方若把 `vstore` 结果当值使用属误用,测试覆盖。 + +--- + +## 四、Vectorizer pass 设计与签名(`scratchv/optimizer/vectorize.py` 新增) + +### 4.1 模块结构 + +```python +"""FOR-loop strip-mining vectorizer (Topic 29, phase 1).""" +from __future__ import annotations +from dataclasses import dataclass +from typing import Any, Optional + +from scratchv.ir.types import OpCode, Value, Instruction, BasicBlock, Function, Program +from scratchv.pass_interface import CompilerPass, PassResult + +# REASON_* 常量(见 1.2) +# LoopVectorizationRecord(见 1.2) + +class Vectorizer(CompilerPass): + name -> "vectorize" + __init__(self, program, *, width=4, elem_bytes=4) + run(self, input_data) -> PassResult + # 内部状态 + _counter: int # 新鲜 Value 命名计数器 + _report: list[LoopVectorizationRecord] + last_report 属性 +``` + +### 4.2 主流程 + +``` +run(program): + self._report.clear() + changes = 0 + for func in program.functions: + for block in func.blocks: + i = 0 + while i < len(block.instructions): + if instr is FOR: + region = _collect_region(block, i) # 找匹配 ENDFOR,限同一 block + rec = _try_vectorize(func, block, i, region) + self._report.append(rec) + if rec.status == "vectorized": + changes += 1 + i = region.end_index + 1 # 跳过重写后的区域 + continue + i += 1 + return PassResult(data=program, changes=changes, + message=f"vectorized {changes}/{len(self._report)} loop(s), width={self.width}", + warnings=[...]) +``` + +### 4.3 内部方法(建议签名) + +```python +def _collect_region(self, block, for_index) -> _LoopRegion # 扫描到配对 ENDFOR;不匹配则整块放弃 +def _try_vectorize(self, func, block, for_index, region) -> LoopVectorizationRecord +def _check_bounds(self, for_instr) -> tuple[int, int, str | None] # (n, W?, reason) +def _classify(self, region) -> _Plan | str # 返回计划或拒绝原因 +def _rewrite(self, block, region, plan) -> tuple[int, int] # (vector_ops, strips) +def _clone_remainder(self, block, region, plan) -> int # 返回生成的尾声循环数 +def _fresh(self, prefix="v") -> str # "v_1", "v_2", ... +``` + +### 4.4 判定算法(与设计文档 2.2.1 的 C1–C7 一一对应) + +1. **C2/C3**:读 `for_instr.attrs["start"|"end"|"step"]`,要求 `int` 常量、`start==0`、`step==1`、`n>=W`;否则返回对应 `REASON_*`。 +2. **C1**:区域内出现 `FOR/ENDFOR/BR/BR_IF/LABEL/RETURN` → `REASON_NESTED_CONTROL_FLOW`。 +3. **建立局部 def 表**:`defs: dict[str, Instruction]`(`instr.dest.name -> instr`),并记录区域外定义集合 `outer_defs`(函数内所有区域外 defs + `func.params` + 常量)。 +4. **C4 地址链识别** `_match_address(use_instr)`: + - `addr` 的 def 形如 `ADD(x, y)`,其中一侧是 `MUL(iv, c)` 或 `MUL(c, iv)` 且 `c.const_value == elem_bytes`;另一侧 `base ∈ outer_defs`; + - 同一 `base` 在区域内必须始终配同一种偏移形式; + - 找到 ≥1 条地址链,否则 `REASON_NO_ELEMENT_PATTERN`。 +5. **C5 指令分类**:对区域内每条指令: + - 严格属于地址链集合 → 分类为 `ADDR`; + - `opcode ∈ {LOAD_CONST}` → `CONST`; + - `opcode ∈ {ADD,SUB,MUL,DIV,RELU,NEG}` 且所有操作数 ∈ {常量, outer_defs, 已分类的元素结果} → `ELEM`(操作数中出现 `iv` → `REASON_NON_ELEMENTWISE_IV`); + - 其余 → `REASON_UNSUPPORTED_OP`; + - 分类为 `ELEM`/`CONST` 但结果未被任何 STORE 使用 → `REASON_UNSUPPORTED_OP`(防死代码混入)。 +6. **C7 别名**:收集 `LOAD.base` 集合 `L` 与 `STORE.base` 集合 `S`;`S - L == ∅`(原地允许),且每个 base 的 STORE ≤ 1 条、且 STORE 与 LOAD 的偏移形式一致;否则 `REASON_ALIASING_STORE`。 +7. 通过后构造 `_Plan`:`n, W, strips, rem`,以及按原顺序排列的映射(`LOAD→VLOAD`、`STORE→VSTORE`、`RELU→VRELU`、`ADD→VADD`…)。 + +### 4.5 重写算法 + +- 用 `_fresh()` 生成 strip iv 与全部向量中间值的 `Value`(`shape=(W,)`,`dtype` 继承被替换指令的 `dest.dtype`); +- 地址链重建:`c_scale = load_const(W*elem_bytes)`(新的常量 Value)、`boff = mul(strip_iv, c_scale)`、`pa = add(base, boff)`; +- 原地替换区域内容:`FOR` 的 `dest` 换为 strip iv、`attrs` 换为 `{start:0, end:strips, step:1, vector_width:W, elem_bytes, orig_trip:n}`;把 `ADDR/CONST/ELEM` 指令替换为对应向量指令;`LOAD/STORE` 替换为 `VLOAD/VSTORE`;保留 `ENDFOR`; +- **禁改**:区域外指令、`FOR` 之前/`ENDFOR` 之后的指令一律不动。 + +### 4.6 余数克隆 + +```python +def _clone_remainder(block, region, plan): + if plan.rem == 0: return 0 + new_for = Instruction(opcode=OpCode.FOR, dest=region.iv, attrs={ + "start": plan.strips * plan.width, "end": plan.n, "step": 1}) + cloned = _clone_instrs(region.body, suffix="__rem") # 深克隆 + def/use 同步改名 + block.instructions[insert_at:insert_at] = [new_for, *cloned, endfor] + return 1 +``` + +`_clone_instrs` 规则:为每个 `dest` 生成新 `Value(name + "__rem")`;克隆指令的 `operands` 中若引用被克隆的旧名则替换为新名,否则保持(区域外引用与常量不动);`FOR/ENDFOR` 不入克隆体。 + +### 4.7 确定性 + +- 遍历顺序 = 函数序 × 块序 × 指令序; +- `_fresh()` 单调计数; +- 不依赖 `set/dict` 迭代顺序做任何输出决策(集合只用于成员判定)。 + +--- + +## 五、后端标量降级:实现位置与算法 + +### 5.1 位置与接线 + +新增 `scratchv/backend/vector_scalar.py`(约 150 行),并在 `instruction_select.py` 三处接线: + +```python +# instruction_select.py:19 __init__ 内 +from scratchv.backend.vector_scalar import VectorScalarExpander +self._vector_expander = VectorScalarExpander() + +# :38 _select_function 开头 +self._vector_expander.begin_function(func.name) + +# :47 _select_instruction 开头 +if instr.opcode.is_vector(): + self._instructions.extend(self._vector_expander.expand(instr)) + return +``` + +不新增 `MachineOp`;不修改 `machine_types.py`、`asm_emit.py`、`regalloc_linear.py`、`register_alloc.py`。 + +### 5.2 展开器状态与算法 + +```python +class VectorScalarExpander: + def __init__(self, *, default_width: int = 4) -> None: + self._default_width = default_width + self._lanes: dict[str, list[MachineOperand]] = {} + self._counter = 0 + + def begin_function(self, func_name: str) -> None: + self._lanes.clear(); self._counter = 0 + + def expand(self, instr: Instruction) -> list[MachineInstr]: + op = instr.opcode + if op is OpCode.VLOAD: return self._expand_load(instr) + if op is OpCode.VSTORE: return self._expand_store(instr) + if op is OpCode.VBCAST: return self._expand_bcast(instr) + if op in (VADD, VSUB, VMUL, VDIV): return self._expand_binary(instr) + if op is OpCode.VRELU: return self._expand_unary(instr) + raise VectorLoweringError(...) +``` + +核心辅助: + +```python +def _lanes_of(self, value: Value, width: int) -> list[MachineOperand]: + # 向量值 → 已绑定 lane 列表;常量 → LI 物化后广播(不缓存跨指令) + ... + +def _lane_addr(self, base: MachineOperand, k: int) -> list[MachineInstr]: + # k==0: 直接返回 base;k>0: [ADDI tmp, base, 4*k] + # 关键:只用 ADDI,禁止 ADD rd, rs, imm(编码器 R 型立即数会静默变 x0) + ... +``` + +逐 op: + +- `VLOAD`:`addr = _operand_reg(instr.operands[0])`(常量先 LI);对 k:`a_k = _lane_addr(addr, k)`;`LW vd_k, a_k`;`self._lanes[dest.name] = [vd_k...]`。 +- `VSTORE`:同地址链;`SW a_k, vs_k`(`vs_k` 从 `_lanes` 取)。 +- `VBCAST`:`src = _operand_reg(scalar)`;`self._lanes[dest.name] = [src] * width`(无机器指令)。 +- `VADD/VSUB/VMUL/VDIV`:逐 k `ADD/SUB/MUL/DIV vd_k, va_k, vb_k`;登记 lane。 +- `VRELU`:逐 k `MAX vd_k, va_k, MachineOperand.immediate(0)`。 +- 宽度一致性:所有 `expand` 先读 `int(instr.attrs.get("width", self._default_width))`;`VSTORE` 的 `vec` lane 数不匹配 → `VectorLoweringError`。 + +命名约定(确定性,测试可断言前缀):lane 数据 vreg `f"{name}__lane{k}"`;lane 地址 vreg `f"{name}__addr{k}"`;常量物化 vreg `f"vcst_{counter}"`。 + +### 5.3 P0 前置修复:R 型常量操作数物化 + +**现状缺陷(已在 HEAD 复现)**: + +```text +IR: %off = mul %iv %c4 # c4 是 load_const(4) 的 dest(is_constant=True) +ASM: mul t2, t0, 4 # _op() 把常量变 immediate +BIN: 编码器 _reg_num("4") → 0 # riscv_encoder.py:103 对非寄存器文本静默返回 0 +实际: mul t2, t0, x0 # 静默错误 +``` + +**修复**(`instruction_select.py`,仅标量路径): + +```python +def _op_reg(self, instr: Instruction, idx: int) -> MachineOperand: + """Like _op, but materializes constants into a vreg (encoder-safe).""" + op = instr.operands[idx] + if op.is_constant and op.const_value is not None: + tmp = MachineOperand.vreg(f"const__{op.name}") + self._emit(MachineOp.LI, tmp, + MachineOperand.immediate(int(op.const_value)), + comment=f"const {op.const_value}") + return tmp + return MachineOperand.vreg(op.name) +``` + +替换点:`_select_add`、`_select_sub`、`_select_mul`、`_select_div`(`instruction_select.py:88-102`)与 `_select_gelu` 的 `DIV dst, dst, immediate(2)`(`:140`)。保留 `_op()` 给立即数安全的消费者(`ADDI`、`MAX ... 0`、`SUB 0, rs`)。 + +**建议同时做的防护(可选 P0b,非向量功能)**:编码器对 R 型指令的非寄存器操作数 `raise ValueError`,消除“静默变 x0”。若实施,需单独提交并在 commit message 注明与课题 29 无关。 + +**回归测试**(`tests/test_backend.py` 追加):编译含 `mul`/`add` 常量操作数的 IR,断言 asm 文本不匹配 `r"^\s*(add|sub|mul|div)\s+\w+,\s*\w+,\s*-?\d"`。 + +--- + +## 六、`riscv_encoder.py`:向量指令显式拒绝(一期) + +`_encode_line`(`riscv_encoder.py:326`)在取到 `op` 后立即检查: + +```python +VECTOR_MNEMONIC_RE = re.compile( + r"^v(setvli|setivli|set|le|se|lw|sw|add|sub|mul|div|rem|max|min|" + r"mv|fmv|fadd|fsub|fmul|fdiv|redsum|rgather|slide|merge|macc|nclip|" + r"widen|narrow|and|or|xor)") +VECTOR_ENCODING_MSG = ( + "vector instruction '{op}' is not supported: " + "ScratchV phase 1 targets RV32IM only") + +class VectorEncodingError(ValueError): + pass +``` + +```python + op = tokens[0].lower() + if VECTOR_MNEMONIC_RE.match(op): + raise VectorEncodingError(VECTOR_ENCODING_MSG.format(op=op)) +``` + +要点: + +- 该护栏只做 **fail-loud**,不实现任何向量编码;RVV 32-bit 编码涉及 vtype/vreg/可变长度,明确不做; +- 正则只匹配 `v` 开头的 RVV/P 助记符;RV32IM 无 `v` 开头助记符,不产生误伤(新增标量指令若以 `v` 开头需评审); +- 一期正常路径根本不会触碰该分支(降级后全为标量),它是“防止未来某处漏出向量 op”的保险丝;测试见 10.3。 + +--- + +## 七、仿真器无需改动的原因说明 + +**结论:一期不修改 `scratchv/simulator/rv32_emulator.py` 与 `scratchv/standalone/benchmark.py`。** + +理由链: + +1. **产物层面无向量指令**:`Vectorizer` 只改 IR;`VectorScalarExpander` 把每条向量 op 展开为 `LW/SW/ADDI/ADD/SUB/MUL/DIV/MAX`,`AsmEmitter` 产出的汇编与编码器产出的二进制里不存在任何 RVV/P 指令。仿真器“本来就只认 RV32IM”,因此无需扩展。 +2. **仿真器没有可扩展点**:`RV32Emulator._execute`(`rv32_emulator.py:246`)按 7-bit opcode 分派,没有向量寄存器堆、没有 `vtype/vl/vstart` CSR、没有 RVV 编码表。为未发射的指令预埋解码/状态是死代码,违反“不做无验证的功能”。 +3. **未知 opcode 是静默跳过**:`_execute` 对未识别 opcode 没有 `else` 分支,等价于 no-op。这意味着**一旦有向量指令漏进二进制,仿真器不会报错而是算错**。因此一期的防线必须是编码器拒绝(第六节)+ 展开后置断言,而不是“让仿真器报错”。这条是风险控制的核心,写入验收标准。 +4. **benchmark.py 同理**:其分类器只识别 RV32IM opcode(`standalone/benchmark.py:48-65`),向量指令会落进 `unknown` 类且可能产生错误周期数。一期没有向量指令进入该工具,故不动;二期需要 VLEN 感知模型时才扩展,并作为二期依赖(第十三节)。 +5. **可执行验证仍能覆盖语义**:标量展开后的程序与原标量程序共享同一套指令语义(同构展开),用现有 `RV32Emulator.run()` 对拍即可验证“向量化没有改变结果”。这正是把一期设计成“IR 向量 + 后端降级”的原因。 + +--- + +## 八、`compiler.py` 集成点 + +### 8.1 `CompilerConfig`(`compiler.py:33-75`) + +追加三字段(见 1.5),并在 `docstring` 的 Attributes 列表补三行说明。默认值保证关闭时与现状逐字节一致。 + +### 8.2 `compile()` 中的插入点 + +```python +# --- 3. Optimize --- # compiler.py:260-264 之后 +if self.config.vectorize: + vres = self._run_vectorizer(program) + warnings.extend(vres.warnings) + if vres.message: + opt_message = (opt_message + "; " if opt_message else "") + vres.message + +# --- IR dump --- # compiler.py:266-278:此时 dump 可见向量 IR + +# --- 4. Code generation --- # compiler.py:280-287 之前插入 ISA 拒绝 +if self.config.vectorize and self.config.vector_isa != "scalar": + return CompileResult( + success=False, + errors=[f"vector-ISA '{self.config.vector_isa}' is not implemented " + "(phase 2); use --vector-isa scalar"], + ir_dump=ir_dump, + ) +``` + +新方法: + +```python + def _run_vectorizer(self, program) -> PassResult: + from scratchv.optimizer.vectorize import Vectorizer + vec = Vectorizer(program, width=self.config.vector_width) + return vec.run(program) +``` + +DAG 兼容:`_generate_code` 若走 `use_dag_isel`(`compiler.py:393`),向量 IR 无法被 `scratchv_dag` 处理 → 在 `compile()` 的向量化分支里加: + +```python +if self.config.use_dag_isel: + warnings.append("vectorize is incompatible with --dag-isel; falling back to linear isel") + self.config.use_dag_isel = False +``` + +### 8.3 warnings 透传 + +现状 `_run_optimizations` 的返回值只取 `message`(`compiler.py:263`),丢弃 `warnings`。向量化拒绝原因是 `warnings` 的主要出口,必须在新分支 `warnings.extend(vres.warnings)`(如上),不要改 `_PassAdapter`/`PassManager` 语义。 + +### 8.4 已知限制(集成时必须知晓,不在本期修) + +- `reg_alloc="linear"` 的 `LinearScanAllocator.emit` 把标签输出为 `.label name`、跳转目标只放在注释,导致编码阶段标签丢失(实测 `j # .Lloop_header_1` → `IndexError`)。CLI 默认 `--reg-alloc greedy`,向量化验证路径使用 greedy;`CompilerConfig` 默认 `"linear"` 是既有不一致,建议在向量化开启且 `reg_alloc=="linear"` 时追加 warning 并回退 greedy(实现时可选项,需测试)。 +- `_select_alloca` 使用 `vreg("sp")` 会被寄存器分配改名(alloca 结果错误)。向量化样例与测试一律用 `load_const` 绝对地址作 base,避免 `alloca`;这是既有缺陷,单独跟踪。 +- `--extended-isel`、`--verify-ir` 存在“CLI 已声明但 `args_to_config` 未接线”的历史缺口;本期新增三参数必须双侧修改,并以 10.4 的接线测试守门。 + +--- + +## 九、`main.py` CLI 接线 + +1. `build_arg_parser()`(`main.py:24-128`)在 “Topic module flags” 段(`:69-106`)追加 1.5 的三个 `add_argument`。 +2. `args_to_config()`(`main.py:135-156`)在 `CompilerConfig(...)` 调用中追加三个关键字实参。 +3. `main()` 无需改动:`--vectorize` 生效路径完全走 `CompilerDriver.compile()`。 + +--- + +## 十、测试文件与用例 + +### 10.1 `tests/test_vectorize.py`(新增) + +| 用例 | 说明 | 关键断言 | +|---|---|---| +| `TestVectorIrBuilders::test_builder_emits_vector_ops` | 8 个 builder API 各构造一次 | opcode/attrs/`dest.shape` 精确匹配 1.1 | +| `TestVectorIrBuilders::test_vstore_returns_instruction` | `vstore` 返回值类型 | `isinstance(instr, Instruction)` 且 `dest is None` | +| `TestVectorizerStructure::test_strip_mining_no_remainder` | n=16,W=4 | FOR attrs `{0,4,1}` + `vector_width=4`;`2×VLOAD+VMUL+VRELU+VSTORE`;无第二个 FOR | +| `...::test_strip_mining_with_remainder` | n=17,W=4 | 第二个 FOR `start=16,end=17`,体内无向量 op;克隆 defs 不重名 | +| `...::test_width_2` | n=16,W=2(参数化) | strips=8,向量 attrs `width=2` | +| `...::test_trip_too_small_ir_unchanged` | n=3,W=4 | reject;`program.dump()` 前后相同 | +| `TestVectorizerRejects::test_reject_reasons` | 设计文档 I1–I4 | `reason` 字符串逐项相等;`changes == 0` | +| `TestVectorizeReport::test_report_counts` | 1 可向量化 + 1 拒绝 | `message == "vectorized 1/2 loop(s), width=4"`;warnings 各一行 | + +构造“可向量化 IR”的辅助函数(两处复用,放本文件顶部): + +```python +def _make_map_loop(n: int) -> Program: + b = IRBuilder(); b.new_function("main"); b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + bb = b.load_const(0x410000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)); vb = b.load(b.add(bb, off)) + r = b.relu(b.mul(va, vb)) + b.store(b.add(o, off), r) + b.endfor(); b.ret() + return b.program +``` + +### 10.2 `tests/test_vector_lowering.py`(新增,含现有仿真器执行验证) + +| 用例 | 说明 | +|---|---| +| `TestLoweringSanity::test_no_vector_mnemonic_in_asm` | 向量化 → 后端 → asm;正则 `^\s*v[a-z]` 无命中;selector 结束态无向量 opcode | +| `TestLoweringSanity::test_lane_addressing_uses_addi` | asm 中 lane 地址用 `addi`;不出现 `add .* , -?\d+$` 这类 R 型立即数 | +| `TestEmulatorDifferential::test_vectorized_matches_scalar` | 见下 | +| `TestLoweringErrors::test_missing_lane_binding` | 手造只含 `VADD` 的孤儿 IR → `VectorLoweringError` | + +**对拍骨架(已用当前 HEAD 预验证可行,含 P0 修复)**: + +```python +def _compile_and_run(program, base_in_a=0x400000, base_in_b=0x410000, + base_out=0x420000, a=None, b=None) -> list[int]: + from scratchv.backend.instruction_select import InstructionSelector + from scratchv.backend.register_alloc import RegisterAllocator + from scratchv.backend.asm_emit import AsmEmitter + from scratchv.backend.riscv_encoder import assemble_to_binary + from scratchv.simulator.rv32_emulator import RV32Emulator + + mi = InstructionSelector(program).run() + asm = AsmEmitter(RegisterAllocator(mi, mode="greedy").run()).emit() + bin_ = assemble_to_binary(asm) # P0 修复后编码正确 + + emu = RV32Emulator() + emu.load_code(bytes(bin_)) # 代码从 0 起,数据区 ≥ 0x400000 + for i, x in enumerate(a): emu.write_i32(base_in_a + 4*i, int(x)) + for i, x in enumerate(b): emu.write_i32(base_in_b + 4*i, int(x)) + emu.run(max_instr=100000) # 停在 jalr x0, ra, 0 + return [emu.read_i32(base_out + 4*i) for i in range(len(a))] +``` + +断言:`run(S) == run(Vectorizer(S, width=4)) == [max(x*y, 0) for x, y in zip(a, b)]`(i32 回绕按 Python 位运算给出期望值);输入含负数与 0;`emu._instr_count` 仅 `print` 记录,**不做性能断言**(避免把未测量数字变成 CI 门槛)。 + +### 10.3 `tests/test_vector_encoder.py`(新增) + +```python +@pytest.mark.parametrize("text", [ + "vadd.vv v1, v2, v3", + "vsetvli t0, a0, e32, m1, ta, ma", + "vle32.v v1, (a0)", + "vse32.v v1, (a0)", + "vmv.v.x v1, a0", +]) +def test_vector_mnemonic_rejected(text): + with pytest.raises(VectorEncodingError) as ei: + assemble_to_binary(text) + assert "phase 1 targets RV32IM only" in str(ei.value) + +def test_scalar_still_encodes(): + assert len(assemble_to_binary("add a0, a1, a2\n")) == 4 +``` + +### 10.4 `tests/test_backend.py`(追加) + +| 用例 | 说明 | +|---|---| +| `TestCLIWiring::test_vector_flags_parse_and_convert` | `build_arg_parser().parse_args(["m.onnx","--vectorize","--vector-width","2","--vector-isa","scalar"])` → `args_to_config()` 三字段断言(守门课题 28 的漏接线问题) | +| `TestCLIWiring::test_vector_isa_defaults_scalar` | 默认 `vectorize is False`、`vector_width == 4`、`vector_isa == "scalar"` | +| `TestDriverIntegration::test_compile_vectorized_program` | monkeypatch `CompilerDriver._parse` 返回 10.1 的 IR;`CompilerConfig(vectorize=True, reg_alloc="greedy")`;`compile()` 成功、asm 无 `v*`、`warnings` 含向量化 message | +| `TestDriverIntegration::test_vector_isa_v_rejected` | `vectorize=True, vector_isa="v"` → `result.success is False` 且错误文本含 `phase 2` | +| `TestConstantOperandMaterialization::test_no_rtype_immediate` | P0 回归(见 5.3) | + +### 10.5 运行 + +```bash +python3 -m pytest tests/test_vectorize.py tests/test_vector_lowering.py \ + tests/test_vector_encoder.py tests/test_backend.py -v +make test # 全量回归 +``` + +--- + +## 十一、实施顺序与验收标准 + +### 11.1 顺序(依赖串行) + +``` +P0 常量物化修复 + 回归 → P1 types/builder + 单测 +→ P2 Vectorizer + IR 结构测试 → P3 expander + asm/仿真器对拍 +→ P4 encoder 护栏 + 测试 → P5 compiler/main 接线 + CLI 测试 +→ P6 全量回归 make test → P7 文档同步(本目录两份文档) +``` + +### 11.2 验收标准(全部满足才可声明完成) + +- [ ] `python3 -m pytest tests/ -v` 全绿(含新增 4 个测试文件),无 skip 新增。 +- [ ] `--vectorize` 默认关闭时,同一输入产出的 asm 与基线逐字节相同(回归测试或人工 diff)。 +- [ ] `Vectorizer` 对设计文档 2.2.1 的 C1–C7 逐条有测试覆盖;拒绝原因字符串与 1.2 完全一致。 +- [ ] 向量化程序经 `InstructionSelector`(greedy 路径)→ `assemble_to_binary` → `RV32Emulator` 执行成功,输出与标量基线逐 i32 位相等(10.2)。 +- [ ] 向量化产物的 asm 与 bin 中不存在任何 `v*` 助记符;编码器对 RVV 样例抛 `VectorEncodingError`(10.3)。 +- [ ] `--vector-isa v|p` 返回 `CompileResult(success=False)`,错误文本含 `phase 2`(10.4)。 +- [ ] CLI 三参数在 parser 与 `args_to_config` 双侧接通(10.4)。 +- [ ] `scratchv/simulator/rv32_emulator.py`、`scratchv/standalone/benchmark.py` 的 `git diff` 为空。 +- [ ] 文档中所有性能表述均带“未测量”标注;不出现任何测量口径的 SIMD 收益声明。 +- [ ] commit message 英文(仓库规范),正文注明“phase 1: IR + strip-mining + scalar lowering; RVV deferred”。 + +### 11.3 建议提交切分(便于审查与回退) + +1. `fix: materialize constant operands in instruction selection (prerequisite)`(P0) +2. `feat(ir): add phase-1 vector opcodes and builder APIs (topic 29)` +3. `feat(opt): add FOR strip-mining vectorizer (topic 29)` +4. `feat(backend): lower vector ops to scalar machine code (topic 29)` +5. `feat(encoder): reject vector mnemonics explicitly (topic 29)` +6. `feat(cli): wire --vectorize/--vector-width/--vector-isa (topic 29)` +7. `test(topic29): vector IR, lowering differential, encoder rejection` +8. `docs(topic29): design and development docs` + +--- + +## 十二、风险与回退 + +| # | 风险 | 触发条件 | 缓解 | 回退 | +|---|---|---|---|---| +| R1 | P0 修复改变既有 asm,触发快照/计数类测试失败 | 有测试锁定 `mul ..., 4` 形态 | 先跑 `make test`;只改 R 型消费者;快照类测试若断言的是错误形态,修正期望并注明 | 单独 revert P0 commit;向量化样例改用无常量 IR(不推荐) | +| R2 | 向量模式匹配过窄,真实程序零命中 | 前端暂无数组/索引,IR 少有此形态 | 一期以手写 IR 测试覆盖;报告如实显示 `vectorized 0/N`;前端数组支持另立课题 | 关闭 `--vectorize`,零影响 | +| R3 | 匹配过宽导致语义错误 | 别名/依赖判定漏洞 | C7 收缩(读 base 集合与写 base 必须相容);对拍用负数与大值覆盖回绕 | 拒绝策略收紧为“无别名才向量化” | +| R4 | lane 展开造成寄存器压力上升(W×活跃值) | 长表达式+W=4 | 默认 W=4,提供 `--vector-width 2`;线性扫描可溢出到栈(既有机制) | W=2 或关闭 | +| R5 | 向量 op 漏进后端/编码器 | 接线遗漏或未来改动 | `is_vector()` 分派 + `VectorLoweringError` + 编码器 `VectorEncodingError` 双层护栏;仿真器未知 opcode 静默跳过是必须避免的来源 | 恢复编码器拒绝测试 | +| R6 | 误把一期 loop 摊销当 SIMD 收益对外表述 | 文档/汇报 | 设计文档 5.2 与验收标准强制“未测量”标注 | 修正文案,不改代码 | +| R7 | `reg_alloc="linear"` 路径缺标签导致 `IndexError` | 用户显式 `--reg-alloc linear` 且含循环 | 向量化路径文档化要求 greedy;可选加 warning 回退 | 修复线性路径标签发射(另立 task) | +| R8 | 与课题 28 在 `types.py` 冲突 | 两课题同改枚举 | 尾部注释分区 + append-only 约定;实施前 rebase 后复核分区顺序 | 手工合并,保留双方分区 | + +**总回退开关**:`--vectorize` 默认 `False`;删除 `optimizer/vectorize.py` 与 `backend/vector_scalar.py` 后,IR/builder 的新增枚举与 API 无调用方,不影响既有管线(最坏情况保留死代码,语义零影响)。 + +--- + +## 十三、二期 RVV 依赖清单 + +| # | 依赖 | 现状 | 二期需要 | 备注 | +|---|---|---|---|---| +| D1 | RVV 汇编/编码 | `riscv_encoder.py` 仅 RV32IM,且一期只加拒绝 | 文本发射不需要编码器;二进制路径需要 binutils ≥ 2.40 或 LLVM MC ≥ 14 的 `rv32gcv` 支持 | 零依赖约束 → 外部工具显式 opt-in | +| D2 | RVV 仿真器 | `RV32Emulator`/TinyFive 仅 RV32IM,无向量寄存器堆/CSR | Spike `--isa=rv32gcv` 或 QEMU `rv32,v=true` | 一期禁止预埋死代码 | +| D3 | Benchmark 周期模型 | `benchmark.py` 分类仅 RV32IM,无 VLEN 概念 | 新增向量类别与 VLEN 感知周期模型 | 未扩展前不得产出“RVV 实测”数字 | +| D4 | `vsetvli/vtype` 配置 | 无 | 固定 `SEW=32, LMUL=1`,`VLEN=128` 假设显式写入发射器参数并记录 provenance | 与 2.4 映射表一致 | +| D5 | 数据对齐策略 | 一期只需 4B 对齐 | `vle32.v` 非对齐行为依赖实现;不支持的系统需 peel 到 16B | 需在发射器里可配置 | +| D6 | 端到端测试链路 | 无 RVV 执行能力 | `tests/test_rvv_emit.py`:只断言文本,不执行;执行测试加 `pytest.mark.skipif(工具链缺失)` | 不允许用“文本正确”冒充“行为正确” | +| D7 | P-extension | 无 | **不做**:i32 lane 无法打包,需先量化到 i16(模型/前端改造) | 若未来做,需独立调研 | + +--- + +## 附录 A:现有代码锚点(HEAD `d146515`) + +| 锚点 | 位置 | 用途 | +|---|---|---| +| `OpCode` 枚举 | `scratchv/ir/types.py:14-72` | 追加向量分区 | +| `is_control_flow()` | `scratchv/ir/types.py:69` | `is_vector()` 放其后 | +| `IRBuilder._emit` | `scratchv/ir/builder.py:29-41` | 新增 v* API 复用 | +| `IRBuilder.reshape` | `scratchv/ir/builder.py:206-209` | 追加点 | +| `InstructionSelector._select_function` | `scratchv/backend/instruction_select.py:38-45` | `begin_function()` | +| `_select_instruction` | `:47-52` | `is_vector()` 分派 | +| `_op` | `:63-69` | P0 修复对照 | +| add/sub/mul/div 处理器 | `:88-102` | P0 替换点 | +| `_select_gelu` DIV | `:140` | P0 替换点 | +| `_select_endfor` | `:210-223` | +1 递增语义(strip 设计依据) | +| `_select_load/_select_store` | `:159-163` | `LW/SW` 形态 | +| `RISCVAEncoder._reg_num` | `scratchv/backend/riscv_encoder.py:103-111` | 静默返回 0 的根源 | +| `_encode_line` | `:326` | 护栏插入点 | +| `compile()` 步骤 | `scratchv/compiler.py:224-321` | 集成插入点 | +| `_run_optimizations` | `:364-382` | warnings 透传对照 | +| `_generate_code` | `:386-395` | DAG 回退点 | +| `build_arg_parser` | `scratchv/main.py:24-128` | CLI 追加 | +| `args_to_config` | `:135-156` | 接线追加 | +| `CompilerPass`/`PassResult` | `scratchv/pass_interface.py:34-88` | pass 基类契约 | +| `RV32Emulator._execute` | `scratchv/simulator/rv32_emulator.py:246-368` | 无向量状态、未知 opcode 静默 | +| benchmark 分类 | `scratchv/standalone/benchmark.py:48-65` | 无向量类别 | + +## 附录 B:变更清单(实施后核对用) + +``` +新增: + scratchv/optimizer/vectorize.py + scratchv/backend/vector_scalar.py + tests/test_vectorize.py + tests/test_vector_lowering.py + tests/test_vector_encoder.py +修改: + scratchv/ir/types.py (+8 enum, +is_vector, +_VECTOR_OPS) + scratchv/ir/builder.py (+8 API, +_shape_width) + scratchv/backend/instruction_select.py (+_op_reg/P0, +is_vector 分派, +begin_function) + scratchv/backend/riscv_encoder.py (+VectorEncodingError, +VECTOR_MNEMONIC_RE, +拒绝) + scratchv/compiler.py (+3 config 字段, +_run_vectorizer, +ISA 拒绝, +warnings) + scratchv/main.py (+3 CLI, +args_to_config) + tests/test_backend.py (+CLI 接线/driver 集成/P0 回归) +禁止改动(diff 必须为空): + scratchv/simulator/rv32_emulator.py + scratchv/standalone/benchmark.py + scratchv/standalone/onnx_to_riscv_standalone.py +``` + +--- + +## 实现结果(2026-09-14 集成) + +> **集成 commit**:`02305c0`(`feat(topic29): add vector IR ops and strip-mining vectorizer with scalar lowering`) +> **集成位置**:`Seven_big_summary` 上第 11 个 topic commit(最后一个) +> **集成后全量**:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **1011 passed / 13 xfailed / 20 xpassed / 0 failed** + +### 实现文件与要点 + +| 文件 | 要点 | +|------|------| +| `scratchv/ir/types.py` | `[Topic 29]` 分区 8 个向量 OpCode(排在 Topic 28 之后)+ `is_vector()` | +| `scratchv/ir/builder.py` | 8 个 `v*` API + `_shape_width` | +| `scratchv/optimizer/vectorize.py`(新增) | strip-mining 向量化器:C1–C7 判定、余数深克隆 | +| `scratchv/backend/vector_scalar.py`(新增) | 逐 lane 标量降级 | +| `scratchv/backend/instruction_select.py` | `is_vector` 分派 + P0 常量操作数修复 | +| `scratchv/backend/riscv_encoder.py` | 向量助记符拒绝护栏 | +| `scratchv/compiler.py`、`scratchv/main.py` | `--vectorize` / `--vector-width` / `--vector-isa` 双侧接线 | +| `tests/test_vectorize.py`、`tests/test_vector_lowering.py`、`tests/test_vector_encoder.py`、`tests/test_backend.py`(追加) | 新增 49 用例 | + +### 测试数字 + +| 口径 | 结果 | +|------|------| +| 定向(4 个测试文件新增部分) | 49 用例 | +| 分支全量(cherry-pick 前) | 614 passed | +| 集成后全量 | 1011 passed / 13 xfailed / 20 xpassed / 0 failed | + +可执行对拍证据:选择器 → greedy → AsmEmitter → `RV32Emulator` 三条链路逐 i32 位相等。 + +### 与本文档的偏差 / 未完成项 + +- 内存操作数改为 `ADDI a1, addr, 4k` + `lw/sw a1(0)`,规避既有编码器静默落 x0 的缺陷。 +- VRELU 使用 zero 寄存器。 +- NEG 按 unsupported-op 显式拒绝。 +- 余数循环不再复查(不做二次向量化检查)。 +- 二进制 op 对拍使用 W=2(受既有分配器 >19 vreg 误编译与仿真器 SW 缺陷限制)。 + +### 已知限制 + +- Phase 1 仅标量展开(`--vector-isa p/v` 显式拒绝)。 +- 未做 `linear` 自动回退 greedy(R7)。 +- 无任何性能声明(不把 loop 摊销当 SIMD 收益)。 diff --git "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" new file mode 100644 index 0000000..4e278f0 --- /dev/null +++ "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -0,0 +1,503 @@ +# 课题 29:SIMD 向量化设计文档 + +> 文档版本:v1.0 +> 编写日期:2026-09-14 +> 涉及模块:`scratchv/ir/types.py`(向量 OpCode)、`scratchv/ir/builder.py`(向量构造 API)、`scratchv/optimizer/vectorize.py`(新增 Vectorizer)、`scratchv/backend/vector_scalar.py`(新增逐 lane 标量展开)、`scratchv/backend/instruction_select.py`(接线 + P0 常量操作数修复)、`scratchv/backend/riscv_encoder.py`(向量助记符显式拒绝)、`scratchv/compiler.py` / `scratchv/main.py`(config/CLI 接线) +> 只读依赖(一期不改):`scratchv/simulator/rv32_emulator.py`、`scratchv/standalone/benchmark.py` +> 功能范围:一期 = IR 层向量指令 + FOR loop strip-mining 向量化 + 后端逐 lane 标量展开(可用现有 RV32IM 仿真器执行验证语义);二期 = RVV 文本发射规划(本期只定义映射表、接口与拒绝策略,不实现发射) + +--- + +## 一、功能介绍 + +### 1.1 功能概述 + +#### 现状盘点(2026-09-14,HEAD `d146515`) + +| 层 | 现状 | 结论 | +|----|------|------| +| IR `OpCode` | 仅标量:`ADD/SUB/MUL/DIV/RELU/...`(`scratchv/ir/types.py:14`) | 无任何向量操作定义 | +| IR 循环 | `FOR/ENDFOR` 仅支持常量 `start/end/step`,后端 `_select_endfor` 恒按 `+1` 递增(`instruction_select.py:210`) | 循环可变换面窄,但可用 strip 索引绕过 | +| 后端 | `InstructionSelector` 逐 IR op 选择 RV32IM 机器指令 | 遇到未知 opcode 直接 `ValueError`(安全网) | +| 编码器 | `riscv_encoder.py` 仅 RV32IM;未知助记符 `raise ValueError`(`:492`) | 无 V/P 扩展,且未知指令一律拒绝 | +| 仿真器 | `RV32Emulator` 仅 RV32IM;未识别 opcode 静默跳过 | 不新增向量状态,保持零改动 | +| Benchmark | `standalone/benchmark.py` 只按 RV32IM 分类计数 | 一期标量降级产物可直接测量 | + +#### 目标重定义(诚实前提) + +课题概述中“per-MAC 指令数从 ~12 降至 ~3-5”**不是本课题的现实前提**,原因: + +1. ScratchV 当前可执行链路是 **RV32IM 标量 + Q16.16 定点**,lane 宽度为 i32; +2. **P-extension 无法打包 i32 lane**:其 SIMD 语义作用于 16/8-bit 打包子字(`add16/mul16/...`)。要吃到 P 的红利,必须先量化到 i16,属于模型/前端改造,不在本课题范围; +3. **V-extension 在 RV32 上的工具链在本仓库完全缺失**:无 RVV 编码器、无 RVV 仿真器、无 RVV 汇编器(零依赖约束下亦不引入外部工具链);Spike/TinyFive 现有适配均按 RV32IM 口径; +4. 因此本期把课题切为两期,**一期只交付语义基础设施与可执行验证,不承诺性能**。 + +#### 分期定义 + +| 期 | 交付 | 可执行性 | 性能声明 | +|----|------|----------|----------| +| **一期** | IR 向量指令最小集;FOR 循环 strip-mining 向量化 pass;向量 op 的**后端逐 lane 标量展开**;编码器向量助记符拒绝;完整测试与对拍 | 生成纯 RV32IM 机器码,可在现有 `RV32Emulator` / `benchmark.py` 执行 | 只给理论指令数分析(标注**未测量**) | +| **二期(规划)** | RVV 文本发射 `RVVTextEmitter`;`--vector-isa v` 通路;二进制编码与仿真接入(依赖外部工具链) | 只允许文本发射;二进制路径显式拒绝 | 依赖外部仿真器实测,本期不产生数字 | + +一期向量化经过后端展开后,语义与标量基线**逐指令同构**:每条标量指令与基线循环体执行的指令一一对应(仅迭代分组变化),因此对合法输入两者结果位精确一致。这是本课题可验证性的基石。 + +### 1.2 设计目标 + +- **语义等价**:向量化 + 标量展开后的程序与标量基线在合法输入上结果位精确一致(整数 i32 运算,无浮点重排问题)。 +- **零运行时改动**:一期不修改仿真器、不修改编码器语义;编码器只增加“显式拒绝”护栏,避免未知指令被静默误编码。 +- **可观测**:`--dump-ir` 能看到向量 IR;向量化决策有结构化报告(每条循环的接受/拒绝原因)。 +- **可回退**:`--vectorize` 默认关闭;关闭时管线与现状逐字节一致。 +- **不虚报**:性能结论只能是理论指令数分析,并标注未测量;不把 loop unroll 带来的开销摊销包装成 SIMD 收益。 +- **与课题 28 解耦**:`OpCode` 采用“枚举末尾追加 + 注释分区”约定,避免与课题 28 的扩展指令选择在同一区域产生合并冲突。 +- **为二期留口**:向量 op 的选择入口按 `opcode.is_vector()` 分派,二期可用一个 RVV 发射器替换标量展开器,接口不变。 + +--- + +## 二、设计规范 + +### 2.1 一期向量 IR 最小指令集 + +#### 2.1.1 记法约定 + +- `W` = strip 宽度(lane 数),一期取值 `{2, 4}`,默认 `4`。 +- 向量值:`Value.shape == (W,)`,`dtype` 为 lane 元素类型(Q16.16 场景为 `DataType.INT32`;`FLOAT32` 视为同一 i32 载荷的别名)。 +- `elem_bytes = 4`:每个 lane 占 4 字节(与现有 `LW/SW` 一致)。 +- 向量指令 `attrs` 必须包含 `width`;内存类另含 `elem_bytes`、`align`。 +- 地址操作数 `addr` 是标量 `Value`,表示 **lane 0 的字节地址**;lane k 的地址为 `addr + k*elem_bytes`。 + +#### 2.1.2 指令定义(BNF) + +``` +vector_op ::= vload_op | vstore_op | vbcast_op | vbin_op | vrelu_op + +vload_op ::= "vload" vreg "," sval +vstore_op ::= "vstore" sval "," vreg +vbcast_op ::= "vbcast" vreg "," sval +vbin_op ::= ("vadd" | "vsub" | "vmul" | "vdiv") vreg "," vreg "," vreg +vrelu_op ::= "vrelu" vreg "," vreg +``` + +| IR opcode(enum 名 / 字符串) | 形式 | 必需 attrs | 语义(∀k ∈ [0,W)) | +|---|---|---|---| +| `VLOAD` / `vload` | `%vd = vload %addr` | `width`, `elem_bytes=4`, `align=4` | `vd[k] = Mem32[addr + 4k]`(有符号 i32 读) | +| `VSTORE` / `vstore` | `vstore %addr, %vs` | `width`, `elem_bytes=4`, `align=4` | `Mem32[addr + 4k] = vs[k]` | +| `VBCAST` / `vbcast` | `%vd = vbcast %s` | `width` | `vd[k] = s`(标量广播) | +| `VADD` / `vadd` | `%vd = vadd %va, %vb` | `width` | `vd[k] = (va[k] + vb[k]) mod 2^32` | +| `VSUB` / `vsub` | `%vd = vsub %va, %vb` | `width` | `vd[k] = (va[k] - vb[k]) mod 2^32` | +| `VMUL` / `vmul` | `%vd = vmul %va, %vb` | `width` | `vd[k] = (va[k] * vb[k]) mod 2^32`(与标量 `MUL` 同语义) | +| `VDIV` / `vdiv` | `%vd = vdiv %va, %vb` | `width` | `vb[k] != 0 ? va[k] / vb[k] : 0xFFFFFFFF`(与 `RV32Emulator` 的 `DIV` 除零行为一致,`rv32_emulator.py:267`) | +| `VRELU` / `vrelu` | `%vd = vrelu %va` | `width` | `vd[k] = max(va[k], 0)` | + +补充规则: + +- `OpCode.is_vector()` 返回 `{VLOAD,VSTORE,VBCAST,VADD,VSUB,VMUL,VDIV,VRELU}` 的成员判定。 +- 向量 op 的 `dest`(若有)必须 `shape == (width,)`;`VSTORE` 无 `dest`。 +- 二元向量 op 的两个操作数必须是同 `width`、同 `dtype` 的向量值(`VBCAST` 的结果视为向量值)。 +- 一期**不定义**跨 lane 归约(`VSUM/VDOT/VMAX`)与跨 lane 置换(`VSHUFFLE`);见 2.6。 + +### 2.2 向量化 pass 规则 + +#### 2.2.1 可向量化循环模式 + +一期只处理**单块直落(straight-line)FOR 区域**,判定按以下顺序执行,任一失败即整环拒绝(不做部分向量化): + +``` +FOR 区域 ::= FOR(iv) instr* ENDFOR + +C1 结构:区域内不得出现 FOR/ENDFOR/BR/BR_IF/LABEL/RETURN +C2 界 :attrs.start == 0(常量);attrs.end == n(常量 int);attrs.step == 1 +C3 规模 :n >= W +C4 元素模式:区域内至少一条 LW/STORE,其地址形如 + addr = ADD(base, MUL(iv, c)) 且 c.const_value == elem_bytes (4) + 其中 base 在区域外定义(循环不变量) +C5 元素树:区域内每个标量指令必须属于下列之一 + (a) 地址链:MUL(iv, 4) / ADD(base, off) / LOAD / STORE + (b) 元素表达式:操作数为 {常量, 区域外定义值, 已分类的元素结果} + 且 opcode ∈ {ADD, SUB, MUL, DIV, RELU, NEG} + (c) LOAD_CONST +C6 无跨迭代标量:区域内的定义只被区域内指令使用(SSA 名唯一,天然满足); + iv 只允许出现在地址链的 MUL 中(其他使用 → 拒绝) +C7 无别名:STORE 的 base 与 LOAD 的 base 不同(原地逐元素 `a[i]=f(a[i])` 允许, + 即 STORE.base == 唯一 LOAD.base 且同 lane 偏移);同一 base 多次 STORE → 拒绝 +``` + +合法的可向量化循环模式(一期支持的三类): + +| 模式 | 标量体形态(伪 IR) | 向量体 | +|------|---------------------|--------| +| P1 一元 map | `v=LOAD(a+4i); r=RELU(v); STORE(o+4i,r)` | `va=VLOAD; vr=VRELU(va); VSTORE` | +| P2 二元 map | `va=LOAD(a+4i); vb=LOAD(b+4i); r=MUL(va,vb); STORE(o+4i,r)` | `VLOAD,VLOAD,VMUL,VSTORE` | +| P3 广播 map | `va=LOAD(a+4i); r=DIV(va, K); STORE(...)` | `VLOAD,VBCAST,VDIV,VSTORE` | + +链式表达式(P4)是 P1–P3 的递归组合:`r = RELU(MUL(LOAD(a+4i), LOAD(b+4i)))`。 + +#### 2.2.2 strip-mining 变换规则 + +设 `n = end - start`(`start == 0`),`strips = n // W`,`rem = n % W`: + +``` +原区域: FOR(iv: [0, n)) B(iv) ENDFOR + +变换后: + FOR(vs: [0, strips); attrs += {vector_width=W, elem_bytes=4, orig_trip=n}) + B_vec(vs) # 每条 LOAD 地址 (vs*W)*4 起、宽 W + ENDFOR + [rem > 0] FOR(ri: [strips*W, n)) B'(ri) ENDFOR # B' = 原标量体克隆 +``` + +- `B_vec(vs)`:把 `B` 中所有 `base + iv*4` 地址改写为 `base + vs*(W*4)`;对应 op 替换为向量 op(`LOAD→VLOAD`、`STORE→VSTORE`、`RELU→VRELU`、`ADD/SUB/MUL/DIV→V*`)。 +- `B'(ri)`:原标量体的**深克隆**,defs 与引用同步重命名(后缀 `__rem`),避免与向量体共用 SSA 名导致寄存器分配把两处定义当成同一 vreg。 +- 余数循环复用原 `iv` Value(`start`/`end` 为常量),`_select_for` 支持任意常量 `start`,`ENDFOR` 的 `+1` 递增语义正确。 +- `rem == 0` 时不生成余数循环;`n < W` 直接拒绝(不做掩码加载,避免越界读)。 +- 不允许 `W` 跨迭代变化(strip 索引 vs 的步长为 1,与后端 `ENDFOR` 的 `+1` 递增一致)。 + +#### 2.2.3 宽度、对齐、规模 + +- **宽度**:`W ∈ {2,4}`,来自 `--vector-width`(默认 4)。W 只影响本 pass 的向量语义与后端展开;IR 中每指令显式携带 `width`,允许未来混合宽度。 +- **对齐**:lane 访问全部是 4 字节 `LW/SW`,**只需 4 字节对齐**。ScratchV 的 Q16.16 元素步长恒为 4,`MemoryPlan` 对 workspace/权重按 64 字节对齐(`standalone/onnx_to_riscv_standalone.py` 的内存规划),因此 `base + 4m` 天然 4 字节对齐;**一期不做 peel/mask**,pass 在 attrs 中记录 `align=4` 供审计。 +- **规模**:`min_strips = 1`(即 `n >= W`)。不引入“最小收益阈值”,避免第一期引入无法验证的启发式。 + +#### 2.2.4 trip count 与余数 + +- 界必须是常量(现有 IR `FOR` 只支持常量 `start/end/step`)。 +- 非整除余数走 `B'` 标量尾声;语义与基线逐个元素一致。 +- 动态 trip count(运行时变量)一期不支持;拒绝原因 `non-constant-bounds`。 +- `step != 1` 拒绝:后端 `ENDFOR` 恒 `+1`,变换会静默改变迭代空间。 +- `start != 0` 拒绝:避免在地址中引入第二个常量基址项,简化一期语义。 + +#### 2.2.5 拒绝原因(稳定字符串,供测试与报告断言) + +| 常量名(`vectorize.py`) | 字符串 | 触发 | +|---|---|---| +| `REASON_NON_CONSTANT_BOUNDS` | `non-constant-bounds` | 界/步长非 int 常量 | +| `REASON_UNSUPPORTED_STEP` | `unsupported-loop-step` | `step != 1` | +| `REASON_UNSUPPORTED_START` | `unsupported-loop-start` | `start != 0` | +| `REASON_TRIP_TOO_SMALL` | `trip-count-too-small` | `n < W` | +| `REASON_NESTED_CONTROL_FLOW` | `nested-control-flow` | C1 违反 | +| `REASON_NO_ELEMENT_PATTERN` | `no-memory-element-pattern` | C4 违反 | +| `REASON_NON_ELEMENTWISE_IV` | `non-elementwise-iv-use` | iv 用于非地址链 | +| `REASON_ALIASING_STORE` | `aliasing-store` | C7 违反 | +| `REASON_UNSUPPORTED_OP` | `unsupported-op` | C5 违反 | + +拒绝不是错误:管线继续编译标量程序,原因写入 `PassResult.warnings` 与 `Vectorizer.last_report`。 + +### 2.3 标量降级规则 + +#### 2.3.1 降级位置与形态 + +降级发生在**指令选择阶段**(`scratchv/backend/vector_scalar.py` 的 `VectorScalarExpander`,由 `InstructionSelector` 在 `opcode.is_vector()` 时分派)。选择这一层而非再做一次 IR→IR 降级 pass 的理由: + +1. 机器指令层可以安全使用 `ADDI rd, rs, imm` 生成 lane 地址(编码器对 `addi` 的 12-bit 立即数支持完整),而 IR 层的常量操作数会被 `InstructionSelector._op` 转成 R 型立即数,触发编码器的静默错误(见 4.2 P0); +2. 不需要新增 IR pass,`--dump-ir` 保留向量 IR 供观测与测试; +3. 二期只需替换分派目标为 RVV 发射器,`Vectorizer` 与 IR 完全不动。 + +语义:strip 循环仍是**标量循环**(iv 步长 1,一次迭代处理 W 个元素),每条向量 op 展开为 W 条逐 lane 标量机器指令。 + +#### 2.3.2 逐 op 展开表(`addr_k = addr + 4k`,k=0 时直接复用 `addr`) + +| 向量 op | 机器展开(每 strip 迭代) | +|---|---| +| `VLOAD %vd, %addr` | `ADDI a1, addr, 4` … `ADDI a_{W-1}, addr, 4(W-1)`;`LW vd_k, a_k` ×W | +| `VSTORE %addr, %vs` | 同上地址链;`SW a_k, vs_k` ×W | +| `VBCAST %vd, %s` | 无指令;lane k 全部绑定到 `s`(若 `s` 是常量 → `LI c, imm` 一次) | +| `VADD/SUB/MUL/DIV %vd,%va,%vb` | `ADD/SUB/MUL/DIV vd_k, va_k, vb_k` ×W | +| `VRELU %vd, %va` | `MAX vd_k, va_k, 0` ×W(编码器把 `max` 伪指令展开为分支序列,0 映射 `x0`,行为正确) | + +lane 绑定:`VectorScalarExpander` 维护 `lanes: dict[str, list[MachineOperand]]`(逻辑向量值名 → W 个机器操作数),跨指令在本函数内有效;`begin_function()` 时清空。 + +#### 2.3.3 地址与常量处理约束 + +- **lane 地址**只用 `ADDI`(4/8/12… 均在 12-bit 有符号范围内),不生成 `ADD rd, rs, imm`。 +- **标量常量操作数**(如 `VBCAST` 的常量源、向量 op 引用的常量)必须先 `LI tmp, imm` 物化为 vreg,再参与 R 型运算;这是对现有 P0 缺陷(2.3.4)的一致处理。 +- 展开产物全部是机器指令,不含任何 `v*` 助记符;`AsmEmitter` / `LinearScanAllocator` 均无需改动。 +- 展开顺序与 lane 顺序固定 `k = 0..W-1`,保证确定性输出(测试可断言 asm 文本)。 + +#### 2.3.4 后置校验与 P0 前置修复 + +- **后置校验**:`InstructionSelector.run()` 结束时断言机器指令流中不存在向量指令/向量 vreg 泄漏;`VectorScalarExpander` 遇到无法解析的向量值(无 lane 绑定)抛 `VectorLoweringError`。 +- **P0 前置修复(与向量无关的既有标量缺陷,必须先修)**:`InstructionSelector._op()`(`instruction_select.py:63`)把常量操作数直接交给机器层的 R 型指令(`add/sub/mul/div`),`AsmEmitter` 输出 `mul t2, t0, 4`,而编码器 `_reg_num("4")` 对非寄存器文本**静默返回 0**(`riscv_encoder.py:103`),实际编码成 `mul t2, t0, x0`。任何含常量操作数的标量程序(包括本课题的 strip 地址 `MUL(vs, 16)` 与基线循环 `MUL(iv, 4)`)都会算错但“编译成功”。修复位置在指令选择层:R 型处理器遇到常量操作数时先 `LI` 物化到临时 vreg。已在当前 HEAD 复现: + ``` + li t3, 4 + mul t4, t1, t3 # P0 修复后(正确) + mul t2, t0, 4 # P0 修复前(编码为 mul t2, t0, x0,错误) + ``` + 该修复不改变编码器、不改变仿真器,不触碰向量语义。 + +### 2.4 二期 RVV 映射表(规划,仅文本发射) + +约束:二期只做**文本发射与拒绝策略**——`--vector-isa v` 时允许输出含 RVV 助记符的 `.s` 文本;任何二进制路径(`assemble_to_binary`)必须抛 `VectorEncodingError`。RVV 实测依赖外部工具链与外部仿真器(见 4.3)。 + +| 一期向量 op | RVV 1.0 映射(SEW=32, LMUL=1, VLEN=128 假设) | 备注 | +|---|---|---| +| (循环前置) | `vsetvli t0, a0, e32, m1, ta, ma` | 每 strip 一次,可 hoist | +| `VLOAD` | `vle32.v vd, (rs1)` | 元素对齐即可;非对齐支持依赖实现 | +| `VSTORE` | `vse32.v vs, (rs1)` | 同上 | +| `VBCAST` | `vmv.v.x vd, rs1` | i32 lane | +| `VADD` | `vadd.vv vd, va, vb` | | +| `VSUB` | `vsub.vv vd, va, vb` | | +| `VMUL` | `vmul.vv vd, va, vb` | V 1.0 整数乘 | +| `VDIV` | 无整数向量除法 → 标量回退或软件序列 | 二期保留标量降级即可 | +| `VRELU` | `vmax.vx vd, va, x0` | | +| (规划)归约 | `vredsum.vs`, `vwmacc.vv` | 仅二期+ 讨论,用于 DOT/MAC | +| P-extension | 不映射 | i32 lane 无法被 16/8-bit 打包,**不做目标** | + +### 2.5 合法/非法示例 + +**合法示例 L1(P2 二元 map,`n=16, W=4`)**:见附录 5.1,`strips=4, rem=0`,生成 1 个 strip 循环 + 4 条向量 op;展开后每 strip 执行 4×(2 LW + MUL + 4×MAX + SW) + 地址/循环开销。 + +**合法示例 L2(余数)**:`n=17, W=4` → `strips=4, rem=1`,生成 strip 循环 + 1 次迭代的标量尾声(`FOR ri=[16,17)`)。 + +**合法示例 L3(原地)**:`a[i] = relu(a[i])`,STORE.base == LOAD.base 且 lane 偏移一致 → 允许。 + +**非法示例 I1(归约/跨迭代标量)**: +``` +for i = 0, 10 + s = add(s, i) # s 在区域外定义,iv 参与非地址运算 +endfor +``` +→ `no-memory-element-pattern`(无 iv 地址链)或 `non-elementwise-iv-use`。 + +**非法示例 I2(跨迭代内存依赖)**: +``` +for i = 0, 16 + v = load(a + 4i) + store(a + 4(i-1), v) # 读-写同一 base 且偏移不同 +endfor +``` +→ `aliasing-store`。 + +**非法示例 I3(动态界)**:`FOR` 无 `start/end` 常量 attrs → `non-constant-bounds`。 + +**非法示例 I4(嵌套控制流)**:区域内含 `BR_IF` → `nested-control-flow`。 + +### 2.6 约束与范围边界 + +- 一期**不改** `scratchv/simulator/rv32_emulator.py`:不新增向量寄存器堆、vtype 状态与向量 opcode 解码。 +- 一期**不改** `scratchv/backend/riscv_encoder.py` 的编码语义:只增加 `VectorEncodingError` 显式拒绝(fail-loud 护栏)。 +- 一期**不做**归约向量化、跨 lane 运算、动态 trip count、非 4 字节元素、非 0 起点、非 1 步长。 +- 一期**不接入** standalone ONNX 直发机器码管线(`onnx_to_riscv_standalone.py` 不经过 IR);向量化只在 IR 管线(`scratchv` CLI / `CompilerDriver`)生效。 +- 性能数字只能是理论分析且标注**未测量**(见 5.2)。 + +--- + +## 三、测试设计 + +一期测试全部为 Python/pytest,落在 `tests/`,可无外部工具链执行。 + +### 3.1 测试用例 1:向量化后 IR 结构正确 + +**文件**:`tests/test_vectorize.py::TestVectorizerStructure` + +**输入**:用 `IRBuilder` 手写程序(附录 5.1 的标量体:`out[i] = relu(a[i]*b[i])`), +`n=16, W=4`;`Vectorizer(program, width=4).run(program)`。 + +**预期输出**: +- 恰好 2 个 `FOR` 指令组(strip + 无余数不生成尾声); +- strip `FOR` attrs:`{start:0, end:4, step:1, vector_width:4, elem_bytes:4, orig_trip:16}`; +- strip 体内恰好 `2×VLOAD + VMUL + VRELU + VSTORE` 各 1 条,`width=4`,向量 dest `shape==(4,)`; +- 向量 op 只出现在 strip 体内,尾声不存在; +- `last_report[0].status == "vectorized"`。 + +另设两个断言变体:`n=17` 时存在第二个 `FOR`(`start=16,end=17`)且其体内全为标量 op,且克隆 defs 名与向量体不重名;`n=3, W=4` 时拒绝且 IR 的 `dump()` 与输入逐字节相同。 + +**验证点**:`OpCode.is_vector()` 覆盖、attrs 精确、SSA 名不冲突、拒绝时 IR 不变。 + +### 3.2 测试用例 2:标量降级语义可执行对拍(现有仿真器) + +**文件**:`tests/test_vector_lowering.py::TestEmulatorDifferential` + +**输入**: +- 构造标量程序 S:`for i in [0,16): out[i] = max(a[i]*b[i], 0)`,`a/b/out` 基址分别为 `0x400000/0x410000/0x420000`(`load_const` 得到),元素为 i32; +- 程序 V:`Vectorizer(S, width=4)`;程序 B:S 的副本不做向量化; +- 对 B 与 V 分别执行同一条可执行链路:`InstructionSelector`(含 P0 修复)→ `RegisterAllocator(mode="greedy")` → `AsmEmitter` → `assemble_to_binary` → `RV32Emulator`; +- 用 `emu.write_i32` 在基址处填入固定向量(含负数、0、大值,覆盖 `VRELU` 与乘法回绕),运行后读取 `out` 区。 + +**预期输出**: +- 两个二进制都正常运行到 `RET`(`run()` 返回执行条数); +- `out_V == out_B`(逐 i32 位相等); +- `out_V == [max(a[i]*b[i], 0) for i in range(16)]` 的 numpy/整数参考值; +- V 的汇编文本中不出现任何 `v` 前缀助记符(`regex ^\s*v[a-z]` 无命中)。 + +**验证点**:降级语义等价、可被现有 RV32IM 仿真器执行、编码器不接触向量指令。 + +> 注:该用例必须走 `reg_alloc="greedy"`(CLI 默认值)。`reg_alloc="linear"` 的 `LinearScanAllocator.emit` 当前把标签输出为 `.label name` 且 `j` 的目标仅存在于注释里(`j # .Lloop_header_1`),编码阶段标签丢失——属既有缺陷,不在本期范围,见 4.4 已知限制。 + +### 3.3 测试用例 3:不可向量化场景拒绝 + +**文件**:`tests/test_vectorize.py::TestVectorizerRejects` + +**输入**:四类非法循环(见 2.5 I1–I4),每个循环独立成程序。 + +**预期输出**: +- `Vectorizer.run(program).changes == 0`(对 I1–I4 无任何变换); +- `last_report` 中对应记录 `status == "rejected"` 且 `reason` 精确等于预期字符串(`non-elementwise-iv-use` / `no-memory-element-pattern` / `aliasing-store` / `nested-control-flow`); +- `PassResult.warnings` 中包含含原因字符串的可读消息; +- `program.dump()` 与输入相同(拒绝即不触碰 IR)。 + +**验证点**:拒绝原因可机读、拒绝无副作用、编译不失败。 + +### 3.4 测试用例 4:编码器拒绝 RVV 助记符 + +**文件**:`tests/test_vector_encoder.py::TestVectorMnemonicRejection` + +**输入**:`assemble_to_binary("vadd.vv v1, v2, v3")`、`assemble_to_binary("vsetvli t0, a0, e32, m1, ta, ma")`、`assemble_to_binary("vle32.v v1, (a0)")`。 + +**预期输出**:每次调用抛 `VectorEncodingError`,消息含助记符原文与 “phase 1 / RV32IM only” 说明;标量样例 `"add a0, a1, a2"` 仍正常编码。 + +**验证点**:fail-loud 护栏存在,且不影响标量编码回归。 + +### 3.5 覆盖矩阵 + +| 维度 | 覆盖用例 | +|---|---| +| IR 指令集 | 3.1(全部 8 个向量 op 至少各出现一次;`VBCAST/VSUB/VDIV` 在 P3 变体) | +| 宽度 | 3.1/3.2 使用 W=4;另加 W=2 参数化 | +| trip/余数 | 3.1(整除 16、非整除 17、过小 3) | +| 拒绝 | 3.3 | +| 可执行语义 | 3.2(基线 vs 向量化 vs 参考值三方对拍) | +| 编码器护栏 | 3.4 | +| 集成/CLI | 4.4 列出的 `TestCLIWiring` / `TestDriverIntegration` | + +--- + +## 四、修改模块与实现步骤 + +### 4.1 涉及文件 + +| 类别 | 文件 | 改动 | +|---|---|---| +| 修改 | `scratchv/ir/types.py` | `OpCode` 末尾注释分区追加 8 个向量 op;新增 `is_vector()` | +| 修改 | `scratchv/ir/builder.py` | 追加 `vload/vstore/vbcast/vadd/vsub/vmul/vdiv/vrelu` | +| 新增 | `scratchv/optimizer/vectorize.py` | `Vectorizer` pass、`LoopVectorizationRecord`、拒绝原因常量 | +| 新增 | `scratchv/backend/vector_scalar.py` | `VectorScalarExpander`、`VectorLoweringError` | +| 修改 | `scratchv/backend/instruction_select.py` | P0 常量物化修复;`opcode.is_vector()` 分派;函数级 `begin_function()` | +| 修改 | `scratchv/backend/riscv_encoder.py` | `VectorEncodingError` + 向量助记符显式拒绝 | +| 修改 | `scratchv/compiler.py` | `CompilerConfig` 三字段;`_run_vectorizer`;ISA 拒绝;warning 透传 | +| 修改 | `scratchv/main.py` | `--vectorize`、`--vector-width`、`--vector-isa` 与 `args_to_config` 接线 | +| 新增 | `tests/test_vectorize.py`、`tests/test_vector_lowering.py`、`tests/test_vector_encoder.py` | 见第三部分 | +| 修改 | `tests/test_backend.py` | 追加 CLI 接线与 driver 集成用例 | +| 不修改 | `scratchv/simulator/rv32_emulator.py`、`scratchv/standalone/benchmark.py` | 一期只读依赖 | +| 不修改 | `scratchv/standalone/onnx_to_riscv_standalone.py` | 直发机器码管线,不经过 IR | + +### 4.2 一期实现步骤 + +**P0(前置修复,标量正确性)**:修 `InstructionSelector` 常量操作数物化。 +- 位置:`instruction_select.py:63` 附近新增 `_op_reg()`(或扩展 `_op`),在 `_select_add/_sub/_mul/_div` 与 `_select_gelu` 的 `DIV` 处使用;对常量操作数先 `LI tmp, imm` 再参与 R 型运算。 +- 交付证据:`mul rd, rs, ` 不再出现在任何输出;新增回归测试断言。 +- 不做:不改 `riscv_encoder.py` 的编码逻辑(只在 P4 加拒绝护栏)。 + +**P1(IR 层)**:`types.py` 末尾追加向量分区与 `is_vector()`;`builder.py` 追加 8 个 API(语义见开发文档《接口契约》)。 + +**P2(Vectorizer)**:新建 `scratchv/optimizer/vectorize.py`,实现 2.2 的判定树、strip-mining 重写、余数克隆与报告;`PassResult.changes` = 被向量化循环数,`message` 形如 `vectorized 1/3 loop(s), width=4`。 + +**P3(标量展开)**:新建 `scratchv/backend/vector_scalar.py`;`InstructionSelector._select_function` 调 `begin_function()`;`_select_instruction` 先判 `is_vector()` 再走标量分派。 + +**P4(编码器护栏)**:`riscv_encoder.py` 新增 `VectorEncodingError` 与向量助记符前缀拒绝;消息固定为 +`vector instruction '{op}' is not supported: ScratchV phase 1 targets RV32IM only`。 + +**P5(驱动与 CLI)**:`compiler.py` 增 `vectorize/vector_width/vector_isa` 三字段、`_run_vectorizer()`、`--vector-isa != "scalar"` 的显式失败;`main.py` 增三参数并接入 `args_to_config`。**必须同时改 parser 与 config 两侧**(课题 28 的 `--extended-isel` 只加了 parser、未接入 `args_to_config`,是可复制的反面教材)。 + +**P6(测试)**:落实第三部分 4 个用例 + CLI 接线 + driver 集成。 + +**P7(文档/报告)**:`Vectorizer.last_report` 通过 `--dump-ir`(stderr 打印)或编译 warnings 呈现;不新增报告文件格式。 + +### 4.3 二期实现步骤(规划) + +1. **RVV 文本发射器**:`scratchv/backend/rvv_emit.py::RVVTextEmitter`,复用 `Vectorizer` 产物,按 2.4 映射表输出 `vsetvli` + 向量指令文本;`--vector-isa v` 时替换 `VectorScalarExpander`。 +2. **编码拒绝**:二进制路径保持 `VectorEncodingError`(RVV 32-bit 编码涉及 vtype/vreg 与可变长度,ScratchV 编码器不扩展)。 +3. **外部工具链**(可选、显式 opt-in):`riscv64-unknown-elf-as`(binutils ≥ 2.40)或 LLVM MC ≥ 14 汇编 RVV 文本;不进入默认依赖。 +4. **仿真验证**:Spike `--isa=rv32gcv` 或 QEMU `rv32,v=true`;`benchmark.py` 需新增 VLEN 感知的类别与周期模型后,才能产生测量数字。 +5. **实测对比**:只有在 3/4 完成后,才允许把“per-MAC 指令数”从理论分析升级为测量值。 + +### 4.4 集成与回归测试 + +- **回归**:`make test`(`python3 -m pytest tests/ -v`,当前约 348+ 用例)必须全绿;`--vectorize` 关闭时不得有任何行为差异。 +- **CLI 接线**:`build_arg_parser().parse_args([...,"--vectorize","--vector-width","2","--vector-isa","scalar"])` → `args_to_config()` 三字段逐一断言;`--vector-isa v` 编译返回 `success=False` 且错误消息含 `phase 2`。 +- **driver 集成**:`CompilerDriver(CompilerConfig(vectorize=True, reg_alloc="greedy"))` + monkeypatch `_parse` 返回手写 IR,验证 `compile()` 成功且产物 asm 中无 `v*` 助记符。 +- **已知限制(不在本期修,文档化)**: + - `reg_alloc="linear"` 的标签/跳转发射缺陷(3.2 注);向量化集成路径建议使用 greedy(CLI 默认)。 + - `InstructionSelector._select_alloca` 使用 `vreg("sp")`,会被寄存器分配改名导致 alloca 结果错误;本期样例与测试避免 `alloca`(用 `load_const` 绝对地址作 base)。 + - `--extended-isel`、`--verify-ir` 已有“只加 CLI 未接线”的历史缺口;本期新增参数必须两侧同时改,并有测试守门。 + +--- + +## 五、附录 + +### 5.1 完整 IR 示例 + +**标量 IR(向量化前,`n=16`)**: + +``` +fun $main( + .entry: + for $iv [start=0 end=16 step=1] + $c4 = load_const 4 [value=4] + $off = mul $iv $c4 + $pa = add $a_ptr $off + $pb = add $b_ptr $off + $po = add $o_ptr $off + $va = load $pa + $vb = load $pb + $pr = mul $va $vb + $y = relu $pr + store $po $y + endfor + return +``` + +**向量 IR(`Vectorizer(width=4)` 之后)**: + +``` + for $vs [start=0 end=4 step=1] [vector_width=4 elem_bytes=4 orig_trip=16] + $c16 = load_const 16 [value=16] + $boff = mul $vs $c16 + $pa = add $a_ptr $boff + $pb = add $b_ptr $boff + $po = add $o_ptr $boff + $va = vload $pa [width=4 elem_bytes=4 align=4] + $vb = vload $pb [width=4 elem_bytes=4 align=4] + $pr = vmul $va $vb [width=4] + $y = vrelu $pr [width=4] + vstore $po $y [width=4 elem_bytes=4 align=4] + endfor + return +``` + +**后端展开(伪汇编,每个 strip 迭代)**: + +``` + li t3, 16 + mul boff, vs, t3 # P0 修复后:常量经 vreg 参与 R 型 + add pa, a_ptr, boff + add pb, b_ptr, boff + add po, o_ptr, boff + lw vd0, pa + addi a1, pa, 4 + lw vd1, a1 + addi a2, pa, 8 + lw vd2, a2 + addi a3, pa, 12 + lw vd3, a3 + ...(b 同理;mul ×4;max ×4;sw ×4) +``` + +余数示例(`n=17`)追加:`FOR ri=[16,17)` 的标量体(克隆,defs 后缀 `__rem`)。 + +### 5.2 理论指令数分析(未测量) + +> **免责声明**:以下全部为静态推演,**未在任何仿真器/硬件上测量**;实测需等二期的工具链接入。任何引用必须保留“未测量”标注。 + +标量基线(Q16.16 i32,`y[i]=relu(a[i]*b[i])`,当前后端逐元素体)动态指令数约: +`1 (li c4) + 1 (mul off) + 3 (add pa/pb/po) + 2 (lw) + 1 (mul) + ~4 (max 伪指令展开) + 1 (sw) + 2 (addi+j) ≈ 15/元素`。 + +一期向量化+标量展开(W=4)每 strip 约: +`2 (li c16/mul) + 3 (add) + 4×(2 lw + mul + 4 max + sw) + 2 (addi+j) + 2 (bge 展开) ≈ 41/4 元素 ≈ 10.3/元素`。 + +结论:一期的收益**仅来自循环开销与地址计算的摊销**(约 10–15% 量级的理论差异,未测量),与 SIMD 无关。真正的 per-MAC 收益必须等二期 RVV 实测;P-extension 对本项目的 i32 lane 不适用。 + +### 5.3 参考资料 + +- RISC-V V-extension 规范: +- 课题 29 目标文档:`docs/topics/29-SIMD向量化.md` +- 相关课题:课题 28(扩展指令选择 `inst_select_ext.py`)、课题 27(RV32 全量 Benchmark)、课题 19(Standalone RISC-V 编译器) +- 模板:`设计文档模板.md` From 88d9eed27bdc1484a412ef82dd6b5c9ce85ae621 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 21:23:10 +0800 Subject: [PATCH 3/7] fix(topic29): adapt vector driver tests to eager DSL validation --- tests/test_backend.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/tests/test_backend.py b/tests/test_backend.py index 7809910..41bf2b7 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -194,8 +194,10 @@ def test_compile_vectorized_program(self, monkeypatch, tmp_path): vectorize=True, vector_width=2, reg_alloc="greedy")) program = _vector_ir() monkeypatch.setattr(driver, "_parse", lambda *a, **k: program) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") - result = driver.compile("input.dsl", str(tmp_path / "out.s")) + result = driver.compile(str(source), str(tmp_path / "out.s")) assert result.success assert "vectorized 1/1 loop(s), width=2" in result.stats["opt_message"] @@ -207,8 +209,10 @@ def test_vector_isa_v_rejected(self, monkeypatch, tmp_path): driver = CompilerDriver(CompilerConfig( vectorize=True, vector_isa="v", reg_alloc="greedy")) monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") - result = driver.compile("input.dsl", str(tmp_path / "out.s")) + result = driver.compile(str(source), str(tmp_path / "out.s")) assert result.success is False assert any("phase 2" in err for err in result.errors) @@ -217,7 +221,9 @@ def test_vectorize_disabled_matches_baseline(self, monkeypatch, tmp_path): config = CompilerConfig(vectorize=False, reg_alloc="greedy") driver = CompilerDriver(config) monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) - result = driver.compile("input.dsl", str(tmp_path / "out.s")) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") + result = driver.compile(str(source), str(tmp_path / "out.s")) assert result.success assert result.stats["opt_message"] == "" From 286a863f319cf7ba2733e6597dcc16a43183b121 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 23:07:47 +0800 Subject: [PATCH 4/7] fix(topic29): correct remainder insertion and reject unsafe regions - track the post-rewrite strip ENDFOR index so the scalar remainder is inserted as its sibling instead of nesting (longer body) or landing after return (shorter body, dead code) (review F1) - scan for the rewritten region's top-level ENDFOR when resuming the pass so consecutive remainder loops are handled correctly (F1) - reject loops whose region-local definitions (including the original induction variable) escape the region, avoiding dangling SSA values (region-value-escapes, F2) - tighten C7 aliasing: different base names are only accepted when both are constant addresses with provably disjoint ranges; name-based overlap such as src = sub(out, 4) is now rejected (F3) - validate strip width (int >= 2) in Vectorizer.__init__ (F6) --- scratchv/optimizer/vectorize.py | 172 +++++++++++++++++++++++++++----- 1 file changed, 148 insertions(+), 24 deletions(-) diff --git a/scratchv/optimizer/vectorize.py b/scratchv/optimizer/vectorize.py index 6a32571..12e5b22 100644 --- a/scratchv/optimizer/vectorize.py +++ b/scratchv/optimizer/vectorize.py @@ -44,6 +44,21 @@ REASON_NON_ELEMENTWISE_IV = "non-elementwise-iv-use" REASON_ALIASING_STORE = "aliasing-store" REASON_UNSUPPORTED_OP = "unsupported-op" +REASON_REGION_VALUE_ESCAPES = "region-value-escapes" + + +def validate_vector_width(width: object) -> int: + """Validate a phase-1 strip width (``>= 2``) and return it. + + The CLI already restricts ``--vector-width`` to ``{2, 4}``, but the + programmatic ``CompilerConfig`` API does not, and an unchecked width + of ``0`` previously crashed with ``ZeroDivisionError`` inside the + vectorizer (review F6). + """ + if isinstance(width, bool) or not isinstance(width, int) or width < 2: + raise ValueError( + f"vector width must be an integer >= 2 (got {width!r})") + return width # ── Element op → vector op mapping ─────────────────────────────────────── @@ -147,7 +162,7 @@ class Vectorizer(CompilerPass): def __init__(self, program: Program, *, width: int = 4, elem_bytes: int = 4) -> None: self.program = program - self.width = width + self.width = validate_vector_width(width) self.elem_bytes = elem_bytes self._report: list[LoopVectorizationRecord] = [] self._counter = 0 @@ -194,7 +209,7 @@ def run(self, input_data: Any = None) -> PassResult: self._report.append(rec) if rec.status == "vectorized": changes += 1 - i = self._skip_rewritten(region, rec) + i = self._skip_rewritten(block, region, rec) continue warnings.append( f"loop {rec.function}:{rec.block}[{rec.index}] " @@ -231,14 +246,31 @@ def _collect_region(self, block: BasicBlock, ) return None - def _skip_rewritten(self, region: _LoopRegion, + def _skip_rewritten(self, block: BasicBlock, region: _LoopRegion, rec: LoopVectorizationRecord) -> int: - """Index of the instruction after the rewritten region.""" - next_index = region.end_index + 1 - if rec.remainder: - body_len = region.end_index - region.for_index - 1 - next_index += 1 + body_len + 1 - return next_index + """Index of the instruction after the rewritten region. + + The rewritten region contains one top-level ``FOR`` (the strip + loop) plus, when there is a remainder, a second one inserted + directly after the strip ``ENDFOR``. The original ``end_index`` + is stale after the rewrite, so the position is found by scanning + the block for the matching top-level ``ENDFOR`` (review F1). + """ + target = 2 if rec.remainder else 1 + depth = 0 + closed = 0 + for j in range(region.for_index, len(block.instructions)): + op = block.instructions[j].opcode + if op is OpCode.FOR: + depth += 1 + elif op is OpCode.ENDFOR: + depth -= 1 + if depth == 0: + closed += 1 + if closed == target: + return j + 1 + # Defensive: the rewrite always produces the expected loops. + return region.end_index + 1 # ── Decision tree (design doc 2.2.1 C1–C7) ────────────────────────── @@ -290,7 +322,7 @@ def _try_vectorize(self, func: Function, block: BasicBlock, return self._reject(rec, REASON_NO_ELEMENT_PATTERN) # C7: aliasing stores (shallow, conservative base analysis). - reason = self._alias_reason(mem_refs) + reason = self._alias_reason(mem_refs, n) if reason is not None: return self._reject(rec, reason) @@ -299,6 +331,13 @@ def _try_vectorize(self, func: Function, block: BasicBlock, if reason is not None: return self._reject(rec, reason) + # C6 (live-out): values defined inside the region (including the + # original induction variable) must not be used outside it — the + # rewrite replaces or drops those definitions (review F2). + reason = self._region_escape_reason(func, region, defs, iv_name) + if reason is not None: + return self._reject(rec, reason) + plan = _Plan( n=n, width=self.width, elem_bytes=self.elem_bytes, strips=n // self.width, remainder=n % self.width, @@ -312,8 +351,9 @@ def _try_vectorize(self, func: Function, block: BasicBlock, return self._reject(rec, reason) original_iv = region.for_instr.dest - vector_ops = self._rewrite(block, region, plan) - self._clone_remainder(block, region, plan, original_iv) + vector_ops, new_end_index = self._rewrite(block, region, plan) + self._clone_remainder(block, region, plan, original_iv, + new_end_index) rec.status = "vectorized" rec.reason = "" @@ -384,25 +424,93 @@ def _mem_ref(self, instr: Instruction, index: int, return _MemRef(instr=instr, index=index, base=base, offset=offset, canonical=canonical) - def _alias_reason(self, mem_refs: list[_MemRef]) -> Optional[str]: - loads: dict[str, list[_MemRef]] = {} - stores: dict[str, list[_MemRef]] = {} + def _alias_reason(self, mem_refs: list[_MemRef], + n: int) -> Optional[str]: + """C7: conservative aliasing check (review F3). + + Two references on the *same* base are only safe when the accesses + are the canonical element chain with at most one store and one + load and both use the same lane offset (in-place patterns). Two + references on *different* base names are only safe when both + bases are constant absolute addresses with provably disjoint + ``[base, base + n*elem_bytes)`` byte ranges; anything else is + rejected because a shallow name-based analysis cannot rule out + overlap (e.g. ``src = sub(out, 4)``). + """ + by_base: dict[str, list[_MemRef]] = {} for ref in mem_refs: if ref.base is None: + # Addresses that are not a canonical element chain are + # rejected later by classification; they never receive a + # vector form, so they cannot be reordered here. continue - bucket = loads if ref.is_load else stores - bucket.setdefault(ref.base.name, []).append(ref) + by_base.setdefault(ref.base.name, []).append(ref) - for base_name, store_refs in stores.items(): + for base_name, refs in by_base.items(): + store_refs = [ref for ref in refs if not ref.is_load] if len(store_refs) > 1: return REASON_ALIASING_STORE - if base_name not in loads: + if not store_refs: + continue + load_refs = [ref for ref in refs if ref.is_load] + if not load_refs: continue - load_refs = loads[base_name] if len(load_refs) != 1: return REASON_ALIASING_STORE if load_refs[0].lane_key != store_refs[0].lane_key: return REASON_ALIASING_STORE + + store_bases = [ + name for name, refs in by_base.items() + if any(not ref.is_load for ref in refs) + ] + for store_name in store_bases: + store_base = by_base[store_name][0].base + for other_name, other_refs in by_base.items(): + if other_name == store_name: + continue + if not self._provably_disjoint( + store_base, other_refs[0].base, n): + return REASON_ALIASING_STORE + return None + + def _provably_disjoint(self, lhs: Optional[Value], + rhs: Optional[Value], n: int) -> bool: + """True when two constant bases cannot overlap over *n* elements.""" + if lhs is None or rhs is None: + return False + if not all(value.is_constant and value.const_value is not None + and _is_int(value.const_value) for value in (lhs, rhs)): + return False + span = n * self.elem_bytes + assert lhs.const_value is not None and rhs.const_value is not None + return abs(int(lhs.const_value) - int(rhs.const_value)) >= span + + def _region_escape_reason(self, func: Function, region: _LoopRegion, + defs: dict[str, Instruction], + iv_name: str) -> Optional[str]: + """C6 live-out: no region-local definition may escape the region. + + ``_rewrite`` replaces or drops every definition inside + ``region.body`` and redefines the ``FOR`` destination as the strip + index, so a use of any of those values outside the region would + become a dangling SSA reference (review F2). Checking the whole + function also covers the remainder-less case where the original + induction variable is never redefined. + """ + local_names = set(defs) + if iv_name: + local_names.add(iv_name) + if not local_names: + return None + body_ids = {id(instr) for instr in region.body} + for block in func.blocks: + for instr in block.instructions: + if id(instr) in body_ids: + continue + for op in instr.operands: + if op.name in local_names: + return REASON_REGION_VALUE_ESCAPES return None def _iv_use_reason(self, body: list[Instruction], @@ -500,7 +608,14 @@ def _classify(self, plan: _Plan, region: _LoopRegion, # ── Rewrite (strip-mining) ────────────────────────────────────────── def _rewrite(self, block: BasicBlock, region: _LoopRegion, - plan: _Plan) -> int: + plan: _Plan) -> tuple[int, int]: + """Rewrite the region in place. + + Returns ``(vector_ops, new_end_index)`` where ``new_end_index`` is + the position of the strip ``ENDFOR`` *after* the body replacement + (``region.end_index`` is stale once ``new_body`` changes length; + review F1). + """ old_iv = region.for_instr.dest iv_dtype = old_iv.dtype if old_iv is not None else DataType.INT32 strip_iv = Value(name=self._fresh("v"), dtype=iv_dtype) @@ -603,7 +718,8 @@ def _rewrite(self, block: BasicBlock, region: _LoopRegion, "orig_trip": plan.n, } block.instructions[region.for_index + 1:region.end_index] = new_body - return vector_ops + new_end_index = region.for_index + 1 + len(new_body) + return vector_ops, new_end_index def _element_value(self, value: Value, plan: _Plan, val_map: dict[str, Value], @@ -625,7 +741,15 @@ def _element_value(self, value: Value, plan: _Plan, f"vectorizer internal error: no vector form for '{value.name}'") def _clone_remainder(self, block: BasicBlock, region: _LoopRegion, - plan: _Plan, original_iv: Optional[Value]) -> int: + plan: _Plan, original_iv: Optional[Value], + new_end_index: int) -> int: + """Insert the scalar remainder loop after the rewritten region. + + ``new_end_index`` is the strip ``ENDFOR`` position returned by + ``_rewrite``; inserting at any other index either nests the + remainder inside the strip loop or leaves it after ``return`` + (review F1). + """ if plan.remainder == 0: return 0 new_for = Instruction( @@ -634,7 +758,7 @@ def _clone_remainder(self, block: BasicBlock, region: _LoopRegion, "end": plan.n, "step": 1}) cloned = self._clone_instrs(region.body) endfor = Instruction(opcode=OpCode.ENDFOR) - insert_at = region.end_index + 1 + insert_at = new_end_index + 1 block.instructions[insert_at:insert_at] = [new_for, *cloned, endfor] return 1 From bcb559090d278795d067515fc427889af936ea63 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 23:07:54 +0800 Subject: [PATCH 5/7] fix(topic29): fail loud on unsupported vectorize driver configs - reject vectorize + backend=llvm in compile(): the LLVM backend turned vector ops into comments and returned success=True (review F5) - validate vector_width before parsing and return CompileResult failure instead of ZeroDivisionError deep in the pass (F6) - fall back from reg_alloc=linear to greedy with a warning when vectorize is enabled (linear-scan label emission is broken, F7) --- scratchv/compiler.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/scratchv/compiler.py b/scratchv/compiler.py index a68f698..68fc018 100644 --- a/scratchv/compiler.py +++ b/scratchv/compiler.py @@ -249,6 +249,26 @@ def compile(self, input_path: str, output_path: str | None = None, if output_path is None: output_path = "output.ll" if self.config.backend == "llvm" else "output.s" + # Vectorization preconditions (Topic 29 phase 1, review F5/F6): + # the LLVM backend silently drops vector ops into comments, and an + # out-of-range width used to crash with ZeroDivisionError deep in + # the pass. Both are rejected with a clear error before parsing. + if self.config.vectorize: + from scratchv.optimizer.vectorize import validate_vector_width + try: + validate_vector_width(self.config.vector_width) + except ValueError as exc: + return CompileResult(success=False, errors=[str(exc)]) + if self.config.backend == "llvm": + return CompileResult( + success=False, + errors=[ + "vectorize is not supported with --backend llvm " + "(Topic 29 phase 1 targets the RISC-V backend); " + "disable --vectorize or use --backend riscv" + ], + ) + use_dsl = ( dsl_source is not None or (input_path and input_path.endswith(".dsl")) @@ -313,6 +333,12 @@ def compile(self, input_path: str, output_path: str | None = None, "vectorize is incompatible with --dag-isel; " "falling back to linear isel") self.config.use_dag_isel = False + if self.config.reg_alloc == "linear": + warnings.append( + "vectorize requires the greedy register allocator; " + "linear-scan label emission is broken, falling back " + "to greedy") + self.config.reg_alloc = "greedy" vec_result = self._run_vectorizer(program) warnings.extend(vec_result.warnings) if vec_result.message: From d3d3fe34bbd11133dd4dee10cf131b2bd1ef7727 Mon Sep 17 00:00:00 2001 From: Seven Gao <799889633@qq.com> Date: Mon, 14 Sep 2026 23:08:01 +0800 Subject: [PATCH 6/7] test(topic29): add review regression matrix and sync topic docs - structural regressions for remainder placement (shorter/longer vector body x W=2/4), consecutive remainder loops, IV and region-local live-out, cross-base aliasing, width validation (review F1-F4, F6) - executable differential matrix W x {divisible, remainder} x {map, broadcast, in-place} plus explicit F1 reproductions; W=4+remainder map/broadcast stay structural because the pre-existing greedy allocator cannot spill/reload beyond 19 vregs (review section 4) - driver tests for llvm-backend rejection, invalid width and linear -> greedy fallback (F5-F7) - sync design/development docs: C6 live-out, conservative C7, new rejection reason, remainder insertion and driver preconditions --- ...00\345\217\221\346\226\207\346\241\243.md" | 41 ++- ...76\350\256\241\346\226\207\346\241\243.md" | 14 +- tests/test_backend.py | 45 ++++ tests/test_vector_lowering.py | 77 +++++- tests/test_vectorize.py | 242 ++++++++++++++++++ 5 files changed, 405 insertions(+), 14 deletions(-) diff --git "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" index 7d4747f..875e146 100644 --- "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" +++ "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\345\274\200\345\217\221\346\226\207\346\241\243.md" @@ -46,6 +46,7 @@ REASON_NO_ELEMENT_PATTERN = "no-memory-element-pattern" REASON_NON_ELEMENTWISE_IV = "non-elementwise-iv-use" REASON_ALIASING_STORE = "aliasing-store" REASON_UNSUPPORTED_OP = "unsupported-op" +REASON_REGION_VALUE_ESCAPES = "region-value-escapes" ``` 报告记录: @@ -306,28 +307,33 @@ def _fresh(self, prefix="v") -> str # "v_1", "v_2" - `opcode ∈ {ADD,SUB,MUL,DIV,RELU,NEG}` 且所有操作数 ∈ {常量, outer_defs, 已分类的元素结果} → `ELEM`(操作数中出现 `iv` → `REASON_NON_ELEMENTWISE_IV`); - 其余 → `REASON_UNSUPPORTED_OP`; - 分类为 `ELEM`/`CONST` 但结果未被任何 STORE 使用 → `REASON_UNSUPPORTED_OP`(防死代码混入)。 -6. **C7 别名**:收集 `LOAD.base` 集合 `L` 与 `STORE.base` 集合 `S`;`S - L == ∅`(原地允许),且每个 base 的 STORE ≤ 1 条、且 STORE 与 LOAD 的偏移形式一致;否则 `REASON_ALIASING_STORE`。 -7. 通过后构造 `_Plan`:`n, W, strips, rem`,以及按原顺序排列的映射(`LOAD→VLOAD`、`STORE→VSTORE`、`RELU→VRELU`、`ADD→VADD`…)。 +6. **C6 live-out**:函数级扫描,区域内定义的任何值(含原 `iv`、区域内 LOAD 结果与常量)在 `region.body` 之外被使用 → `REASON_REGION_VALUE_ESCAPES`(保守拒绝;重写会替换或删除这些定义)。 +7. **C7 别名**(浅别名分析,保守):同 base 仅允许“唯一 LOAD + 唯一 STORE 且 lane 偏移一致”的原地模式,同 base 多次 STORE → 拒绝;异 base 仅当两者均为常量绝对地址且 `[base, base + n*elem_bytes)` 可证不重叠时允许;其余 → `REASON_ALIASING_STORE`。 +8. 通过后构造 `_Plan`:`n, W, strips, rem`,以及按原顺序排列的映射(`LOAD→VLOAD`、`STORE→VSTORE`、`RELU→VRELU`、`ADD→VADD`…)。 ### 4.5 重写算法 - 用 `_fresh()` 生成 strip iv 与全部向量中间值的 `Value`(`shape=(W,)`,`dtype` 继承被替换指令的 `dest.dtype`); - 地址链重建:`c_scale = load_const(W*elem_bytes)`(新的常量 Value)、`boff = mul(strip_iv, c_scale)`、`pa = add(base, boff)`; - 原地替换区域内容:`FOR` 的 `dest` 换为 strip iv、`attrs` 换为 `{start:0, end:strips, step:1, vector_width:W, elem_bytes, orig_trip:n}`;把 `ADDR/CONST/ELEM` 指令替换为对应向量指令;`LOAD/STORE` 替换为 `VLOAD/VSTORE`;保留 `ENDFOR`; +- `_rewrite` 返回 `(vector_ops, new_end_index)`,其中 `new_end_index = for_index + 1 + len(new_body)` 是替换后 strip `ENDFOR` 的真实位置(`region.end_index` 在体长变化后失效,余数插入必须用它); - **禁改**:区域外指令、`FOR` 之前/`ENDFOR` 之后的指令一律不动。 ### 4.6 余数克隆 ```python -def _clone_remainder(block, region, plan): +def _clone_remainder(block, region, plan, original_iv, new_end_index): if plan.rem == 0: return 0 - new_for = Instruction(opcode=OpCode.FOR, dest=region.iv, attrs={ + new_for = Instruction(opcode=OpCode.FOR, dest=original_iv, attrs={ "start": plan.strips * plan.width, "end": plan.n, "step": 1}) cloned = _clone_instrs(region.body, suffix="__rem") # 深克隆 + def/use 同步改名 + insert_at = new_end_index + 1 # strip ENDFOR 之后 block.instructions[insert_at:insert_at] = [new_for, *cloned, endfor] return 1 ``` +余数块必须插在 strip `ENDFOR` 之后(与 strip 循环同级、且在 `return` 之前);用旧的 `region.end_index + 1` 会导致新体更长时余数嵌进 strip 循环、新体更短时插到 `return` 之后成为死代码。`_skip_rewritten` 相应改为按块内顶层 `ENDFOR` 扫描定位下一次扫描起点。 + `_clone_instrs` 规则:为每个 `dest` 生成新 `Value(name + "__rem")`;克隆指令的 `operands` 中若引用被克隆的旧名则替换为新名,否则保持(区域外引用与常量不动);`FOR/ENDFOR` 不入克隆体。 ### 4.7 确定性 @@ -536,10 +542,16 @@ if self.config.use_dag_isel: ### 8.4 已知限制(集成时必须知晓,不在本期修) -- `reg_alloc="linear"` 的 `LinearScanAllocator.emit` 把标签输出为 `.label name`、跳转目标只放在注释,导致编码阶段标签丢失(实测 `j # .Lloop_header_1` → `IndexError`)。CLI 默认 `--reg-alloc greedy`,向量化验证路径使用 greedy;`CompilerConfig` 默认 `"linear"` 是既有不一致,建议在向量化开启且 `reg_alloc=="linear"` 时追加 warning 并回退 greedy(实现时可选项,需测试)。 +- `reg_alloc="linear"` 的 `LinearScanAllocator.emit` 把标签输出为 `.label name`、跳转目标只放在注释,导致编码阶段标签丢失(实测 `j # .Lloop_header_1` → `IndexError`)。CLI 默认 `--reg-alloc greedy`,向量化验证路径使用 greedy;`CompilerConfig` 默认 `"linear"` 是既有不一致,**已实现**:向量化开启且 `reg_alloc=="linear"` 时追加 warning 并回退 greedy(见 8.5)。 - `_select_alloca` 使用 `vreg("sp")` 会被寄存器分配改名(alloca 结果错误)。向量化样例与测试一律用 `load_const` 绝对地址作 base,避免 `alloca`;这是既有缺陷,单独跟踪。 - `--extended-isel`、`--verify-ir` 存在“CLI 已声明但 `args_to_config` 未接线”的历史缺口;本期新增三参数必须双侧修改,并以 10.4 的接线测试守门。 +### 8.5 `compile()` 前置校验(评审修复 F5/F6/F7) + +- `vectorize and backend == "llvm"` → 直接返回 `success=False`,错误消息指明 Phase 1 只支持 RISC-V 后端(LLVM 后端会把向量 op 写成注释丢弃,属静默错误产物)。 +- `vector_width < 2`(或非 int)→ 在解析前返回 `success=False`,错误消息 `vector width must be an integer >= 2 (got ...)`;`Vectorizer.__init__` 内部同样调用 `validate_vector_width()` 抛 `ValueError`(API 防呆双保险)。 +- `vectorize and reg_alloc == "linear"` → 追加 warning 并把 config 回退到 `"greedy"`(线性扫描标签发射缺陷,见 8.4 第一条)。 + --- ## 九、`main.py` CLI 接线 @@ -815,5 +827,22 @@ P0 常量物化修复 + 回归 → P1 types/builder + 单测 ### 已知限制 - Phase 1 仅标量展开(`--vector-isa p/v` 显式拒绝)。 -- 未做 `linear` 自动回退 greedy(R7)。 - 无任何性能声明(不把 loop 摊销当 SIMD 收益)。 + +--- + +## 评审修复(2026-09-14,分支 `impl/topic29`) + +> 对应评审:`GaoMD/ScratchV/Review/分支评审-2026-09-14/topic29-review.md`(评审对象 `88d9eed`) + +| 评审 ID | 修复 | +|---|---| +| F1(P0) | `_rewrite` 返回替换后 strip `ENDFOR` 的真实下标,`_clone_remainder` 以此插入余数块,`_skip_rewritten` 改为按块内顶层 `ENDFOR` 扫描定位;消除“长体嵌套 / 短体死代码”两种错位 | +| F2(P0) | 新增 C6 live-out 检查:区域内定义(含原 `iv`)在 `region.body` 之外被使用 → `region-value-escapes` 拒绝,消除悬空 SSA | +| F3(P1) | C7 收紧为同 base(唯一 LOAD+STORE、lane 一致)或异 base 常量绝对地址区间可证不重叠;其余异名可重叠 base → `aliasing-store` | +| F4(P1) | 新增余数错位结构回归(NB × W=2/4)、IV/区域值 live-out、异名 base 别名、宽度矩阵对拍(W×{整除,余数}×{map,广播,原地},其中 W=4+余数的 map/广播受既有分配器 >19 vreg 缺陷限制,仅做结构覆盖) | +| F5(P2) | `vectorize + backend=llvm` 在 `compile()` 前置拒绝(`success=False`) | +| F6(P2) | `validate_vector_width()`:`width < 2` 或非 int 抛 `ValueError`;`compile()` 前置校验返回 `success=False`(不再 `ZeroDivisionError`) | +| F7(P2) | `vectorize + reg_alloc=linear` 追加 warning 并回退 greedy(R7 落地) | + +修复后全量:`PYTHONPATH=. python3.11 -m pytest tests/ -q` → **753 passed / 0 failed**(修复前 720 passed)。 diff --git "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" index 4e278f0..fd7e66f 100644 --- "a/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" +++ "b/docs/topics/29-SIMD\345\220\221\351\207\217\345\214\226-\350\256\276\350\256\241\346\226\207\346\241\243.md" @@ -116,9 +116,16 @@ C5 元素树:区域内每个标量指令必须属于下列之一 且 opcode ∈ {ADD, SUB, MUL, DIV, RELU, NEG} (c) LOAD_CONST C6 无跨迭代标量:区域内的定义只被区域内指令使用(SSA 名唯一,天然满足); - iv 只允许出现在地址链的 MUL 中(其他使用 → 拒绝) -C7 无别名:STORE 的 base 与 LOAD 的 base 不同(原地逐元素 `a[i]=f(a[i])` 允许, - 即 STORE.base == 唯一 LOAD.base 且同 lane 偏移);同一 base 多次 STORE → 拒绝 + iv 只允许出现在地址链的 MUL 中(其他使用 → 拒绝); + 区域内的定义(含原 iv、区域内 LOAD/常量结果)不得在 FOR 区域外 + 被使用(live-out → 拒绝,region-value-escapes),因为重写会替换或 + 删除这些定义,否则会产生悬空 SSA 引用 +C7 无别名(浅别名分析,保守): + (a) 同 base:仅允许“唯一 LOAD + 唯一 STORE 且 lane 偏移一致”的 + 原地模式(`a[i]=f(a[i])`);同一 base 多次 STORE → 拒绝 + (b) 异 base:仅当两个 base 均为常量绝对地址、且 + [base, base + n*elem_bytes) 区间可证不重叠时才允许; + 其余(异名指针、`src = sub(out, 4)` 等可重叠形态)→ 拒绝 ``` 合法的可向量化循环模式(一期支持的三类): @@ -178,6 +185,7 @@ C7 无别名:STORE 的 base 与 LOAD 的 base 不同(原地逐元素 `a[i]=f | `REASON_NON_ELEMENTWISE_IV` | `non-elementwise-iv-use` | iv 用于非地址链 | | `REASON_ALIASING_STORE` | `aliasing-store` | C7 违反 | | `REASON_UNSUPPORTED_OP` | `unsupported-op` | C5 违反 | +| `REASON_REGION_VALUE_ESCAPES` | `region-value-escapes` | 区域内定义在区域外被使用(live-out) | 拒绝不是错误:管线继续编译标量程序,原因写入 `PassResult.warnings` 与 `Vectorizer.last_report`。 diff --git a/tests/test_backend.py b/tests/test_backend.py index 41bf2b7..82c713c 100644 --- a/tests/test_backend.py +++ b/tests/test_backend.py @@ -7,6 +7,7 @@ from scratchv.backend.instruction_select import InstructionSelector from scratchv.backend.register_alloc import RegisterAllocator, MachineOp from scratchv.backend.asm_emit import AsmEmitter +from scratchv.backend.riscv_encoder import assemble_to_binary from scratchv.frontend.dsl_parser import DSLParser from scratchv.compiler import CompilerConfig, CompilerDriver from scratchv.main import args_to_config, build_arg_parser @@ -229,6 +230,50 @@ def test_vectorize_disabled_matches_baseline(self, monkeypatch, tmp_path): assert result.stats["opt_message"] == "" assert not re.search(r"^\s*v[a-z]", result.output_text, re.MULTILINE) + def test_vectorize_llvm_backend_rejected(self, monkeypatch, tmp_path): + """Review F5: the LLVM backend used to drop vector ops silently.""" + driver = CompilerDriver(CompilerConfig( + vectorize=True, backend="llvm", reg_alloc="greedy")) + monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") + + result = driver.compile(str(source), str(tmp_path / "out.ll")) + + assert result.success is False + assert any("llvm" in err.lower() for err in result.errors) + assert not (tmp_path / "out.ll").exists() + + def test_vectorize_invalid_width_rejected(self, monkeypatch, tmp_path): + """Review F6: vector_width=0 used to raise ZeroDivisionError.""" + driver = CompilerDriver(CompilerConfig( + vectorize=True, vector_width=0, reg_alloc="greedy")) + monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") + + result = driver.compile(str(source), str(tmp_path / "out.s")) + + assert result.success is False + assert any("width" in err for err in result.errors) + + def test_vectorize_linear_regalloc_falls_back_to_greedy( + self, monkeypatch, tmp_path): + """Review F7: the default linear allocator cannot emit labels.""" + driver = CompilerDriver(CompilerConfig( + vectorize=True, vector_width=2)) # reg_alloc defaults to linear + monkeypatch.setattr(driver, "_parse", lambda *a, **k: _vector_ir()) + source = tmp_path / "input.dsl" + source.write_text("x = add(a, b)\n", encoding="utf-8") + + result = driver.compile(str(source), str(tmp_path / "out.s")) + + assert result.success + assert driver.config.reg_alloc == "greedy" + assert any("greedy" in warning for warning in result.warnings) + assert ".label" not in result.output_text + assert len(assemble_to_binary(result.output_text)) > 0 + # ── Topic 29 P0: constant operands in R-type instructions ─────────────── diff --git a/tests/test_vector_lowering.py b/tests/test_vector_lowering.py index 801a025..adf1507 100644 --- a/tests/test_vector_lowering.py +++ b/tests/test_vector_lowering.py @@ -60,6 +60,28 @@ def _make_relu_loop(n: int): return b.program +def _make_inplace_relu_loop(n: int): + """a[i] = relu(a[i]); a separate address ADD per access (F1 NB insertion).""" + inputs = [abs(x) + 1 for x in (self.A_NEG + [11])] + reference = [x // 3 for x in inputs] + self._run_pair(_make_div_loop, 17, 2, inputs, None, reference) + def test_relu_map_w4_matches_scalar(self): reference = [max(x, 0) for x in self.A_NEG] self._run_pair(_make_relu_loop, 16, 4, self.A_NEG, None, reference) diff --git a/tests/test_vectorize.py b/tests/test_vectorize.py index 0f5c142..fb46a1d 100644 --- a/tests/test_vectorize.py +++ b/tests/test_vectorize.py @@ -4,6 +4,10 @@ (C1-C7 of the design document) and the structured rejection report. """ +from __future__ import annotations + +import pytest + from scratchv.ir.builder import IRBuilder from scratchv.ir.types import DataType, Instruction, OpCode, Program from scratchv.optimizer.vectorize import ( @@ -12,6 +16,7 @@ REASON_NO_ELEMENT_PATTERN, REASON_NON_CONSTANT_BOUNDS, REASON_NON_ELEMENTWISE_IV, + REASON_REGION_VALUE_ESCAPES, REASON_TRIP_TOO_SMALL, REASON_UNSUPPORTED_START, REASON_UNSUPPORTED_STEP, @@ -77,6 +82,51 @@ def _make_chain_loop(n: int) -> Program: return b.program +def _make_inplace_relu_loop(n: int) -> Program: + """a[i] = relu(a[i]) with a separate address ADD per access. + + The vector rewrite deduplicates the two ``ADD`` instructions into one + (same base), so the rewritten body is *shorter* than the original + body: the F1 N < B case. + """ + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.relu(va) + b.store(b.add(a, off), r) + b.endfor() + b.ret() + return b.program + + +def _make_div_two_base_loop(n: int) -> Program: + """out[i] = a[i] / k with both input and output element chains. + + The rewritten body gains a ``VBCAST``, so it is *longer* than the + original body: the F1 N > B case. + """ + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + k = b.load_const(3, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.div(va, k) + b.store(b.add(o, off), r) + b.endfor() + b.ret() + return b.program + + def _instructions(program: Program) -> list[Instruction]: return program.functions[0].blocks[0].instructions @@ -100,6 +150,19 @@ def _find(instrs: list[Instruction], opcode: OpCode) -> list[Instruction]: return [instr for instr in instrs if instr.opcode is opcode] +def _matching_endfor(instrs: list[Instruction], for_index: int) -> int: + depth = 0 + for j in range(for_index, len(instrs)): + op = instrs[j].opcode + if op is OpCode.FOR: + depth += 1 + elif op is OpCode.ENDFOR: + depth -= 1 + if depth == 0: + return j + raise AssertionError("FOR without matching ENDFOR") + + # ── Builder API ───────────────────────────────────────────────────────── class TestVectorIrBuilders: @@ -267,6 +330,91 @@ def test_trip_too_small_ir_unchanged(self): assert vec.last_report[0].status == "rejected" assert vec.last_report[0].reason == REASON_TRIP_TOO_SMALL + def test_consecutive_remainder_loops_both_vectorized(self): + """After a rewritten region the scan must resume past its remainder. + + Guards ``_skip_rewritten``: a stale index would re-enter the + remainder loop of the first region and/or skip the second one. + """ + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + for _ in range(2): + iv = b.for_loop(0, 17) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.relu(va) + b.store(b.add(a, off), r) + b.endfor() + b.ret() + + vec = Vectorizer(b.program, width=2) + result = vec.run(b.program) + + assert result.changes == 2 + assert [rec.status for rec in vec.last_report] == [ + "vectorized", "vectorized"] + assert len(_for_indices(b.program)) == 4 + + +# ── Remainder loop placement (review F1) ──────────────────────────────── +# +# The remainder block must be inserted *after* the rewritten strip ENDFOR +# (its position shifts with the length of the new body) and before the +# trailing `return`. The original bug inserted it at the stale pre-rewrite +# index: a longer vector body nested the remainder inside the strip loop, +# a shorter one left it after `return` as dead code. + +class TestRemainderPlacement: + @pytest.mark.parametrize("width", [2, 4]) + @pytest.mark.parametrize( + "builder, relation", + [ + pytest.param(_make_inplace_relu_loop, "NB", + id="longer-vector-body"), + ], + ) + def test_remainder_is_sibling_after_strip_loop(self, builder, relation, + width): + n = 17 + program = builder(n) + vec = Vectorizer(program, width=width) + result = vec.run(program) + + assert result.changes == 1 + instrs = _instructions(program) + for_indices = _for_indices(program) + assert len(for_indices) == 2 + + strip_for, rem_for = for_indices + strip_endfor = _matching_endfor(instrs, strip_for) + + # The strip loop must not contain the remainder FOR (no nesting). + assert not any(instr.opcode is OpCode.FOR + for instr in instrs[strip_for + 1:strip_endfor]) + # The remainder FOR is the immediate sibling of the strip ENDFOR. + assert rem_for == strip_endfor + 1 + + strips = n // width + assert instrs[rem_for].attrs == { + "start": strips * width, "end": n, "step": 1} + rem_endfor = _matching_endfor(instrs, rem_for) + # The remainder loop is before the trailing `return`, never dead. + assert rem_endfor < len(instrs) - 1 + assert instrs[-1].opcode is OpCode.RETURN + assert not any(instr.opcode is OpCode.RETURN + for instr in instrs[:rem_endfor]) + + remainder_body = _body(program, rem_for) + assert remainder_body + assert not any(instr.opcode.is_vector() for instr in remainder_body) + assert all(instr.dest.name.endswith("__rem") + for instr in remainder_body if instr.dest) + # ── Rejections (design doc 2.5 I1-I4) ─────────────────────────────────── @@ -322,6 +470,85 @@ def test_i2_aliasing_store(self): b.ret() assert self._reject_reason(b.program) == REASON_ALIASING_STORE + @pytest.mark.parametrize("n", [16, 17]) + def test_iv_live_out_rejected(self, n): + """Review F2: the original IV is redefined as the strip index.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, n) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + b.store(b.add(o, off), va) + b.endfor() + c4b = b.load_const(4, dtype=DataType.INT32) + b.store(b.add(o, c4b), iv) # IV used after ENDFOR + b.ret() + assert self._reject_reason(b.program) == REASON_REGION_VALUE_ESCAPES + + def test_region_local_value_live_out_rejected(self): + """Review F2: a region-local LOAD result escapes the region.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + a = b.load_const(0x400000, dtype=DataType.INT32) + o = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(a, off)) + r = b.relu(va) + b.store(b.add(o, off), r) + b.endfor() + c4b = b.load_const(4, dtype=DataType.INT32) + b.store(b.add(o, c4b), va) # region-local LOAD result live-out + b.ret() + assert self._reject_reason(b.program) == REASON_REGION_VALUE_ESCAPES + + def test_cross_base_overlapping_rejected(self): + """Review F3: different base names may still overlap in memory.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + out = b.load_const(0x420000, dtype=DataType.INT32) + c4 = b.load_const(4, dtype=DataType.INT32) + src = b.sub(out, c4) # element-wise alias: src[i] == out[i - 1] + iv = b.for_loop(0, 16) + c4b = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4b) + va = b.load(b.add(src, off)) + b.store(b.add(out, off), va) + b.endfor() + b.ret() + assert self._reject_reason(b.program) == REASON_ALIASING_STORE + + def test_cross_base_unknown_pointer_rejected(self): + """Review F3: non-constant bases cannot be proven disjoint.""" + b = IRBuilder() + b.new_function("main") + b.new_block("entry") + src = b.make_value(name="src", dtype=DataType.INT32) + out = b.load_const(0x420000, dtype=DataType.INT32) + iv = b.for_loop(0, 16) + c4 = b.load_const(4, dtype=DataType.INT32) + off = b.mul(iv, c4) + va = b.load(b.add(src, off)) + b.store(b.add(out, off), va) + b.endfor() + b.ret() + assert self._reject_reason(b.program) == REASON_ALIASING_STORE + + def test_cross_base_distinct_constants_allowed(self): + """Distinct constant bases with disjoint ranges stay vectorizable.""" + program = _make_map_loop(16) + vec = Vectorizer(program, width=4) + result = vec.run(program) + assert result.changes == 1 + assert vec.last_report[0].status == "vectorized" + def test_i3_dynamic_bounds(self): program = _make_map_loop(16) for_instr = _instructions(program)[_for_indices(program)[0]] @@ -415,3 +642,18 @@ def test_report_counts(self): assert vec.last_report[1].status == "vectorized" assert len(result.warnings) == 1 assert "rejected: no-memory-element-pattern" in result.warnings[0] + + +# ── Width validation (review F6) ──────────────────────────────────────── + +class TestVectorWidthValidation: + @pytest.mark.parametrize("width", [0, 1, -2, True, "4"]) + def test_invalid_width_raises_value_error(self, width): + program = _make_map_loop(16) + with pytest.raises(ValueError, match="vector width"): + Vectorizer(program, width=width) + + @pytest.mark.parametrize("width", [2, 4]) + def test_valid_width_is_accepted(self, width): + program = _make_map_loop(16) + assert Vectorizer(program, width=width).width == width From 0a7c8e30b5cf528ecbd8001b7a869cc6d436bea4 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 15 Sep 2026 00:59:17 +0800 Subject: [PATCH 7/7] feat(topic29): add SIMD-vectorize feature case report and CI regressions --- .github/workflows/ci.yml | 20 + .../cases/topic29_vectorize_feature.dsl | 13 + benchmarks/run_topic29_vectorize_case.py | 622 ++++++++++++++++++ tests/test_topic29_vectorize_case_report.py | 142 ++++ 4 files changed, 797 insertions(+) create mode 100644 benchmarks/cases/topic29_vectorize_feature.dsl create mode 100644 benchmarks/run_topic29_vectorize_case.py create mode 100644 tests/test_topic29_vectorize_case_report.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15aae4b..b4017eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,15 @@ jobs: run: | python3.12 -m pytest tests/test_pr37_regression.py -v --tb=short + - name: Run topic29 SIMD-vectorize regressions + run: | + python3.12 -m pytest \ + tests/test_vectorize.py \ + tests/test_vector_lowering.py \ + tests/test_vector_encoder.py \ + tests/test_topic29_vectorize_case_report.py \ + -v --tb=short + - name: Generate test visualization page if: github.ref == 'refs/heads/main' run: | @@ -218,6 +227,14 @@ jobs: --json benchmark_reports/const_merge_report.json \ --markdown benchmark_reports/const_merge_report.md + # ── 3.1.2 课题29:SIMD 向量化 case 报告(A/B + RV32 执行等价) ───── + - name: Topic 29 SIMD vectorize case report + run: | + mkdir -p benchmark_reports + python3.12 benchmarks/run_topic29_vectorize_case.py \ + --json benchmark_reports/vectorize_report.json \ + --markdown benchmark_reports/vectorize_report.md + # ── 3.2 DSL 用例编译 + 模拟基准 ──────────────────────────────────── - name: DSL case compilation benchmarks run: | @@ -363,6 +380,9 @@ jobs: if [ -f benchmark_reports/const_merge_report.md ]; then cat benchmark_reports/const_merge_report.md >> $GITHUB_STEP_SUMMARY fi + if [ -f benchmark_reports/vectorize_report.md ]; then + cat benchmark_reports/vectorize_report.md >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY if [ -f benchmark_reports/github_summary.md ]; then cat benchmark_reports/github_summary.md >> $GITHUB_STEP_SUMMARY diff --git a/benchmarks/cases/topic29_vectorize_feature.dsl b/benchmarks/cases/topic29_vectorize_feature.dsl new file mode 100644 index 0000000..ac5ab15 --- /dev/null +++ b/benchmarks/cases/topic29_vectorize_feature.dsl @@ -0,0 +1,13 @@ +# SIMD vectorize feature case (Topic 29, phase 1). +# +# Scalar shape mirrored by the report's vectorizable IR case: +# out[i] = relu(a[i] + a[i]) for i in [0, 16) +# The phase-1 DSL grammar has no array load/store syntax, so the report +# builds the canonical element-addressing IR (design doc appendix 5.1) +# and compiles it through CompilerDriver; this file is still read and +# validated by every driver call and compiled off/on in the wiring check. +for i = 0, 16 + t = add(x, x) + y = relu(t) +endfor +return y diff --git a/benchmarks/run_topic29_vectorize_case.py b/benchmarks/run_topic29_vectorize_case.py new file mode 100644 index 0000000..8fb76b1 --- /dev/null +++ b/benchmarks/run_topic29_vectorize_case.py @@ -0,0 +1,622 @@ +#!/usr/bin/env python3 +"""Run one Topic 29 SIMD-vectorize feature case and emit auditable CI reports. + +The report proves four separate facts: + +1. the configured compiler pipeline honours the ``vectorize`` opt-in on a + real DSL input (both configurations compile; the pass only runs when + enabled); +2. the canonical element-addressing loop (design doc appendix 5.1, + ``out[i] = relu(a[i] + a[i])``, ``n=16``, ``W=4``, ``rem=0``) is + rewritten to vector IR ops when enabled and left scalar when disabled; +3. the vectorized artifact assembles to RV32IM and the RV32 emulator + produces the same architectural state (``a0``, observed register, + output memory) for the scalar and vectorized programs; +4. unsupported driver configurations fail loudly: ``vectorize`` plus the + LLVM backend, illegal ``vector_width`` values and a non-scalar + ``vector_isa``. + +This is a deterministic feature/integration case, not a real-workload +speedup claim. Real ONNX benchmark numbers remain separate in +``run_benchmark.py``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import statistics +import tempfile +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from scratchv.backend._asm_parser import parse_asm +from scratchv.backend.riscv_encoder import assemble_to_binary +from scratchv.compiler import CompilerConfig, CompilerDriver +from scratchv.ir.builder import IRBuilder +from scratchv.ir.types import DataType, Program +from scratchv.simulator.rv32_emulator import REG_ID, RV32Emulator + +SCHEMA_VERSION = "topic29-vectorize-case/1" +DEFAULT_CASE = ( + Path(__file__).parent / "cases" / "topic29_vectorize_feature.dsl" +) +DEFAULT_JSON = Path("benchmark_reports/vectorize_report.json") +DEFAULT_MARKDOWN = Path("benchmark_reports/vectorize_report.md") + +#: Fixed phase-1 strip width for both A/B sides. +VECTOR_WIDTH = 4 +#: Trip count of the feature case; ``n % W == 0`` on purpose (review F1). +TRIP_COUNT = 16 +#: Element base address of the input array written by the report. +INPUT_BASE = 0x400000 +#: Element base address of the output array produced by the case. +OUTPUT_BASE = 0x420000 +#: Deterministic inputs (negative values included); the case must not use +#: division, whose negative semantics the RV32 emulator stores unsigned. +CASE_INPUTS = [7, -3, 0, 100000, -1, 2, -2048, 2047, + 123456, -654321, 0, -5, 9, -9, 42, -42] +#: ``out[i] = relu(a[i] + a[i])`` computed in Python for the same inputs. +EXPECTED_OUTPUTS = [max(value + value, 0) for value in CASE_INPUTS] +#: Expected return value: the case returns ``out[0]``. +EXPECTED_RESULT = EXPECTED_OUTPUTS[0] +#: Registers that carry observable results. Loop counters and strip +#: induction variables necessarily differ between the scalar and the +#: vectorized program, so only the return register is compared; the full +#: state is still recorded for auditing, as is the output memory. +OBSERVED_REGISTERS = ("x10",) +#: Illegal ``vector_width`` values for the pre-validation matrix. +INVALID_WIDTHS = (0, 1, -2, True, "4") + +_VECTOR_MNEMONIC_RE = re.compile(r"^\s*v[a-z]", re.MULTILINE) + +HONESTY = ( + "Deterministic phase-1 feature case, not a workload speedup claim. " + "The program is the canonical element-addressing loop of design doc " + "appendix 5.1 (out[i] = relu(a[i] + a[i]), n=16, W=4, rem=0), built " + "with IRBuilder because the phase-1 DSL grammar has no array " + "load/store syntax; CompilerDriver still reads and validates the DSL " + "case file on every run and only the final parse step is replaced by " + "the equivalent IR program. Known review findings are avoided by " + "shape: n % W == 0 leaves the remainder clone untouched (F1), the " + "original induction variable is not used after ENDFOR (F2), and the " + "single element base plus a provably disjoint constant output base " + "cannot alias (F3); the branch's regression tests for those defect " + "shapes live in tests/test_vectorize.py and " + "tests/test_vector_lowering.py. The case also stays at W=4/n=16 " + "because the pre-existing greedy allocator miscompiles vectorized " + "loops that exceed its 19-register window (e.g. the two-load mul map " + "at W=4). Instruction counts are RV32 emulator dynamic counts, not " + "hardware cycles; phase 1 lowers vector ops to per-lane scalar " + "RV32IM, so the assembly contains no vector mnemonics and the " + "observed dynamic-instruction drop is loop-overhead amortization on " + "this case, not a general speedup. The report writes the input " + "arrays itself, so no external workload is run." +) + + +class _FeatureCaseDriver(CompilerDriver): + """CompilerDriver that compiles the pre-built IR feature case. + + The driver, its pre-validation, the configured passes, the vectorizer, + the backend and the output writer are used unmodified; only the final + ``_parse`` step returns the IRBuilder-built equivalent of the DSL case + because the phase-1 DSL grammar cannot express array element chains. + """ + + def __init__(self, config: CompilerConfig, program: Program) -> None: + super().__init__(config) + self.case_program = program + + def _parse(self, input_path: str, dsl_source: str | None = None): + return self.case_program + + +def build_case_program(n: int = TRIP_COUNT) -> Program: + """Build the deterministic vectorizable feature case. + + Shape: ``out[i] = relu(a[i] + a[i])`` for ``i in [0, n)``, followed by + ``return out[0]``. The shape is chosen inside the phase-1 verified + subset: ``n % W == 0`` (no remainder clone, review F1), the induction + variable is not used after ``endfor`` (review F2) and the element base + plus the disjoint constant output base cannot alias (review F3). + """ + builder = IRBuilder() + builder.new_function("main") + builder.new_block("entry") + base = builder.load_const(INPUT_BASE, dtype=DataType.INT32) + out = builder.load_const(OUTPUT_BASE, dtype=DataType.INT32) + iv = builder.for_loop(0, n) + c4 = builder.load_const(4, dtype=DataType.INT32) + offset = builder.mul(iv, c4) + va = builder.load(builder.add(base, offset)) + r = builder.relu(builder.add(va, va)) + builder.store(builder.add(out, offset), r) + builder.endfor() + last = builder.load(out) + builder.ret(last) + return builder.program + + +def count_ir(program: Program) -> int: + return sum( + 1 + for func in program.functions + for block in func.blocks + for _ in block.instructions + ) + + +def vector_op_counts(program: Program) -> dict[str, int]: + counts: dict[str, int] = {} + for func in program.functions: + for block in func.blocks: + for instr in block.instructions: + if instr.opcode.is_vector(): + key = instr.opcode.value + counts[key] = counts.get(key, 0) + 1 + return dict(sorted(counts.items())) + + +def count_asm(asm: str) -> int: + return sum( + line.opcode is not None and not line.is_directive + for line in parse_asm(asm) + ) + + +def execute_binary(binary: bytes) -> dict[str, Any]: + """Execute *binary* with the case inputs; return state and counters.""" + emulator = RV32Emulator() + emulator.load_code(binary) + for index, value in enumerate(CASE_INPUTS): + emulator.write_i32(INPUT_BASE + 4 * index, value) + dynamic = emulator.run(max_instr=200000) + outputs = [ + emulator.read_i32(OUTPUT_BASE + 4 * index) + for index in range(TRIP_COUNT) + ] + return { + "backend": "rv32-emulator", + "registers": {f"x{i}": emulator.regs[i] for i in range(32)}, + "a0": emulator.regs[REG_ID["a0"]], + "outputs": outputs, + "dynamic_instructions": dynamic, + } + + +def _empty_side(vectorize: bool, + errors: list[str] | None = None) -> dict[str, Any]: + return { + "vectorize": vectorize, + "success": False, + "errors": list(errors or []), + "warnings": [], + "opt_message": "", + "vector_ops": 0, + "vector_ops_by_opcode": {}, + "ir_instructions": 0, + "asm_instructions": 0, + "asm_bytes": 0, + "binary_bytes": 0, + "assembles": False, + "vector_mnemonics_in_asm": False, + "compile_time_ms": 0.0, + "runs": 0, + "artifact_deterministic": False, + "asm_sha256": "", + "execution": None, + "asm_head": [], + } + + +def _measure_side(*, vectorize: bool, repeats: int, + case_path: Path = DEFAULT_CASE) -> dict[str, Any]: + """Compile and execute the feature case with vectorization off/on. + + Runs at least twice so ``artifact_deterministic`` is never vacuous. + """ + runs = max(repeats, 2) + times: list[float] = [] + asm_texts: list[str] = [] + program: Program | None = None + result = None + for _ in range(runs): + program = build_case_program() + driver = _FeatureCaseDriver( + CompilerConfig( + vectorize=vectorize, + vector_width=VECTOR_WIDTH, + reg_alloc="greedy", + optimize_level="none", + ), + program, + ) + with tempfile.TemporaryDirectory() as tmp: + started = time.perf_counter() + result = driver.compile(str(case_path), str(Path(tmp) / "case.s")) + times.append((time.perf_counter() - started) * 1000.0) + if not result.success: + return _empty_side(vectorize, result.errors) + asm_texts.append(result.output_text) + + assert program is not None and result is not None + asm = asm_texts[-1] + counts = vector_op_counts(program) + side: dict[str, Any] = { + "vectorize": vectorize, + "success": True, + "errors": [], + "warnings": list(result.warnings), + "opt_message": result.stats.get("opt_message", ""), + "vector_ops": sum(counts.values()), + "vector_ops_by_opcode": counts, + "ir_instructions": count_ir(program), + "asm_instructions": count_asm(asm), + "asm_bytes": len(asm), + "binary_bytes": 0, + "assembles": False, + "vector_mnemonics_in_asm": bool(_VECTOR_MNEMONIC_RE.search(asm)), + "compile_time_ms": round(statistics.median(times), 4), + "runs": runs, + "artifact_deterministic": len(set(asm_texts)) == 1, + "asm_sha256": hashlib.sha256(asm.encode("utf-8")).hexdigest(), + "execution": None, + "asm_head": asm.splitlines()[:12], + } + try: + binary = bytes(assemble_to_binary(asm)) + except Exception as exc: # pragma: no cover - report, never crash + side["assemble_error"] = str(exc) + return side + side["assembles"] = len(binary) > 0 + side["binary_bytes"] = len(binary) + try: + side["execution"] = execute_binary(binary) + except Exception as exc: # pragma: no cover - report, never crash + side["execution_error"] = str(exc) + return side + + +def measure_dsl_wiring(case_path: Path) -> dict[str, Any]: + """Prove the opt-in flag is wired through the real DSL driver path.""" + source = case_path.read_text(encoding="utf-8") + common = dict(optimize_level="none", reg_alloc="greedy") + with tempfile.TemporaryDirectory() as tmp: + off = CompilerDriver( + CompilerConfig(vectorize=False, **common) + ).compile("", str(Path(tmp) / "off.s"), dsl_source=source) + on = CompilerDriver( + CompilerConfig( + vectorize=True, vector_width=VECTOR_WIDTH, **common, + ) + ).compile("", str(Path(tmp) / "on.s"), dsl_source=source) + off_message = off.stats.get("opt_message", "") + on_message = on.stats.get("opt_message", "") + return { + "off_success": off.success, + "on_success": on.success, + "off_vectorizer_ran": "vectorized" in off_message, + "on_vectorizer_ran": "vectorized" in on_message, + "on_opt_message": on_message, + "on_rejections": [ + warning for warning in on.warnings if "rejected" in warning + ], + } + + +def _attempt_failure(case_path: Path, label: str, + **overrides: Any) -> dict[str, Any]: + config = CompilerConfig( + vectorize=True, + vector_width=VECTOR_WIDTH, + optimize_level="none", + reg_alloc="greedy", + ) + for key, value in overrides.items(): + setattr(config, key, value) + driver = _FeatureCaseDriver(config, build_case_program()) + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / ( + "out.ll" if config.backend == "llvm" else "out.s" + ) + result = driver.compile(str(case_path), str(output)) + artifact_written = output.exists() + return { + "label": label, + "config": { + "backend": config.backend, + "vectorize": config.vectorize, + "vector_width": config.vector_width, + "vector_isa": config.vector_isa, + }, + "success": result.success, + "errors": list(result.errors), + "artifact_written": artifact_written, + } + + +def measure_failure_matrix(case_path: Path) -> dict[str, Any]: + """Run the driver pre-validation matrix for unsupported configurations.""" + llvm = _attempt_failure(case_path, "backend=llvm", backend="llvm") + widths = [ + _attempt_failure(case_path, f"vector_width={value!r}", + vector_width=value) + for value in INVALID_WIDTHS + ] + vector_isa = _attempt_failure(case_path, "vector_isa=v", vector_isa="v") + all_rows = [llvm, vector_isa, *widths] + return { + "llvm": llvm, + "vector_isa": vector_isa, + "invalid_widths": widths, + "llvm_rejected": ( + not llvm["success"] + and any("llvm" in error.lower() for error in llvm["errors"]) + ), + "invalid_widths_rejected": all( + (not row["success"]) + and any("width" in error for error in row["errors"]) + for row in widths + ), + "vector_isa_rejected": ( + not vector_isa["success"] + and any("phase 2" in error for error in vector_isa["errors"]) + ), + "no_artifact_written_on_failure": all( + not row["artifact_written"] for row in all_rows + ), + } + + +def evaluate(case_path: Path, repeats: int) -> dict[str, Any]: + """Build the full report payload and run the hard invariants.""" + off = _measure_side(vectorize=False, repeats=repeats, case_path=case_path) + on = _measure_side(vectorize=True, repeats=repeats, case_path=case_path) + wiring = measure_dsl_wiring(case_path) + matrix = measure_failure_matrix(case_path) + + exec_off = off.get("execution") or {} + exec_on = on.get("execution") or {} + hard_checks = { + "dsl_case_compiles_with_and_without_opt_in": ( + wiring["off_success"] and wiring["on_success"] + ), + "dsl_opt_in_runs_vectorizer_only_when_enabled": ( + wiring["on_vectorizer_ran"] and not wiring["off_vectorizer_ran"] + ), + "both_configs_compile": off["success"] and on["success"], + "vector_ops_present_when_enabled": on["vector_ops"] > 0, + "vector_ops_absent_when_disabled": off["vector_ops"] == 0, + "opt_in_reports_vectorized_loop": ( + "vectorized 1/1" in on["opt_message"] + ), + "on_artifact_assembles": on["assembles"], + "output_is_rv32im_only": ( + not on["vector_mnemonics_in_asm"] + and not off["vector_mnemonics_in_asm"] + ), + "execution_result_is_expected": bool( + exec_off and exec_on + and exec_off["a0"] == EXPECTED_RESULT + and exec_on["a0"] == EXPECTED_RESULT + and exec_off["outputs"] == EXPECTED_OUTPUTS + and exec_on["outputs"] == EXPECTED_OUTPUTS + ), + "observed_registers_identical": bool( + exec_off and exec_on + and all( + exec_off["registers"][reg] == exec_on["registers"][reg] + for reg in OBSERVED_REGISTERS + ) + ), + "output_memory_identical": bool( + exec_off and exec_on + and exec_off["outputs"] == exec_on["outputs"] + ), + "llvm_backend_rejected": matrix["llvm_rejected"], + "invalid_widths_rejected": matrix["invalid_widths_rejected"], + "vector_isa_rejected": matrix["vector_isa_rejected"], + "failed_configs_write_no_artifact": matrix[ + "no_artifact_written_on_failure" + ], + "artifacts_deterministic": ( + off["artifact_deterministic"] and on["artifact_deterministic"] + ), + } + failed = sorted(name for name, ok in hard_checks.items() if not ok) + + return { + "schema_version": SCHEMA_VERSION, + "topic": "topic29-simd-vectorize", + "generated_at": datetime.now(timezone.utc).isoformat(), + "case": str(case_path), + "vector_width": VECTOR_WIDTH, + "trip_count": TRIP_COUNT, + "strips": TRIP_COUNT // VECTOR_WIDTH, + "remainder": TRIP_COUNT % VECTOR_WIDTH, + "expected_result": EXPECTED_RESULT, + "expected_outputs": EXPECTED_OUTPUTS, + "observed_registers": list(OBSERVED_REGISTERS), + "case_inputs": CASE_INPUTS, + "config": { + "optimize_level": "none", + "reg_alloc": "greedy", + "vector_width": VECTOR_WIDTH, + }, + "runs": repeats, + "dsl_wiring": wiring, + "vectorize_off": off, + "vectorize_on": on, + "failure_matrix": matrix, + "hard_checks": hard_checks, + "hard_failures": failed, + "honesty": HONESTY, + } + + +def render_markdown(report: dict[str, Any]) -> str: + off, on = report["vectorize_off"], report["vectorize_on"] + exec_off = off.get("execution") or {} + exec_on = on.get("execution") or {} + dyn_off = exec_off.get("dynamic_instructions", 0) + dyn_on = exec_on.get("dynamic_instructions", 0) + saved = dyn_off - dyn_on + pct = (saved / dyn_off * 100) if dyn_off else 0.0 + opcodes = sorted( + set(off["vector_ops_by_opcode"]) | set(on["vector_ops_by_opcode"]) + ) + lines = [ + "# Topic 29 SIMD Vectorize Feature Case", + "", + f"- Schema: `{report['schema_version']}`", + f"- Case: `{report['case']}` " + f"(IR shape `out[i] = relu(a[i] + a[i])`, n=" + f"{report['trip_count']}, W={report['vector_width']}, rem=" + f"{report['remainder']})", + f"- Generated: {report['generated_at']}", + f"- Hard checks: " + f"{'PASS' if not report['hard_failures'] else 'FAIL'} " + f"({len(report['hard_checks']) - len(report['hard_failures'])}" + f"/{len(report['hard_checks'])})", + "", + "## A/B summary", + "", + "| Metric | vectorize off | vectorize on | delta |", + "|--------|--------------:|-------------:|------:|", + f"| Compilation success | {'yes' if off['success'] else 'no'} | " + f"{'yes' if on['success'] else 'no'} | - |", + f"| IR instructions | {off['ir_instructions']} | " + f"{on['ir_instructions']} | " + f"{on['ir_instructions'] - off['ir_instructions']:+d} |", + f"| Vector ops (IR) | {off['vector_ops']} | {on['vector_ops']} | " + f"{on['vector_ops'] - off['vector_ops']:+d} |", + f"| ASM instructions | {off['asm_instructions']} | " + f"{on['asm_instructions']} | " + f"{on['asm_instructions'] - off['asm_instructions']:+d} |", + f"| Binary bytes | {off['binary_bytes']} | {on['binary_bytes']} | " + f"{on['binary_bytes'] - off['binary_bytes']:+d} |", + f"| Dynamic instructions (emulator) | {dyn_off} | {dyn_on} | " + f"-{saved} ({pct:.1f}%) |", + f"| `a0` result | {exec_off.get('a0', 'n/a')} | " + f"{exec_on.get('a0', 'n/a')} | expected " + f"{report['expected_result']} |", + f"| Compile time (ms, median of {on['runs']} runs) | " + f"{off['compile_time_ms']:.4f} | {on['compile_time_ms']:.4f} | - |", + f"| Artifact SHA-256 | `{off['asm_sha256'][:16]}` | " + f"`{on['asm_sha256'][:16]}` | - |", + "", + "## Vector op statistics (on)", + "", + "| Opcode | off | on |", + "|--------|----:|---:|", + ] + for opcode in opcodes: + lines.append( + f"| `{opcode}` | {off['vector_ops_by_opcode'].get(opcode, 0)} | " + f"{on['vector_ops_by_opcode'].get(opcode, 0)} |" + ) + residual = ( + "YES" if on["vector_mnemonics_in_asm"] + else "none (lowered per-lane to RV32IM)" + ) + lines += [ + "", + f"- Vectorizer message: `{on['opt_message']}`", + f"- Residual vector mnemonics in on-side assembly: {residual}", + f"- Deterministic artifacts across {on['runs']} compiles: " + f"{'yes' if on['artifact_deterministic'] else 'no'}", + "", + "## Execution equivalence (RV32 emulator)", + "", + f"- Inputs written at `0x{INPUT_BASE:x}`: {report['case_inputs']}", + f"- Expected outputs: {report['expected_outputs']}", + f"- off outputs: {exec_off.get('outputs', 'n/a')}", + f"- on outputs: {exec_on.get('outputs', 'n/a')}", + f"- `a0`: off={exec_off.get('a0', 'n/a')}, " + f"on={exec_on.get('a0', 'n/a')}, " + f"expected={report['expected_result']}", + f"- Observed registers {report['observed_registers']}:", + ] + for side_name, execution in (("off", exec_off), ("on", exec_on)): + observed = [ + execution.get("registers", {}).get(reg) + for reg in report["observed_registers"] + ] + lines.append(f" - {side_name}: {observed}") + lines += [ + "", + "## DSL wiring (real CompilerDriver path)", + "", + f"- off compile success: " + f"{report['dsl_wiring']['off_success']}, vectorizer ran: " + f"{report['dsl_wiring']['off_vectorizer_ran']}", + f"- on compile success: " + f"{report['dsl_wiring']['on_success']}, vectorizer ran: " + f"{report['dsl_wiring']['on_vectorizer_ran']} " + f"(`{report['dsl_wiring']['on_opt_message']}`)", + f"- on-side phase-1 rejections: " + f"{report['dsl_wiring']['on_rejections'] or 'none'}", + "", + "## Rejection matrix (driver pre-validation)", + "", + "| Config | success | error |", + "|--------|---------|-------|", + ] + matrix = report["failure_matrix"] + for row in [matrix["llvm"], matrix["vector_isa"], + *matrix["invalid_widths"]]: + error = row["errors"][0] if row["errors"] else "" + if len(error) > 90: + error = error[:87] + "..." + lines.append( + f"| {row['label']} | {row['success']} | {error} |" + ) + lines += [ + "", + "## Hard checks", + "", + ] + for name, ok in report["hard_checks"].items(): + lines.append(f"- [{'x' if ok else ' '}] {name}") + lines += [ + "", + "## Honesty", + "", + report["honesty"], + "", + ] + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--case", type=Path, default=DEFAULT_CASE) + parser.add_argument("--json", type=Path, default=DEFAULT_JSON) + parser.add_argument("--markdown", type=Path, default=DEFAULT_MARKDOWN) + parser.add_argument("--repeats", type=int, default=5) + args = parser.parse_args(argv) + if args.repeats < 1: + parser.error("--repeats must be positive") + if not args.case.is_file(): + parser.error(f"feature case not found: {args.case}") + + report = evaluate(args.case, args.repeats) + args.json.parent.mkdir(parents=True, exist_ok=True) + args.json.write_text(json.dumps(report, indent=2) + "\n") + args.markdown.parent.mkdir(parents=True, exist_ok=True) + args.markdown.write_text(render_markdown(report) + "\n") + print(render_markdown(report)) + if report["hard_failures"]: + print("HARD FAILURES: " + ", ".join(report["hard_failures"])) + return 1 + print(f"reports written: {args.json}, {args.markdown}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_topic29_vectorize_case_report.py b/tests/test_topic29_vectorize_case_report.py new file mode 100644 index 0000000..057cf5f --- /dev/null +++ b/tests/test_topic29_vectorize_case_report.py @@ -0,0 +1,142 @@ +"""Tests for the Topic 29 SIMD-vectorize feature case report. + +The report is the CI artifact that proves the vectorize opt-in is wired +through the configured compiler pipeline, actually rewrites the canonical +element-addressing loop, and keeps architectural results identical under +the RV32 emulator. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from benchmarks.run_topic29_vectorize_case import ( + EXPECTED_OUTPUTS, + EXPECTED_RESULT, + SCHEMA_VERSION, + _measure_side, + evaluate, + main, + measure_dsl_wiring, + measure_failure_matrix, +) + +CASE = ( + Path(__file__).resolve().parents[1] + / "benchmarks" / "cases" / "topic29_vectorize_feature.dsl" +) + + +def test_ab_sides_compile_and_on_emits_vector_ops(): + off = _measure_side(vectorize=False, repeats=1, case_path=CASE) + on = _measure_side(vectorize=True, repeats=1, case_path=CASE) + + assert off["success"] and on["success"] + assert off["vector_ops"] == 0 + assert on["vector_ops"] == 4 + assert on["vector_ops_by_opcode"] == { + "vadd": 1, "vload": 1, "vrelu": 1, "vstore": 1, + } + assert on["assembles"] and on["binary_bytes"] > 0 + assert not on["vector_mnemonics_in_asm"] + assert "vectorized 1/1 loop(s), width=4" in on["opt_message"] + + +def test_execution_is_equivalent(): + off = _measure_side(vectorize=False, repeats=1, case_path=CASE) + on = _measure_side(vectorize=True, repeats=1, case_path=CASE) + exec_off = off["execution"] + exec_on = on["execution"] + + assert exec_off["a0"] == EXPECTED_RESULT == exec_on["a0"] + assert exec_off["outputs"] == EXPECTED_OUTPUTS + assert exec_on["outputs"] == EXPECTED_OUTPUTS + assert exec_off["outputs"] == exec_on["outputs"] + assert exec_off["registers"]["x10"] == exec_on["registers"]["x10"] + assert exec_off["dynamic_instructions"] > 0 + assert exec_on["dynamic_instructions"] > 0 + + +def test_failure_matrix_rejects_llvm_and_bad_widths(): + matrix = measure_failure_matrix(CASE) + + assert matrix["llvm_rejected"] + assert matrix["invalid_widths_rejected"] + assert matrix["vector_isa_rejected"] + assert matrix["no_artifact_written_on_failure"] + assert not matrix["llvm"]["success"] + assert not matrix["vector_isa"]["success"] + assert all(not row["success"] for row in matrix["invalid_widths"]) + + +def test_compilation_is_deterministic(): + first = _measure_side(vectorize=True, repeats=2, case_path=CASE) + second = _measure_side(vectorize=True, repeats=2, case_path=CASE) + + assert first["artifact_deterministic"] + assert second["artifact_deterministic"] + assert first["runs"] >= 2 + assert first["asm_sha256"] == second["asm_sha256"] + + +def test_dsl_case_wiring_and_evaluate_pass_all_hard_checks(): + wiring = measure_dsl_wiring(CASE) + assert wiring["off_success"] and wiring["on_success"] + assert wiring["on_vectorizer_ran"] and not wiring["off_vectorizer_ran"] + + report = evaluate(CASE, repeats=1) + assert report["schema_version"] == SCHEMA_VERSION + assert report["topic"] == "topic29-simd-vectorize" + assert report["hard_failures"] == [] + assert all(report["hard_checks"].values()) + assert report["honesty"] + + +def test_main_writes_json_and_markdown(tmp_path, capsys): + json_path = tmp_path / "report.json" + md_path = tmp_path / "report.md" + exit_code = main([ + "--case", str(CASE), + "--json", str(json_path), + "--markdown", str(md_path), + "--repeats", "1", + ]) + + assert exit_code == 0 + data = json.loads(json_path.read_text()) + assert data["hard_failures"] == [] + assert data["topic"] == "topic29-simd-vectorize" + assert data["observed_registers"] == ["x10"] + assert data["vectorize_off"]["vector_ops"] == 0 + assert data["vectorize_on"]["vector_ops"] > 0 + markdown = md_path.read_text() + assert "Topic 29 SIMD Vectorize Feature Case" in markdown + assert "Vector op statistics" in markdown + assert "Execution equivalence" in markdown + assert "Honesty" in markdown + assert capsys.readouterr().out + + +def test_main_rejects_missing_case(tmp_path): + with pytest.raises(SystemExit) as exc: + main(["--case", str(tmp_path / "missing.dsl")]) + assert exc.value.code == 2 + + +def test_hard_check_gate_is_not_vacuous(monkeypatch): + """A case without vector ops must be reported as a hard failure.""" + def fake_side(*, vectorize, repeats, case_path=None): + side = _measure_side(vectorize=vectorize, repeats=1, case_path=CASE) + side["vector_ops"] = 0 + side["assembles"] = False + return side + + monkeypatch.setattr( + "benchmarks.run_topic29_vectorize_case._measure_side", fake_side) + report = evaluate(CASE, repeats=1) + + assert "vector_ops_present_when_enabled" in report["hard_failures"] + assert "on_artifact_assembles" in report["hard_failures"]