From 0bb171c5cf5dcf5895a3232b756dd0013f4a52b1 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 13:11:08 -0700 Subject: [PATCH 01/51] Compile hot integer loops to AArch64 machine code Add a native backend that turns backward-branching integer loops in the register bytecode into machine code emitted directly from Nim, with no external compiler or JIT library. - machine.nim reserves write-then-execute pages, handling Apple silicon's MAP_JIT and per-thread write protection, mprotect on Linux, and VirtualProtect on Windows. - arm64.nim encodes the AArch64 subset the compiler needs, with labels and forward-reference patching. Every encoding is checked against clang's assembler in test_arm64.nim. - bytecode.nim holds Op and Instruction so the interpreter and the native compiler can share them. - jit.nim proves each participating global still holds an integer, hoists it into a callee-saved register, and runs the loop without tags, memory traffic, or dispatch. Unmodelled operations and non-integer values fall back to the interpreter, and the meter is charged exactly as before, so a script cannot tell which path ran. Measured on an M4 Pro: arithmetic 16.2 ms to 0.69 ms, branches 26.0 ms to 1.34 ms, nested loops 15.8 ms to 0.67 ms, with identical results and identical instruction and work accounting. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 141 ++++++------ src/bassy/arm64.nim | 472 +++++++++++++++++++++++++++++++++++++++++ src/bassy/bytecode.nim | 73 +++++++ src/bassy/jit.nim | 417 ++++++++++++++++++++++++++++++++++++ src/bassy/machine.nim | 218 +++++++++++++++++++ tests/bench_jit.nim | 84 ++++++++ tests/test_arm64.nim | 199 +++++++++++++++++ tests/test_jit.nim | 190 +++++++++++++++++ 8 files changed, 1723 insertions(+), 71 deletions(-) create mode 100644 src/bassy/arm64.nim create mode 100644 src/bassy/bytecode.nim create mode 100644 src/bassy/jit.nim create mode 100644 src/bassy/machine.nim create mode 100644 tests/bench_jit.nim create mode 100644 tests/test_arm64.nim create mode 100644 tests/test_jit.nim diff --git a/src/bassy.nim b/src/bassy.nim index 7dee677..644e200 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -5,9 +5,9 @@ import std/[strutils, tables], - bassy/[numbers, texts] + bassy/[bytecode, jit, numbers, texts] -export numbers +export bytecode, jit, numbers const DefaultMaxStrings* = 256 @@ -137,75 +137,6 @@ type line: int32 column: int32 - Op = enum - MeterOp, - LoadImmediateOp, - LoadFixedOp, - LoadStringOp, - TextCallOp, - MoveOp, - LoadGlobalOp, - LoadHostDataOp, - StoreGlobalOp, - StoreGlobalImmediateOp, - MoveGlobalOp, - AddGlobalImmediateOp, - AddGlobalOp, - AddGlobalHostDataOp, - AddGlobalRegisterOp, - ModuloGlobalImmediateOp, - AddGlobalArrayGlobalIndexOp, - AddOp, - SubtractOp, - MultiplyOp, - DivideOp, - IntegerDivideOp, - ModuloOp, - NegateOp, - EqualOp, - NotEqualOp, - LessOp, - LessEqualOp, - GreaterOp, - GreaterEqualOp, - AndOp, - OrOp, - XorOp, - EqvOp, - ImpOp, - NotOp, - JumpOp, - JumpIfZeroOp, - JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp, - JumpUnlessGlobalModuloEqualZeroOp, - ArrayGetOp, - ArraySetOp, - ArrayAddGlobalsOp, - SetArgumentOp, - SetArgumentImmediateOp, - SetArgumentGlobalOp, - HostCallOp, - CallOp, - GosubOp, - ReturnOp, - ReturnLabelOp, - ExitSubOp, - HaltOp, - PrintTextOp, - PrintValueOp, - PrintNewlineOp - - Instruction = object - op: Op - a: int32 - b: int32 - c: int32 - BasicArray = object name: string base: int32 @@ -283,6 +214,8 @@ type printedEvents: int64 allocatedBytes: int64 finished: bool + regionAt: seq[Region] + bypass: int32 Expr = object text: bool @@ -3189,6 +3122,48 @@ proc instructions*(program: Program): int {.inline.} = ## Returns the number of metered register-machine instructions. program.code.len +proc compileNative*(runtime: var Runtime): int = + ## Compiles the hot integer loops of this program to machine code and + ## returns how many were accepted. Loops the compiler does not model are + ## left to the interpreter, so behavior never depends on the result. + runtime.bypass = -1 + runtime.regionAt = @[] + if not jitSupported(): + return 0 + let regions = compileLoops(runtime.program.code) + if regions.len == 0: + return 0 + runtime.regionAt = newSeq[Region](runtime.program.code.len) + for start, region in regions: + runtime.regionAt[int(start)] = region + regions.len + +proc nativeRegions*(runtime: Runtime): int = + ## Returns how many compiled loops are still active. + for region in runtime.regionAt: + if region != nil: + inc result + +proc bytecode*(program: Program): lent seq[Instruction] {.inline.} = + ## Exposes the metered bytecode for tools and the native compiler. + program.code + +proc globalsAddress*(runtime: var Runtime): pointer {.inline.} = + ## Returns the base of the scalar global storage. + if runtime.globals.len == 0: nil else: runtime.globals[0].addr + +proc globalValue*(runtime: Runtime, index: int32): Value {.inline.} = + ## Reads one scalar global by index. + runtime.globals[int(index)] + +proc offset*(runtime: Runtime): int32 {.inline.} = + ## Returns the bytecode offset the runtime will execute next. + runtime.pc + +proc remainingBudget*(runtime: Runtime): (int64, int64) {.inline.} = + ## Returns the unspent instruction and work budgets. + (runtime.remainingInstructions, runtime.remainingWork) + proc globals*(program: Program): int {.inline.} = ## Returns the number of implicitly declared scalar globals. program.globalNames.len @@ -3554,6 +3529,30 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = while not runtime.finished: var item = fetch() if item.op == MeterOp: + if runtime.regionAt.len > 0 and runtime.pc != runtime.bypass: + let region = runtime.regionAt[int(runtime.pc)] + if region != nil: + var context = NativeContext( + globals: runtime.globals[0].addr, + remainingInstructions: runtime.remainingInstructions, + remainingWork: runtime.remainingWork, + pc: runtime.pc + ) + let status = region.invoke(context) + runtime.remainingInstructions = context.remainingInstructions + runtime.remainingWork = context.remainingWork + runtime.pc = context.pc + case status + of NativeCompleted: + runtime.bypass = -1 + of NativeExhausted: + # Let the interpreter re-run the meter and raise the real error. + runtime.bypass = context.pc + of NativeGuardFailed: + # A value stopped being an integer, so retire the compiled form. + runtime.regionAt[int(region.start)] = nil + runtime.bypass = -1 + continue let cost = int64(item.a) instructionCount = int64(item.b) diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim new file mode 100644 index 0000000..d79d865 --- /dev/null +++ b/src/bassy/arm64.nim @@ -0,0 +1,472 @@ +## Encodes the AArch64 subset the BASIC compiler needs. +## Every instruction is one 32-bit word, so labels patch in place. +## Encodings follow the Arm Architecture Reference Manual field layouts. + +import numbers + +type + Register* = distinct uint32 + + Width* = enum + ## Selects the 32-bit W or 64-bit X view of a register. + Word32, + Word64 + + Condition* = enum + ## Branch and select conditions, in architectural encoding order. + EqualCondition, + NotEqualCondition, + CarrySetCondition, + CarryClearCondition, + NegativeCondition, + PositiveCondition, + OverflowCondition, + NoOverflowCondition, + UnsignedGreaterCondition, + UnsignedLessEqualCondition, + GreaterEqualCondition, + LessCondition, + GreaterCondition, + LessEqualCondition, + AlwaysCondition + + Label* = distinct int + + FixupKind = enum + Branch26Fixup, + Branch19Fixup + + Fixup = object + kind: FixupKind + at: int + label: int + + Assembler* = object + ## Collects instruction words plus unresolved label references. + code*: seq[uint32] + targets: seq[int] + fixups: seq[Fixup] + +const + x0* = Register(0) + x1* = Register(1) + x2* = Register(2) + x3* = Register(3) + x4* = Register(4) + x5* = Register(5) + x6* = Register(6) + x7* = Register(7) + x8* = Register(8) + x9* = Register(9) + x10* = Register(10) + x11* = Register(11) + x12* = Register(12) + x13* = Register(13) + x14* = Register(14) + x15* = Register(15) + x16* = Register(16) + x19* = Register(19) + x20* = Register(20) + x21* = Register(21) + x22* = Register(22) + x23* = Register(23) + x24* = Register(24) + x25* = Register(25) + x26* = Register(26) + x27* = Register(27) + x28* = Register(28) + framePointer* = Register(29) + linkRegister* = Register(30) + zeroRegister* = Register(31) + stackPointer* = Register(31) + +proc number(register: Register): uint32 {.inline, raises: [].} = + ## Returns the five-bit encoding of a register. + uint32(register) and 31'u32 + +proc sizeBit(width: Width): uint32 {.inline, raises: [].} = + ## Returns the sf field that selects the 64-bit form. + if width == Word64: 1'u32 shl 31 else: 0'u32 + +proc fail(message: string) {.noreturn, raises: [BasicError].} = + ## Reports a controlled encoding failure. + raise newException(BasicError, "BASIC " & message) + +proc emit(assembler: var Assembler, word: uint32) {.inline, raises: [].} = + ## Appends one encoded instruction. + assembler.code.add(word) + +proc position*(assembler: Assembler): int {.inline, raises: [].} = + ## Returns the index of the next instruction word. + assembler.code.len + +## Labels + +proc label*(assembler: var Assembler): Label {.raises: [].} = + ## Reserves an unplaced branch target. + assembler.targets.add(-1) + Label(assembler.targets.len - 1) + +proc place*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Fixes a label at the current instruction position. + assembler.targets[int(target)] = assembler.code.len + +proc resolve*(assembler: var Assembler) {.raises: [BasicError].} = + ## Patches every recorded branch once all labels are placed. + for fixup in assembler.fixups: + let destination = assembler.targets[fixup.label] + if destination < 0: + fail("assembler label was never placed") + let distance = destination - fixup.at + case fixup.kind + of Branch26Fixup: + if distance < -(1 shl 25) or distance >= (1 shl 25): + fail("assembler branch is out of range") + assembler.code[fixup.at] = assembler.code[fixup.at] or + (uint32(distance) and 0x03FFFFFF'u32) + of Branch19Fixup: + if distance < -(1 shl 18) or distance >= (1 shl 18): + fail("assembler branch is out of range") + assembler.code[fixup.at] = assembler.code[fixup.at] or + ((uint32(distance) and 0x0007FFFF'u32) shl 5) + assembler.fixups.setLen(0) + +## Moves and immediates + +proc moveZero*(assembler: var Assembler, width: Width, destination: Register, + value: uint16, shift = 0) {.raises: [].} = + ## Writes a 16-bit field and zeroes the rest of the register. + let base = if width == Word64: 0xD2800000'u32 else: 0x52800000'u32 + assembler.emit( + base or (uint32(shift div 16) shl 21) or (uint32(value) shl 5) or + destination.number + ) + +proc moveNot*(assembler: var Assembler, width: Width, destination: Register, + value: uint16, shift = 0) {.raises: [].} = + ## Writes the inverse of a 16-bit field into a cleared register. + let base = if width == Word64: 0x92800000'u32 else: 0x12800000'u32 + assembler.emit( + base or (uint32(shift div 16) shl 21) or (uint32(value) shl 5) or + destination.number + ) + +proc moveKeep*(assembler: var Assembler, width: Width, destination: Register, + value: uint16, shift = 0) {.raises: [].} = + ## Overwrites one 16-bit field and keeps the others. + let base = if width == Word64: 0xF2800000'u32 else: 0x72800000'u32 + assembler.emit( + base or (uint32(shift div 16) shl 21) or (uint32(value) shl 5) or + destination.number + ) + +proc logical(assembler: var Assembler, base: uint32, width: Width, + destination, left, right: Register, shift: int) {.inline, raises: [].} = + ## Encodes one shifted-register logical instruction. + assembler.emit( + base or width.sizeBit or (right.number shl 16) or + (uint32(shift) shl 10) or (left.number shl 5) or destination.number + ) + +proc moveRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Copies one register into another through ORR with the zero register. + assembler.logical( + 0x2A000000'u32, width, destination, zeroRegister, source, 0 + ) + +proc loadImmediate*(assembler: var Assembler, width: Width, + destination: Register, value: int64) {.raises: [].} = + ## Materializes any constant using the fewest wide moves. + let bits = + if width == Word64: cast[uint64](value) + else: cast[uint64](value) and 0xFFFFFFFF'u64 + let fields = if width == Word64: 4 else: 2 + var negated = not bits + if width == Word32: + negated = negated and 0xFFFFFFFF'u64 + var zeroCount = 0 + var onesCount = 0 + for index in 0 ..< fields: + let field = uint16((bits shr (index * 16)) and 0xFFFF'u64) + if field == 0: + inc zeroCount + if field == 0xFFFF'u16: + inc onesCount + if onesCount > zeroCount: + var first = true + for index in 0 ..< fields: + let field = uint16((negated shr (index * 16)) and 0xFFFF'u64) + if first: + assembler.moveNot(width, destination, field, index * 16) + first = false + elif field != 0: + let keep = uint16((bits shr (index * 16)) and 0xFFFF'u64) + assembler.moveKeep(width, destination, keep, index * 16) + else: + var first = true + for index in 0 ..< fields: + let field = uint16((bits shr (index * 16)) and 0xFFFF'u64) + if field == 0 and not first: + continue + if first: + assembler.moveZero(width, destination, field, index * 16) + first = false + else: + assembler.moveKeep(width, destination, field, index * 16) + +## Arithmetic + +proc arithmeticImmediate(assembler: var Assembler, base: uint32, width: Width, + destination, source: Register, value: int) {.raises: [BasicError].} = + ## Encodes an add or subtract with a 12-bit unsigned immediate. + if value < 0 or value > 4095: + fail("assembler immediate is out of range") + assembler.emit( + base or width.sizeBit or (uint32(value) shl 10) or + (source.number shl 5) or destination.number + ) + +proc addImmediate*(assembler: var Assembler, width: Width, + destination, source: Register, value: int) {.raises: [BasicError].} = + ## Adds a small unsigned constant. + assembler.arithmeticImmediate( + 0x11000000'u32, width, destination, source, value + ) + +proc subtractImmediate*(assembler: var Assembler, width: Width, + destination, source: Register, value: int) {.raises: [BasicError].} = + ## Subtracts a small unsigned constant. + assembler.arithmeticImmediate( + 0x51000000'u32, width, destination, source, value + ) + +proc compareImmediate*(assembler: var Assembler, width: Width, + source: Register, value: int) {.raises: [BasicError].} = + ## Sets flags from a subtraction, discarding the difference. + assembler.arithmeticImmediate( + 0x71000000'u32, width, zeroRegister, source, value + ) + +proc arithmeticRegister(assembler: var Assembler, base: uint32, width: Width, + destination, left, right: Register, shift: int) {.inline, raises: [].} = + ## Encodes an add or subtract of an optionally shifted register. + assembler.emit( + base or width.sizeBit or (right.number shl 16) or + (uint32(shift) shl 10) or (left.number shl 5) or destination.number + ) + +proc addRegister*(assembler: var Assembler, width: Width, + destination, left, right: Register, shift = 0) {.raises: [].} = + ## Adds two registers, optionally shifting the second left. + assembler.arithmeticRegister( + 0x0B000000'u32, width, destination, left, right, shift + ) + +proc subtractRegister*(assembler: var Assembler, width: Width, + destination, left, right: Register, shift = 0) {.raises: [].} = + ## Subtracts the second register from the first. + assembler.arithmeticRegister( + 0x4B000000'u32, width, destination, left, right, shift + ) + +proc compareRegister*(assembler: var Assembler, width: Width, + left, right: Register) {.raises: [].} = + ## Sets flags from the difference of two registers. + assembler.arithmeticRegister( + 0x6B000000'u32, width, zeroRegister, left, right, 0 + ) + +proc negate*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Writes the two's complement negation of a register. + assembler.arithmeticRegister( + 0x4B000000'u32, width, destination, zeroRegister, source, 0 + ) + +proc multiplyAdd*(assembler: var Assembler, width: Width, + destination, left, right, addend: Register) {.raises: [].} = + ## Computes addend plus the product of two registers. + assembler.emit( + 0x1B000000'u32 or width.sizeBit or (right.number shl 16) or + (addend.number shl 10) or (left.number shl 5) or destination.number + ) + +proc multiplySubtract*(assembler: var Assembler, width: Width, + destination, left, right, minuend: Register) {.raises: [].} = + ## Subtracts the product of two registers from a third. + assembler.emit( + 0x1B008000'u32 or width.sizeBit or (right.number shl 16) or + (minuend.number shl 10) or (left.number shl 5) or destination.number + ) + +proc multiply*(assembler: var Assembler, width: Width, + destination, left, right: Register) {.raises: [].} = + ## Multiplies two registers. + assembler.multiplyAdd(width, destination, left, right, zeroRegister) + +proc signedDivide*(assembler: var Assembler, width: Width, + destination, left, right: Register) {.raises: [].} = + ## Divides with truncation toward zero, yielding zero on a zero divisor. + assembler.emit( + 0x1AC00C00'u32 or width.sizeBit or (right.number shl 16) or + (left.number shl 5) or destination.number + ) + +## Logic + +proc andRegister*(assembler: var Assembler, width: Width, + destination, left, right: Register, shift = 0) {.raises: [].} = + ## Computes a bitwise conjunction. + assembler.logical(0x0A000000'u32, width, destination, left, right, shift) + +proc orRegister*(assembler: var Assembler, width: Width, + destination, left, right: Register, shift = 0) {.raises: [].} = + ## Computes a bitwise disjunction. + assembler.logical(0x2A000000'u32, width, destination, left, right, shift) + +proc xorRegister*(assembler: var Assembler, width: Width, + destination, left, right: Register, shift = 0) {.raises: [].} = + ## Computes a bitwise exclusive disjunction. + assembler.logical(0x4A000000'u32, width, destination, left, right, shift) + +proc notRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Writes the bitwise complement of a register. + assembler.logical( + 0x2A200000'u32, width, destination, zeroRegister, source, 0 + ) + +proc setOnCondition*(assembler: var Assembler, width: Width, + destination: Register, condition: Condition) {.raises: [].} = + ## Writes BASIC's -1 when the condition holds and zero otherwise. + let inverted = uint32(ord(condition)) xor 1'u32 + assembler.emit( + 0x5A800000'u32 or width.sizeBit or (zeroRegister.number shl 16) or + (inverted shl 12) or (zeroRegister.number shl 5) or destination.number + ) + +## Memory + +proc scaledOffset(offset, scale: int): uint32 {.raises: [BasicError].} = + ## Converts a byte offset into the scaled 12-bit immediate field. + if offset < 0 or offset mod scale != 0 or (offset div scale) > 4095: + fail("assembler memory offset is out of range") + uint32(offset div scale) + +proc loadByte*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads one byte, zero-extending into the destination. + assembler.emit( + 0x39400000'u32 or (scaledOffset(offset, 1) shl 10) or + (base.number shl 5) or destination.number + ) + +proc storeByte*(assembler: var Assembler, source, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Stores the low byte of a register. + assembler.emit( + 0x39000000'u32 or (scaledOffset(offset, 1) shl 10) or + (base.number shl 5) or source.number + ) + +proc loadWord*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads 32 bits, zero-extending into the destination. + assembler.emit( + 0xB9400000'u32 or (scaledOffset(offset, 4) shl 10) or + (base.number shl 5) or destination.number + ) + +proc storeWord*(assembler: var Assembler, source, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Stores the low 32 bits of a register. + assembler.emit( + 0xB9000000'u32 or (scaledOffset(offset, 4) shl 10) or + (base.number shl 5) or source.number + ) + +proc loadDouble*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads 64 bits. + assembler.emit( + 0xF9400000'u32 or (scaledOffset(offset, 8) shl 10) or + (base.number shl 5) or destination.number + ) + +proc storeDouble*(assembler: var Assembler, source, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Stores 64 bits. + assembler.emit( + 0xF9000000'u32 or (scaledOffset(offset, 8) shl 10) or + (base.number shl 5) or source.number + ) + +proc storePair*(assembler: var Assembler, first, second, base: Register, + offset: int, preIndex = false) {.raises: [BasicError].} = + ## Stores two 64-bit registers, optionally updating the base first. + if offset mod 8 != 0 or offset div 8 < -64 or offset div 8 > 63: + fail("assembler memory offset is out of range") + let base32 = if preIndex: 0xA9800000'u32 else: 0xA9000000'u32 + assembler.emit( + base32 or ((uint32(offset div 8) and 0x7F'u32) shl 15) or + (second.number shl 10) or (base.number shl 5) or first.number + ) + +proc loadPair*(assembler: var Assembler, first, second, base: Register, + offset: int, postIndex = false) {.raises: [BasicError].} = + ## Loads two 64-bit registers, optionally updating the base afterward. + if offset mod 8 != 0 or offset div 8 < -64 or offset div 8 > 63: + fail("assembler memory offset is out of range") + let base32 = if postIndex: 0xA8C00000'u32 else: 0xA9400000'u32 + assembler.emit( + base32 or ((uint32(offset div 8) and 0x7F'u32) shl 15) or + (second.number shl 10) or (base.number shl 5) or first.number + ) + +## Branches + +proc branch*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Jumps unconditionally to a label. + assembler.fixups.add( + Fixup(kind: Branch26Fixup, at: assembler.code.len, label: int(target)) + ) + assembler.emit(0x14000000'u32) + +proc branchIf*(assembler: var Assembler, condition: Condition, + target: Label) {.raises: [].} = + ## Jumps to a label when the condition holds. + assembler.fixups.add( + Fixup(kind: Branch19Fixup, at: assembler.code.len, label: int(target)) + ) + assembler.emit(0x54000000'u32 or uint32(ord(condition))) + +proc branchIfZero*(assembler: var Assembler, width: Width, source: Register, + target: Label) {.raises: [].} = + ## Jumps to a label when a register holds zero. + assembler.fixups.add( + Fixup(kind: Branch19Fixup, at: assembler.code.len, label: int(target)) + ) + assembler.emit(0x34000000'u32 or width.sizeBit or source.number) + +proc branchIfNotZero*(assembler: var Assembler, width: Width, + source: Register, target: Label) {.raises: [].} = + ## Jumps to a label when a register holds anything but zero. + assembler.fixups.add( + Fixup(kind: Branch19Fixup, at: assembler.code.len, label: int(target)) + ) + assembler.emit(0x35000000'u32 or width.sizeBit or source.number) + +proc callRegister*(assembler: var Assembler, target: Register) + {.raises: [].} = + ## Calls the address held in a register, setting the link register. + assembler.emit(0xD63F0000'u32 or (target.number shl 5)) + +proc jumpRegister*(assembler: var Assembler, target: Register) + {.raises: [].} = + ## Jumps to the address held in a register without linking. + assembler.emit(0xD61F0000'u32 or (target.number shl 5)) + +proc returnToCaller*(assembler: var Assembler) {.raises: [].} = + ## Returns through the link register. + assembler.emit(0xD65F03C0'u32) diff --git a/src/bassy/bytecode.nim b/src/bassy/bytecode.nim new file mode 100644 index 0000000..f7088a5 --- /dev/null +++ b/src/bassy/bytecode.nim @@ -0,0 +1,73 @@ +## Defines the register bytecode shared by the interpreter and the +## native compiler. Opcodes carry up to three operands, whose meaning +## depends on the operation. + +type + Op* = enum + MeterOp, + LoadImmediateOp, + LoadFixedOp, + LoadStringOp, + TextCallOp, + MoveOp, + LoadGlobalOp, + LoadHostDataOp, + StoreGlobalOp, + StoreGlobalImmediateOp, + MoveGlobalOp, + AddGlobalImmediateOp, + AddGlobalOp, + AddGlobalHostDataOp, + AddGlobalRegisterOp, + ModuloGlobalImmediateOp, + AddGlobalArrayGlobalIndexOp, + AddOp, + SubtractOp, + MultiplyOp, + DivideOp, + IntegerDivideOp, + ModuloOp, + NegateOp, + EqualOp, + NotEqualOp, + LessOp, + LessEqualOp, + GreaterOp, + GreaterEqualOp, + AndOp, + OrOp, + XorOp, + EqvOp, + ImpOp, + NotOp, + JumpOp, + JumpIfZeroOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp, + ArrayGetOp, + ArraySetOp, + ArrayAddGlobalsOp, + SetArgumentOp, + SetArgumentImmediateOp, + SetArgumentGlobalOp, + HostCallOp, + CallOp, + GosubOp, + ReturnOp, + ReturnLabelOp, + ExitSubOp, + HaltOp, + PrintTextOp, + PrintValueOp, + PrintNewlineOp + + Instruction* = object + op*: Op + a*: int32 + b*: int32 + c*: int32 diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim new file mode 100644 index 0000000..ff08ea4 --- /dev/null +++ b/src/bassy/jit.nim @@ -0,0 +1,417 @@ +## Compiles hot integer loops from the register bytecode to machine code. +## +## A region is one backward-branching loop whose every operation is an +## integer operation on global variables. On entry the compiled code proves +## each participating global still holds an integer, hoists it into a +## machine register, and from then on runs without tags, without memory +## traffic, and without dispatch. Any operation the compiler does not +## model, and any value that is not an integer, leaves the loop to the +## interpreter, so the two always agree on results and on budgets. + +import + std/tables, + bytecode, machine, numbers + +export machine.jitSupported + +when defined(arm64): + import arm64 + +type + NativeStatus* = enum + ## Why compiled code returned control to the interpreter. + NativeCompleted, + NativeGuardFailed, + NativeExhausted + + NativeContext* = object + ## The mutable interpreter state compiled code is allowed to touch. + globals*: pointer + remainingInstructions*: int64 + remainingWork*: int64 + pc*: int32 + + NativeCall = proc(context: ptr NativeContext): int32 + {.cdecl, gcsafe, raises: [].} + + Region* = ref object + ## One compiled loop, addressed by the bytecode offset that enters it. + start*: int32 + stop*: int32 + hoisted*: seq[int32] + words*: int + buffer: CodeBuffer + call: NativeCall + +const + ValueStride = 16 + ValuePayload = 8 + MaxHoistedGlobals* = 7 + MaxRegionWords = 4096 + +## Region discovery + +proc isCompilable(item: Instruction): bool {.raises: [].} = + ## Reports whether one operation has a modelled integer translation. + case item.op + of MeterOp, JumpOp, StoreGlobalImmediateOp, MoveGlobalOp, + AddGlobalImmediateOp, AddGlobalOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + true + of JumpUnlessGlobalModuloEqualZeroOp: + # The interpreter raises on a zero divisor; refuse rather than model it. + item.b != 0 + else: + false + +proc touchedGlobals(item: Instruction, globals: var seq[int32]) + {.raises: [].} = + ## Records every global one operation reads or writes. + template note(index: int32) = + if index notin globals: + globals.add(index) + case item.op + of StoreGlobalImmediateOp, AddGlobalImmediateOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp: + note(item.a) + of MoveGlobalOp, AddGlobalOp: + note(item.a) + note(item.b) + else: + discard + +proc branchTarget(item: Instruction, target: var int32): bool + {.raises: [].} = + ## Reports whether an operation branches, and to where. + case item.op + of JumpOp: + target = item.a + true + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp: + target = item.c + true + else: + false + +proc reachesOutside(code: seq[Instruction], start, stop: int): bool + {.raises: [].} = + ## Reports whether the loop can be entered anywhere but its first offset. + for index in 0 ..< code.len: + if index >= start and index < stop: + continue + var target = 0'i32 + if code[index].branchTarget(target): + if int(target) > start and int(target) < stop: + return true + false + +when defined(arm64): + ## AArch64 code generation + ## + ## x0 context pointer, live for the whole region + ## x19 base of the globals array + ## x20 remaining instruction budget + ## x21 remaining work budget + ## x22+ hoisted globals, one per entry in the region's list + ## x9, x10 scratch + + const + GlobalsBase = x19 + RemainingInstructions = x20 + RemainingWork = x21 + FirstHoisted = 22 + Scratch = x9 + OtherScratch = x10 + ResumeOffset = x11 + ResumeStatus = x12 + FrameBytes = 96 + + proc hoistedRegister(slot: int): Register {.raises: [].} = + ## Returns the callee-saved register holding one hoisted global. + Register(uint32(FirstHoisted + slot)) + + proc saveRegisters(assembler: var Assembler) {.raises: [BasicError].} = + ## Preserves the callee-saved registers this region claims. + assembler.storePair( + framePointer, linkRegister, stackPointer, -FrameBytes, true + ) + assembler.storePair(x19, x20, stackPointer, 16) + assembler.storePair(x21, x22, stackPointer, 32) + assembler.storePair(x23, x24, stackPointer, 48) + assembler.storePair(x25, x26, stackPointer, 64) + assembler.storePair(x27, x28, stackPointer, 80) + + proc restoreRegisters(assembler: var Assembler) {.raises: [BasicError].} = + ## Restores the callee-saved registers and pops the frame. + assembler.loadPair(x19, x20, stackPointer, 16) + assembler.loadPair(x21, x22, stackPointer, 32) + assembler.loadPair(x23, x24, stackPointer, 48) + assembler.loadPair(x25, x26, stackPointer, 64) + assembler.loadPair(x27, x28, stackPointer, 80) + assembler.loadPair( + framePointer, linkRegister, stackPointer, FrameBytes, true + ) + + proc compareAgainst(assembler: var Assembler, left: Register, value: int32) + {.raises: [BasicError].} = + ## Compares a register with a constant, widening it when necessary. + if value >= 0 and value <= 4095: + assembler.compareImmediate(Word32, left, int(value)) + else: + assembler.loadImmediate(Word32, Scratch, int64(value)) + assembler.compareRegister(Word32, left, Scratch) + + proc addConstant(assembler: var Assembler, target: Register, value: int32) + {.raises: [BasicError].} = + ## Adds a constant to a register, widening it when necessary. + if value >= 0 and value <= 4095: + assembler.addImmediate(Word32, target, target, int(value)) + elif value < 0 and value >= -4095: + assembler.subtractImmediate(Word32, target, target, int(-value)) + else: + assembler.loadImmediate(Word32, Scratch, int64(value)) + assembler.addRegister(Word32, target, target, Scratch) + + proc jumpCondition(op: Op): Condition {.raises: [].} = + ## Returns the condition on which a fused test takes its branch. + case op + of JumpUnlessGlobalEqualImmediateOp: NotEqualCondition + of JumpUnlessGlobalNotEqualImmediateOp: EqualCondition + of JumpUnlessGlobalLessImmediateOp: GreaterEqualCondition + of JumpUnlessGlobalLessEqualImmediateOp: GreaterCondition + of JumpUnlessGlobalGreaterImmediateOp: LessEqualCondition + of JumpUnlessGlobalGreaterEqualImmediateOp: LessCondition + else: AlwaysCondition + +proc compileRegion*(code: seq[Instruction], start, stop: int): Region + {.raises: [BasicError].} = + ## Compiles one loop, or returns nil when it is outside the modelled set. + when not defined(arm64): + return nil + else: + if not jitSupported(): + return nil + if start < 0 or stop > code.len or start >= stop: + return nil + if code.reachesOutside(start, stop): + return nil + + var hoisted: seq[int32] + for index in start ..< stop: + let item = code[index] + if not item.isCompilable: + return nil + item.touchedGlobals(hoisted) + var target = 0'i32 + if item.branchTarget(target): + if int(target) < 0 or int(target) > code.len: + return nil + if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: + return nil + for index in hoisted: + if int(index) * ValueStride + ValuePayload > 4095: + return nil + + var assembler = Assembler() + var blocks: seq[Label] + for index in start ..< stop: + blocks.add(assembler.label()) + + proc slotOf(index: int32): int {.closure, raises: [].} = + ## Returns which hoisted register holds one global. + for slot, candidate in hoisted: + if candidate == index: + return slot + -1 + + template blockAt(offset: int32): Label = + blocks[int(offset) - start] + let guardFailed = assembler.label() + let resume = assembler.label() + var exits: seq[(Label, int32, NativeStatus)] + + template leaveFor(target: int32, status: NativeStatus) = + ## Branches to a stub that resumes the interpreter at an offset. + let stub = assembler.label() + exits.add((stub, target, status)) + assembler.branch(stub) + + ## Entry: prove every participating global is an integer, then hoist it. + assembler.saveRegisters() + assembler.loadDouble(GlobalsBase, x0, 0) + assembler.loadDouble(RemainingInstructions, x0, 8) + assembler.loadDouble(RemainingWork, x0, 16) + for slot, index in hoisted: + let base = int(index) * ValueStride + assembler.loadByte(Scratch, GlobalsBase, base) + assembler.branchIfNotZero(Word32, Scratch, guardFailed) + assembler.loadWord( + hoistedRegister(slot), GlobalsBase, base + ValuePayload + ) + + ## Body: one native block per bytecode offset, so branches keep working. + for index in start ..< stop: + let item = code[index] + assembler.place(blockAt(int32(index))) + + template branchOut(target: int32, condition: Condition) = + ## Takes an in-region branch directly, or leaves through a stub. + if int(target) >= start and int(target) < stop: + assembler.branchIf(condition, blockAt(target)) + else: + let taken = assembler.label() + let skipped = assembler.label() + assembler.branchIf(condition, taken) + assembler.branch(skipped) + assembler.place(taken) + leaveFor(target, NativeCompleted) + assembler.place(skipped) + + case item.op + of MeterOp: + # Both budgets are checked before either is charged, exactly as the + # interpreter does, so a refusal leaves the counters untouched and + # the interpreter raises the same error when it re-runs this offset. + assembler.loadImmediate(Word64, Scratch, int64(item.b)) + assembler.loadImmediate(Word64, OtherScratch, int64(item.a)) + let charge = assembler.label() + let short = assembler.label() + assembler.compareRegister(Word64, RemainingInstructions, Scratch) + assembler.branchIf(LessCondition, short) + assembler.compareRegister(Word64, RemainingWork, OtherScratch) + assembler.branchIf(GreaterEqualCondition, charge) + assembler.place(short) + leaveFor(int32(index), NativeExhausted) + assembler.place(charge) + assembler.subtractRegister( + Word64, RemainingInstructions, RemainingInstructions, Scratch + ) + assembler.subtractRegister( + Word64, RemainingWork, RemainingWork, OtherScratch + ) + of StoreGlobalImmediateOp: + assembler.loadImmediate( + Word32, hoistedRegister(slotOf(item.a)), int64(item.b) + ) + of MoveGlobalOp: + assembler.moveRegister( + Word32, hoistedRegister(slotOf(item.a)), + hoistedRegister(slotOf(item.b)) + ) + of AddGlobalImmediateOp: + assembler.addConstant(hoistedRegister(slotOf(item.a)), item.b) + of AddGlobalOp: + let target = hoistedRegister(slotOf(item.a)) + assembler.addRegister( + Word32, target, target, hoistedRegister(slotOf(item.b)) + ) + of JumpOp: + if int(item.a) >= start and int(item.a) < stop: + assembler.branch(blockAt(item.a)) + else: + leaveFor(item.a, NativeCompleted) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + assembler.compareAgainst(hoistedRegister(slotOf(item.a)), item.b) + branchOut(item.c, jumpCondition(item.op)) + of JumpUnlessGlobalModuloEqualZeroOp: + let source = hoistedRegister(slotOf(item.a)) + assembler.loadImmediate(Word32, Scratch, int64(item.b)) + assembler.signedDivide(Word32, OtherScratch, source, Scratch) + assembler.multiplySubtract( + Word32, OtherScratch, OtherScratch, Scratch, source + ) + assembler.compareImmediate(Word32, OtherScratch, 0) + branchOut(item.c, NotEqualCondition) + else: + return nil + + ## Falling off the last offset resumes the interpreter at the next one. + leaveFor(int32(stop), NativeCompleted) + + ## Exit stubs: name the resume offset and status, then share one path. + for (stub, target, status) in exits: + assembler.place(stub) + assembler.loadImmediate(Word32, ResumeOffset, int64(target)) + assembler.loadImmediate(Word32, ResumeStatus, int64(ord(status))) + assembler.branch(resume) + + ## Resume: publish the hoisted globals and the budgets, then return. + assembler.place(resume) + for slot, index in hoisted: + let base = int(index) * ValueStride + assembler.storeByte(zeroRegister, GlobalsBase, base) + assembler.storeWord( + hoistedRegister(slot), GlobalsBase, base + ValuePayload + ) + assembler.storeDouble(RemainingInstructions, x0, 8) + assembler.storeDouble(RemainingWork, x0, 16) + assembler.storeWord(ResumeOffset, x0, 24) + assembler.moveRegister(Word32, x0, ResumeStatus) + assembler.restoreRegisters() + assembler.returnToCaller() + + ## Guard failure happens before any global is written, so the loop is + ## simply handed back untouched for the interpreter to run. + assembler.place(guardFailed) + assembler.loadImmediate(Word32, ResumeOffset, int64(start)) + assembler.storeWord(ResumeOffset, x0, 24) + assembler.loadImmediate(Word32, x0, int64(ord(NativeGuardFailed))) + assembler.restoreRegisters() + assembler.returnToCaller() + + assembler.resolve() + if assembler.code.len > MaxRegionWords: + return nil + + result = Region( + start: int32(start), + stop: int32(stop), + hoisted: hoisted, + words: assembler.code.len + ) + result.buffer = initCodeBuffer(assembler.code.len * sizeof(uint32)) + result.buffer.write(assembler.code) + result.buffer.seal() + result.call = cast[NativeCall](result.buffer.entry) + +proc invoke*(region: Region, context: var NativeContext): NativeStatus + {.raises: [].} = + ## Runs one compiled loop and reports why it returned. + NativeStatus(region.call(context.addr)) + +proc compileLoops*(code: seq[Instruction]): Table[int32, Region] + {.raises: [BasicError].} = + ## Compiles every backward-branching loop the code generator models. + for index in 0 ..< code.len: + var target = 0'i32 + if not code[index].branchTarget(target): + continue + if int(target) > index or int(target) < 0: + continue + if target in result: + continue + let region = compileRegion(code, int(target), index + 1) + if region != nil: + result[target] = region diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim new file mode 100644 index 0000000..e964890 --- /dev/null +++ b/src/bassy/machine.nim @@ -0,0 +1,218 @@ +## Allocates writable-then-executable pages for generated machine code. +## Each platform enforces write-xor-execute differently, so the buffer +## keeps the page writable while emitting and seals it before any call. + +import numbers + +type + CodeBuffer* = object + ## One page-aligned region holding finished machine code. + memory: pointer + capacity: int + length: int + sealed: bool + +const PageBytes* = 4096 + +when defined(windows): + const + MemCommit = 0x1000'i32 + MemReserve = 0x2000'i32 + MemRelease = 0x8000'i32 + PageReadWrite = 0x04'i32 + PageExecuteRead = 0x20'i32 + + proc virtualAlloc(address: pointer, size: int, allocation, + protection: int32): pointer + {.importc: "VirtualAlloc", dynlib: "kernel32", stdcall.} + + proc virtualProtect(address: pointer, size: int, protection: int32, + previous: ptr int32): int32 + {.importc: "VirtualProtect", dynlib: "kernel32", stdcall.} + + proc virtualFree(address: pointer, size: int, freeType: int32): int32 + {.importc: "VirtualFree", dynlib: "kernel32", stdcall.} + + proc currentProcess(): pointer + {.importc: "GetCurrentProcess", dynlib: "kernel32", stdcall.} + + proc flushInstructionCache(process, address: pointer, size: int): int32 + {.importc: "FlushInstructionCache", dynlib: "kernel32", stdcall.} +else: + const + ProtNone = 0x0.cint + ProtRead = 0x1.cint + ProtWrite = 0x2.cint + ProtExec = 0x4.cint + MapPrivate = 0x0002.cint + MapFailed = -1 + + when defined(macosx): + const + MapAnonymous = 0x1000.cint + MapJit = 0x0800.cint + else: + const + MapAnonymous = 0x20.cint + MapJit = 0.cint + + proc mmap(address: pointer, length: csize_t, protection, flags, + handle: cint, offset: int): pointer + {.importc: "mmap", header: "".} + + proc mprotect(address: pointer, length: csize_t, protection: cint): cint + {.importc: "mprotect", header: "".} + + proc munmap(address: pointer, length: csize_t): cint + {.importc: "munmap", header: "".} + +when defined(macosx) and defined(arm64): + proc jitWriteProtect(enabled: cint) + {.importc: "pthread_jit_write_protect_np", header: "".} + + proc invalidateInstructionCache(address: pointer, length: csize_t) + {.importc: "sys_icache_invalidate", + header: "".} +elif defined(arm64): + proc clearCache(start, stop: pointer) + {.importc: "__builtin___clear_cache", nodecl.} + +proc fail(message: string) {.noreturn, raises: [BasicError].} = + ## Reports a controlled code buffer failure. + raise newException(BasicError, "BASIC " & message) + +proc jitSupported*(): bool {.raises: [].} = + ## Reports whether this build can emit and run native code. + when defined(arm64) or defined(amd64): + when defined(macosx) or defined(linux) or defined(windows): + true + else: + false + else: + false + +proc roundedToPage(size: int): int {.raises: [].} = + ## Rounds a byte count up to whole pages. + ((size + PageBytes - 1) div PageBytes) * PageBytes + +proc initCodeBuffer*(capacity: int): CodeBuffer {.raises: [BasicError].} = + ## Reserves writable pages sized to hold the requested byte count. + if capacity <= 0: + fail("code buffer capacity must be positive") + let size = roundedToPage(capacity) + when defined(windows): + let memory = virtualAlloc( + nil, size, MemCommit or MemReserve, PageReadWrite + ) + if memory == nil: + fail("code buffer reservation failed") + else: + let memory = mmap( + nil, + csize_t(size), + ProtRead or ProtWrite or ProtExec, + MapPrivate or MapAnonymous or MapJit, + -1, + 0 + ) + if cast[int](memory) == MapFailed: + fail("code buffer reservation failed") + result = CodeBuffer( + memory: memory, capacity: size, length: 0, sealed: false + ) + +proc len*(buffer: CodeBuffer): int {.inline, raises: [].} = + ## Returns how many bytes have been emitted so far. + buffer.length + +proc capacity*(buffer: CodeBuffer): int {.inline, raises: [].} = + ## Returns the reserved byte count, rounded up to whole pages. + buffer.capacity + +proc beginWrite(buffer: var CodeBuffer) {.raises: [].} = + ## Makes the pages writable on platforms that enforce write-xor-execute. + when defined(macosx) and defined(arm64): + jitWriteProtect(0) + +proc endWrite(buffer: var CodeBuffer) {.raises: [].} = + ## Restores execute permission after a batch of writes. + when defined(macosx) and defined(arm64): + jitWriteProtect(1) + +proc write*(buffer: var CodeBuffer, source: pointer, size: int) + {.raises: [BasicError].} = + ## Appends raw bytes, refusing to run past the reserved pages. + if buffer.sealed: + fail("code buffer is already sealed") + if size < 0 or buffer.length + size > buffer.capacity: + fail("code buffer capacity exceeded") + if size == 0: + return + buffer.beginWrite() + copyMem( + cast[pointer](cast[int](buffer.memory) + buffer.length), source, size + ) + buffer.endWrite() + buffer.length += size + +proc write*(buffer: var CodeBuffer, words: openArray[uint32]) + {.raises: [BasicError].} = + ## Appends fixed-width instruction words, as used by AArch64. + if words.len == 0: + return + buffer.write(words[0].addr, words.len * sizeof(uint32)) + +proc write*(buffer: var CodeBuffer, bytes: openArray[byte]) + {.raises: [BasicError].} = + ## Appends a variable-length instruction stream, as used by x86-64. + if bytes.len == 0: + return + buffer.write(bytes[0].addr, bytes.len) + +proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = + ## Publishes the emitted bytes so the processor may execute them. + if buffer.sealed: + return + if buffer.length == 0: + fail("code buffer holds no instructions") + when defined(windows): + var previous = 0'i32 + if virtualProtect( + buffer.memory, buffer.capacity, PageExecuteRead, previous.addr + ) == 0: + fail("code buffer could not be made executable") + discard flushInstructionCache( + currentProcess(), buffer.memory, buffer.length + ) + elif defined(macosx) and defined(arm64): + invalidateInstructionCache(buffer.memory, csize_t(buffer.length)) + else: + if mprotect( + buffer.memory, csize_t(buffer.capacity), ProtRead or ProtExec + ) != 0: + fail("code buffer could not be made executable") + when defined(arm64): + clearCache( + buffer.memory, + cast[pointer](cast[int](buffer.memory) + buffer.length) + ) + buffer.sealed = true + +proc entry*(buffer: CodeBuffer): pointer {.raises: [BasicError].} = + ## Returns the address of the first instruction once sealed. + if not buffer.sealed: + fail("code buffer must be sealed before it is called") + buffer.memory + +proc release*(buffer: var CodeBuffer) {.raises: [].} = + ## Returns the pages to the operating system. + if buffer.memory == nil: + return + when defined(windows): + discard virtualFree(buffer.memory, 0, MemRelease) + else: + discard munmap(buffer.memory, csize_t(buffer.capacity)) + buffer.memory = nil + buffer.capacity = 0 + buffer.length = 0 + buffer.sealed = false diff --git a/tests/bench_jit.nim b/tests/bench_jit.nim new file mode 100644 index 0000000..4e14ebf --- /dev/null +++ b/tests/bench_jit.nim @@ -0,0 +1,84 @@ +## Compares interpreted and natively compiled execution of the same loops. + +import + std/strformat, + benchy, + bassy + +const + ArithmeticSource = """ +i = 0 +total = 0 +while i < 1000000 + total = total + i + i = i + 1 +wend +""" + + BranchSource = """ +i = 0 +even = 0 +odd = 0 +while i < 1000000 + if i mod 2 = 0 then + even = even + 1 + else + odd = odd + 1 + end if + i = i + 1 +wend +""" + + NestedSource = """ +outer = 0 +hits = 0 +inner = 0 +while outer < 1000 + inner = 0 + while inner < 1000 + hits = hits + 1 + inner = inner + 1 + wend + outer = outer + 1 +wend +""" + +proc benchLimits(): Limits = + ## Returns limits large enough for every benchmark workload. + result = defaultLimits() + result.maxInstructions = 100_000_000 + result.maxWorkUnits = 100_000_000 + +proc measure(name, source: string) = + ## Times one script on both paths and prints the result of each. + let limits = benchLimits() + let program = compile(source, limits) + + var plain = initRuntime(program, limits) + var fast = initRuntime(program, limits) + let regions = fast.compileNative() + + timeIt &"{name} interpreted", 5: + plain.restart() + discard plain.run() + + timeIt &"{name} native ({regions} loops)", 5: + fast.restart() + discard fast.run() + + plain.restart() + discard plain.run() + fast.restart() + discard fast.run() + var agree = true + for index in 0 ..< program.globals: + if plain.globalValue(int32(index)).asInt != + fast.globalValue(int32(index)).asInt: + agree = false + echo &" results agree: {agree}, instructions charged: " & + &"{plain.instructionsUsed} vs {fast.instructionsUsed}" + +echo "native compilation available: ", jitSupported() +measure("arithmetic", ArithmeticSource) +measure("branches", BranchSource) +measure("nested", NestedSource) diff --git a/tests/test_arm64.nim b/tests/test_arm64.nim new file mode 100644 index 0000000..8bbd39b --- /dev/null +++ b/tests/test_arm64.nim @@ -0,0 +1,199 @@ +## Checks every AArch64 encoder against the system assembler. +## Each case names the instruction in assembly text and emits it with the +## encoder, then both byte streams must agree exactly. + +import + std/[os, osproc, sequtils, strutils], + bassy/arm64 + +var cases: seq[(string, seq[uint32])] + +template encodes(text: string, body: untyped) = + ## Records one assembly line beside the words the encoder produced. + block: + var assembler {.inject.} = Assembler() + body + assembler.resolve() + cases.add((text, assembler.code)) + +proc assembled(lines: seq[string]): seq[seq[uint32]] = + ## Assembles each line with clang and returns its instruction words. + let + directory = getTempDir() / "bassy-arm64-check" + source = directory / "check.s" + objectFile = directory / "check.o" + createDir(directory) + var text = "" + for line in lines: + text.add("\t" & line & "\n") + writeFile(source, text) + let build = execCmdEx( + "clang -c -target arm64-apple-macos -o " & objectFile & " " & source + ) + if build.exitCode != 0: + quit("assembler rejected a reference line:\n" & build.output) + let dump = execCmdEx("otool -t -X " & objectFile) + if dump.exitCode != 0: + quit("otool failed:\n" & dump.output) + var words: seq[uint32] + for line in dump.output.splitLines: + let fields = line.splitWhitespace() + if fields.len < 2: + continue + for index in 1 ..< fields.len: + words.add(uint32(parseHexInt(fields[index]))) + # Regroup the flat word stream back into per-case instruction counts. + var start = 0 + for (_, produced) in cases: + var chunk: seq[uint32] + for index in 0 ..< produced.len: + if start >= words.len: + quit("assembler produced fewer words than the encoder") + chunk.add(words[start]) + inc start + result.add(chunk) + if start != words.len: + quit("assembler produced more words than the encoder") + +## Moves and immediates + +encodes "mov w3, w7": + assembler.moveRegister(Word32, x3, x7) +encodes "mov x3, x7": + assembler.moveRegister(Word64, x3, x7) +encodes "movz x3, #4660, lsl #16": + assembler.moveZero(Word64, x3, 4660, 16) +encodes "movk w5, #255": + assembler.moveKeep(Word32, x5, 255) +encodes "movn x9, #1": + assembler.moveNot(Word64, x9, 1) + +## Arithmetic + +encodes "add w2, w2, w1": + assembler.addRegister(Word32, x2, x2, x1) +encodes "add x10, x11, x12, lsl #4": + assembler.addRegister(Word64, x10, x11, x12, 4) +encodes "add w1, w1, #1": + assembler.addImmediate(Word32, x1, x1, 1) +encodes "sub x4, x5, #4095": + assembler.subtractImmediate(Word64, x4, x5, 4095) +encodes "sub w6, w7, w8": + assembler.subtractRegister(Word32, x6, x7, x8) +encodes "cmp w1, w0": + assembler.compareRegister(Word32, x1, x0) +encodes "cmp x1, #17": + assembler.compareImmediate(Word64, x1, 17) +encodes "neg w3, w4": + assembler.negate(Word32, x3, x4) +encodes "mul w1, w2, w3": + assembler.multiply(Word32, x1, x2, x3) +encodes "madd x1, x2, x3, x4": + assembler.multiplyAdd(Word64, x1, x2, x3, x4) +encodes "msub w9, w10, w11, w12": + assembler.multiplySubtract(Word32, x9, x10, x11, x12) +encodes "sdiv w1, w2, w3": + assembler.signedDivide(Word32, x1, x2, x3) + +## Logic + +encodes "and w1, w2, w3": + assembler.andRegister(Word32, x1, x2, x3) +encodes "orr x1, x2, x3": + assembler.orRegister(Word64, x1, x2, x3) +encodes "eor w4, w5, w6": + assembler.xorRegister(Word32, x4, x5, x6) +encodes "mvn w7, w8": + assembler.notRegister(Word32, x7, x8) +encodes "csetm w1, lt": + assembler.setOnCondition(Word32, x1, LessCondition) +encodes "csetm w2, eq": + assembler.setOnCondition(Word32, x2, EqualCondition) +encodes "csetm x3, ge": + assembler.setOnCondition(Word64, x3, GreaterEqualCondition) + +## Memory + +encodes "ldr w5, [x6, #12]": + assembler.loadWord(x5, x6, 12) +encodes "str w7, [x8]": + assembler.storeWord(x7, x8) +encodes "ldr x9, [x10, #4088]": + assembler.loadDouble(x9, x10, 4088) +encodes "str x11, [x12, #16]": + assembler.storeDouble(x11, x12, 16) +encodes "stp x29, x30, [sp, #-32]!": + assembler.storePair(framePointer, linkRegister, stackPointer, -32, true) +encodes "ldp x29, x30, [sp], #32": + assembler.loadPair(framePointer, linkRegister, stackPointer, 32, true) +encodes "ldp x19, x20, [sp, #16]": + assembler.loadPair(x19, x20, stackPointer, 16) + +## Branches + +encodes "blr x16": + assembler.callRegister(x16) +encodes "br x9": + assembler.jumpRegister(x9) +encodes "ret": + assembler.returnToCaller() + +## Multi-word immediate construction + +encodes "movz w0, #0": + assembler.loadImmediate(Word32, x0, 0) +encodes "movz w0, #4660": + assembler.loadImmediate(Word32, x0, 4660) +encodes "movn w1, #60875": + assembler.loadImmediate(Word32, x1, 0xFFFF1234) +encodes "movn x2, #0": + assembler.loadImmediate(Word64, x2, -1) +encodes "movz x3, #22136\n\tmovk x3, #43981, lsl #32": + assembler.loadImmediate(Word64, x3, 0x0000ABCD_00005678'i64) + +## Branch displacement resolution + +block: + var assembler = Assembler() + let top = assembler.label() + let done = assembler.label() + assembler.place(top) + assembler.compareRegister(Word32, x1, x0) + assembler.branchIf(GreaterEqualCondition, done) + assembler.addRegister(Word32, x2, x2, x1) + assembler.branch(top) + assembler.place(done) + assembler.returnToCaller() + assembler.resolve() + cases.add(( + "cmp w1, w0\n\tb.ge 1f\n\tadd w2, w2, w1\n\tb . - 12\n1:\tret", + assembler.code + )) + +block: + var assembler = Assembler() + let done = assembler.label() + assembler.branchIfZero(Word32, x4, done) + assembler.branchIfNotZero(Word64, x5, done) + assembler.place(done) + assembler.returnToCaller() + assembler.resolve() + cases.add(("cbz w4, 1f\n\tcbnz x5, 1f\n1:\tret", assembler.code)) + +var lines: seq[string] +for (text, _) in cases: + lines.add(text) + +let reference = assembled(lines) +var failures = 0 +for index, (text, produced) in cases: + let expected = reference[index] + if produced != expected: + inc failures + echo "mismatch for: ", text.replace("\n\t", " ; ") + echo " encoder: ", produced.mapIt(it.toHex(8)).join(" ") + echo " assembler: ", expected.mapIt(it.toHex(8)).join(" ") + +if failures > 0: + quit($failures & " of " & $cases.len & " encodings disagree") +echo "all ", cases.len, " AArch64 encodings match the system assembler" diff --git a/tests/test_jit.nim b/tests/test_jit.nim new file mode 100644 index 0000000..2df780f --- /dev/null +++ b/tests/test_jit.nim @@ -0,0 +1,190 @@ +## Checks that compiled loops agree with the interpreter. +## Every script runs twice, once interpreted and once with its hot loops +## executed as machine code. The globals and both budgets must match +## exactly, because a script must not be able to tell which path ran. + +import + std/[strformat, strutils], + bassy + +type Outcome = object + globals: seq[Value] + instructions: int64 + work: int64 + failure: string + regions: int + +proc describe(value: Value): string = + ## Renders a global for comparison output. + case value.kind + of IntegerValue: $value.asInt + of FixedValue: $value.asFixed + of StringValue: "" + +proc execute(source: string, native: bool): Outcome = + ## Runs one script with or without native compilation. + let program = compile(source) + var runtime = initRuntime(program) + if native: + result.regions = runtime.compileNative() + try: + discard runtime.run() + except BasicError as error: + result.failure = error.msg + for index in 0 ..< program.globals: + result.globals.add(runtime.globalValue(int32(index))) + let (instructions, work) = runtime.remainingBudget + result.instructions = instructions + result.work = work + +proc check(name, source: string, expectRegions = true) = + ## Compares the two execution paths and reports any disagreement. + let plain = execute(source, false) + let fast = execute(source, true) + var problems: seq[string] + if expectRegions and fast.regions == 0: + problems.add("no loop was compiled") + if plain.globals.len != fast.globals.len: + problems.add("global count differs") + else: + for index in 0 ..< plain.globals.len: + if plain.globals[index].describe != fast.globals[index].describe: + problems.add( + &"global {index}: interpreted {plain.globals[index].describe} " & + &"but native {fast.globals[index].describe}" + ) + if plain.instructions != fast.instructions: + problems.add( + &"instruction budget: interpreted {plain.instructions} " & + &"but native {fast.instructions}" + ) + if plain.work != fast.work: + problems.add( + &"work budget: interpreted {plain.work} but native {fast.work}" + ) + if plain.failure != fast.failure: + problems.add( + &"failure: interpreted '{plain.failure}' but native '{fast.failure}'" + ) + if problems.len > 0: + echo "FAIL ", name + for problem in problems: + echo " ", problem + quit(1) + let note = + if fast.regions > 0: &"{fast.regions} compiled" + else: "interpreted only" + echo &" ok {name:<34} {note}" + +echo "native compilation available: ", jitSupported() + +check "counting loop", """ +i = 0 +total = 0 +while i < 1000 + total = total + i + i = i + 1 +wend +""" + +check "integer wraparound", """ +i = 0 +total = 0 +while i < 100000 + total = total + 987654321 + i = i + 1 +wend +""" + +check "modulo branch", """ +i = 0 +even = 0 +while i < 1000 + if i mod 2 = 0 then + even = even + 1 + end if + i = i + 1 +wend +""" + +check "negative step", """ +i = 100 +total = 0 +while i > 0 + total = total + i + i = i + -1 +wend +""" + +check "nested loops", """ +outer = 0 +inner = 0 +hits = 0 +while outer < 50 + inner = 0 + while inner < 20 + hits = hits + 1 + inner = inner + 1 + wend + outer = outer + 1 +wend +""" + +check "loop that never runs", """ +i = 500 +total = 0 +while i < 100 + total = total + 1 + i = i + 1 +wend +""" + +# A loop whose counter becomes fixed point must fall back to the +# interpreter without changing the answer. +check "fixed point defeats the guard", """ +i = 0.5 +total = 0 +while i < 10 + total = total + 1 + i = i + 1 +wend +""", expectRegions = false + +# The instruction budget has to be refused at exactly the same point. +proc checkBudget(name: string, source: string, maximum: int64) = + ## Compares budget exhaustion between the two paths. + var limits = defaultLimits() + limits.maxInstructions = maximum + let program = compile(source, limits) + var plainRuntime = initRuntime(program, limits) + var plainFailure = "" + try: + discard plainRuntime.run() + except BasicError as error: + plainFailure = error.msg + var fastRuntime = initRuntime(program, limits) + discard fastRuntime.compileNative() + var fastFailure = "" + try: + discard fastRuntime.run() + except BasicError as error: + fastFailure = error.msg + let (plainLeft, _) = plainRuntime.remainingBudget + let (fastLeft, _) = fastRuntime.remainingBudget + if plainFailure != fastFailure or plainLeft != fastLeft: + echo "FAIL ", name + echo &" interpreted '{plainFailure}' left {plainLeft}" + echo &" native '{fastFailure}' left {fastLeft}" + quit(1) + echo &" ok {name:<34} stopped with {plainLeft} left" + +checkBudget("instruction budget runs out", """ +i = 0 +total = 0 +while i < 1000000 + total = total + i + i = i + 1 +wend +""", 5000) + +echo "native compilation matches the interpreter" From 07be0e6b44e8d13b4fcc5f4e361802caf208ad75 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 13:42:28 -0700 Subject: [PATCH 02/51] Keep the interpreter as the only path where no backend exists Gate every platform declaration behind a single NativeCode constant, so a target without a code generator emits no mmap, VirtualAlloc or cache intrinsic at all. Add -d:bassyNoJit to force the interpreter on a target that would otherwise qualify. Verified three ways on this machine: native AArch64, -d:bassyNoJit, and a real emscripten wasm32 build run under node. All three agree with the interpreter on results and on budgets. Co-Authored-By: Claude Opus 5 --- src/bassy/jit.nim | 11 +++---- src/bassy/machine.nim | 70 +++++++++++++++++++++++++++---------------- tests/test_jit.nim | 2 +- 3 files changed, 52 insertions(+), 31 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index ff08ea4..15ec415 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -14,7 +14,9 @@ import export machine.jitSupported -when defined(arm64): +const NativeArm64 = NativeCode and defined(arm64) + +when NativeArm64: import arm64 type @@ -122,7 +124,7 @@ proc reachesOutside(code: seq[Instruction], start, stop: int): bool return true false -when defined(arm64): +when NativeArm64: ## AArch64 code generation ## ## x0 context pointer, live for the whole region @@ -203,11 +205,10 @@ when defined(arm64): proc compileRegion*(code: seq[Instruction], start, stop: int): Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. - when not defined(arm64): + when not NativeArm64: + # No backend for this target: the interpreter is the only path. return nil else: - if not jitSupported(): - return nil if start < 0 or stop > code.len or start >= stop: return nil if code.reachesOutside(start, stop): diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index e964890..f07a37a 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -12,9 +12,24 @@ type length: int sealed: bool -const PageBytes* = 4096 +const + PageBytes* = 4096 + + ## Whether this target can emit and run machine code at all. Everywhere + ## else, including WebAssembly, the interpreter is the only path and no + ## platform-specific declaration is emitted. Define bassyNoJit to force + ## the interpreter on a target that would otherwise qualify. + NativeCode* = + when defined(bassyNoJit): + false + elif defined(arm64) and ( + defined(macosx) or defined(linux) or defined(windows) + ): + true + else: + false -when defined(windows): +when NativeCode and defined(windows): const MemCommit = 0x1000'i32 MemReserve = 0x2000'i32 @@ -38,7 +53,7 @@ when defined(windows): proc flushInstructionCache(process, address: pointer, size: int): int32 {.importc: "FlushInstructionCache", dynlib: "kernel32", stdcall.} -else: +elif NativeCode: const ProtNone = 0x0.cint ProtRead = 0x1.cint @@ -66,14 +81,14 @@ else: proc munmap(address: pointer, length: csize_t): cint {.importc: "munmap", header: "".} -when defined(macosx) and defined(arm64): +when NativeCode and defined(macosx) and defined(arm64): proc jitWriteProtect(enabled: cint) {.importc: "pthread_jit_write_protect_np", header: "".} proc invalidateInstructionCache(address: pointer, length: csize_t) {.importc: "sys_icache_invalidate", header: "".} -elif defined(arm64): +elif NativeCode and defined(arm64): proc clearCache(start, stop: pointer) {.importc: "__builtin___clear_cache", nodecl.} @@ -81,15 +96,9 @@ proc fail(message: string) {.noreturn, raises: [BasicError].} = ## Reports a controlled code buffer failure. raise newException(BasicError, "BASIC " & message) -proc jitSupported*(): bool {.raises: [].} = +proc jitSupported*(): bool {.inline, raises: [].} = ## Reports whether this build can emit and run native code. - when defined(arm64) or defined(amd64): - when defined(macosx) or defined(linux) or defined(windows): - true - else: - false - else: - false + NativeCode proc roundedToPage(size: int): int {.raises: [].} = ## Rounds a byte count up to whole pages. @@ -100,12 +109,17 @@ proc initCodeBuffer*(capacity: int): CodeBuffer {.raises: [BasicError].} = if capacity <= 0: fail("code buffer capacity must be positive") let size = roundedToPage(capacity) - when defined(windows): + when not NativeCode: + fail("this build has no native code backend") + elif defined(windows): let memory = virtualAlloc( nil, size, MemCommit or MemReserve, PageReadWrite ) if memory == nil: fail("code buffer reservation failed") + result = CodeBuffer( + memory: memory, capacity: size, length: 0, sealed: false + ) else: let memory = mmap( nil, @@ -117,9 +131,9 @@ proc initCodeBuffer*(capacity: int): CodeBuffer {.raises: [BasicError].} = ) if cast[int](memory) == MapFailed: fail("code buffer reservation failed") - result = CodeBuffer( - memory: memory, capacity: size, length: 0, sealed: false - ) + result = CodeBuffer( + memory: memory, capacity: size, length: 0, sealed: false + ) proc len*(buffer: CodeBuffer): int {.inline, raises: [].} = ## Returns how many bytes have been emitted so far. @@ -131,12 +145,12 @@ proc capacity*(buffer: CodeBuffer): int {.inline, raises: [].} = proc beginWrite(buffer: var CodeBuffer) {.raises: [].} = ## Makes the pages writable on platforms that enforce write-xor-execute. - when defined(macosx) and defined(arm64): + when NativeCode and defined(macosx) and defined(arm64): jitWriteProtect(0) proc endWrite(buffer: var CodeBuffer) {.raises: [].} = ## Restores execute permission after a batch of writes. - when defined(macosx) and defined(arm64): + when NativeCode and defined(macosx) and defined(arm64): jitWriteProtect(1) proc write*(buffer: var CodeBuffer, source: pointer, size: int) @@ -175,7 +189,9 @@ proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = return if buffer.length == 0: fail("code buffer holds no instructions") - when defined(windows): + when not NativeCode: + fail("this build has no native code backend") + elif defined(windows): var previous = 0'i32 if virtualProtect( buffer.memory, buffer.capacity, PageExecuteRead, previous.addr @@ -184,8 +200,12 @@ proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = discard flushInstructionCache( currentProcess(), buffer.memory, buffer.length ) - elif defined(macosx) and defined(arm64): - invalidateInstructionCache(buffer.memory, csize_t(buffer.length)) + buffer.sealed = true + elif defined(macosx): + # MAP_JIT pages are already executable; only the cache needs a flush. + when defined(arm64): + invalidateInstructionCache(buffer.memory, csize_t(buffer.length)) + buffer.sealed = true else: if mprotect( buffer.memory, csize_t(buffer.capacity), ProtRead or ProtExec @@ -196,7 +216,7 @@ proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = buffer.memory, cast[pointer](cast[int](buffer.memory) + buffer.length) ) - buffer.sealed = true + buffer.sealed = true proc entry*(buffer: CodeBuffer): pointer {.raises: [BasicError].} = ## Returns the address of the first instruction once sealed. @@ -208,9 +228,9 @@ proc release*(buffer: var CodeBuffer) {.raises: [].} = ## Returns the pages to the operating system. if buffer.memory == nil: return - when defined(windows): + when NativeCode and defined(windows): discard virtualFree(buffer.memory, 0, MemRelease) - else: + elif NativeCode: discard munmap(buffer.memory, csize_t(buffer.capacity)) buffer.memory = nil buffer.capacity = 0 diff --git a/tests/test_jit.nim b/tests/test_jit.nim index 2df780f..0daaee6 100644 --- a/tests/test_jit.nim +++ b/tests/test_jit.nim @@ -42,7 +42,7 @@ proc check(name, source: string, expectRegions = true) = let plain = execute(source, false) let fast = execute(source, true) var problems: seq[string] - if expectRegions and fast.regions == 0: + if jitSupported() and expectRegions and fast.regions == 0: problems.add("no loop was compiled") if plain.globals.len != fast.globals.len: problems.add("global count differs") From 2de066c4b506febbd2fd89428549c8b50619e9ee Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 13:51:54 -0700 Subject: [PATCH 03/51] Add an x86-64 backend and cover every platform in CI Restructure the region compiler around a small set of emitters that each architecture supplies, so the walk over the bytecode is written once and AArch64 and x86-64 cannot drift apart. - amd64.nim encodes the x86-64 subset the compiler needs. Its test checks that each encoding decodes to the intended instruction rather than matching clang byte for byte, because clang picks shorter immediate and displacement forms that mean the same thing. - jit.nim gains the System V register assignment. Windows x64 passes its first argument elsewhere and preserves a different register set, so it keeps using the interpreter. - Dividing by one or minus one leaves no remainder, and minus one traps on x86, so neither ever reaches a divide instruction. - bench_jit.nim now times itself instead of pulling in benchy, so CI can report speedups on every runner. The matrix now covers arm64 and x86-64 on both Linux and macOS plus x86-64 Windows, which exercises the interpreter-only path. Verified on linux/amd64 in Docker: results and both budgets match the interpreter on every case, including budget exhaustion and the guard fallback. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 17 +- src/bassy/amd64.nim | 345 +++++++++++++++++++++++ src/bassy/jit.nim | 527 +++++++++++++++++++++++++----------- src/bassy/machine.nim | 4 + tests/bench_jit.nim | 50 ++-- tests/test_amd64.nim | 164 +++++++++++ tests/test_arm64.nim | 4 + 7 files changed, 936 insertions(+), 175 deletions(-) create mode 100644 src/bassy/amd64.nim create mode 100644 tests/test_amd64.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c90f5c9..8bb0cfc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -15,7 +15,12 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest, windows-latest] + os: + - ubuntu-latest # x86-64 Linux, native backend + - ubuntu-24.04-arm # arm64 Linux, native backend + - macos-latest # arm64 macOS, native backend + - macos-13 # x86-64 macOS, native backend + - windows-latest # x86-64 Windows, interpreter only runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 @@ -46,6 +51,16 @@ jobs: run: nim r -d:fixedChecks tests/tests.nim - name: Test C++ determinism run: nim cpp -r -d:release tests/tests.nim + - name: Test the machine code encoders against the system assembler + run: | + nim r -d:release tests/test_arm64.nim + nim r -d:release tests/test_amd64.nim + - name: Test compiled loops against the interpreter + run: nim r -d:release tests/test_jit.nim + - name: Test the same scripts with the interpreter forced + run: nim r -d:release -d:bassyNoJit tests/test_jit.nim + - name: Report native compilation speedups + run: nim r -d:release tests/bench_jit.nim - name: Run examples run: | nim r examples/hello.nim diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim new file mode 100644 index 0000000..a58ece5 --- /dev/null +++ b/src/bassy/amd64.nim @@ -0,0 +1,345 @@ +## Encodes the x86-64 subset the BASIC compiler needs. +## Instructions vary in length, so labels record the displacement field +## and patch it once every target is placed. + +import numbers + +type + Register* = distinct uint32 + + Width* = enum + ## Selects the 32-bit or 64-bit operand size. + Word32, + Word64 + + Condition* = enum + ## Branch conditions, in architectural encoding order. + OverflowCondition, + NoOverflowCondition, + BelowCondition, + AboveEqualCondition, + EqualCondition, + NotEqualCondition, + BelowEqualCondition, + AboveCondition, + SignCondition, + NoSignCondition, + ParityCondition, + NoParityCondition, + LessCondition, + GreaterEqualCondition, + LessEqualCondition, + GreaterCondition + + Label* = distinct int + + Fixup = object + at: int + next: int + label: int + + Assembler* = object + ## Collects encoded bytes plus unresolved label references. + code*: seq[byte] + targets: seq[int] + fixups: seq[Fixup] + +const + rax* = Register(0) + rcx* = Register(1) + rdx* = Register(2) + rbx* = Register(3) + rsp* = Register(4) + rbp* = Register(5) + rsi* = Register(6) + rdi* = Register(7) + r8* = Register(8) + r9* = Register(9) + r10* = Register(10) + r11* = Register(11) + r12* = Register(12) + r13* = Register(13) + r14* = Register(14) + r15* = Register(15) + +proc number(register: Register): uint32 {.inline, raises: [].} = + ## Returns the four-bit encoding of a register. + uint32(register) and 15'u32 + +proc fail(message: string) {.noreturn, raises: [BasicError].} = + ## Reports a controlled encoding failure. + raise newException(BasicError, "BASIC " & message) + +proc emit(assembler: var Assembler, value: byte) {.inline, raises: [].} = + ## Appends one encoded byte. + assembler.code.add(value) + +proc emitDouble(assembler: var Assembler, value: int32) {.raises: [].} = + ## Appends a little-endian 32-bit field. + let bits = cast[uint32](value) + for shift in [0, 8, 16, 24]: + assembler.emit(byte((bits shr shift) and 0xFF'u32)) + +proc emitQuad(assembler: var Assembler, value: int64) {.raises: [].} = + ## Appends a little-endian 64-bit field. + let bits = cast[uint64](value) + for shift in [0, 8, 16, 24, 32, 40, 48, 56]: + assembler.emit(byte((bits shr shift) and 0xFF'u64)) + +proc position*(assembler: Assembler): int {.inline, raises: [].} = + ## Returns the offset of the next byte. + assembler.code.len + +## Prefixes and operand encoding + +proc prefix(assembler: var Assembler, width: Width, reg, rm: Register) + {.raises: [].} = + ## Emits a REX prefix when the operands or the width require one. + var value = 0x40'u32 + if width == Word64: + value = value or 0x08'u32 + if reg.number >= 8: + value = value or 0x04'u32 + if rm.number >= 8: + value = value or 0x01'u32 + if value != 0x40'u32: + assembler.emit(byte(value)) + +proc directOperand(assembler: var Assembler, reg, rm: Register) + {.raises: [].} = + ## Encodes a register-to-register operand pair. + assembler.emit( + byte(0xC0'u32 or ((reg.number and 7'u32) shl 3) or (rm.number and 7'u32)) + ) + +proc memoryOperand(assembler: var Assembler, reg, base: Register, + displacement: int) {.raises: [BasicError].} = + ## Encodes a register plus a base register with a displacement. + if base.number == 4 or base.number == 12: + fail("assembler cannot address through this base register") + let low = (reg.number and 7'u32) shl 3 + let rm = base.number and 7'u32 + # An r13 base always needs an explicit displacement byte. + if displacement == 0 and rm != 5: + assembler.emit(byte(0x00'u32 or low or rm)) + elif displacement >= -128 and displacement <= 127: + assembler.emit(byte(0x40'u32 or low or rm)) + assembler.emit(byte(cast[uint8](int8(displacement)))) + else: + assembler.emit(byte(0x80'u32 or low or rm)) + assembler.emitDouble(int32(displacement)) + +## Labels + +proc label*(assembler: var Assembler): Label {.raises: [].} = + ## Reserves an unplaced branch target. + assembler.targets.add(-1) + Label(assembler.targets.len - 1) + +proc place*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Fixes a label at the current byte offset. + assembler.targets[int(target)] = assembler.code.len + +proc resolve*(assembler: var Assembler) {.raises: [BasicError].} = + ## Patches every recorded displacement once all labels are placed. + for fixup in assembler.fixups: + let destination = assembler.targets[fixup.label] + if destination < 0: + fail("assembler label was never placed") + let distance = destination - fixup.next + if distance < low(int32) or distance > high(int32): + fail("assembler branch is out of range") + let bits = cast[uint32](int32(distance)) + for index in 0 ..< 4: + assembler.code[fixup.at + index] = + byte((bits shr (index * 8)) and 0xFF'u32) + assembler.fixups.setLen(0) + +## Moves + +proc moveRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Copies one register into another. + assembler.prefix(width, source, destination) + assembler.emit(0x89) + assembler.directOperand(source, destination) + +proc loadImmediate*(assembler: var Assembler, width: Width, + destination: Register, value: int64) {.raises: [].} = + ## Materializes a constant, using the shortest form that holds it. + if width == Word32 or (value >= 0 and value <= high(int32)): + if destination.number >= 8: + assembler.emit(0x41) + assembler.emit(byte(0xB8'u32 + (destination.number and 7'u32))) + assembler.emitDouble(int32(value)) + else: + assembler.prefix(Word64, Register(0), destination) + assembler.emit(byte(0xB8'u32 + (destination.number and 7'u32))) + assembler.emitQuad(value) + +## Memory + +proc loadWord*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads 32 bits into the destination. + assembler.prefix(Word32, destination, base) + assembler.emit(0x8B) + assembler.memoryOperand(destination, base, offset) + +proc storeWord*(assembler: var Assembler, source, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Stores the low 32 bits of a register. + assembler.prefix(Word32, source, base) + assembler.emit(0x89) + assembler.memoryOperand(source, base, offset) + +proc loadDouble*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads 64 bits into the destination. + assembler.prefix(Word64, destination, base) + assembler.emit(0x8B) + assembler.memoryOperand(destination, base, offset) + +proc storeDouble*(assembler: var Assembler, source, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Stores 64 bits from a register. + assembler.prefix(Word64, source, base) + assembler.emit(0x89) + assembler.memoryOperand(source, base, offset) + +proc loadByteZeroed*(assembler: var Assembler, destination, base: Register, + offset = 0) {.raises: [BasicError].} = + ## Loads one byte, zero-extending it into the destination. + assembler.prefix(Word32, destination, base) + assembler.emit(0x0F) + assembler.emit(0xB6) + assembler.memoryOperand(destination, base, offset) + +proc storeByteImmediate*(assembler: var Assembler, base: Register, + offset: int, value: byte) {.raises: [BasicError].} = + ## Stores a constant byte through a base register. + assembler.prefix(Word32, Register(0), base) + assembler.emit(0xC6) + assembler.memoryOperand(Register(0), base, offset) + assembler.emit(value) + +## Arithmetic + +proc addRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Adds the source into the destination. + assembler.prefix(width, source, destination) + assembler.emit(0x01) + assembler.directOperand(source, destination) + +proc subtractRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Subtracts the source from the destination. + assembler.prefix(width, source, destination) + assembler.emit(0x29) + assembler.directOperand(source, destination) + +proc groupImmediate(assembler: var Assembler, width: Width, + extension: uint32, target: Register, value: int32) {.raises: [].} = + ## Encodes one of the immediate arithmetic forms by its opcode extension. + assembler.prefix(width, Register(extension), target) + assembler.emit(0x81) + assembler.directOperand(Register(extension), target) + assembler.emitDouble(value) + +proc addImmediate*(assembler: var Assembler, width: Width, + target: Register, value: int32) {.raises: [].} = + ## Adds a constant to a register. + assembler.groupImmediate(width, 0, target, value) + +proc subtractImmediate*(assembler: var Assembler, width: Width, + target: Register, value: int32) {.raises: [].} = + ## Subtracts a constant from a register. + assembler.groupImmediate(width, 5, target, value) + +proc compareImmediate*(assembler: var Assembler, width: Width, + target: Register, value: int32) {.raises: [].} = + ## Sets flags from a register against a constant. + assembler.groupImmediate(width, 7, target, value) + +proc compareRegister*(assembler: var Assembler, width: Width, + left, right: Register) {.raises: [].} = + ## Sets flags from the difference of two registers. + assembler.prefix(width, right, left) + assembler.emit(0x39) + assembler.directOperand(right, left) + +proc testRegister*(assembler: var Assembler, width: Width, + left, right: Register) {.raises: [].} = + ## Sets flags from the conjunction of two registers. + assembler.prefix(width, right, left) + assembler.emit(0x85) + assembler.directOperand(right, left) + +proc multiplyRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Multiplies the destination by the source. + assembler.prefix(width, destination, source) + assembler.emit(0x0F) + assembler.emit(0xAF) + assembler.directOperand(destination, source) + +proc signExtendToPair*(assembler: var Assembler, width: Width) + {.raises: [].} = + ## Widens the accumulator into the high half before a signed divide. + if width == Word64: + assembler.emit(0x48) + assembler.emit(0x99) + +proc signedDivide*(assembler: var Assembler, width: Width, + divisor: Register) {.raises: [].} = + ## Divides the widened accumulator, leaving the remainder in rdx. + assembler.prefix(width, Register(7), divisor) + assembler.emit(0xF7) + assembler.directOperand(Register(7), divisor) + +## Stack and control flow + +proc push*(assembler: var Assembler, source: Register) {.raises: [].} = + ## Pushes a 64-bit register. + if source.number >= 8: + assembler.emit(0x41) + assembler.emit(byte(0x50'u32 + (source.number and 7'u32))) + +proc pop*(assembler: var Assembler, destination: Register) {.raises: [].} = + ## Pops a 64-bit register. + if destination.number >= 8: + assembler.emit(0x41) + assembler.emit(byte(0x58'u32 + (destination.number and 7'u32))) + +proc branch*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Jumps unconditionally to a label. + assembler.emit(0xE9) + assembler.fixups.add( + Fixup(at: assembler.code.len, next: assembler.code.len + 4, + label: int(target)) + ) + assembler.emitDouble(0) + +proc branchIf*(assembler: var Assembler, condition: Condition, + target: Label) {.raises: [].} = + ## Jumps to a label when the condition holds. + assembler.emit(0x0F) + assembler.emit(byte(0x80'u32 + uint32(ord(condition)))) + assembler.fixups.add( + Fixup(at: assembler.code.len, next: assembler.code.len + 4, + label: int(target)) + ) + assembler.emitDouble(0) + +proc returnToCaller*(assembler: var Assembler) {.raises: [].} = + ## Returns to the caller. + assembler.emit(0xC3) + +proc storeWordImmediate*(assembler: var Assembler, base: Register, + offset: int, value: int32) {.raises: [BasicError].} = + ## Stores a 32-bit constant through a base register. + assembler.prefix(Word32, Register(0), base) + assembler.emit(0xC7) + assembler.memoryOperand(Register(0), base, offset) + assembler.emitDouble(value) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 15ec415..dbbabc4 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -7,6 +7,9 @@ ## traffic, and without dispatch. Any operation the compiler does not ## model, and any value that is not an integer, leaves the loop to the ## interpreter, so the two always agree on results and on budgets. +## +## The region walker below is written once. Each architecture supplies the +## same small set of emitters, so AArch64 and x86-64 stay in step. import std/tables, @@ -14,10 +17,14 @@ import export machine.jitSupported -const NativeArm64 = NativeCode and defined(arm64) +const + NativeArm64* = NativeCode and defined(arm64) + NativeAmd64* = NativeCode and defined(amd64) when NativeArm64: import arm64 +elif NativeAmd64: + import amd64 type NativeStatus* = enum @@ -41,15 +48,27 @@ type start*: int32 stop*: int32 hoisted*: seq[int32] - words*: int + size*: int buffer: CodeBuffer call: NativeCall + Test = enum + ## An architecture-neutral branch condition. + EqualTest, + NotEqualTest, + LessTest, + LessEqualTest, + GreaterTest, + GreaterEqualTest + const ValueStride = 16 ValuePayload = 8 + ContextInstructions = 8 + ContextWork = 16 + ContextOffset = 24 MaxHoistedGlobals* = 7 - MaxRegionWords = 4096 + MaxRegionBytes = 32 * 1024 ## Region discovery @@ -112,6 +131,17 @@ proc branchTarget(item: Instruction, target: var int32): bool else: false +proc takenOn(op: Op): Test {.raises: [].} = + ## Returns the condition on which a fused test takes its branch. + case op + of JumpUnlessGlobalEqualImmediateOp: NotEqualTest + of JumpUnlessGlobalNotEqualImmediateOp: EqualTest + of JumpUnlessGlobalLessImmediateOp: GreaterEqualTest + of JumpUnlessGlobalLessEqualImmediateOp: GreaterTest + of JumpUnlessGlobalGreaterImmediateOp: LessEqualTest + of JumpUnlessGlobalGreaterEqualImmediateOp: LessTest + else: NotEqualTest + proc reachesOutside(code: seq[Instruction], start, stop: int): bool {.raises: [].} = ## Reports whether the loop can be entered anywhere but its first offset. @@ -132,80 +162,310 @@ when NativeArm64: ## x20 remaining instruction budget ## x21 remaining work budget ## x22+ hoisted globals, one per entry in the region's list - ## x9, x10 scratch + ## x9, x10 scratch; x11, x12 resume offset and status const + Context = x0 GlobalsBase = x19 - RemainingInstructions = x20 - RemainingWork = x21 + Instructions = x20 + Work = x21 FirstHoisted = 22 Scratch = x9 OtherScratch = x10 ResumeOffset = x11 ResumeStatus = x12 FrameBytes = 96 + MaxDisplacement = 4095 - proc hoistedRegister(slot: int): Register {.raises: [].} = + proc slotRegister(slot: int): Register {.raises: [].} = ## Returns the callee-saved register holding one hoisted global. Register(uint32(FirstHoisted + slot)) - proc saveRegisters(assembler: var Assembler) {.raises: [BasicError].} = - ## Preserves the callee-saved registers this region claims. - assembler.storePair( + proc nativeCondition(test: Test): Condition {.raises: [].} = + ## Maps a neutral condition onto the architecture's encoding. + case test + of EqualTest: EqualCondition + of NotEqualTest: NotEqualCondition + of LessTest: LessCondition + of LessEqualTest: LessEqualCondition + of GreaterTest: GreaterCondition + of GreaterEqualTest: GreaterEqualCondition + + proc branchWhen(emitter: var Assembler, test: Test, target: Label) + {.raises: [].} = + ## Branches when the neutral condition holds. + emitter.branchIf(nativeCondition(test), target) + + proc startRegion(emitter: var Assembler) {.raises: [BasicError].} = + ## Saves callee-saved registers and loads the interpreter state. + emitter.storePair( framePointer, linkRegister, stackPointer, -FrameBytes, true ) - assembler.storePair(x19, x20, stackPointer, 16) - assembler.storePair(x21, x22, stackPointer, 32) - assembler.storePair(x23, x24, stackPointer, 48) - assembler.storePair(x25, x26, stackPointer, 64) - assembler.storePair(x27, x28, stackPointer, 80) - - proc restoreRegisters(assembler: var Assembler) {.raises: [BasicError].} = - ## Restores the callee-saved registers and pops the frame. - assembler.loadPair(x19, x20, stackPointer, 16) - assembler.loadPair(x21, x22, stackPointer, 32) - assembler.loadPair(x23, x24, stackPointer, 48) - assembler.loadPair(x25, x26, stackPointer, 64) - assembler.loadPair(x27, x28, stackPointer, 80) - assembler.loadPair( + emitter.storePair(x19, x20, stackPointer, 16) + emitter.storePair(x21, x22, stackPointer, 32) + emitter.storePair(x23, x24, stackPointer, 48) + emitter.storePair(x25, x26, stackPointer, 64) + emitter.storePair(x27, x28, stackPointer, 80) + emitter.loadDouble(GlobalsBase, Context, 0) + emitter.loadDouble(Instructions, Context, ContextInstructions) + emitter.loadDouble(Work, Context, ContextWork) + + proc endRegion(emitter: var Assembler) {.raises: [BasicError].} = + ## Restores callee-saved registers and returns to the interpreter. + emitter.loadPair(x19, x20, stackPointer, 16) + emitter.loadPair(x21, x22, stackPointer, 32) + emitter.loadPair(x23, x24, stackPointer, 48) + emitter.loadPair(x25, x26, stackPointer, 64) + emitter.loadPair(x27, x28, stackPointer, 80) + emitter.loadPair( framePointer, linkRegister, stackPointer, FrameBytes, true ) + emitter.returnToCaller() + + proc guardInteger(emitter: var Assembler, base: int, failed: Label) + {.raises: [BasicError].} = + ## Leaves the region unless the global at this offset holds an integer. + emitter.loadByte(Scratch, GlobalsBase, base) + emitter.branchIfNotZero(Word32, Scratch, failed) + + proc loadHoisted(emitter: var Assembler, slot: int, base: int) + {.raises: [BasicError].} = + ## Reads one global into its register. + emitter.loadWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + + proc storeHoisted(emitter: var Assembler, slot: int, base: int) + {.raises: [BasicError].} = + ## Publishes one register back as an integer value. + emitter.storeByte(zeroRegister, GlobalsBase, base) + emitter.storeWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + + proc setSlot(emitter: var Assembler, slot: int, value: int32) + {.raises: [BasicError].} = + ## Loads a constant into a hoisted register. + emitter.loadImmediate(Word32, slotRegister(slot), int64(value)) + + proc copySlot(emitter: var Assembler, destination, source: int) + {.raises: [BasicError].} = + ## Copies one hoisted register into another. + emitter.moveRegister( + Word32, slotRegister(destination), slotRegister(source) + ) + + proc addSlots(emitter: var Assembler, destination, source: int) + {.raises: [BasicError].} = + ## Adds one hoisted register into another, wrapping on overflow. + let target = slotRegister(destination) + emitter.addRegister(Word32, target, target, slotRegister(source)) - proc compareAgainst(assembler: var Assembler, left: Register, value: int32) + proc addToSlot(emitter: var Assembler, slot: int, value: int32) {.raises: [BasicError].} = - ## Compares a register with a constant, widening it when necessary. - if value >= 0 and value <= 4095: - assembler.compareImmediate(Word32, left, int(value)) + ## Adds a constant to a hoisted register, wrapping on overflow. + let target = slotRegister(slot) + if value >= 0 and value <= MaxDisplacement: + emitter.addImmediate(Word32, target, target, int(value)) + elif value < 0 and value >= -MaxDisplacement: + emitter.subtractImmediate(Word32, target, target, int(-value)) else: - assembler.loadImmediate(Word32, Scratch, int64(value)) - assembler.compareRegister(Word32, left, Scratch) + emitter.loadImmediate(Word32, Scratch, int64(value)) + emitter.addRegister(Word32, target, target, Scratch) - proc addConstant(assembler: var Assembler, target: Register, value: int32) + proc compareSlot(emitter: var Assembler, slot: int, value: int32) {.raises: [BasicError].} = - ## Adds a constant to a register, widening it when necessary. - if value >= 0 and value <= 4095: - assembler.addImmediate(Word32, target, target, int(value)) - elif value < 0 and value >= -4095: - assembler.subtractImmediate(Word32, target, target, int(-value)) + ## Sets flags from a hoisted register against a constant. + let target = slotRegister(slot) + if value >= 0 and value <= MaxDisplacement: + emitter.compareImmediate(Word32, target, int(value)) else: - assembler.loadImmediate(Word32, Scratch, int64(value)) - assembler.addRegister(Word32, target, target, Scratch) - - proc jumpCondition(op: Op): Condition {.raises: [].} = - ## Returns the condition on which a fused test takes its branch. - case op - of JumpUnlessGlobalEqualImmediateOp: NotEqualCondition - of JumpUnlessGlobalNotEqualImmediateOp: EqualCondition - of JumpUnlessGlobalLessImmediateOp: GreaterEqualCondition - of JumpUnlessGlobalLessEqualImmediateOp: GreaterCondition - of JumpUnlessGlobalGreaterImmediateOp: LessEqualCondition - of JumpUnlessGlobalGreaterEqualImmediateOp: LessCondition - else: AlwaysCondition + emitter.loadImmediate(Word32, Scratch, int64(value)) + emitter.compareRegister(Word32, target, Scratch) + + proc remainderTest(emitter: var Assembler, slot: int, divisor: int32) + {.raises: [BasicError].} = + ## Sets flags so NotEqualTest means the remainder is not zero. + let source = slotRegister(slot) + emitter.loadImmediate(Word32, Scratch, int64(divisor)) + emitter.signedDivide(Word32, OtherScratch, source, Scratch) + emitter.multiplySubtract( + Word32, OtherScratch, OtherScratch, Scratch, source + ) + emitter.compareImmediate(Word32, OtherScratch, 0) + + proc budgetGate(emitter: var Assembler, instructionCount, workCost: int64, + short: Label) {.raises: [BasicError].} = + ## Checks both budgets before charging either, as the interpreter does. + emitter.loadImmediate(Word64, Scratch, instructionCount) + emitter.compareRegister(Word64, Instructions, Scratch) + emitter.branchIf(LessCondition, short) + emitter.loadImmediate(Word64, OtherScratch, workCost) + emitter.compareRegister(Word64, Work, OtherScratch) + emitter.branchIf(LessCondition, short) + emitter.subtractRegister(Word64, Instructions, Instructions, Scratch) + emitter.subtractRegister(Word64, Work, Work, OtherScratch) + + proc exitStub(emitter: var Assembler, offset: int32, status: int32, + writeback: Label) {.raises: [BasicError].} = + ## Names the resume offset and status, then joins the shared exit. + emitter.loadImmediate(Word32, ResumeOffset, int64(offset)) + emitter.loadImmediate(Word32, ResumeStatus, int64(status)) + emitter.branch(writeback) + + proc publishState(emitter: var Assembler) {.raises: [BasicError].} = + ## Writes the budgets, the resume offset, and the status. + emitter.storeDouble(Instructions, Context, ContextInstructions) + emitter.storeDouble(Work, Context, ContextWork) + emitter.storeWord(ResumeOffset, Context, ContextOffset) + emitter.moveRegister(Word32, Context, ResumeStatus) + + proc guardExit(emitter: var Assembler, start: int32) + {.raises: [BasicError].} = + ## Hands the loop back untouched after a failed guard. + emitter.loadImmediate(Word32, ResumeOffset, int64(start)) + emitter.storeWord(ResumeOffset, Context, ContextOffset) + emitter.loadImmediate(Word32, Context, int64(ord(NativeGuardFailed))) + emitter.endRegion() + +elif NativeAmd64: + ## x86-64 code generation, System V calling convention + ## + ## rdi context pointer, live for the whole region + ## rbx base of the globals array + ## r12 remaining instruction budget + ## r13 remaining work budget + ## r14, r15, rsi, rcx, r8, r9, r10 hoisted globals + ## rax and rdx are reserved for the divide; r11 is scratch + + const + Context = rdi + GlobalsBase = rbx + Instructions = r12 + Work = r13 + Scratch = r11 + Hoisted = [r14, r15, rsi, rcx, r8, r9, r10] + Saved = [rbx, r12, r13, r14, r15] + + proc slotRegister(slot: int): Register {.raises: [].} = + ## Returns the register holding one hoisted global. + Hoisted[slot] + + proc nativeCondition(test: Test): Condition {.raises: [].} = + ## Maps a neutral condition onto the architecture's encoding. + case test + of EqualTest: EqualCondition + of NotEqualTest: NotEqualCondition + of LessTest: LessCondition + of LessEqualTest: LessEqualCondition + of GreaterTest: GreaterCondition + of GreaterEqualTest: GreaterEqualCondition + + proc branchWhen(emitter: var Assembler, test: Test, target: Label) + {.raises: [].} = + ## Branches when the neutral condition holds. + emitter.branchIf(nativeCondition(test), target) + + proc startRegion(emitter: var Assembler) {.raises: [BasicError].} = + ## Saves callee-saved registers and loads the interpreter state. + for register in Saved: + emitter.push(register) + emitter.loadDouble(GlobalsBase, Context, 0) + emitter.loadDouble(Instructions, Context, ContextInstructions) + emitter.loadDouble(Work, Context, ContextWork) + + proc endRegion(emitter: var Assembler) {.raises: [BasicError].} = + ## Restores callee-saved registers and returns to the interpreter. + for index in countdown(Saved.len - 1, 0): + emitter.pop(Saved[index]) + emitter.returnToCaller() + + proc guardInteger(emitter: var Assembler, base: int, failed: Label) + {.raises: [BasicError].} = + ## Leaves the region unless the global at this offset holds an integer. + emitter.loadByteZeroed(Scratch, GlobalsBase, base) + emitter.testRegister(Word32, Scratch, Scratch) + emitter.branchIf(NotEqualCondition, failed) + + proc loadHoisted(emitter: var Assembler, slot: int, base: int) + {.raises: [BasicError].} = + ## Reads one global into its register. + emitter.loadWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + + proc storeHoisted(emitter: var Assembler, slot: int, base: int) + {.raises: [BasicError].} = + ## Publishes one register back as an integer value. + emitter.storeByteImmediate(GlobalsBase, base, 0) + emitter.storeWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + + proc setSlot(emitter: var Assembler, slot: int, value: int32) + {.raises: [BasicError].} = + ## Loads a constant into a hoisted register. + emitter.loadImmediate(Word32, slotRegister(slot), int64(value)) + + proc copySlot(emitter: var Assembler, destination, source: int) + {.raises: [BasicError].} = + ## Copies one hoisted register into another. + emitter.moveRegister( + Word32, slotRegister(destination), slotRegister(source) + ) + + proc addSlots(emitter: var Assembler, destination, source: int) + {.raises: [BasicError].} = + ## Adds one hoisted register into another, wrapping on overflow. + emitter.addRegister( + Word32, slotRegister(destination), slotRegister(source) + ) + + proc addToSlot(emitter: var Assembler, slot: int, value: int32) + {.raises: [BasicError].} = + ## Adds a constant to a hoisted register, wrapping on overflow. + emitter.addImmediate(Word32, slotRegister(slot), value) + + proc compareSlot(emitter: var Assembler, slot: int, value: int32) + {.raises: [BasicError].} = + ## Sets flags from a hoisted register against a constant. + emitter.compareImmediate(Word32, slotRegister(slot), value) + + proc remainderTest(emitter: var Assembler, slot: int, divisor: int32) + {.raises: [BasicError].} = + ## Sets flags so NotEqualTest means the remainder is not zero. + emitter.moveRegister(Word32, rax, slotRegister(slot)) + emitter.signExtendToPair(Word32) + emitter.loadImmediate(Word32, Scratch, int64(divisor)) + emitter.signedDivide(Word32, Scratch) + emitter.testRegister(Word32, rdx, rdx) + + proc budgetGate(emitter: var Assembler, instructionCount, workCost: int64, + short: Label) {.raises: [BasicError].} = + ## Checks both budgets before charging either, as the interpreter does. + ## Both counts come from int32 operands, so they fit the immediate form. + emitter.compareImmediate(Word64, Instructions, int32(instructionCount)) + emitter.branchIf(LessCondition, short) + emitter.compareImmediate(Word64, Work, int32(workCost)) + emitter.branchIf(LessCondition, short) + emitter.subtractImmediate(Word64, Instructions, int32(instructionCount)) + emitter.subtractImmediate(Word64, Work, int32(workCost)) + + proc exitStub(emitter: var Assembler, offset: int32, status: int32, + writeback: Label) {.raises: [BasicError].} = + ## Names the resume offset and status, then joins the shared exit. + emitter.storeWordImmediate(Context, ContextOffset, offset) + emitter.loadImmediate(Word32, rax, int64(status)) + emitter.branch(writeback) + + proc publishState(emitter: var Assembler) {.raises: [BasicError].} = + ## Writes the budgets back; the status already sits in the result. + emitter.storeDouble(Instructions, Context, ContextInstructions) + emitter.storeDouble(Work, Context, ContextWork) + + proc guardExit(emitter: var Assembler, start: int32) + {.raises: [BasicError].} = + ## Hands the loop back untouched after a failed guard. + emitter.storeWordImmediate(Context, ContextOffset, start) + emitter.loadImmediate(Word32, rax, int64(ord(NativeGuardFailed))) + emitter.endRegion() proc compileRegion*(code: seq[Instruction], start, stop: int): Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. - when not NativeArm64: + when not (NativeArm64 or NativeAmd64): # No backend for this target: the interpreter is the only path. return nil else: @@ -226,14 +486,11 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region return nil if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: return nil - for index in hoisted: - if int(index) * ValueStride + ValuePayload > 4095: - return nil - - var assembler = Assembler() - var blocks: seq[Label] - for index in start ..< stop: - blocks.add(assembler.label()) + when NativeArm64: + # The guard reads the tag through a scaled byte offset. + for index in hoisted: + if int(index) * ValueStride + ValuePayload > 4095: + return nil proc slotOf(index: int32): int {.closure, raises: [].} = ## Returns which hoisted register holds one global. @@ -242,90 +499,68 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region return slot -1 + var emitter = Assembler() + var blocks: seq[Label] + for index in start ..< stop: + blocks.add(emitter.label()) + let guardFailed = emitter.label() + let writeback = emitter.label() + var exits: seq[(Label, int32, NativeStatus)] + template blockAt(offset: int32): Label = blocks[int(offset) - start] - let guardFailed = assembler.label() - let resume = assembler.label() - var exits: seq[(Label, int32, NativeStatus)] template leaveFor(target: int32, status: NativeStatus) = ## Branches to a stub that resumes the interpreter at an offset. - let stub = assembler.label() + let stub = emitter.label() exits.add((stub, target, status)) - assembler.branch(stub) + emitter.branch(stub) ## Entry: prove every participating global is an integer, then hoist it. - assembler.saveRegisters() - assembler.loadDouble(GlobalsBase, x0, 0) - assembler.loadDouble(RemainingInstructions, x0, 8) - assembler.loadDouble(RemainingWork, x0, 16) + emitter.startRegion() for slot, index in hoisted: let base = int(index) * ValueStride - assembler.loadByte(Scratch, GlobalsBase, base) - assembler.branchIfNotZero(Word32, Scratch, guardFailed) - assembler.loadWord( - hoistedRegister(slot), GlobalsBase, base + ValuePayload - ) + emitter.guardInteger(base, guardFailed) + emitter.loadHoisted(slot, base) ## Body: one native block per bytecode offset, so branches keep working. for index in start ..< stop: let item = code[index] - assembler.place(blockAt(int32(index))) + emitter.place(blockAt(int32(index))) - template branchOut(target: int32, condition: Condition) = + template branchOut(target: int32, test: Test) = ## Takes an in-region branch directly, or leaves through a stub. if int(target) >= start and int(target) < stop: - assembler.branchIf(condition, blockAt(target)) + emitter.branchWhen(test, blockAt(target)) else: - let taken = assembler.label() - let skipped = assembler.label() - assembler.branchIf(condition, taken) - assembler.branch(skipped) - assembler.place(taken) + let taken = emitter.label() + let skipped = emitter.label() + emitter.branchWhen(test, taken) + emitter.branch(skipped) + emitter.place(taken) leaveFor(target, NativeCompleted) - assembler.place(skipped) + emitter.place(skipped) case item.op of MeterOp: - # Both budgets are checked before either is charged, exactly as the - # interpreter does, so a refusal leaves the counters untouched and - # the interpreter raises the same error when it re-runs this offset. - assembler.loadImmediate(Word64, Scratch, int64(item.b)) - assembler.loadImmediate(Word64, OtherScratch, int64(item.a)) - let charge = assembler.label() - let short = assembler.label() - assembler.compareRegister(Word64, RemainingInstructions, Scratch) - assembler.branchIf(LessCondition, short) - assembler.compareRegister(Word64, RemainingWork, OtherScratch) - assembler.branchIf(GreaterEqualCondition, charge) - assembler.place(short) + let short = emitter.label() + let past = emitter.label() + emitter.budgetGate(int64(item.b), int64(item.a), short) + emitter.branch(past) + emitter.place(short) leaveFor(int32(index), NativeExhausted) - assembler.place(charge) - assembler.subtractRegister( - Word64, RemainingInstructions, RemainingInstructions, Scratch - ) - assembler.subtractRegister( - Word64, RemainingWork, RemainingWork, OtherScratch - ) + emitter.place(past) of StoreGlobalImmediateOp: - assembler.loadImmediate( - Word32, hoistedRegister(slotOf(item.a)), int64(item.b) - ) + emitter.setSlot(slotOf(item.a), item.b) of MoveGlobalOp: - assembler.moveRegister( - Word32, hoistedRegister(slotOf(item.a)), - hoistedRegister(slotOf(item.b)) - ) + emitter.copySlot(slotOf(item.a), slotOf(item.b)) of AddGlobalImmediateOp: - assembler.addConstant(hoistedRegister(slotOf(item.a)), item.b) + emitter.addToSlot(slotOf(item.a), item.b) of AddGlobalOp: - let target = hoistedRegister(slotOf(item.a)) - assembler.addRegister( - Word32, target, target, hoistedRegister(slotOf(item.b)) - ) + emitter.addSlots(slotOf(item.a), slotOf(item.b)) of JumpOp: if int(item.a) >= start and int(item.a) < stop: - assembler.branch(blockAt(item.a)) + emitter.branch(blockAt(item.a)) else: leaveFor(item.a, NativeCompleted) of JumpUnlessGlobalEqualImmediateOp, @@ -334,66 +569,46 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region JumpUnlessGlobalLessEqualImmediateOp, JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: - assembler.compareAgainst(hoistedRegister(slotOf(item.a)), item.b) - branchOut(item.c, jumpCondition(item.op)) + emitter.compareSlot(slotOf(item.a), item.b) + branchOut(item.c, takenOn(item.op)) of JumpUnlessGlobalModuloEqualZeroOp: - let source = hoistedRegister(slotOf(item.a)) - assembler.loadImmediate(Word32, Scratch, int64(item.b)) - assembler.signedDivide(Word32, OtherScratch, source, Scratch) - assembler.multiplySubtract( - Word32, OtherScratch, OtherScratch, Scratch, source - ) - assembler.compareImmediate(Word32, OtherScratch, 0) - branchOut(item.c, NotEqualCondition) + # Dividing by one or minus one always leaves no remainder, and + # minus one would trap on x86, so never emit the divide for those. + if item.b != 1 and item.b != -1: + emitter.remainderTest(slotOf(item.a), item.b) + branchOut(item.c, NotEqualTest) else: return nil ## Falling off the last offset resumes the interpreter at the next one. leaveFor(int32(stop), NativeCompleted) - ## Exit stubs: name the resume offset and status, then share one path. for (stub, target, status) in exits: - assembler.place(stub) - assembler.loadImmediate(Word32, ResumeOffset, int64(target)) - assembler.loadImmediate(Word32, ResumeStatus, int64(ord(status))) - assembler.branch(resume) + emitter.place(stub) + emitter.exitStub(target, int32(ord(status)), writeback) - ## Resume: publish the hoisted globals and the budgets, then return. - assembler.place(resume) + ## Shared exit: publish the hoisted globals and budgets, then return. + emitter.place(writeback) for slot, index in hoisted: - let base = int(index) * ValueStride - assembler.storeByte(zeroRegister, GlobalsBase, base) - assembler.storeWord( - hoistedRegister(slot), GlobalsBase, base + ValuePayload - ) - assembler.storeDouble(RemainingInstructions, x0, 8) - assembler.storeDouble(RemainingWork, x0, 16) - assembler.storeWord(ResumeOffset, x0, 24) - assembler.moveRegister(Word32, x0, ResumeStatus) - assembler.restoreRegisters() - assembler.returnToCaller() + emitter.storeHoisted(slot, int(index) * ValueStride) + emitter.publishState() + emitter.endRegion() ## Guard failure happens before any global is written, so the loop is ## simply handed back untouched for the interpreter to run. - assembler.place(guardFailed) - assembler.loadImmediate(Word32, ResumeOffset, int64(start)) - assembler.storeWord(ResumeOffset, x0, 24) - assembler.loadImmediate(Word32, x0, int64(ord(NativeGuardFailed))) - assembler.restoreRegisters() - assembler.returnToCaller() - - assembler.resolve() - if assembler.code.len > MaxRegionWords: + emitter.place(guardFailed) + emitter.guardExit(int32(start)) + + emitter.resolve() + let size = emitter.code.len * sizeof(emitter.code[0]) + if size > MaxRegionBytes: return nil result = Region( - start: int32(start), - stop: int32(stop), - hoisted: hoisted, - words: assembler.code.len + start: int32(start), stop: int32(stop), hoisted: hoisted, size: size ) - result.buffer = initCodeBuffer(assembler.code.len * sizeof(uint32)) - result.buffer.write(assembler.code) + result.buffer = initCodeBuffer(size) + result.buffer.write(emitter.code) result.buffer.seal() result.call = cast[NativeCall](result.buffer.entry) diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index f07a37a..689e2ba 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -26,6 +26,10 @@ const defined(macosx) or defined(linux) or defined(windows) ): true + elif defined(amd64) and (defined(macosx) or defined(linux)): + # Windows x64 passes its first argument elsewhere and saves a + # different register set, so it stays on the interpreter for now. + true else: false diff --git a/tests/bench_jit.nim b/tests/bench_jit.nim index 4e14ebf..578133f 100644 --- a/tests/bench_jit.nim +++ b/tests/bench_jit.nim @@ -1,11 +1,14 @@ -## Compares interpreted and natively compiled execution of the same loops. +## Reports how much faster compiled loops run than the interpreter. +## Times its own runs so it can execute on any CI machine without pulling +## in a benchmarking dependency. import - std/strformat, - benchy, + std/[monotimes, strformat, times], bassy const + Runs = 5 + ArithmeticSource = """ i = 0 total = 0 @@ -49,8 +52,19 @@ proc benchLimits(): Limits = result.maxInstructions = 100_000_000 result.maxWorkUnits = 100_000_000 +proc fastest(runtime: var Runtime): float = + ## Returns the shortest of several runs, in milliseconds. + result = Inf + for run in 1 .. Runs: + runtime.restart() + let started = getMonoTime() + discard runtime.run() + let elapsed = (getMonoTime() - started).inNanoseconds.float / 1_000_000.0 + if elapsed < result: + result = elapsed + proc measure(name, source: string) = - ## Times one script on both paths and prints the result of each. + ## Times one script on both paths and reports the ratio. let limits = benchLimits() let program = compile(source, limits) @@ -58,27 +72,27 @@ proc measure(name, source: string) = var fast = initRuntime(program, limits) let regions = fast.compileNative() - timeIt &"{name} interpreted", 5: - plain.restart() - discard plain.run() + let plainTime = plain.fastest() + let fastTime = fast.fastest() - timeIt &"{name} native ({regions} loops)", 5: - fast.restart() - discard fast.run() - - plain.restart() - discard plain.run() - fast.restart() - discard fast.run() var agree = true for index in 0 ..< program.globals: if plain.globalValue(int32(index)).asInt != fast.globalValue(int32(index)).asInt: agree = false - echo &" results agree: {agree}, instructions charged: " & - &"{plain.instructionsUsed} vs {fast.instructionsUsed}" + let charged = plain.instructionsUsed == fast.instructionsUsed + + let ratio = + if fastTime > 0.0 and regions > 0: &"{plainTime / fastTime:6.1f}x" + else: " --" + echo &" {name:<12} interpreted {plainTime:8.3f} ms " & + &"native {fastTime:8.3f} ms {ratio} " & + &"loops {regions} results {agree} budget {charged}" + if not agree or not charged: + quit("the two paths disagreed") -echo "native compilation available: ", jitSupported() +echo &"native compilation available: {jitSupported()}" +echo &"host: {hostCPU} {hostOS}" measure("arithmetic", ArithmeticSource) measure("branches", BranchSource) measure("nested", NestedSource) diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim new file mode 100644 index 0000000..744c91e --- /dev/null +++ b/tests/test_amd64.nim @@ -0,0 +1,164 @@ +## Checks every x86-64 encoder against the system assembler. +## Each case names the instruction in Intel syntax and emits it with the +## encoder, then both byte streams must agree exactly. + +import + std/[os, osproc, sequtils, strutils], + bassy/amd64 + +var cases: seq[(string, seq[byte])] + +template encodes(text: string, body: untyped) = + ## Records one assembly line beside the bytes the encoder produced. + block: + var assembler {.inject.} = Assembler() + body + assembler.resolve() + cases.add((text, assembler.code)) + +proc disassemble(body: string, name: string): seq[string] = + ## Assembles a fragment and returns its disassembled instruction text. + ## Both the encoder's bytes and the reference line go through this same + ## path, so a shorter but equivalent encoding still compares equal. + let + directory = getTempDir() / "bassy-amd64-check" + source = directory / name & ".s" + objectFile = directory / name & ".o" + createDir(directory) + writeFile(source, ".intel_syntax noprefix\n" & body) + let build = execCmdEx( + "clang -c -target x86_64-apple-macos -o " & objectFile & " " & source + ) + if build.exitCode != 0: + quit("assembler rejected " & name & ":\n" & build.output & body) + let dump = execCmdEx("otool -tV -X -arch x86_64 " & objectFile) + if dump.exitCode != 0: + quit("otool failed:\n" & dump.output) + for line in dump.output.splitLines: + let fields = line.splitWhitespace() + if fields.len < 2 or not fields[0].endsWith(":"): + continue + result.add(fields[1 .. ^1].join(" ")) + +proc byteLines(bytes: seq[byte]): string = + ## Renders encoder output as assembler byte directives. + for value in bytes: + result.add("\t.byte 0x" & value.toHex(2) & "\n") + +if findExe("otool") == "" or findExe("clang") == "": + echo "skipping: needs clang and otool for the reference encoding" + quit(0) + +## Moves + +encodes "mov ebx, esi": + assembler.moveRegister(Word32, rbx, rsi) +encodes "mov rbx, r14": + assembler.moveRegister(Word64, rbx, r14) +encodes "mov r15, rdi": + assembler.moveRegister(Word64, r15, rdi) +encodes "mov ecx, 4660": + assembler.loadImmediate(Word32, rcx, 4660) +encodes "mov r9d, -1": + assembler.loadImmediate(Word32, r9, -1) +encodes "movabs r10, 4294967296": + assembler.loadImmediate(Word64, r10, 4294967296'i64) + +## Memory + +encodes "mov esi, dword ptr [rbx + 24]": + assembler.loadWord(rsi, rbx, 24) +encodes "mov dword ptr [rbx + 4104], r14d": + assembler.storeWord(r14, rbx, 4104) +encodes "mov rbx, qword ptr [rdi]": + assembler.loadDouble(rbx, rdi, 0) +encodes "mov r12, qword ptr [rdi + 8]": + assembler.loadDouble(r12, rdi, 8) +encodes "mov qword ptr [rdi + 16], r13": + assembler.storeDouble(r13, rdi, 16) +encodes "movzx eax, byte ptr [rbx + 32]": + assembler.loadByteZeroed(rax, rbx, 32) +encodes "movzx r11d, byte ptr [rbx]": + assembler.loadByteZeroed(r11, rbx, 0) +encodes "mov byte ptr [rbx + 48], 0": + assembler.storeByteImmediate(rbx, 48, 0) + +## Arithmetic + +encodes "add esi, ecx": + assembler.addRegister(Word32, rsi, rcx) +encodes "add r14d, r15d": + assembler.addRegister(Word32, r14, r15) +encodes "sub r12, rax": + assembler.subtractRegister(Word64, r12, rax) +encodes "add ecx, 1": + assembler.addImmediate(Word32, rcx, 1) +encodes "add r8d, -3": + assembler.addImmediate(Word32, r8, -3) +encodes "sub esi, 100": + assembler.subtractImmediate(Word32, rsi, 100) +encodes "cmp r14d, 1000000": + assembler.compareImmediate(Word32, r14, 1000000) +encodes "cmp esi, ecx": + assembler.compareRegister(Word32, rsi, rcx) +encodes "cmp r12, rax": + assembler.compareRegister(Word64, r12, rax) +encodes "test edx, edx": + assembler.testRegister(Word32, rdx, rdx) +encodes "imul ecx, esi": + assembler.multiplyRegister(Word32, rcx, rsi) +encodes "cdq": + assembler.signExtendToPair(Word32) +encodes "cqo": + assembler.signExtendToPair(Word64) +encodes "idiv r11d": + assembler.signedDivide(Word32, r11) +encodes "idiv ecx": + assembler.signedDivide(Word32, rcx) + +## Stack and control flow + +encodes "push rbx": + assembler.push(rbx) +encodes "push r15": + assembler.push(r15) +encodes "pop r12": + assembler.pop(r12) +encodes "pop rbp": + assembler.pop(rbp) +encodes "ret": + assembler.returnToCaller() + +## Branch displacement resolution + +block: + var assembler = Assembler() + let top = assembler.label() + let done = assembler.label() + assembler.place(top) + assembler.compareImmediate(Word32, rsi, 10) + assembler.branchIf(GreaterEqualCondition, done) + assembler.addRegister(Word32, rcx, rsi) + assembler.branch(top) + assembler.place(done) + assembler.returnToCaller() + assembler.resolve() + cases.add(( + "1:\tcmp esi, 10\n\tjge 2f\n\tadd ecx, esi\n\tjmp 1b\n2:\tret", + assembler.code + )) + +var failures = 0 +for index, (expected, produced) in cases: + let wanted = disassemble("\t" & expected & "\n", "wanted" & $index) + let got = disassemble(produced.byteLines, "got" & $index) + if wanted != got: + inc failures + echo "mismatch for: ", expected.replace("\n\t", " ; ") + echo " encoder bytes: ", produced.mapIt(it.toHex(2)).join(" ") + echo " encoder means: ", got.join(" ; ") + echo " reference: ", wanted.join(" ; ") + +if failures > 0: + quit($failures & " of " & $cases.len & " encodings disagree") +echo "all ", cases.len, " x86-64 encodings decode to the intended instruction" diff --git a/tests/test_arm64.nim b/tests/test_arm64.nim index 8bbd39b..4d38b86 100644 --- a/tests/test_arm64.nim +++ b/tests/test_arm64.nim @@ -55,6 +55,10 @@ proc assembled(lines: seq[string]): seq[seq[uint32]] = if start != words.len: quit("assembler produced more words than the encoder") +if findExe("otool") == "" or findExe("clang") == "": + echo "skipping: needs clang and otool for the reference encoding" + quit(0) + ## Moves and immediates encodes "mov w3, w7": From 9b820b27d995a1f84a30d24367a856ac319e7f2e Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 13:58:39 -0700 Subject: [PATCH 04/51] Port the vmath raytracer to BASIC as a realistic workload The scene, lights and algorithm follow tests/bench_raytracer.nim in vmath. Three things change because of the language rather than the algorithm: numbers are Q16.16 fixed point, the scene lives in parallel arrays because there are no records, and anything that must survive a recursive call is passed as an argument because every scalar that is not a parameter is global. It compiles no loops at all. Every hot path is fixed-point arithmetic, array indexing, or a subroutine call, and the native compiler models none of those yet, so interpreted and native run at the same speed. That is the point of adding it: it marks where the speedup does not reach. It doubles as a cross-architecture determinism check. Fixed point is the reason this VM has no floats, so the pinned checksum has to match everywhere. Verified identical on arm64 macOS and amd64 Linux. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 2 + .gitignore | 1 + tests/bench_raytracer.nim | 133 ++++++++++++++++ tests/raytracer.bas | 306 ++++++++++++++++++++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 tests/bench_raytracer.nim create mode 100644 tests/raytracer.bas diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8bb0cfc..fe2f497 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -59,6 +59,8 @@ jobs: run: nim r -d:release tests/test_jit.nim - name: Test the same scripts with the interpreter forced run: nim r -d:release -d:bassyNoJit tests/test_jit.nim + - name: Render the BASIC raytracer and check cross-platform determinism + run: nim r -d:release tests/bench_raytracer.nim - name: Report native compilation speedups run: nim r -d:release tests/bench_jit.nim - name: Run examples diff --git a/.gitignore b/.gitignore index 446e076..6281cf7 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ nimcache nimble.paths nimble.develop nimbledeps/ +tests/raytracer.ppm diff --git a/tests/bench_raytracer.nim b/tests/bench_raytracer.nim new file mode 100644 index 0000000..b62effe --- /dev/null +++ b/tests/bench_raytracer.nim @@ -0,0 +1,133 @@ +## Runs the BASIC raytracer on both execution paths. +## +## This is the realistic end of the workload range. Almost all of its time +## goes to fixed-point arithmetic, arrays, and subroutine calls, none of +## which the native compiler models, so it compiles no loops at all. It is +## here to show where the speedup does not yet reach, and to check that a +## script this large still agrees on both paths. + +import + std/[monotimes, strformat, times], + bassy + +const + Source = staticRead("raytracer.bas") + Size = 48 + Runs = 3 + +var pixels: seq[byte] + +proc squareRoot(arguments: openArray[Value]): Value = + ## Returns the fixed-point square root of a non-negative number. + let value = arguments[0].asFixed + if value <= 0'fx: + return toValue(0'fx) + # Newton's method, which settles well inside Q16.16 in a few rounds. + var estimate = value + if estimate < 1'fx: + estimate = 1'fx + for round in 1 .. 12: + estimate = (estimate + value / estimate) / 2'fx + toValue(estimate) + +proc floorOf(arguments: openArray[Value]): Value = + ## Returns the largest whole number not greater than the argument. + ## Fixxy's own conversions are used because a plain int32 conversion + ## would read the raw Q16.16 bits rather than the whole part. + toValue(arguments[0].asFixed.floor.toInt) + +proc clampByte(arguments: openArray[Value]): Value = + ## Converts a colour channel to the usual zero to 255 range. + let value = arguments[0].asFixed + if value <= 0'fx: + return toValue(0'i32) + if value >= 1'fx: + return toValue(255'i32) + toValue((value * 255'fx).toInt) + +proc plot(arguments: openArray[Value]): Value = + ## Collects one rendered pixel so the image can be written out. + for index in 0 .. 2: + pixels.add(byte(arguments[index].asInt and 0xFF)) + toValue(0'i32) + +proc buildHost(): Host = + ## Supplies the numeric helpers BASIC does not provide itself. + result = initHost() + discard result.addFunction("sqr", 1, squareRoot, workUnits = 20) + discard result.addFunction("floorOf", 1, floorOf, workUnits = 2) + discard result.addFunction("clampByte", 1, clampByte, workUnits = 2) + discard result.addFunction("plot", 3, plot, workUnits = 2) + discard result.addData("size", toValue(0'i32)) + discard result.addData("half", toValue(0'fx)) + +proc traceLimits(): Limits = + ## Returns limits large enough to finish the image. + result = defaultLimits() + result.maxInstructions = 2_000_000_000 + result.maxWorkUnits = 2_000_000_000 + result.maxCallDepth = 64 + +proc renderOnce(runtime: var Runtime): (float, int32) = + ## Renders the image once and returns the elapsed milliseconds. + runtime.restart() + runtime.setData("size", toValue(Size)) + runtime.setData("half", toValue(fixed(Size) / 2'fx)) + pixels.setLen(0) + let started = getMonoTime() + discard runtime.run() + let elapsed = (getMonoTime() - started).inNanoseconds.float / 1_000_000.0 + (elapsed, runtime.getGlobal("checksum")) + +proc render(runtime: var Runtime): (float, int32) = + ## Returns the fastest of several renders and the checksum. + result[0] = Inf + for attempt in 1 .. Runs: + let (elapsed, checksum) = runtime.renderOnce() + if elapsed < result[0]: + result[0] = elapsed + result[1] = checksum + +var host = buildHost() +let limits = traceLimits() +let program = compile(Source, host, limits) + +echo &"native compilation available: {jitSupported()}" +echo &"host: {hostCPU} {hostOS}" +echo &"image: {Size} by {Size}, bytecode {program.instructions} instructions" + +var plain = initRuntime(program, host, limits) +var fast = initRuntime(program, host, limits) +let regions = fast.compileNative() + +let (plainTime, plainSum) = plain.render() +let (fastTime, fastSum) = fast.render() + +echo &" interpreted {plainTime:9.2f} ms checksum {plainSum}" +echo &" native {fastTime:9.2f} ms checksum {fastSum} " & + &"compiled loops {regions}" +if regions > 0 and fastTime > 0.0: + echo &" ratio {plainTime / fastTime:9.2f}x" +echo &" instructions charged: {plain.instructionsUsed} vs " & + &"{fast.instructionsUsed}" + +# Fixed point is the reason this VM has no floats. The same scene must +# therefore render to the same bytes on every architecture, so the +# checksum is pinned rather than merely compared between the two paths. +const ExpectedChecksum = 1488834'i32 +if plainSum != ExpectedChecksum: + quit(&"expected checksum {ExpectedChecksum} but rendered {plainSum}") + +if plainSum != fastSum: + quit("the two paths disagreed on the image") +if plain.instructionsUsed != fast.instructionsUsed: + quit("the two paths disagreed on the budget") + +if pixels.len == Size * Size * 3: + var text = &"P6\n{Size} {Size}\n255\n" + for value in pixels: + text.add(char(value)) + writeFile("tests/raytracer.ppm", text) + echo " wrote tests/raytracer.ppm" + +echo "raytracer agrees on both paths" diff --git a/tests/raytracer.bas b/tests/raytracer.bas new file mode 100644 index 0000000..d59a603 --- /dev/null +++ b/tests/raytracer.bas @@ -0,0 +1,306 @@ +' A port of the vmath raytracer benchmark, itself based on +' https://github.com/edin/raytracer, to Bassy's BASIC. +' +' Three differences follow from the language rather than the algorithm. +' Numbers are Q16.16 fixed point, so "far away" is 20000 rather than a +' million and the light falloff is coarser. There are no records, so the +' scene lives in parallel arrays. Subroutines cannot return values and +' every scalar that is not a parameter is global, so anything that must +' survive a recursive call is passed as an argument. + +dim thingKind(3) +dim thingSurface(3) +dim thingAx(3) +dim thingAy(3) +dim thingAz(3) +dim thingExtra(3) + +dim lightX(4) +dim lightY(4) +dim lightZ(4) +dim lightR(4) +dim lightG(4) +dim lightB(4) + +' Surfaces +shiny = 0 +checkerboard = 1 + +' Shapes +planeKind = 0 +sphereKind = 1 + +farAway = 200 +maxDepth = 3 +thingCount = 3 +lightCount = 4 + +sub setPlane(slot, nx, ny, nz, offset, surface) + thingKind(slot) = planeKind + thingSurface(slot) = surface + thingAx(slot) = nx + thingAy(slot) = ny + thingAz(slot) = nz + thingExtra(slot) = offset +end sub + +sub setSphere(slot, cx, cy, cz, radius, surface) + thingKind(slot) = sphereKind + thingSurface(slot) = surface + thingAx(slot) = cx + thingAy(slot) = cy + thingAz(slot) = cz + thingExtra(slot) = radius * radius +end sub + +sub setLight(slot, lx, ly, lz, r, g, b) + lightX(slot) = lx + lightY(slot) = ly + lightZ(slot) = lz + lightR(slot) = r + lightG(slot) = g + lightB(slot) = b +end sub + +' Writes a unit vector into normX, normY, normZ. +sub normalize(vx, vy, vz) + mag = sqr(vx * vx + vy * vy + vz * vz) + if mag = 0 then + normX = 0 + normY = 0 + normZ = 0 + exit sub + end if + normX = vx / mag + normY = vy / mag + normZ = vz / mag +end sub + +' Writes the surface normal at a point into normX, normY, normZ. +sub surfaceNormal(thing, px, py, pz) + if thingKind(thing) = sphereKind then + normalize(px - thingAx(thing), py - thingAy(thing), pz - thingAz(thing)) + else + normX = thingAx(thing) + normY = thingAy(thing) + normZ = thingAz(thing) + end if +end sub + +' Writes the nearest hit into hitThing and hitDist. A hitThing of -1 means +' the ray escaped. +sub intersections(sx, sy, sz, dx, dy, dz) + hitThing = -1 + hitDist = farAway + probe = 0 + while probe < thingCount + candidate = -1 + candidateDist = 0 + if thingKind(probe) = sphereKind then + eox = thingAx(probe) - sx + eoy = thingAy(probe) - sy + eoz = thingAz(probe) - sz + v = eox * dx + eoy * dy + eoz * dz + if v >= 0 then + disc = thingExtra(probe) - (eox * eox + eoy * eoy + eoz * eoz - v * v) + if disc >= 0 then + candidateDist = v - sqr(disc) + if candidateDist <> 0 then + candidate = probe + end if + end if + end if + else + denom = thingAx(probe) * dx + thingAy(probe) * dy + thingAz(probe) * dz + if denom < 0 then + candidateDist = (thingAx(probe) * sx + thingAy(probe) * sy + thingAz(probe) * sz + thingExtra(probe)) / (0 - denom) + candidate = probe + end if + end if + if candidate >= 0 then + if candidateDist < hitDist then + hitThing = candidate + hitDist = candidateDist + end if + end if + probe = probe + 1 + wend +end sub + +' Writes the surface description at a point into diffuseR/G/B, +' specularR/G/B, reflectance and roughness. +sub surfaceAt(thing, px, py, pz) + if thingSurface(thing) = shiny then + diffuseR = 1 + diffuseG = 1 + diffuseB = 1 + specularR = 0.5 + specularG = 0.5 + specularB = 0.5 + reflectance = 0.7 + roughness = 6 + else + squares = floorOf(pz) + floorOf(px) + if squares mod 2 <> 0 then + reflectance = 0.1 + diffuseR = 1 + diffuseG = 1 + diffuseB = 1 + else + reflectance = 0.7 + diffuseR = 0 + diffuseG = 0 + diffuseB = 0 + end if + specularR = 1 + specularG = 1 + specularB = 1 + roughness = 5 + end if +end sub + +' Adds one light's contribution to lightSumR/G/B. +sub applyLight(slot, px, py, pz, nx, ny, nz, dx, dy, dz, shineR, shineG, shineB, gloss, power) + ldx = lightX(slot) - px + ldy = lightY(slot) - py + ldz = lightZ(slot) - pz + normalize(ldx, ldy, ldz) + livx = normX + livy = normY + livz = normZ + intersections(px, py, pz, livx, livy, livz) + if hitThing >= 0 then + if hitDist <= sqr(ldx * ldx + ldy * ldy + ldz * ldz) then + exit sub + end if + end if + illum = livx * nx + livy * ny + livz * nz + if illum > 0 then + lightSumR = lightSumR + illum * lightR(slot) * shineR + lightSumG = lightSumG + illum * lightG(slot) * shineG + lightSumB = lightSumB + illum * lightB(slot) * shineB + end if + specular = livx * dx + livy * dy + livz * dz + if specular > 0 then + falloff = specular + turn = 1 + while turn < power + falloff = falloff * specular + turn = turn + 1 + wend + lightSumR = lightSumR + falloff * lightR(slot) * gloss + lightSumG = lightSumG + falloff * lightG(slot) * gloss + lightSumB = lightSumB + falloff * lightB(slot) * gloss + end if +end sub + +sub traceRay(sx, sy, sz, dx, dy, dz, depth) + intersections(sx, sy, sz, dx, dy, dz) + if hitThing < 0 then + colorR = 0 + colorG = 0 + colorB = 0 + exit sub + end if + shade(hitThing, hitDist, sx, sy, sz, dx, dy, dz, depth) +end sub + +' Combines the already-computed natural colour with a reflected ray. Every +' value it needs is a parameter, because the recursive call below will +' overwrite the globals the caller was using. +sub addReflection(naturalR, naturalG, naturalB, weight, px, py, pz, rdx, rdy, rdz, depth) + traceRay(px + rdx * 0.002, py + rdy * 0.002, pz + rdz * 0.002, rdx, rdy, rdz, depth + 1) + colorR = naturalR + colorR * weight + colorG = naturalG + colorG * weight + colorB = naturalB + colorB * weight +end sub + +sub shade(thing, dist, sx, sy, sz, dx, dy, dz, depth) + px = sx + dx * dist + py = sy + dy * dist + pz = sz + dz * dist + surfaceNormal(thing, px, py, pz) + nx = normX + ny = normY + nz = normZ + reflectDot = nx * dx + ny * dy + nz * dz + rdx = dx - 2 * reflectDot * nx + rdy = dy - 2 * reflectDot * ny + rdz = dz - 2 * reflectDot * nz + surfaceAt(thing, px, py, pz) + keepDiffuseR = diffuseR + keepDiffuseG = diffuseG + keepDiffuseB = diffuseB + keepSpecular = specularR + keepReflect = reflectance + keepRoughness = roughness + + lightSumR = 0 + lightSumG = 0 + lightSumB = 0 + slot = 0 + while slot < lightCount + applyLight(slot, px, py, pz, nx, ny, nz, rdx, rdy, rdz, keepDiffuseR, keepDiffuseG, keepDiffuseB, keepSpecular, keepRoughness) + slot = slot + 1 + wend + + if depth >= maxDepth then + colorR = lightSumR + 0.5 + colorG = lightSumG + 0.5 + colorB = lightSumB + 0.5 + exit sub + end if + addReflection(lightSumR, lightSumG, lightSumB, keepReflect, px, py, pz, rdx, rdy, rdz, depth) +end sub + +' Scene +setPlane(0, 0, 1, 0, 0, checkerboard) +setSphere(1, 0, 1, -0.25, 1, shiny) +setSphere(2, -1, 0.5, 1.5, 0.5, shiny) +setLight(0, -2, 2.5, 0, 0.49, 0.07, 0.07) +setLight(1, 1.5, 2.5, 1.5, 0.07, 0.07, 0.49) +setLight(2, 1.5, 2.5, -1.5, 0.07, 0.49, 0.071) +setLight(3, 0, 3.5, 0, 0.21, 0.21, 0.35) + +' Camera at (3, 2, 4) looking at (-1, 0.5, 0) +normalize(-4, -1.5, -4) +forwardX = normX +forwardY = normY +forwardZ = normZ +' right = forward cross down, with down = (0, -1, 0) +rightRawX = forwardY * 0 - forwardZ * -1 +rightRawY = forwardZ * 0 - forwardX * 0 +rightRawZ = forwardX * -1 - forwardY * 0 +normalize(rightRawX, rightRawY, rightRawZ) +rightX = normX * 1.5 +rightY = normY * 1.5 +rightZ = normZ * 1.5 +upRawX = forwardY * rightRawZ - forwardZ * rightRawY +upRawY = forwardZ * rightRawX - forwardX * rightRawZ +upRawZ = forwardX * rightRawY - forwardY * rightRawX +normalize(upRawX, upRawY, upRawZ) +upX = normX * 1.5 +upY = normY * 1.5 +upZ = normZ * 1.5 + +checksum = 0 +py2 = 0 +while py2 < size + px2 = 0 + while px2 < size + recenterX = (px2 - half) / half + recenterY = (half - py2) / half + rayX = forwardX + recenterX * rightX + recenterY * upX + rayY = forwardY + recenterX * rightY + recenterY * upY + rayZ = forwardZ + recenterX * rightZ + recenterY * upZ + normalize(rayX, rayY, rayZ) + traceRay(3, 2, 4, normX, normY, normZ, 0) + red = clampByte(colorR) + green = clampByte(colorG) + blue = clampByte(colorB) + checksum = checksum + red + green * 3 + blue * 7 + plot(red, green, blue) + px2 = px2 + 1 + wend + py2 = py2 + 1 +wend From 57b8f39fabcef36bacea6fa0ab6985543c885c72 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 14:09:58 -0700 Subject: [PATCH 05/51] Pin loop results so CI proves cross-architecture agreement Comparing the two paths against each other only shows that they agree on the machine running them. The raytracer made this obvious: it compiles no loops, so its native run was the interpreter and the comparison proved nothing. Fold every global into one value and pin it. Now the benchmark fails unless generated machine code produces the same answers everywhere, and the check keeps its meaning on targets that compile nothing. Verified three ways: arm64 macOS with the native backend, the same machine with -d:bassyNoJit, and amd64 Linux with the native backend. Co-Authored-By: Claude Opus 5 --- tests/bench_jit.nim | 38 +++++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/tests/bench_jit.nim b/tests/bench_jit.nim index 578133f..220d826 100644 --- a/tests/bench_jit.nim +++ b/tests/bench_jit.nim @@ -9,6 +9,11 @@ import const Runs = 5 + # Pinned so every architecture must agree, not just the two paths. + ArithmeticResult = 1814293664'i32 + BranchResult = 977000000'i32 + NestedResult = 31962000'i32 + ArithmeticSource = """ i = 0 total = 0 @@ -63,8 +68,16 @@ proc fastest(runtime: var Runtime): float = if elapsed < result: result = elapsed -proc measure(name, source: string) = +proc checksum(runtime: Runtime, count: int): int32 = + ## Folds every global into one value, for comparing whole runs. + for index in 0 ..< count: + result = result *% 31'i32 +% runtime.globalValue(int32(index)).asInt + +proc measure(name, source: string, expected: int32) = ## Times one script on both paths and reports the ratio. + ## The expected value is pinned so CI proves that generated machine code + ## produces the same answers on every architecture, not merely the same + ## answers as the interpreter running beside it. let limits = benchLimits() let program = compile(source, limits) @@ -75,11 +88,9 @@ proc measure(name, source: string) = let plainTime = plain.fastest() let fastTime = fast.fastest() - var agree = true - for index in 0 ..< program.globals: - if plain.globalValue(int32(index)).asInt != - fast.globalValue(int32(index)).asInt: - agree = false + let plainSum = plain.checksum(program.globals) + let fastSum = fast.checksum(program.globals) + let agree = plainSum == fastSum let charged = plain.instructionsUsed == fast.instructionsUsed let ratio = @@ -88,11 +99,16 @@ proc measure(name, source: string) = echo &" {name:<12} interpreted {plainTime:8.3f} ms " & &"native {fastTime:8.3f} ms {ratio} " & &"loops {regions} results {agree} budget {charged}" - if not agree or not charged: - quit("the two paths disagreed") + if not agree: + quit(&"{name}: interpreted {plainSum} but native {fastSum}") + if not charged: + quit(&"{name}: budgets differ") + if plainSum != expected: + quit(&"{name}: expected {expected} but both paths gave {plainSum}") echo &"native compilation available: {jitSupported()}" echo &"host: {hostCPU} {hostOS}" -measure("arithmetic", ArithmeticSource) -measure("branches", BranchSource) -measure("nested", NestedSource) +measure("arithmetic", ArithmeticSource, ArithmeticResult) +measure("branches", BranchSource, BranchResult) +measure("nested", NestedSource, NestedResult) +echo "every result matched the value pinned for all architectures" From deeb512b2ce5a11a5d95bab70b19deb5616ffd82 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 14:58:16 -0700 Subject: [PATCH 06/51] Fix the raytracer's field of view and cut a fifth of its work The port divided the screen offset by half the image size where the reference divides by twice it, so the camera had four times the intended field of view. That is what made the floor fan out. With it corrected the composition matches the reference: two spheres above a checkerboard. Three other fidelity gaps close with it. The reflection depth is five again, the reference roughness values of 250 and 150 are affordable now that a host function raises to them by squaring instead of a multiply loop in BASIC, and shadow rays start slightly off the surface so fixed point cannot make a point shadow itself. Square roots now use the digit-by-digit method on the raw Q16.16 bits, which needs no division and no iteration count. Then four optimizations, each leaving the image byte for byte identical: - The floor is the only plane and is tested first, so its intersection is written out directly and the object loop starts at the spheres. Nothing in the loop is a plane any more, so the kind test goes too. - applyLight took fifteen parameters it could read as globals, since it is never on the recursive path. - A light facing away from the surface that also makes no highlight adds nothing whether or not it is blocked, so its shadow ray is skipped. - Six values were copied before uses that nothing could disturb. Together: 4,986,746 instructions down to 3,833,996, and 24.6 ms down to 20.0 ms at 48 by 48 on an M4 Pro. Profiling says where the rest goes. Loading and storing globals is 36% of everything executed, because every BASIC scalar is a global and so every mention of one is a memory round trip. Two script-level attempts to beat that made it worse: replacing three divides with a reciprocal and three multiplies, and hoisting array reads out of a branch, both traded cheaper arithmetic for more instructions and lost. In this VM the instruction count is the cost. Co-Authored-By: Claude Opus 5 --- tests/bench_raytracer.nim | 55 ++++++++++++++++---- tests/raytracer.bas | 105 +++++++++++++++++--------------------- 2 files changed, 92 insertions(+), 68 deletions(-) diff --git a/tests/bench_raytracer.nim b/tests/bench_raytracer.nim index b62effe..aca9337 100644 --- a/tests/bench_raytracer.nim +++ b/tests/bench_raytracer.nim @@ -19,16 +19,50 @@ var pixels: seq[byte] proc squareRoot(arguments: openArray[Value]): Value = ## Returns the fixed-point square root of a non-negative number. + ## + ## A Q16.16 number holds its value times 65536, so the root of those raw + ## bits shifted up by another 16 is exactly the root's own raw bits. The + ## digit-by-digit method below finds it with shifts and subtractions + ## alone, which is both faster and more predictable than iterating. let value = arguments[0].asFixed if value <= 0'fx: return toValue(0'fx) - # Newton's method, which settles well inside Q16.16 in a few rounds. - var estimate = value - if estimate < 1'fx: - estimate = 1'fx - for round in 1 .. 12: - estimate = (estimate + value / estimate) / 2'fx - toValue(estimate) + var + remainder = int64(int32(value)) shl 16 + root = 0'i64 + bit = 1'i64 shl 46 + while bit > remainder: + bit = bit shr 2 + while bit != 0: + if remainder >= root + bit: + remainder -= root + bit + root = (root shr 1) + bit + else: + root = root shr 1 + bit = bit shr 2 + toValue(Fixed(int32(root))) + +proc powerOf(arguments: openArray[Value]): Value = + ## Raises a fixed-point base to a whole exponent by repeated squaring. + ## The reference raytracer uses roughness values in the hundreds, which + ## a multiply loop in BASIC could not afford. + var + base = arguments[0].asFixed + exponent = arguments[1].asInt + total = 1'fx + if exponent <= 0: + return toValue(total) + while exponent > 0: + if (exponent and 1) != 0: + total = total * base + exponent = exponent shr 1 + if exponent == 0: + break + base = base * base + if base == 0'fx: + # The base has fallen below what Q16.16 can hold, so has the result. + return toValue(0'fx) + toValue(total) proc floorOf(arguments: openArray[Value]): Value = ## Returns the largest whole number not greater than the argument. @@ -54,12 +88,14 @@ proc plot(arguments: openArray[Value]): Value = proc buildHost(): Host = ## Supplies the numeric helpers BASIC does not provide itself. result = initHost() - discard result.addFunction("sqr", 1, squareRoot, workUnits = 20) + discard result.addFunction("sqr", 1, squareRoot, workUnits = 8) + discard result.addFunction("powerOf", 2, powerOf, workUnits = 8) discard result.addFunction("floorOf", 1, floorOf, workUnits = 2) discard result.addFunction("clampByte", 1, clampByte, workUnits = 2) discard result.addFunction("plot", 3, plot, workUnits = 2) discard result.addData("size", toValue(0'i32)) discard result.addData("half", toValue(0'fx)) + discard result.addData("span", toValue(0'fx)) proc traceLimits(): Limits = ## Returns limits large enough to finish the image. @@ -73,6 +109,7 @@ proc renderOnce(runtime: var Runtime): (float, int32) = runtime.restart() runtime.setData("size", toValue(Size)) runtime.setData("half", toValue(fixed(Size) / 2'fx)) + runtime.setData("span", toValue(fixed(Size) * 2'fx)) pixels.setLen(0) let started = getMonoTime() discard runtime.run() @@ -114,7 +151,7 @@ echo &" instructions charged: {plain.instructionsUsed} vs " & # Fixed point is the reason this VM has no floats. The same scene must # therefore render to the same bytes on every architecture, so the # checksum is pinned rather than merely compared between the two paths. -const ExpectedChecksum = 1488834'i32 +const ExpectedChecksum = 1356659'i32 if plainSum != ExpectedChecksum: quit(&"expected checksum {ExpectedChecksum} but rendered {plainSum}") diff --git a/tests/raytracer.bas b/tests/raytracer.bas index d59a603..3300d12 100644 --- a/tests/raytracer.bas +++ b/tests/raytracer.bas @@ -31,7 +31,7 @@ planeKind = 0 sphereKind = 1 farAway = 200 -maxDepth = 3 +maxDepth = 5 thingCount = 3 lightCount = 4 @@ -65,6 +65,7 @@ end sub ' Writes a unit vector into normX, normY, normZ. sub normalize(vx, vy, vz) mag = sqr(vx * vx + vy * vy + vz * vz) + normMag = mag if mag = 0 then normX = 0 normY = 0 @@ -92,36 +93,30 @@ end sub sub intersections(sx, sy, sz, dx, dy, dz) hitThing = -1 hitDist = farAway - probe = 0 + if dy < 0 then + floorDist = sy / (0 - dy) + if floorDist < hitDist then + hitThing = 0 + hitDist = floorDist + end if + end if + probe = 1 while probe < thingCount - candidate = -1 - candidateDist = 0 - if thingKind(probe) = sphereKind then - eox = thingAx(probe) - sx - eoy = thingAy(probe) - sy - eoz = thingAz(probe) - sz - v = eox * dx + eoy * dy + eoz * dz - if v >= 0 then - disc = thingExtra(probe) - (eox * eox + eoy * eoy + eoz * eoz - v * v) - if disc >= 0 then - candidateDist = v - sqr(disc) - if candidateDist <> 0 then - candidate = probe + eox = thingAx(probe) - sx + eoy = thingAy(probe) - sy + eoz = thingAz(probe) - sz + v = eox * dx + eoy * dy + eoz * dz + if v >= 0 then + disc = thingExtra(probe) - (eox * eox + eoy * eoy + eoz * eoz - v * v) + if disc >= 0 then + candidateDist = v - sqr(disc) + if candidateDist <> 0 then + if candidateDist < hitDist then + hitThing = probe + hitDist = candidateDist end if end if end if - else - denom = thingAx(probe) * dx + thingAy(probe) * dy + thingAz(probe) * dz - if denom < 0 then - candidateDist = (thingAx(probe) * sx + thingAy(probe) * sy + thingAz(probe) * sz + thingExtra(probe)) / (0 - denom) - candidate = probe - end if - end if - if candidate >= 0 then - if candidateDist < hitDist then - hitThing = candidate - hitDist = candidateDist - end if end if probe = probe + 1 wend @@ -138,7 +133,7 @@ sub surfaceAt(thing, px, py, pz) specularG = 0.5 specularB = 0.5 reflectance = 0.7 - roughness = 6 + roughness = 250 else squares = floorOf(pz) + floorOf(px) if squares mod 2 <> 0 then @@ -155,42 +150,40 @@ sub surfaceAt(thing, px, py, pz) specularR = 1 specularG = 1 specularB = 1 - roughness = 5 + roughness = 150 end if end sub ' Adds one light's contribution to lightSumR/G/B. -sub applyLight(slot, px, py, pz, nx, ny, nz, dx, dy, dz, shineR, shineG, shineB, gloss, power) +sub applyLight(slot) ldx = lightX(slot) - px ldy = lightY(slot) - py ldz = lightZ(slot) - pz normalize(ldx, ldy, ldz) - livx = normX - livy = normY - livz = normZ - intersections(px, py, pz, livx, livy, livz) + illum = normX * nx + normY * ny + normZ * nz + specular = normX * rdx + normY * rdy + normZ * rdz + if illum <= 0 then + if specular <= 0 then + exit sub + end if + end if + lightRange = normMag + intersections(px + normX * 0.002, py + normY * 0.002, pz + normZ * 0.002, normX, normY, normZ) if hitThing >= 0 then - if hitDist <= sqr(ldx * ldx + ldy * ldy + ldz * ldz) then + if hitDist <= lightRange then exit sub end if end if - illum = livx * nx + livy * ny + livz * nz if illum > 0 then - lightSumR = lightSumR + illum * lightR(slot) * shineR - lightSumG = lightSumG + illum * lightG(slot) * shineG - lightSumB = lightSumB + illum * lightB(slot) * shineB + lightSumR = lightSumR + illum * lightR(slot) * diffuseR + lightSumG = lightSumG + illum * lightG(slot) * diffuseG + lightSumB = lightSumB + illum * lightB(slot) * diffuseB end if - specular = livx * dx + livy * dy + livz * dz if specular > 0 then - falloff = specular - turn = 1 - while turn < power - falloff = falloff * specular - turn = turn + 1 - wend - lightSumR = lightSumR + falloff * lightR(slot) * gloss - lightSumG = lightSumG + falloff * lightG(slot) * gloss - lightSumB = lightSumB + falloff * lightB(slot) * gloss + falloff = powerOf(specular, roughness) + lightSumR = lightSumR + falloff * lightR(slot) * specularR + lightSumG = lightSumG + falloff * lightG(slot) * specularR + lightSumB = lightSumB + falloff * lightB(slot) * specularR end if end sub @@ -228,19 +221,13 @@ sub shade(thing, dist, sx, sy, sz, dx, dy, dz, depth) rdy = dy - 2 * reflectDot * ny rdz = dz - 2 * reflectDot * nz surfaceAt(thing, px, py, pz) - keepDiffuseR = diffuseR - keepDiffuseG = diffuseG - keepDiffuseB = diffuseB - keepSpecular = specularR - keepReflect = reflectance - keepRoughness = roughness lightSumR = 0 lightSumG = 0 lightSumB = 0 slot = 0 while slot < lightCount - applyLight(slot, px, py, pz, nx, ny, nz, rdx, rdy, rdz, keepDiffuseR, keepDiffuseG, keepDiffuseB, keepSpecular, keepRoughness) + applyLight(slot) slot = slot + 1 wend @@ -250,7 +237,7 @@ sub shade(thing, dist, sx, sy, sz, dx, dy, dz, depth) colorB = lightSumB + 0.5 exit sub end if - addReflection(lightSumR, lightSumG, lightSumB, keepReflect, px, py, pz, rdx, rdy, rdz, depth) + addReflection(lightSumR, lightSumG, lightSumB, reflectance, px, py, pz, rdx, rdy, rdz, depth) end sub ' Scene @@ -288,8 +275,8 @@ py2 = 0 while py2 < size px2 = 0 while px2 < size - recenterX = (px2 - half) / half - recenterY = (half - py2) / half + recenterX = (px2 - half) / span + recenterY = (half - py2) / span rayX = forwardX + recenterX * rightX + recenterY * upX rayY = forwardY + recenterX * rightY + recenterY * upY rayZ = forwardZ + recenterX * rightZ + recenterY * upZ From f221ceced4dca70864444a7bdf6785c6d08071f1 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 16:02:27 -0700 Subject: [PATCH 07/51] Settle a loop's budget once instead of at every block The generated code spent most of itself on metering. Twenty-six instructions ran per pass of the counting benchmark to do two instructions of work: two budget checks that each rebuilt their limits from scratch, a comparison constant rebuilt every pass, and an unconditional branch after every block to hop over an inline stub. - Exit stubs move out of line, so a conditional branch goes straight to one and the common path falls through with nothing to jump over. - A loop whose body has no internal branch costs the same every pass, so the budget is settled on entry by dividing: how many passes can both budgets certainly afford. The loop then only counts. One pass is held back, because the pass that finally leaves charges on the way out. - A loop whose passes differ adds up what it spends and asks once a pass whether another whole pass is still affordable. Blocks inside it charge with two adds and no branch. - Compare constants too wide for an immediate are hoisted into registers before the loop rather than rebuilt inside it. - A remainder against a power of two is a bit test, not a divide and a multiply, and that holds for negative values under truncating division. - Compiled loops are found through a sequence indexed by offset rather than a table. The counting loop is now eight instructions a pass: cmp x13, x14 ; passes left b.ge handback cmp w22, w1 ; i against a constant already in a register b.ge exit add w23, w23, w22 add w22, w22, #1 add x13, x13, #1 b loop On an M4 Pro: counting 0.67 ms to 0.25 ms, 24x to 65x. Branching 1.22 ms to 1.02 ms. Nested 0.53 ms to 0.46 ms. x86-64, which keeps the per-block check, still gains the out-of-line stubs. Budgets are charged to the unit as before, and refusals still land on the exact offset, because a loop that cannot afford a whole pass is handed back to the interpreter rather than approximated. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 21 +-- src/bassy/arm64.nim | 13 ++ src/bassy/jit.nim | 350 ++++++++++++++++++++++++++++++++++++++----- tests/test_arm64.nim | 7 + 4 files changed, 344 insertions(+), 47 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 644e200..50e00bd 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3122,6 +3122,12 @@ proc instructions*(program: Program): int {.inline.} = ## Returns the number of metered register-machine instructions. program.code.len +proc nativeRegions*(runtime: Runtime): int = + ## Returns how many compiled loops are still active. + for region in runtime.regionAt: + if region != nil: + inc result + proc compileNative*(runtime: var Runtime): int = ## Compiles the hot integer loops of this program to machine code and ## returns how many were accepted. Loops the compiler does not model are @@ -3130,19 +3136,8 @@ proc compileNative*(runtime: var Runtime): int = runtime.regionAt = @[] if not jitSupported(): return 0 - let regions = compileLoops(runtime.program.code) - if regions.len == 0: - return 0 - runtime.regionAt = newSeq[Region](runtime.program.code.len) - for start, region in regions: - runtime.regionAt[int(start)] = region - regions.len - -proc nativeRegions*(runtime: Runtime): int = - ## Returns how many compiled loops are still active. - for region in runtime.regionAt: - if region != nil: - inc result + runtime.regionAt = compileLoops(runtime.program.code) + runtime.nativeRegions proc bytecode*(program: Program): lent seq[Instruction] {.inline.} = ## Exposes the metered bytecode for tools and the native compiler. diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index d79d865..7d8b9e5 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -470,3 +470,16 @@ proc jumpRegister*(assembler: var Assembler, target: Register) proc returnToCaller*(assembler: var Assembler) {.raises: [].} = ## Returns through the link register. assembler.emit(0xD65F03C0'u32) + +proc testLowBits*(assembler: var Assembler, width: Width, source: Register, + count: int) {.raises: [BasicError].} = + ## Sets flags from the lowest bits of a register, leaving the result + ## nowhere. The logical immediate for a run of ones starting at bit zero + ## is simply its length minus one. + if count < 1 or count > (if width == Word64: 63 else: 31): + fail("assembler bit count is out of range") + assembler.emit( + 0x72000000'u32 or width.sizeBit or + (if width == Word64: 1'u32 shl 22 else: 0'u32) or + (uint32(count - 1) shl 10) or (source.number shl 5) or 31'u32 + ) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index dbbabc4..1c20fc7 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -12,7 +12,6 @@ ## same small set of emitters, so AArch64 and x86-64 stay in step. import - std/tables, bytecode, machine, numbers export machine.jitSupported @@ -49,6 +48,7 @@ type stop*: int32 hoisted*: seq[int32] size*: int + listing*: seq[byte] buffer: CodeBuffer call: NativeCall @@ -142,6 +142,87 @@ proc takenOn(op: Op): Test {.raises: [].} = of JumpUnlessGlobalGreaterEqualImmediateOp: LessTest else: NotEqualTest +type + LoopPlan = object + ## What one pass through a loop costs, when that is knowable. + ## + ## A loop whose body has no internal branch runs the same operations + ## every time around, so its budget can be settled once on entry + ## instead of at every block. The counter below then replaces both + ## budget checks, and the interpreter is handed back a count to charge. + counted: bool + spending: bool + instructions: int64 + work: int64 + passInstructions: int64 + passWork: int64 + partialInstructions: seq[int64] + partialWork: seq[int64] + +proc planLoop(code: seq[Instruction], start, stop: int): LoopPlan + {.raises: [].} = + ## Measures one pass through a loop and reports whether it is countable. + result.partialInstructions = newSeq[int64](stop - start) + result.partialWork = newSeq[int64](stop - start) + if stop - start < 2: + return + let last = code[stop - 1] + if last.op != JumpOp or int(last.a) != start: + return + if code[start].op != MeterOp: + return + # Every meter in the region bounds what a single pass can cost, however + # the branches inside it fall. + for index in start ..< stop: + if code[index].op == MeterOp: + result.passInstructions += int64(code[index].b) + result.passWork += int64(code[index].a) + result.spending = result.passInstructions > 0 and result.passWork > 0 + var + instructions = 0'i64 + work = 0'i64 + for index in start ..< stop: + let item = code[index] + result.partialInstructions[index - start] = instructions + result.partialWork[index - start] = work + if item.op == MeterOp: + instructions += int64(item.b) + work += int64(item.a) + var target = 0'i32 + if item.branchTarget(target): + if index == stop - 1: + continue + # Any branch back into the loop means the cost of a pass depends on + # which way it went, so the count would not be a count. + if int(target) >= start and int(target) < stop: + return + if instructions <= 0 or work <= 0: + return + result.instructions = instructions + result.work = work + result.counted = true + +proc pooledConstants(code: seq[Instruction], start, stop, room: int): + seq[int64] {.raises: [].} = + ## Collects the compare constants worth holding in a register, which are + ## the ones too wide for an immediate and so rebuilt on every pass. + for index in start ..< stop: + let item = code[index] + case item.op + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + if item.b >= 0 and item.b <= 4095: + continue + let value = int64(item.b) + if value notin result and result.len < room: + result.add(value) + else: + discard + proc reachesOutside(code: seq[Instruction], start, stop: int): bool {.raises: [].} = ## Reports whether the loop can be entered anywhere but its first offset. @@ -174,9 +255,27 @@ when NativeArm64: OtherScratch = x10 ResumeOffset = x11 ResumeStatus = x12 + Counter = x13 + Allowance = x14 + SpentInstructions = x5 + SpentWork = x6 + LimitInstructions = x7 + LimitWork = x8 + FirstPooled = 1 FrameBytes = 96 MaxDisplacement = 4095 + const MaxPooled* = 4 + + proc pooledRegister(slot: int): Register {.raises: [].} = + ## Returns the caller-saved register holding one hoisted constant. + Register(uint32(FirstPooled + slot)) + + proc loadPooled(emitter: var Assembler, slot: int, value: int64) + {.raises: [BasicError].} = + ## Materializes a loop-invariant constant once, before the loop. + emitter.loadImmediate(Word32, pooledRegister(slot), value) + proc slotRegister(slot: int): Register {.raises: [].} = ## Returns the callee-saved register holding one hoisted global. Register(uint32(FirstHoisted + slot)) @@ -269,20 +368,129 @@ when NativeArm64: emitter.loadImmediate(Word32, Scratch, int64(value)) emitter.addRegister(Word32, target, target, Scratch) - proc compareSlot(emitter: var Assembler, slot: int, value: int32) - {.raises: [BasicError].} = - ## Sets flags from a hoisted register against a constant. + proc compareSlot(emitter: var Assembler, slot: int, value: int32, + pooled = -1) {.raises: [BasicError].} = + ## Sets flags from a hoisted register against a constant, using the + ## register that already holds it when the loop has one. let target = slotRegister(slot) - if value >= 0 and value <= MaxDisplacement: + if pooled >= 0: + emitter.compareRegister(Word32, target, pooledRegister(pooled)) + elif value >= 0 and value <= MaxDisplacement: emitter.compareImmediate(Word32, target, int(value)) else: emitter.loadImmediate(Word32, Scratch, int64(value)) emitter.compareRegister(Word32, target, Scratch) + proc beginCountedLoop(emitter: var Assembler, instructions, work: int64, + refused: Label) {.raises: [BasicError].} = + ## Settles the whole loop's budget once: how many passes both budgets + ## can certainly afford. One pass is held back so that the pass which + ## finally leaves the loop, charging up to a full pass on the way out, + ## still cannot overrun. + emitter.loadImmediate(Word64, Scratch, instructions) + emitter.signedDivide(Word64, Allowance, Instructions, Scratch) + emitter.loadImmediate(Word64, Scratch, work) + emitter.signedDivide(Word64, OtherScratch, Work, Scratch) + let smaller = emitter.label() + emitter.compareRegister(Word64, Allowance, OtherScratch) + emitter.branchIf(LessEqualCondition, smaller) + emitter.moveRegister(Word64, Allowance, OtherScratch) + emitter.place(smaller) + emitter.subtractImmediate(Word64, Allowance, Allowance, 1) + # Fewer than one affordable pass means the loop must go back to the + # interpreter, which alone can refuse the budget at the right place. + # Accepting zero here would hand the same offset back for ever. + emitter.compareImmediate(Word64, Allowance, 1) + emitter.branchIf(LessCondition, refused) + emitter.loadImmediate(Word64, Counter, 0) + + proc checkCounter(emitter: var Assembler, handBack: Label) + {.raises: [BasicError].} = + ## Leaves the loop once the settled number of passes is used up. + emitter.compareRegister(Word64, Counter, Allowance) + emitter.branchIf(GreaterEqualCondition, handBack) + + proc advanceCounter(emitter: var Assembler) {.raises: [BasicError].} = + ## Records that one more pass finished. + emitter.addImmediate(Word64, Counter, Counter, 1) + + proc beginSpendingLoop(emitter: var Assembler, + passInstructions, passWork: int64, refused: Label) + {.raises: [BasicError].} = + ## Prepares a loop whose passes differ in cost. Rather than refusing + ## the budget block by block, the loop adds up what it spends and asks + ## once a pass whether another pass could still be afforded outright. + emitter.loadImmediate(Word64, SpentInstructions, 0) + emitter.loadImmediate(Word64, SpentWork, 0) + emitter.loadImmediate(Word64, Scratch, passInstructions) + emitter.subtractRegister( + Word64, LimitInstructions, Instructions, Scratch + ) + emitter.loadImmediate(Word64, Scratch, passWork) + emitter.subtractRegister(Word64, LimitWork, Work, Scratch) + emitter.compareImmediate(Word64, LimitInstructions, 0) + emitter.branchIf(LessCondition, refused) + emitter.compareImmediate(Word64, LimitWork, 0) + emitter.branchIf(LessCondition, refused) + + proc checkSpending(emitter: var Assembler, handBack: Label) + {.raises: [BasicError].} = + ## Leaves the loop while another whole pass is still certainly afforded. + emitter.compareRegister(Word64, SpentInstructions, LimitInstructions) + emitter.branchIf(GreaterCondition, handBack) + emitter.compareRegister(Word64, SpentWork, LimitWork) + emitter.branchIf(GreaterCondition, handBack) + + proc recordSpending(emitter: var Assembler, instructions, work: int64) + {.raises: [BasicError].} = + ## Adds one block's charge, with nothing to test and nowhere to branch. + emitter.addImmediate( + Word64, SpentInstructions, SpentInstructions, int(instructions) + ) + emitter.addImmediate(Word64, SpentWork, SpentWork, int(work)) + + proc chargeSpending(emitter: var Assembler) {.raises: [BasicError].} = + ## Hands back exactly what the passes added up to. + emitter.subtractRegister( + Word64, Instructions, Instructions, SpentInstructions + ) + emitter.subtractRegister(Word64, Work, Work, SpentWork) + + proc chargeCounted(emitter: var Assembler, instructions, work: int64, + partialInstructions, partialWork: int64) {.raises: [BasicError].} = + ## Charges whole passes plus however far the last one got. + emitter.loadImmediate(Word64, Scratch, instructions) + emitter.loadImmediate(Word64, OtherScratch, partialInstructions) + emitter.multiplyAdd(Word64, Scratch, Counter, Scratch, OtherScratch) + emitter.subtractRegister(Word64, Instructions, Instructions, Scratch) + emitter.loadImmediate(Word64, Scratch, work) + emitter.loadImmediate(Word64, OtherScratch, partialWork) + emitter.multiplyAdd(Word64, Scratch, Counter, Scratch, OtherScratch) + emitter.subtractRegister(Word64, Work, Work, Scratch) + + proc lowBitCount(divisor: int32): int {.raises: [].} = + ## Returns how many low bits decide divisibility, when the divisor is + ## a power of two and so only those bits matter. + var magnitude = int64(divisor) + if magnitude < 0: + magnitude = -magnitude + if magnitude < 2 or (magnitude and (magnitude - 1)) != 0: + return 0 + while magnitude > 1: + magnitude = magnitude shr 1 + inc result + proc remainderTest(emitter: var Assembler, slot: int, divisor: int32) {.raises: [BasicError].} = ## Sets flags so NotEqualTest means the remainder is not zero. let source = slotRegister(slot) + # Truncating division leaves no remainder against a power of two + # exactly when the low bits are clear, for negative values as well, so + # a bit test stands in for a divide and a multiply. + let bits = lowBitCount(divisor) + if bits > 0: + emitter.testLowBits(Word32, source, bits) + return emitter.loadImmediate(Word32, Scratch, int64(divisor)) emitter.signedDivide(Word32, OtherScratch, source, Scratch) emitter.multiplySubtract( @@ -418,8 +626,10 @@ elif NativeAmd64: ## Adds a constant to a hoisted register, wrapping on overflow. emitter.addImmediate(Word32, slotRegister(slot), value) - proc compareSlot(emitter: var Assembler, slot: int, value: int32) - {.raises: [BasicError].} = + const MaxPooled* = 0 + + proc compareSlot(emitter: var Assembler, slot: int, value: int32, + pooled = -1) {.raises: [BasicError].} = ## Sets flags from a hoisted register against a constant. emitter.compareImmediate(Word32, slotRegister(slot), value) @@ -499,22 +709,46 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region return slot -1 + const CountedLoops = NativeArm64 + let plan = planLoop(code, start, stop) + let counted = CountedLoops and plan.counted + let spending = CountedLoops and plan.spending and not plan.counted + let pool = + if MaxPooled > 0: pooledConstants(code, start, stop, MaxPooled) + else: @[] + + proc poolSlot(value: int32): int {.closure, raises: [].} = + ## Returns which register already holds a constant, if one does. + for slot, held in pool: + if held == int64(value): + return slot + -1 + var emitter = Assembler() var blocks: seq[Label] for index in start ..< stop: blocks.add(emitter.label()) let guardFailed = emitter.label() let writeback = emitter.label() - var exits: seq[(Label, int32, NativeStatus)] + var exits: seq[(Label, int32, NativeStatus, int)] template blockAt(offset: int32): Label = blocks[int(offset) - start] - template leaveFor(target: int32, status: NativeStatus) = - ## Branches to a stub that resumes the interpreter at an offset. - let stub = emitter.label() - exits.add((stub, target, status)) - emitter.branch(stub) + proc exitLabel(target: int32, status: NativeStatus, + leaving: int): Label = + ## Names the out-of-line stub that resumes the interpreter here. + ## A countable loop charges for how far the leaving pass got, so + ## stubs differ by where they leave as well as where they go. + for (stub, existing, kind, origin) in exits: + if existing == target and kind == status and origin == leaving: + return stub + result = emitter.label() + exits.add((result, target, status, leaving)) + + template leaveFor(target: int32, status: NativeStatus, leaving: int) = + ## Jumps to the stub that resumes the interpreter at an offset. + emitter.branch(exitLabel(target, status, leaving)) ## Entry: prove every participating global is an integer, then hoist it. emitter.startRegion() @@ -522,6 +756,21 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region let base = int(index) * ValueStride emitter.guardInteger(base, guardFailed) emitter.loadHoisted(slot, base) + when CountedLoops: + for slot, value in pool: + emitter.loadPooled(slot, value) + # Nothing has been written yet, so a refusal here hands the loop + # back exactly as it was found. + if counted: + emitter.beginCountedLoop( + plan.instructions, plan.work, + exitLabel(int32(start), NativeExhausted, start) + ) + elif spending: + emitter.beginSpendingLoop( + plan.passInstructions, plan.passWork, + exitLabel(int32(start), NativeExhausted, start) + ) ## Body: one native block per bytecode offset, so branches keep working. for index in start ..< stop: @@ -533,23 +782,36 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region if int(target) >= start and int(target) < stop: emitter.branchWhen(test, blockAt(target)) else: - let taken = emitter.label() - let skipped = emitter.label() - emitter.branchWhen(test, taken) - emitter.branch(skipped) - emitter.place(taken) - leaveFor(target, NativeCompleted) - emitter.place(skipped) + emitter.branchWhen( + test, exitLabel(target, NativeCompleted, index) + ) case item.op of MeterOp: - let short = emitter.label() - let past = emitter.label() - emitter.budgetGate(int64(item.b), int64(item.a), short) - emitter.branch(past) - emitter.place(short) - leaveFor(int32(index), NativeExhausted) - emitter.place(past) + when CountedLoops: + if counted: + # The budget was settled on entry, so a countable loop needs + # only to know it has passes left. + if index == start: + emitter.checkCounter( + exitLabel(int32(start), NativeCompleted, start) + ) + elif spending: + if index == start: + emitter.checkSpending( + exitLabel(int32(start), NativeCompleted, start) + ) + emitter.recordSpending(int64(item.b), int64(item.a)) + else: + emitter.budgetGate( + int64(item.b), int64(item.a), + exitLabel(int32(index), NativeExhausted, index) + ) + else: + emitter.budgetGate( + int64(item.b), int64(item.a), + exitLabel(int32(index), NativeExhausted, index) + ) of StoreGlobalImmediateOp: emitter.setSlot(slotOf(item.a), item.b) of MoveGlobalOp: @@ -560,16 +822,19 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region emitter.addSlots(slotOf(item.a), slotOf(item.b)) of JumpOp: if int(item.a) >= start and int(item.a) < stop: + when CountedLoops: + if counted and index == stop - 1: + emitter.advanceCounter() emitter.branch(blockAt(item.a)) else: - leaveFor(item.a, NativeCompleted) + leaveFor(item.a, NativeCompleted, index) of JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, JumpUnlessGlobalLessEqualImmediateOp, JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: - emitter.compareSlot(slotOf(item.a), item.b) + emitter.compareSlot(slotOf(item.a), item.b, poolSlot(item.b)) branchOut(item.c, takenOn(item.op)) of JumpUnlessGlobalModuloEqualZeroOp: # Dividing by one or minus one always leaves no remainder, and @@ -581,10 +846,20 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region return nil ## Falling off the last offset resumes the interpreter at the next one. - leaveFor(int32(stop), NativeCompleted) + leaveFor(int32(stop), NativeCompleted, stop - 1) - for (stub, target, status) in exits: + for (stub, target, status, leaving) in exits: emitter.place(stub) + when CountedLoops: + if counted and status == NativeCompleted: + # Charge the passes that ran, plus how far the leaving one got. + emitter.chargeCounted( + plan.instructions, plan.work, + plan.partialInstructions[leaving - start], + plan.partialWork[leaving - start] + ) + elif spending and status == NativeCompleted: + emitter.chargeSpending() emitter.exitStub(target, int32(ord(status)), writeback) ## Shared exit: publish the hoisted globals and budgets, then return. @@ -607,6 +882,9 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region result = Region( start: int32(start), stop: int32(stop), hoisted: hoisted, size: size ) + result.listing = newSeq[byte](size) + if size > 0: + copyMem(result.listing[0].addr, emitter.code[0].addr, size) result.buffer = initCodeBuffer(size) result.buffer.write(emitter.code) result.buffer.seal() @@ -617,17 +895,21 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus ## Runs one compiled loop and reports why it returned. NativeStatus(region.call(context.addr)) -proc compileLoops*(code: seq[Instruction]): Table[int32, Region] +proc compileLoops*(code: seq[Instruction]): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. + ## The result is indexed by bytecode offset, so the interpreter reaches + ## a compiled loop with one load rather than a lookup. for index in 0 ..< code.len: var target = 0'i32 if not code[index].branchTarget(target): continue if int(target) > index or int(target) < 0: continue - if target in result: + if result.len > 0 and result[int(target)] != nil: continue let region = compileRegion(code, int(target), index + 1) if region != nil: - result[target] = region + if result.len == 0: + result = newSeq[Region](code.len) + result[int(target)] = region diff --git a/tests/test_arm64.nim b/tests/test_arm64.nim index 4d38b86..9aff539 100644 --- a/tests/test_arm64.nim +++ b/tests/test_arm64.nim @@ -116,6 +116,13 @@ encodes "csetm w2, eq": encodes "csetm x3, ge": assembler.setOnCondition(Word64, x3, GreaterEqualCondition) +encodes "tst w22, #1": + assembler.testLowBits(Word32, x22, 1) +encodes "tst w3, #7": + assembler.testLowBits(Word32, x3, 3) +encodes "tst x4, #0xffff": + assembler.testLowBits(Word64, x4, 16) + ## Memory encodes "ldr w5, [x6, #12]": From 0c6b94f36c16615314f0fca29d0f08d154636dd8 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 16:03:31 -0700 Subject: [PATCH 08/51] Use the current Intel macOS runner label macos-13 is being retired and never left the queue, which kept the whole run from publishing its logs. macos-15-intel is the label GitHub offers for x86-64 macOS now. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fe2f497..02b6656 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,7 +19,7 @@ jobs: - ubuntu-latest # x86-64 Linux, native backend - ubuntu-24.04-arm # arm64 Linux, native backend - macos-latest # arm64 macOS, native backend - - macos-13 # x86-64 macOS, native backend + - macos-15-intel # x86-64 macOS, native backend - windows-latest # x86-64 Windows, interpreter only runs-on: ${{ matrix.os }} steps: From ef6cbce70324bbe0853dca90a8774b9e487555db Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 16:59:03 -0700 Subject: [PATCH 09/51] Compile loops on Windows too, and drop the runner that cannot start Windows x64 reaches the same code generator as System V. The two differ only in which register carries the argument and which ones a callee has to preserve: Windows takes it in rcx and must return rsi and rdi intact, System V takes it in rdi and may use both freely. Everything else, down to the encodings, is shared. The kernel32 declarations now follow the Windows header types exactly. DWORD is an unsigned long there, a different type from an unsigned int even where the two are the same width, and a cross build with mingw rejected the old signatures. Also drop macos-15-intel from the matrix. It never failed on this code: setup-nim-action installs an arm64 Nim on the Intel runners, so nim itself will not start, reporting a bad CPU type. The comment in the workflow says to put it back once the action picks its download by architecture. Windows is verified here only as far as a mingw cross build of every test; the runner is what will actually execute it. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 7 +++-- src/bassy/jit.nim | 23 +++++++++++----- src/bassy/machine.nim | 52 ++++++++++++++++++++----------------- 3 files changed, 50 insertions(+), 32 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 02b6656..c393e7e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,8 +19,11 @@ jobs: - ubuntu-latest # x86-64 Linux, native backend - ubuntu-24.04-arm # arm64 Linux, native backend - macos-latest # arm64 macOS, native backend - - macos-15-intel # x86-64 macOS, native backend - - windows-latest # x86-64 Windows, interpreter only + - windows-latest # x86-64 Windows, native backend + # x86-64 macOS is missing because setup-nim-action installs an + # arm64 Nim on the Intel runners, which then refuse to start + # with "Bad CPU type in executable". Put macos-15-intel back + # once the action picks its download by architecture. runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 1c20fc7..d37504c 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -533,23 +533,34 @@ when NativeArm64: emitter.endRegion() elif NativeAmd64: - ## x86-64 code generation, System V calling convention + ## x86-64 code generation ## - ## rdi context pointer, live for the whole region ## rbx base of the globals array ## r12 remaining instruction budget ## r13 remaining work budget - ## r14, r15, rsi, rcx, r8, r9, r10 hoisted globals ## rax and rdx are reserved for the divide; r11 is scratch + ## + ## The two conventions differ only in which register carries the argument + ## and which ones a callee must preserve. System V takes its argument in + ## rdi and may use rsi and rdi freely; Windows takes its argument in rcx + ## and must preserve both rsi and rdi. const - Context = rdi GlobalsBase = rbx Instructions = r12 Work = r13 Scratch = r11 - Hoisted = [r14, r15, rsi, rcx, r8, r9, r10] - Saved = [rbx, r12, r13, r14, r15] + + when defined(windows): + const + Context = rcx + Hoisted = [r14, r15, rsi, rdi, r8, r9, r10] + Saved = [rbx, r12, r13, r14, r15, rsi, rdi] + else: + const + Context = rdi + Hoisted = [r14, r15, rsi, rcx, r8, r9, r10] + Saved = [rbx, r12, r13, r14, r15] proc slotRegister(slot: int): Register {.raises: [].} = ## Returns the register holding one hoisted global. diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index 689e2ba..2aed6b0 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -26,37 +26,40 @@ const defined(macosx) or defined(linux) or defined(windows) ): true - elif defined(amd64) and (defined(macosx) or defined(linux)): - # Windows x64 passes its first argument elsewhere and saves a - # different register set, so it stays on the interpreter for now. + elif defined(amd64) and ( + defined(macosx) or defined(linux) or defined(windows) + ): true else: false when NativeCode and defined(windows): + # These follow the Windows header types exactly: DWORD is an unsigned + # long there, which is a different type from an unsigned int even where + # the two are the same width. const - MemCommit = 0x1000'i32 - MemReserve = 0x2000'i32 - MemRelease = 0x8000'i32 - PageReadWrite = 0x04'i32 - PageExecuteRead = 0x20'i32 + MemCommit = 0x1000.culong + MemReserve = 0x2000.culong + MemRelease = 0x8000.culong + PageReadWrite = 0x04.culong + PageExecuteRead = 0x20.culong - proc virtualAlloc(address: pointer, size: int, allocation, - protection: int32): pointer - {.importc: "VirtualAlloc", dynlib: "kernel32", stdcall.} + proc virtualAlloc(address: pointer, size: csize_t, + allocation, protection: culong): pointer + {.importc: "VirtualAlloc", header: "", stdcall.} - proc virtualProtect(address: pointer, size: int, protection: int32, - previous: ptr int32): int32 - {.importc: "VirtualProtect", dynlib: "kernel32", stdcall.} + proc virtualProtect(address: pointer, size: csize_t, protection: culong, + previous: ptr culong): cint + {.importc: "VirtualProtect", header: "", stdcall.} - proc virtualFree(address: pointer, size: int, freeType: int32): int32 - {.importc: "VirtualFree", dynlib: "kernel32", stdcall.} + proc virtualFree(address: pointer, size: csize_t, freeType: culong): cint + {.importc: "VirtualFree", header: "", stdcall.} proc currentProcess(): pointer - {.importc: "GetCurrentProcess", dynlib: "kernel32", stdcall.} + {.importc: "GetCurrentProcess", header: "", stdcall.} - proc flushInstructionCache(process, address: pointer, size: int): int32 - {.importc: "FlushInstructionCache", dynlib: "kernel32", stdcall.} + proc flushInstructionCache(process, address: pointer, size: csize_t): cint + {.importc: "FlushInstructionCache", header: "", stdcall.} elif NativeCode: const ProtNone = 0x0.cint @@ -117,7 +120,7 @@ proc initCodeBuffer*(capacity: int): CodeBuffer {.raises: [BasicError].} = fail("this build has no native code backend") elif defined(windows): let memory = virtualAlloc( - nil, size, MemCommit or MemReserve, PageReadWrite + nil, csize_t(size), MemCommit or MemReserve, PageReadWrite ) if memory == nil: fail("code buffer reservation failed") @@ -196,13 +199,14 @@ proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = when not NativeCode: fail("this build has no native code backend") elif defined(windows): - var previous = 0'i32 + var previous = 0.culong if virtualProtect( - buffer.memory, buffer.capacity, PageExecuteRead, previous.addr + buffer.memory, csize_t(buffer.capacity), PageExecuteRead, + previous.addr ) == 0: fail("code buffer could not be made executable") discard flushInstructionCache( - currentProcess(), buffer.memory, buffer.length + currentProcess(), buffer.memory, csize_t(buffer.length) ) buffer.sealed = true elif defined(macosx): @@ -233,7 +237,7 @@ proc release*(buffer: var CodeBuffer) {.raises: [].} = if buffer.memory == nil: return when NativeCode and defined(windows): - discard virtualFree(buffer.memory, 0, MemRelease) + discard virtualFree(buffer.memory, 0.csize_t, MemRelease) elif NativeCode: discard munmap(buffer.memory, csize_t(buffer.capacity)) buffer.memory = nil From 96e5dff44e089d38b1d1cf78a85415ae7947bb8f Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 17:31:27 -0700 Subject: [PATCH 10/51] Treat macOS as arm only in CI The note about restoring an Intel runner implied a plan to cover x86-64 macOS again. There is none, so it goes. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c393e7e..2ba8c7b 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,10 +20,7 @@ jobs: - ubuntu-24.04-arm # arm64 Linux, native backend - macos-latest # arm64 macOS, native backend - windows-latest # x86-64 Windows, native backend - # x86-64 macOS is missing because setup-nim-action installs an - # arm64 Nim on the Intel runners, which then refuse to start - # with "Bad CPU type in executable". Put macos-15-intel back - # once the action picks its download by architecture. + # macOS is arm only here. Intel runners are not covered. runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 From f167effad858db93da85c1c13d2ecfa2327f83f3 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 17:45:03 -0700 Subject: [PATCH 11/51] Close a budget escape and check what compiled code is trusted with Compiled code indexes global storage without checking and writes through offsets worked out at compile time. The interpreter checks every access as it runs and compiled code cannot, so what makes that safe is the proving done before any of it is emitted, and that proving was not thorough enough. The escape: a loop whose passes differ in cost looked at its spending only at the region's first offset. A loop nested inside one of those reaches that offset once for however many times it goes round, so it could spend as long as it liked in between. A script could exceed its instruction budget by whatever factor its inner trip count gave it, which is the denial of service the meter exists to prevent. The spending is now looked at wherever a backward branch lands, so what runs between two looks contains no backward branch and can charge no more than the one pass the limit is set against. tests/test_jit_safety.nim found this; it showed twice the work done for the same budget. Also proved rather than assumed: - Every global index is checked against the storage that exists. Out of range, or negative, and the loop is refused. This was an encoding range check on AArch64 and nothing at all on x86-64. - An operation naming a global the gathering pass did not see now refuses instead of reaching for whichever register came next, which would have been a budget register. - The value and context layouts the generator writes by hand are confirmed at run time before any loop is compiled. These offsets were read off this Nim version; nothing holds them there, and a quiet change would put every compiled store at the wrong address. - A pass that charges nothing cannot turn forever against an unmoving total. The new tests come in two halves: bytecode the language's own compiler could not produce, which must be refused rather than emitted, and scripts run down both paths whose globals, budgets and failures must match, since a script that could tell the two apart could be written to exploit the difference. Four hundred generated scripts are included. CI runs them under both -d:release and -d:danger, the second being where the interpreter's own bounds checks are gone. Speed is unchanged: 0.24 ms, 0.94 ms and 0.45 ms on an M4 Pro. Co-Authored-By: Claude Opus 5 --- .github/workflows/build.yml | 4 + src/bassy.nim | 4 +- src/bassy/jit.nim | 110 ++++++++++-- tests/test_jit_safety.nim | 343 ++++++++++++++++++++++++++++++++++++ 4 files changed, 448 insertions(+), 13 deletions(-) create mode 100644 tests/test_jit_safety.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2ba8c7b..ccd510e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -57,6 +57,10 @@ jobs: nim r -d:release tests/test_amd64.nim - name: Test compiled loops against the interpreter run: nim r -d:release tests/test_jit.nim + - name: Test that compiled loops cannot outrun the sandbox + run: | + nim r -d:release tests/test_jit_safety.nim + nim r -d:danger tests/test_jit_safety.nim - name: Test the same scripts with the interpreter forced run: nim r -d:release -d:bassyNoJit tests/test_jit.nim - name: Render the BASIC raytracer and check cross-platform determinism diff --git a/src/bassy.nim b/src/bassy.nim index 50e00bd..96782e7 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3136,7 +3136,9 @@ proc compileNative*(runtime: var Runtime): int = runtime.regionAt = @[] if not jitSupported(): return 0 - runtime.regionAt = compileLoops(runtime.program.code) + runtime.regionAt = compileLoops( + runtime.program.code, runtime.globals.len + ) runtime.nativeRegions proc bytecode*(program: Program): lent seq[Instruction] {.inline.} = diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index d37504c..fe230fd 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -70,6 +70,45 @@ const MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 +proc fail(message: string) {.noreturn, raises: [BasicError].} = + ## Reports a controlled native compilation failure. + raise newException(BasicError, "BASIC " & message) + +proc layoutMatches*(): bool {.raises: [].} = + ## Confirms the memory layout the code generator writes by hand. + ## + ## Generated code reaches into values and into the context by fixed + ## offsets worked out from this Nim version. Nothing guarantees those + ## stay put, and a silent change would turn every compiled store into a + ## write at the wrong address, so they are checked rather than trusted. + if sizeof(Value) != ValueStride: + return false + var probe = newSeq[Value](2) + probe[0] = toValue(0x5A6B7C0D'i32) + probe[1] = toValue(fixed(1'i32)) + var image: array[ValueStride * 2, byte] + copyMem(image[0].addr, probe[0].addr, ValueStride * 2) + # An integer must be tagged zero, because the guard tests for zero, and + # a fixed-point number must not be, or the guard would let one through. + if image[0] != byte(ord(IntegerValue)): + return false + if image[ValueStride] == byte(ord(IntegerValue)): + return false + var payload = 0'i32 + copyMem(payload.addr, image[ValuePayload].addr, sizeof(int32)) + if payload != 0x5A6B7C0D'i32: + return false + var context: NativeContext + let origin = cast[int](context.addr) + if cast[int](context.remainingInstructions.addr) - origin != + ContextInstructions: + return false + if cast[int](context.remainingWork.addr) - origin != ContextWork: + return false + if cast[int](context.pc.addr) - origin != ContextOffset: + return false + true + ## Region discovery proc isCompilable(item: Instruction): bool {.raises: [].} = @@ -158,12 +197,14 @@ type passWork: int64 partialInstructions: seq[int64] partialWork: seq[int64] + checkpoints: seq[bool] proc planLoop(code: seq[Instruction], start, stop: int): LoopPlan {.raises: [].} = ## Measures one pass through a loop and reports whether it is countable. result.partialInstructions = newSeq[int64](stop - start) result.partialWork = newSeq[int64](stop - start) + result.checkpoints = newSeq[bool](stop - start) if stop - start < 2: return let last = code[stop - 1] @@ -177,7 +218,28 @@ proc planLoop(code: seq[Instruction], start, stop: int): LoopPlan if code[index].op == MeterOp: result.passInstructions += int64(code[index].b) result.passWork += int64(code[index].a) - result.spending = result.passInstructions > 0 and result.passWork > 0 + + # Every backward branch is a place the spending must be looked at. An + # inner loop turning under an outer one would otherwise run as long as + # it liked between two looks, because the outer header is only reached + # once for however many times the inner one goes round. What runs + # between two checks then contains no backward branch, so it can charge + # no more than one pass, which is what the limit is set against. + result.checkpoints[0] = true + for index in start ..< stop: + var target = 0'i32 + if code[index].branchTarget(target): + if int(target) >= start and int(target) <= index: + result.checkpoints[int(target) - start] = true + for offset, wanted in result.checkpoints: + if wanted and code[start + offset].op != MeterOp: + # Nowhere to put the look, so this loop keeps the per-block check. + return + # Every pass runs the meter at the loop head, so charging at least one + # instruction there is what stops a pass from spending nothing and + # looping for ever against an unmoving total. + result.spending = result.passInstructions > 0 and result.passWork > 0 and + code[start].b > 0 var instructions = 0'i64 work = 0'i64 @@ -683,9 +745,14 @@ elif NativeAmd64: emitter.loadImmediate(Word32, rax, int64(ord(NativeGuardFailed))) emitter.endRegion() -proc compileRegion*(code: seq[Instruction], start, stop: int): Region - {.raises: [BasicError].} = +proc compileRegion*(code: seq[Instruction], start, stop, globals: int): + Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. + ## + ## Generated code indexes global storage without checking, so every + ## index it will use is proved to be in range here, before any of it is + ## emitted. The interpreter checks each access as it runs; compiled code + ## cannot, which is exactly why this pass has to be exhaustive. when not (NativeArm64 or NativeAmd64): # No backend for this target: the interpreter is the only path. return nil @@ -695,6 +762,9 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region if code.reachesOutside(start, stop): return nil + if globals < 0: + return nil + var hoisted: seq[int32] for index in start ..< stop: let item = code[index] @@ -703,22 +773,36 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region item.touchedGlobals(hoisted) var target = 0'i32 if item.branchTarget(target): + # A branch may land one past the last offset, where the + # interpreter stops, but never beyond it. if int(target) < 0 or int(target) > code.len: return nil if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: return nil + for index in hoisted: + if index < 0 or int(index) >= globals: + return nil when NativeArm64: - # The guard reads the tag through a scaled byte offset. + # The tag is read through a scaled byte offset, which is narrower + # than the range the bounds check above already allows. for index in hoisted: if int(index) * ValueStride + ValuePayload > 4095: return nil - proc slotOf(index: int32): int {.closure, raises: [].} = - ## Returns which hoisted register holds one global. + proc slotOf(index: int32): int {.closure, raises: [BasicError].} = + ## Returns which hoisted register holds one global. Reaching the end + ## would mean an operation reads a global that was never gathered, + ## and so never bounds checked, so it refuses rather than picking a + ## register that happens to be next. + result = -1 for slot, candidate in hoisted: if candidate == index: - return slot - -1 + result = slot + break + if result < 0: + raise newException( + BasicError, "BASIC native compiler met an ungathered global" + ) const CountedLoops = NativeArm64 let plan = planLoop(code, start, stop) @@ -808,9 +892,9 @@ proc compileRegion*(code: seq[Instruction], start, stop: int): Region exitLabel(int32(start), NativeCompleted, start) ) elif spending: - if index == start: + if plan.checkpoints[index - start]: emitter.checkSpending( - exitLabel(int32(start), NativeCompleted, start) + exitLabel(int32(index), NativeCompleted, index) ) emitter.recordSpending(int64(item.b), int64(item.a)) else: @@ -906,11 +990,13 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus ## Runs one compiled loop and reports why it returned. NativeStatus(region.call(context.addr)) -proc compileLoops*(code: seq[Instruction]): seq[Region] +proc compileLoops*(code: seq[Instruction], globals: int): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. ## The result is indexed by bytecode offset, so the interpreter reaches ## a compiled loop with one load rather than a lookup. + if not layoutMatches(): + return for index in 0 ..< code.len: var target = 0'i32 if not code[index].branchTarget(target): @@ -919,7 +1005,7 @@ proc compileLoops*(code: seq[Instruction]): seq[Region] continue if result.len > 0 and result[int(target)] != nil: continue - let region = compileRegion(code, int(target), index + 1) + let region = compileRegion(code, int(target), index + 1, globals) if region != nil: if result.len == 0: result = newSeq[Region](code.len) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim new file mode 100644 index 0000000..53380c4 --- /dev/null +++ b/tests/test_jit_safety.nim @@ -0,0 +1,343 @@ +## Tries to get the native compiler to do something the interpreter would +## not. Compiled code indexes global storage without checking and writes +## through offsets worked out at compile time, so the checks that make +## that safe are the ones worth attacking. +## +## Two halves. The first hands compileRegion bytecode the language's own +## compiler would never produce, and requires it to refuse rather than +## emit. The second runs generated scripts down both paths and requires +## the results and both budgets to match, because a script that could tell +## the difference could be written to exploit it. + +import + std/[random, strformat], + bassy, + bassy/jit + +var failures = 0 + +proc report(name: string, ok: bool, detail = "") = + ## Records one check. + if ok: + echo &" ok {name}" + else: + inc failures + echo &"FAIL {name}" + if detail.len > 0: + echo &" {detail}" + +## The layout the code generator assumes + +report( + "value and context layout is the one the generator writes", + layoutMatches(), + "compiled stores would land at the wrong offsets" +) + +## Bytecode the language could not produce + +const Globals = 4 + +proc countingLoop(globalIndex: int32, target: int32): seq[Instruction] = + ## A minimal loop, parameterised so it can be made malformed. + @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction( + op: JumpUnlessGlobalLessImmediateOp, a: globalIndex, b: 10, c: target + ), + Instruction(op: AddGlobalImmediateOp, a: globalIndex, b: 1), + Instruction(op: JumpOp, a: 0) + ] + +block: + # The same shape must compile when it is well formed, or the refusals + # below would prove nothing. + let code = countingLoop(1, 4) + report( + "a well formed loop still compiles", + (not jitSupported()) or compileRegion(code, 0, 4, Globals) != nil + ) + +block: + let code = countingLoop(Globals, 4) + report( + "a global one past the end is refused", + compileRegion(code, 0, 4, Globals) == nil, + "an out of range index would become a fixed offset store" + ) + +block: + let code = countingLoop(1_000_000, 4) + report( + "a far out of range global is refused", + compileRegion(code, 0, 4, Globals) == nil + ) + +block: + let code = countingLoop(-1, 4) + report( + "a negative global is refused", + compileRegion(code, 0, 4, Globals) == nil, + "a negative index would address below the globals" + ) + +block: + let code = countingLoop(1, 99) + report( + "a branch past the end of the code is refused", + compileRegion(code, 0, 4, Globals) == nil + ) + +block: + let code = countingLoop(1, -5) + report( + "a negative branch target is refused", + compileRegion(code, 0, 4, Globals) == nil + ) + +block: + let code = countingLoop(1, 4) + report( + "a region reaching past the code is refused", + compileRegion(code, 0, 99, Globals) == nil + ) + +block: + let code = countingLoop(1, 4) + report( + "a region with no storage behind it is refused", + compileRegion(code, 0, 4, 0) == nil + ) + +block: + # Dividing by zero raises in the interpreter, so it must never reach a + # divide instruction. + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: JumpUnlessGlobalModuloEqualZeroOp, a: 1, b: 0, c: 4), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + report( + "a zero divisor is refused", + compileRegion(code, 0, 4, Globals) == nil + ) + +## Scripts, down both paths + +type Outcome = object + globals: seq[int64] + instructions: int64 + work: int64 + failure: string + +proc execute(source: string, native: bool, maximum: int64): Outcome = + ## Runs one script and records everything a script could observe. + var limits = defaultLimits() + limits.maxInstructions = maximum + limits.maxWorkUnits = maximum + let program = compile(source, limits) + var runtime = initRuntime(program, limits) + if native: + discard runtime.compileNative() + try: + discard runtime.run() + except BasicError as error: + result.failure = error.msg + for index in 0 ..< program.globals: + let value = runtime.globalValue(int32(index)) + result.globals.add( + case value.kind + of IntegerValue: int64(value.asInt) + of FixedValue: int64(int32(value.asFixed)) + of StringValue: -1'i64 + ) + let (instructions, work) = runtime.remainingBudget + result.instructions = instructions + result.work = work + +proc agrees(name, source: string, maximum = 2_000_000'i64) = + ## Requires the two paths to be indistinguishable from inside a script. + let plain = execute(source, false, maximum) + let fast = execute(source, true, maximum) + var detail = "" + if plain.globals != fast.globals: + detail = &"globals {plain.globals} then {fast.globals}" + elif plain.instructions != fast.instructions: + detail = &"instructions {plain.instructions} then {fast.instructions}" + elif plain.work != fast.work: + detail = &"work {plain.work} then {fast.work}" + elif plain.failure != fast.failure: + detail = &"failure '{plain.failure}' then '{fast.failure}'" + report(name, detail.len == 0, detail) + +agrees("a loop wider than any budget still stops", """ +i = 0 +total = 0 +while i < 2000000000 + total = total + 1 + i = i + 1 +wend +""") + +agrees("a loop that never advances still stops", """ +i = 0 +seen = 0 +while i < 10 + seen = seen + 1 +wend +""") + +agrees("nesting cannot outrun the budget", """ +a = 0 +b = 0 +hits = 0 +while a < 100000 + b = 0 + while b < 100000 + hits = hits + 1 + b = b + 1 + wend + a = a + 1 +wend +""") + +agrees("wrapping at the top of the range", """ +i = 0 +total = 2147483647 +while i < 100 + total = total + 1 + i = i + 1 +wend +""") + +agrees("wrapping at the bottom of the range", """ +i = 0 +total = -2147483648 +while i < 100 + total = total + -1 + i = i + 1 +wend +""") + +agrees("the most negative value against minus one", """ +i = -2147483648 +hits = 0 +while i < -2147483638 + if i mod -1 = 0 then + hits = hits + 1 + end if + i = i + 1 +wend +""") + +agrees("a remainder against the most negative divisor", """ +i = 0 +hits = 0 +while i < 50 + if i mod -2147483648 = 0 then + hits = hits + 1 + end if + i = i + 1 +wend +""") + +agrees("a counter that turns fractional mid-run", """ +i = 0 +total = 0 +while i < 200 + total = total + 1 + i = i + 1 + if i = 100 then + i = i + 0.5 + end if +wend +""") + +agrees("a budget that runs out inside a loop", """ +i = 0 +total = 0 +while i < 1000000 + total = total + i + i = i + 1 +wend +""", maximum = 733) + +agrees("a budget that runs out on the first pass", """ +i = 0 +total = 0 +while i < 1000000 + total = total + i + i = i + 1 +wend +""", maximum = 3) + +agrees("arrays beside a compiled loop", """ +dim cells(15) +i = 0 +total = 0 +while i < 16 + cells(i) = i + i = i + 1 +wend +j = 0 +while j < 16 + total = total + cells(j) + j = j + 1 +wend +""") + +## Generated scripts + +proc generated(seed: int64): string = + ## Builds a small integer program out of the shapes the compiler models. + var random = initRand(seed) + let names = ["a", "b", "c", "d"] + result = "" + for name in names: + result.add(&"{name} = {random.rand(-40 .. 40)}\n") + let counter = names[random.rand(0 .. 3)] + result.add(&"{counter} = 0\n") + result.add(&"while {counter} < {random.rand(1 .. 60)}\n") + for statement in 0 ..< random.rand(1 .. 4): + let target = names[random.rand(0 .. 3)] + case random.rand(0 .. 3) + of 0: + result.add(&" {target} = {target} + {random.rand(-9 .. 9)}\n") + of 1: + result.add(&" {target} = {target} + {names[random.rand(0 .. 3)]}\n") + of 2: + let divisor = [2, 4, 8, 3, -2][random.rand(0 .. 4)] + result.add(&" if {target} mod {divisor} = 0 then\n") + result.add(&" {target} = {target} + 1\n") + result.add(" end if\n") + else: + result.add(&" {target} = {random.rand(-30 .. 30)}\n") + result.add(&" {counter} = {counter} + 1\n") + result.add("wend\n") + +var generatedFailures = 0 +for seed in 1'i64 .. 400'i64: + let source = generated(seed) + let plain = execute(source, false, 2_000_000) + let fast = execute(source, true, 2_000_000) + if plain.globals != fast.globals or + plain.instructions != fast.instructions or + plain.work != fast.work or plain.failure != fast.failure: + inc generatedFailures + if generatedFailures == 1: + echo "first disagreement, seed ", seed, ":" + echo source + echo &" interpreted {plain.globals} {plain.instructions} " & + &"{plain.work} '{plain.failure}'" + echo &" native {fast.globals} {fast.instructions} " & + &"{fast.work} '{fast.failure}'" +report( + "400 generated scripts agree on both paths", + generatedFailures == 0, + &"{generatedFailures} disagreed" +) + +if failures > 0: + quit($failures & " safety checks failed") +echo "native compilation is indistinguishable from interpretation" From 8751e30d5783d8eb073427dfb1a0ac15d731d530 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 17:46:04 -0700 Subject: [PATCH 12/51] Count every way into a loop, not just the ones the generator models Compiled code proves its globals are integers and loads them into registers on the way in, so arriving anywhere but the first offset would skip the proof and read registers that were never filled. The check for that only knew the branches the generator compiles, and three operations it refuses to compile can still name an offset inside a loop it did: a subroutine call, a label return, and a register test. Nothing could reach those entries today, because a loop is only ever called at its first offset, but the check is what the argument rests on, so it should be the one that is complete. Co-Authored-By: Claude Opus 5 --- src/bassy/jit.nim | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index fe230fd..676dce3 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -285,14 +285,32 @@ proc pooledConstants(code: seq[Instruction], start, stop, room: int): else: discard +proc anyTarget(item: Instruction, target: var int32): bool {.raises: [].} = + ## Reports where an operation can send control, for every operation that + ## can send it anywhere. This is wider than the set the code generator + ## models: a subroutine call or a register test the generator refuses to + ## compile can still name an offset inside a loop it did compile. + case item.op + of JumpOp, GosubOp, ReturnLabelOp: + target = item.a + true + of JumpIfZeroOp: + target = item.b + true + else: + item.branchTarget(target) + proc reachesOutside(code: seq[Instruction], start, stop: int): bool {.raises: [].} = ## Reports whether the loop can be entered anywhere but its first offset. + ## Compiled code proves its globals are integers and loads them into + ## registers on the way in, so arriving anywhere else would skip the + ## proof and read registers that were never filled. for index in 0 ..< code.len: if index >= start and index < stop: continue var target = 0'i32 - if code[index].branchTarget(target): + if code[index].anyTarget(target): if int(target) > start and int(target) < stop: return true false From 7adb0dc897487f135213a72a9dec8443e2e7c1eb Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 18:12:27 -0700 Subject: [PATCH 13/51] Say which Nim versions the layout check is written around The check builds its values in a sequence and reads them through copyMem instead of casting the address of a local. That is not a style choice: Nim 2.2.6 and 2.2.10 both fail to compile a procedure that takes the address of a converter-initialised variant local and also returns early, reporting an index error with no location. Anyone tidying this back into casts would hit it, so the reason is now written down beside it. Also drop an import the tests stopped using. Verified on Nim 2.2.10, which is the minimum the package asks for: the suite, both encoder checks, the equivalence and safety tests under -d:release and -d:danger, the interpreter-only build, an emscripten wasm32 build under node, and the same set on x86-64 Linux in a 2.2.10 container. Co-Authored-By: Claude Opus 5 --- src/bassy/jit.nim | 4 ++++ tests/test_jit.nim | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 676dce3..cd547b0 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -83,6 +83,10 @@ proc layoutMatches*(): bool {.raises: [].} = ## write at the wrong address, so they are checked rather than trusted. if sizeof(Value) != ValueStride: return false + # The values are built in a sequence and read through copyMem rather + # than cast from locals. Nim 2.2.6 and 2.2.10 both fail to compile a + # procedure that takes the address of a converter-initialised variant + # local and also returns early, with an index error and no location. var probe = newSeq[Value](2) probe[0] = toValue(0x5A6B7C0D'i32) probe[1] = toValue(fixed(1'i32)) diff --git a/tests/test_jit.nim b/tests/test_jit.nim index 0daaee6..666ee59 100644 --- a/tests/test_jit.nim +++ b/tests/test_jit.nim @@ -4,7 +4,7 @@ ## exactly, because a script must not be able to tell which path ran. import - std/[strformat, strutils], + std/strformat, bassy type Outcome = object From 6b667bdc6e2b1fe86f5e9bce244d3624944cc2a6 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 18:20:31 -0700 Subject: [PATCH 14/51] Keep pages from being writable and executable, and from leaking Three findings from review, none of which the tests would have caught. Pages were mapped readable, writable and executable everywhere POSIX. Linux dropped write when sealing, but macOS did not, so an Intel Mac left every region writable and executable for as long as it was mapped. Only Apple silicon needs that combination, because there the writing is gated per thread instead; everywhere else the pages are now writable until sealed and executable afterwards, never both. Nothing ever released a region. compileNative drops the previous ones on each call, so a program compiling many scripts accumulated executable mappings. The buffer now owns its pages: it cannot be copied, and its last owner unmaps them. Twenty thousand compiled programs hold steady at 1.4 MB where they would have leaked about eighty. A block charging more than an add-immediate can hold raised out of compileNative instead of leaving that one loop interpreted. Such a block now keeps the per-block check, and compiling many loops survives one it cannot finish either way, because the interpreter runs everything the generator declines. A global whose byte offset would not fit the displacement it is reached through is refused as well; the limits do not allow one, but nothing was checking. The fourth finding, that a cycle inside a loop could run without meeting the budget, was fixed in "Close a budget escape" two commits earlier and is verified again here. Four shapes are exercised, each under a budget far smaller than it wants: nested while, a backward goto inside a while, three levels of nesting, and a program that is one goto cycle with no while at all. All four charge exactly what the interpreter charges and refuse at exactly the same point. Co-Authored-By: Claude Opus 5 --- src/bassy/jit.nim | 15 ++++++++++- src/bassy/machine.nim | 53 +++++++++++++++++++++++++++------------ tests/test_jit_safety.nim | 51 +++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 17 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index cd547b0..9571d8d 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -69,6 +69,8 @@ const ContextOffset = 24 MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 + MaxChargeImmediate = 4095 + MaxDisplacementBytes = int(high(int32)) proc fail(message: string) {.noreturn, raises: [BasicError].} = ## Reports a controlled native compilation failure. @@ -244,6 +246,11 @@ proc planLoop(code: seq[Instruction], start, stop: int): LoopPlan # looping for ever against an unmoving total. result.spending = result.passInstructions > 0 and result.passWork > 0 and code[start].b > 0 + for index in start ..< stop: + if code[index].op == MeterOp: + if code[index].b < 0 or code[index].b > MaxChargeImmediate or + code[index].a < 0 or code[index].a > MaxChargeImmediate: + result.spending = false var instructions = 0'i64 work = 0'i64 @@ -804,6 +811,8 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): for index in hoisted: if index < 0 or int(index) >= globals: return nil + if int(index) > (MaxDisplacementBytes - ValuePayload) div ValueStride: + return nil when NativeArm64: # The tag is read through a scaled byte offset, which is narrower # than the range the bounds check above already allows. @@ -1027,7 +1036,11 @@ proc compileLoops*(code: seq[Instruction], globals: int): seq[Region] continue if result.len > 0 and result[int(target)] != nil: continue - let region = compileRegion(code, int(target), index + 1, globals) + var region: Region = nil + try: + region = compileRegion(code, int(target), index + 1, globals) + except BasicError: + region = nil if region != nil: if result.len == 0: result = newSeq[Region](code.len) diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index 2aed6b0..ccd47c5 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -15,6 +15,10 @@ type const PageBytes* = 4096 + ## Apple silicon is the one target that keeps pages executable while + ## they are written, because it gates the writing per thread instead. + AppleSilicon* = defined(macosx) and defined(arm64) + ## Whether this target can emit and run machine code at all. Everywhere ## else, including WebAssembly, the interpreter is the only path and no ## platform-specific declaration is emitted. Define bassyNoJit to force @@ -69,10 +73,14 @@ elif NativeCode: MapPrivate = 0x0002.cint MapFailed = -1 - when defined(macosx): + when AppleSilicon: const MapAnonymous = 0x1000.cint MapJit = 0x0800.cint + elif defined(macosx): + const + MapAnonymous = 0x1000.cint + MapJit = 0.cint else: const MapAnonymous = 0x20.cint @@ -88,7 +96,7 @@ elif NativeCode: proc munmap(address: pointer, length: csize_t): cint {.importc: "munmap", header: "".} -when NativeCode and defined(macosx) and defined(arm64): +when NativeCode and AppleSilicon: proc jitWriteProtect(enabled: cint) {.importc: "pthread_jit_write_protect_np", header: "".} @@ -99,6 +107,20 @@ elif NativeCode and defined(arm64): proc clearCache(start, stop: pointer) {.importc: "__builtin___clear_cache", nodecl.} +proc `=copy`*(destination: var CodeBuffer, source: CodeBuffer) {.error: + "a code buffer owns its pages and cannot be copied".} + +proc `=destroy`*(buffer: CodeBuffer) {.raises: [].} = + ## Returns the pages when the last owner goes away, so a program that + ## compiles many scripts does not accumulate executable mappings. The + ## buffer cannot be copied, so there is exactly one owner to go away. + if buffer.memory == nil: + return + when NativeCode and defined(windows): + discard virtualFree(buffer.memory, 0.csize_t, MemRelease) + elif NativeCode: + discard munmap(buffer.memory, csize_t(buffer.capacity)) + proc fail(message: string) {.noreturn, raises: [BasicError].} = ## Reports a controlled code buffer failure. raise newException(BasicError, "BASIC " & message) @@ -128,10 +150,13 @@ proc initCodeBuffer*(capacity: int): CodeBuffer {.raises: [BasicError].} = memory: memory, capacity: size, length: 0, sealed: false ) else: + const OpenProtection = + when AppleSilicon: ProtRead or ProtWrite or ProtExec + else: ProtRead or ProtWrite let memory = mmap( nil, csize_t(size), - ProtRead or ProtWrite or ProtExec, + OpenProtection, MapPrivate or MapAnonymous or MapJit, -1, 0 @@ -152,12 +177,12 @@ proc capacity*(buffer: CodeBuffer): int {.inline, raises: [].} = proc beginWrite(buffer: var CodeBuffer) {.raises: [].} = ## Makes the pages writable on platforms that enforce write-xor-execute. - when NativeCode and defined(macosx) and defined(arm64): + when NativeCode and AppleSilicon: jitWriteProtect(0) proc endWrite(buffer: var CodeBuffer) {.raises: [].} = ## Restores execute permission after a batch of writes. - when NativeCode and defined(macosx) and defined(arm64): + when NativeCode and AppleSilicon: jitWriteProtect(1) proc write*(buffer: var CodeBuffer, source: pointer, size: int) @@ -209,12 +234,13 @@ proc seal*(buffer: var CodeBuffer) {.raises: [BasicError].} = currentProcess(), buffer.memory, csize_t(buffer.length) ) buffer.sealed = true - elif defined(macosx): - # MAP_JIT pages are already executable; only the cache needs a flush. - when defined(arm64): - invalidateInstructionCache(buffer.memory, csize_t(buffer.length)) + elif AppleSilicon: + # These pages are executable already, and writing to them is what is + # gated, so there is nothing to drop here and only the cache to flush. + invalidateInstructionCache(buffer.memory, csize_t(buffer.length)) buffer.sealed = true else: + # Drop write as execute is granted, so the pages are never both. if mprotect( buffer.memory, csize_t(buffer.capacity), ProtRead or ProtExec ) != 0: @@ -233,13 +259,8 @@ proc entry*(buffer: CodeBuffer): pointer {.raises: [BasicError].} = buffer.memory proc release*(buffer: var CodeBuffer) {.raises: [].} = - ## Returns the pages to the operating system. - if buffer.memory == nil: - return - when NativeCode and defined(windows): - discard virtualFree(buffer.memory, 0.csize_t, MemRelease) - elif NativeCode: - discard munmap(buffer.memory, csize_t(buffer.capacity)) + ## Returns the pages early, before the owner itself goes away. + `=destroy`(buffer) buffer.memory = nil buffer.capacity = 0 buffer.length = 0 diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 53380c4..df3af48 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -123,6 +123,57 @@ block: compileRegion(code, 0, 4, Globals) == nil ) +block: + # A block whose charge will not fit the instruction that adds it must + # leave that loop interpreted, not abandon the whole compilation. + let code = @[ + Instruction(op: MeterOp, a: 9_000_000, b: 9_000_000), + Instruction( + op: JumpUnlessGlobalLessImmediateOp, a: 1, b: 10, c: 5 + ), + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + var raised = false + try: + discard compileRegion(code, 0, 5, Globals) + except BasicError: + raised = true + # Falling back to the per-block check is a fine outcome here. Refusing + # the whole compilation is not. + report("a charge too wide to add does not abandon compilation", not raised) + +block: + # compileLoops must survive a region it cannot finish, because the + # interpreter can run anything the generator declines. + let code = @[ + Instruction(op: MeterOp, a: 9_000_000, b: 9_000_000), + Instruction( + op: JumpUnlessGlobalLessImmediateOp, a: 1, b: 10, c: 5 + ), + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0), + Instruction(op: HaltOp) + ] + var survived = false + try: + discard compileLoops(code, Globals) + survived = true + except BasicError: + survived = false + report("compiling many loops survives one it cannot finish", survived) + +block: + # Nothing may hand the generator a global so far out that its offset + # would not fit the displacement it is reached through. + let code = countingLoop(high(int32) div 8, 4) + report( + "a global whose offset would not fit is refused", + compileRegion(code, 0, 4, high(int32)) == nil + ) + ## Scripts, down both paths type Outcome = object From 7aaebe6b8bc76d80187f98013f6160d38042d372 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 19:28:10 -0700 Subject: [PATCH 15/51] Compile ordinary expressions, not only the fused forms Coverage was one loop in five. The generator took loops whose every operation was one of a dozen fused global forms the bytecode compiler happens to emit, so `n = n - 3` was already a miss: subtraction has no fused global form, and the loop fell out over a load, a subtract and a store. The benchmarks flattered it, being written by accident in its own dialect. The frame's register slots now reach compiled code. They stay where they are rather than being hoisted, which costs a memory round trip per operand but keeps two properties worth more than the speed: a slot holding something other than an integer hands that offset straight back to the interpreter, and nothing has to be written back when it does, because every result was already written where the interpreter would look for it. The charge for the pass so far is the one every other exit already uses. Added: load immediate, move, load and store global, add, subtract, multiply, negate, the six comparisons, and jump if zero. Every slot index is proved inside the frame before anything is emitted, as global indices already were. subtract 21.4 -> 2.6 ms 8.4x was not compiled at all multiply 27.6 -> 2.0 ms 14.0x was not compiled at all comparison 19.2 -> 0.8 ms 23.3x was not compiled at all fused 15.1 -> 0.2 ms 63.7x unchanged Three loops in five now, the remaining two blocked by arrays and by calls. Slower than the fused forms, as it should be: those keep their values in machine registers across the whole loop, these fetch and store each one. Verified on arm64 macOS and x86-64 Linux, under -d:release and -d:danger, with the interpreter forced, as a wasm32 build under node, and as a Windows cross build. The four budget-escape shapes are still bounded. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 7 +- src/bassy/amd64.nim | 21 +++ src/bassy/arm64.nim | 1 + src/bassy/jit.nim | 293 +++++++++++++++++++++++++++++++++++++- tests/test_amd64.nim | 9 ++ tests/test_jit_safety.nim | 52 +++++-- 6 files changed, 362 insertions(+), 21 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 96782e7..fbfb2b9 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3137,7 +3137,9 @@ proc compileNative*(runtime: var Runtime): int = if not jitSupported(): return 0 runtime.regionAt = compileLoops( - runtime.program.code, runtime.globals.len + runtime.program.code, + runtime.globals.len, + int(runtime.program.maxRegisters) ) runtime.nativeRegions @@ -3531,6 +3533,9 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = if region != nil: var context = NativeContext( globals: runtime.globals[0].addr, + registers: + if runtime.registers.len == 0: nil + else: runtime.registers[int(runtime.base)].addr, remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index a58ece5..430930e 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -343,3 +343,24 @@ proc storeWordImmediate*(assembler: var Assembler, base: Register, assembler.emit(0xC7) assembler.memoryOperand(Register(0), base, offset) assembler.emitDouble(value) + +proc negateRegister*(assembler: var Assembler, width: Width, + target: Register) {.raises: [].} = + ## Replaces a register with its two's complement negation. + assembler.prefix(width, Register(3), target) + assembler.emit(0xF7) + assembler.directOperand(Register(3), target) + +proc setIfCondition*(assembler: var Assembler, target: Register, + condition: Condition) {.raises: [].} = + ## Writes one when the condition holds and zero otherwise. + ## The low byte is set, so the register is cleared first; xor would + ## disturb the flags, and movzx afterwards would need a second register. + assembler.prefix(Word32, Register(0), target) + assembler.emit(0x0F) + assembler.emit(byte(0x90'u32 + uint32(ord(condition)))) + assembler.directOperand(Register(0), target) + assembler.prefix(Word32, target, target) + assembler.emit(0x0F) + assembler.emit(0xB6) + assembler.directOperand(target, target) diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index 7d8b9e5..4833c32 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -65,6 +65,7 @@ const x14* = Register(14) x15* = Register(15) x16* = Register(16) + x17* = Register(17) x19* = Register(19) x20* = Register(20) x21* = Register(21) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 9571d8d..ed76dde 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -34,10 +34,13 @@ type NativeContext* = object ## The mutable interpreter state compiled code is allowed to touch. + ## The register field points at the current frame's first slot, which + ## cannot move while a region runs because a region contains no call. globals*: pointer remainingInstructions*: int64 remainingWork*: int64 pc*: int32 + registers*: pointer NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -67,6 +70,7 @@ const ContextInstructions = 8 ContextWork = 16 ContextOffset = 24 + ContextRegisters = 32 MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 @@ -113,6 +117,8 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.pc.addr) - origin != ContextOffset: return false + if cast[int](context.registers.addr) - origin != ContextRegisters: + return false true ## Region discovery @@ -122,6 +128,10 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = case item.op of MeterOp, JumpOp, StoreGlobalImmediateOp, MoveGlobalOp, AddGlobalImmediateOp, AddGlobalOp, + LoadImmediateOp, MoveOp, LoadGlobalOp, StoreGlobalOp, + AddOp, SubtractOp, MultiplyOp, NegateOp, + EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, + JumpIfZeroOp, JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, @@ -154,6 +164,10 @@ proc touchedGlobals(item: Instruction, globals: var seq[int32]) of MoveGlobalOp, AddGlobalOp: note(item.a) note(item.b) + of LoadGlobalOp: + note(item.b) + of StoreGlobalOp: + note(item.a) else: discard @@ -164,6 +178,9 @@ proc branchTarget(item: Instruction, target: var int32): bool of JumpOp: target = item.a true + of JumpIfZeroOp: + target = item.b + true of JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, @@ -176,6 +193,45 @@ proc branchTarget(item: Instruction, target: var int32): bool else: false +proc touchedSlots(item: Instruction, slots: var seq[int32]) + {.raises: [].} = + ## Records every register slot one operation reads or writes. + template note(index: int32) = + slots.add(index) + case item.op + of LoadImmediateOp, LoadGlobalOp: + note(item.a) + of StoreGlobalOp, NegateOp, JumpIfZeroOp: + note(item.b) + of MoveOp: + note(item.a) + note(item.b) + of AddOp, SubtractOp, MultiplyOp, + EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: + note(item.a) + note(item.b) + note(item.c) + else: + discard + if item.op == NegateOp: + note(item.a) + +proc usesRegisterFile(item: Instruction): bool {.raises: [].} = + ## Reports whether an operation reaches into the frame's slots. + var slots: seq[int32] + item.touchedSlots(slots) + slots.len > 0 + +proc comparisonTest(op: Op): Test {.raises: [].} = + ## Returns the condition a comparison answers true on. + case op + of EqualOp: EqualTest + of NotEqualOp: NotEqualTest + of LessOp: LessTest + of LessEqualOp: LessEqualTest + of GreaterOp: GreaterTest + else: GreaterEqualTest + proc takenOn(op: Op): Test {.raises: [].} = ## Returns the condition on which a fused test takes its branch. case op @@ -348,6 +404,8 @@ when NativeArm64: ResumeStatus = x12 Counter = x13 Allowance = x14 + RegistersBase = x15 + ValueScratch = [x16, x17] SpentInstructions = x5 SpentWork = x6 LimitInstructions = x7 @@ -472,6 +530,83 @@ when NativeArm64: emitter.loadImmediate(Word32, Scratch, int64(value)) emitter.compareRegister(Word32, target, Scratch) + proc loadRegistersBase(emitter: var Assembler) {.raises: [BasicError].} = + ## Points at the first slot of the frame the region runs in. + emitter.loadDouble(RegistersBase, Context, ContextRegisters) + + proc readSlot(emitter: var Assembler, scratch: int, slot: int32, + leave: Label) {.raises: [BasicError].} = + ## Reads one register slot as an integer, leaving the region if it + ## holds anything else. The tag is read into the same register the + ## value will land in, so no third register is needed. + let target = ValueScratch[scratch] + let base = int(slot) * ValueStride + emitter.loadByte(target, RegistersBase, base) + emitter.branchIfNotZero(Word32, target, leave) + emitter.loadWord(target, RegistersBase, base + ValuePayload) + + proc writeSlot(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Writes one register slot as an integer. + let base = int(slot) * ValueStride + emitter.storeByte(zeroRegister, RegistersBase, base) + emitter.storeWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc setScratch(emitter: var Assembler, scratch: int, value: int32) + {.raises: [BasicError].} = + ## Loads a constant into a working register. + emitter.loadImmediate(Word32, ValueScratch[scratch], int64(value)) + + proc addScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Adds the second working register into the first, wrapping. + emitter.addRegister(Word32, ValueScratch[left], ValueScratch[left], + ValueScratch[right]) + + proc subtractScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Subtracts the second working register from the first, wrapping. + emitter.subtractRegister(Word32, ValueScratch[left], ValueScratch[left], + ValueScratch[right]) + + proc multiplyScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Multiplies the first working register by the second, wrapping. + emitter.multiply(Word32, ValueScratch[left], ValueScratch[left], + ValueScratch[right]) + + proc negateScratch(emitter: var Assembler, scratch: int) + {.raises: [BasicError].} = + ## Replaces a working register with its negation, wrapping. + emitter.negate(Word32, ValueScratch[scratch], ValueScratch[scratch]) + + proc compareScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Sets flags from two working registers. + emitter.compareRegister(Word32, ValueScratch[left], ValueScratch[right]) + + proc answerCondition(emitter: var Assembler, scratch: int, test: Test) + {.raises: [BasicError].} = + ## Writes BASIC's -1 for true and zero for false. + emitter.setOnCondition(Word32, ValueScratch[scratch], + nativeCondition(test)) + + proc scratchFromHoisted(emitter: var Assembler, scratch, slot: int) + {.raises: [BasicError].} = + ## Copies a hoisted global into a working register. + emitter.moveRegister(Word32, ValueScratch[scratch], slotRegister(slot)) + + proc hoistedFromScratch(emitter: var Assembler, slot, scratch: int) + {.raises: [BasicError].} = + ## Copies a working register into a hoisted global. + emitter.moveRegister(Word32, slotRegister(slot), ValueScratch[scratch]) + + proc branchIfScratchZero(emitter: var Assembler, scratch: int, + target: Label) {.raises: [BasicError].} = + ## Branches when a working register holds zero. + emitter.branchIfZero(Word32, ValueScratch[scratch], target) + proc beginCountedLoop(emitter: var Assembler, instructions, work: int64, refused: Label) {.raises: [BasicError].} = ## Settles the whole loop's budget once: how many passes both budgets @@ -638,20 +773,22 @@ elif NativeAmd64: const GlobalsBase = rbx + RegistersBase = rbp Instructions = r12 Work = r13 Scratch = r11 + ValueScratch = [rax, rdx] when defined(windows): const Context = rcx Hoisted = [r14, r15, rsi, rdi, r8, r9, r10] - Saved = [rbx, r12, r13, r14, r15, rsi, rdi] + Saved = [rbx, rbp, r12, r13, r14, r15, rsi, rdi] else: const Context = rdi Hoisted = [r14, r15, rsi, rcx, r8, r9, r10] - Saved = [rbx, r12, r13, r14, r15] + Saved = [rbx, rbp, r12, r13, r14, r15] proc slotRegister(slot: int): Register {.raises: [].} = ## Returns the register holding one hoisted global. @@ -693,6 +830,82 @@ elif NativeAmd64: emitter.testRegister(Word32, Scratch, Scratch) emitter.branchIf(NotEqualCondition, failed) + proc loadRegistersBase(emitter: var Assembler) {.raises: [BasicError].} = + ## Points at the first slot of the frame the region runs in. + emitter.loadDouble(RegistersBase, Context, ContextRegisters) + + proc readSlot(emitter: var Assembler, scratch: int, slot: int32, + leave: Label) {.raises: [BasicError].} = + ## Reads one register slot as an integer, leaving the region if it + ## holds anything else. The tag is read into the same register the + ## value will land in, so no third register is needed. + let target = ValueScratch[scratch] + let base = int(slot) * ValueStride + emitter.loadByteZeroed(target, RegistersBase, base) + emitter.testRegister(Word32, target, target) + emitter.branchIf(NotEqualCondition, leave) + emitter.loadWord(target, RegistersBase, base + ValuePayload) + + proc writeSlot(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Writes one register slot as an integer. + let base = int(slot) * ValueStride + emitter.storeByteImmediate(RegistersBase, base, 0) + emitter.storeWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc setScratch(emitter: var Assembler, scratch: int, value: int32) + {.raises: [BasicError].} = + ## Loads a constant into a working register. + emitter.loadImmediate(Word32, ValueScratch[scratch], int64(value)) + + proc addScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Adds the second working register into the first, wrapping. + emitter.addRegister(Word32, ValueScratch[left], ValueScratch[right]) + + proc subtractScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Subtracts the second working register from the first, wrapping. + emitter.subtractRegister(Word32, ValueScratch[left], ValueScratch[right]) + + proc multiplyScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Multiplies the first working register by the second, wrapping. + emitter.multiplyRegister(Word32, ValueScratch[left], ValueScratch[right]) + + proc negateScratch(emitter: var Assembler, scratch: int) + {.raises: [BasicError].} = + ## Replaces a working register with its negation, wrapping. + emitter.negateRegister(Word32, ValueScratch[scratch]) + + proc compareScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Sets flags from two working registers. + emitter.compareRegister(Word32, ValueScratch[left], ValueScratch[right]) + + proc answerCondition(emitter: var Assembler, scratch: int, test: Test) + {.raises: [BasicError].} = + ## Writes BASIC's -1 for true and zero for false. + emitter.setIfCondition(ValueScratch[scratch], nativeCondition(test)) + emitter.negateRegister(Word32, ValueScratch[scratch]) + + proc scratchFromHoisted(emitter: var Assembler, scratch, slot: int) + {.raises: [BasicError].} = + ## Copies a hoisted global into a working register. + emitter.moveRegister(Word32, ValueScratch[scratch], slotRegister(slot)) + + proc hoistedFromScratch(emitter: var Assembler, slot, scratch: int) + {.raises: [BasicError].} = + ## Copies a working register into a hoisted global. + emitter.moveRegister(Word32, slotRegister(slot), ValueScratch[scratch]) + + proc branchIfScratchZero(emitter: var Assembler, scratch: int, + target: Label) {.raises: [BasicError].} = + ## Branches when a working register holds zero. + emitter.testRegister(Word32, ValueScratch[scratch], ValueScratch[scratch]) + emitter.branchIf(EqualCondition, target) + proc loadHoisted(emitter: var Assembler, slot: int, base: int) {.raises: [BasicError].} = ## Reads one global into its register. @@ -774,8 +987,8 @@ elif NativeAmd64: emitter.loadImmediate(Word32, rax, int64(ord(NativeGuardFailed))) emitter.endRegion() -proc compileRegion*(code: seq[Instruction], start, stop, globals: int): - Region {.raises: [BasicError].} = +proc compileRegion*(code: seq[Instruction], start, stop, globals, + slots: int): Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. ## ## Generated code indexes global storage without checking, so every @@ -791,8 +1004,9 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): if code.reachesOutside(start, stop): return nil - if globals < 0: + if globals < 0 or slots < 0: return nil + var usesSlots = false var hoisted: seq[int32] for index in start ..< stop: @@ -800,6 +1014,17 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): if not item.isCompilable: return nil item.touchedGlobals(hoisted) + # Slots are read and written where they sit, so each index only has + # to be proved in range; nothing is carried across the region. + var touched: seq[int32] + item.touchedSlots(touched) + if touched.len > 0: + usesSlots = true + for slot in touched: + if slot < 0 or int(slot) >= slots: + return nil + if int(slot) > (MaxDisplacementBytes - ValuePayload) div ValueStride: + return nil var target = 0'i32 if item.branchTarget(target): # A branch may land one past the last offset, where the @@ -878,6 +1103,8 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): ## Entry: prove every participating global is an integer, then hoist it. emitter.startRegion() + if usesSlots: + emitter.loadRegistersBase() for slot, index in hoisted: let base = int(index) * ValueStride emitter.guardInteger(base, guardFailed) @@ -903,6 +1130,12 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): let item = code[index] emitter.place(blockAt(int32(index))) + # A slot holding anything but an integer hands this offset back to + # the interpreter, which can work in whatever the slot does hold. + # Nothing has been written for this operation yet, and the charge + # for the pass so far is the one every other exit uses. + let leaveHere = exitLabel(int32(index), NativeCompleted, index) + template branchOut(target: int32, test: Test) = ## Takes an in-region branch directly, or leaves through a stub. if int(target) >= start and int(target) < stop: @@ -946,6 +1179,52 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals: int): emitter.addToSlot(slotOf(item.a), item.b) of AddGlobalOp: emitter.addSlots(slotOf(item.a), slotOf(item.b)) + of LoadImmediateOp: + emitter.setScratch(0, item.b) + emitter.writeSlot(0, item.a) + of MoveOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.writeSlot(0, item.a) + of LoadGlobalOp: + emitter.scratchFromHoisted(0, slotOf(item.b)) + emitter.writeSlot(0, item.a) + of StoreGlobalOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.hoistedFromScratch(slotOf(item.a), 0) + of AddOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.readSlot(1, item.c, leaveHere) + emitter.addScratch(0, 1) + emitter.writeSlot(0, item.a) + of SubtractOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.readSlot(1, item.c, leaveHere) + emitter.subtractScratch(0, 1) + emitter.writeSlot(0, item.a) + of MultiplyOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.readSlot(1, item.c, leaveHere) + emitter.multiplyScratch(0, 1) + emitter.writeSlot(0, item.a) + of NegateOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.negateScratch(0) + emitter.writeSlot(0, item.a) + of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.readSlot(1, item.c, leaveHere) + emitter.compareScratch(0, 1) + emitter.answerCondition(0, comparisonTest(item.op)) + emitter.writeSlot(0, item.a) + of JumpIfZeroOp: + emitter.readSlot(0, item.a, leaveHere) + if int(item.b) >= start and int(item.b) < stop: + emitter.branchIfScratchZero(0, blockAt(item.b)) + else: + emitter.branchIfScratchZero( + 0, exitLabel(item.b, NativeCompleted, index) + ) of JumpOp: if int(item.a) >= start and int(item.a) < stop: when CountedLoops: @@ -1021,7 +1300,7 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus ## Runs one compiled loop and reports why it returned. NativeStatus(region.call(context.addr)) -proc compileLoops*(code: seq[Instruction], globals: int): seq[Region] +proc compileLoops*(code: seq[Instruction], globals, slots: int): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. ## The result is indexed by bytecode offset, so the interpreter reaches @@ -1038,7 +1317,7 @@ proc compileLoops*(code: seq[Instruction], globals: int): seq[Region] continue var region: Region = nil try: - region = compileRegion(code, int(target), index + 1, globals) + region = compileRegion(code, int(target), index + 1, globals, slots) except BasicError: region = nil if region != nil: diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index 744c91e..857ae22 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -116,6 +116,15 @@ encodes "idiv r11d": encodes "idiv ecx": assembler.signedDivide(Word32, rcx) +encodes "neg eax": + assembler.negateRegister(Word32, rax) +encodes "neg r11d": + assembler.negateRegister(Word32, r11) +encodes "setl al\n\tmovzx eax, al": + assembler.setIfCondition(rax, LessCondition) +encodes "sete dl\n\tmovzx edx, dl": + assembler.setIfCondition(rdx, EqualCondition) + ## Stack and control flow encodes "push rbx": diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index df3af48..1c73104 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -36,7 +36,9 @@ report( ## Bytecode the language could not produce -const Globals = 4 +const + Globals = 4 + Slots = 8 proc countingLoop(globalIndex: int32, target: int32): seq[Instruction] = ## A minimal loop, parameterised so it can be made malformed. @@ -55,14 +57,14 @@ block: let code = countingLoop(1, 4) report( "a well formed loop still compiles", - (not jitSupported()) or compileRegion(code, 0, 4, Globals) != nil + (not jitSupported()) or compileRegion(code, 0, 4, Globals, Slots) != nil ) block: let code = countingLoop(Globals, 4) report( "a global one past the end is refused", - compileRegion(code, 0, 4, Globals) == nil, + compileRegion(code, 0, 4, Globals, Slots) == nil, "an out of range index would become a fixed offset store" ) @@ -70,14 +72,14 @@ block: let code = countingLoop(1_000_000, 4) report( "a far out of range global is refused", - compileRegion(code, 0, 4, Globals) == nil + compileRegion(code, 0, 4, Globals, Slots) == nil ) block: let code = countingLoop(-1, 4) report( "a negative global is refused", - compileRegion(code, 0, 4, Globals) == nil, + compileRegion(code, 0, 4, Globals, Slots) == nil, "a negative index would address below the globals" ) @@ -85,28 +87,28 @@ block: let code = countingLoop(1, 99) report( "a branch past the end of the code is refused", - compileRegion(code, 0, 4, Globals) == nil + compileRegion(code, 0, 4, Globals, Slots) == nil ) block: let code = countingLoop(1, -5) report( "a negative branch target is refused", - compileRegion(code, 0, 4, Globals) == nil + compileRegion(code, 0, 4, Globals, Slots) == nil ) block: let code = countingLoop(1, 4) report( "a region reaching past the code is refused", - compileRegion(code, 0, 99, Globals) == nil + compileRegion(code, 0, 99, Globals, Slots) == nil ) block: let code = countingLoop(1, 4) report( "a region with no storage behind it is refused", - compileRegion(code, 0, 4, 0) == nil + compileRegion(code, 0, 4, 0, Slots) == nil ) block: @@ -120,7 +122,7 @@ block: ] report( "a zero divisor is refused", - compileRegion(code, 0, 4, Globals) == nil + compileRegion(code, 0, 4, Globals, Slots) == nil ) block: @@ -137,7 +139,7 @@ block: ] var raised = false try: - discard compileRegion(code, 0, 5, Globals) + discard compileRegion(code, 0, 5, Globals, Slots) except BasicError: raised = true # Falling back to the per-block check is a fine outcome here. Refusing @@ -159,7 +161,7 @@ block: ] var survived = false try: - discard compileLoops(code, Globals) + discard compileLoops(code, Globals, Slots) survived = true except BasicError: survived = false @@ -171,7 +173,31 @@ block: let code = countingLoop(high(int32) div 8, 4) report( "a global whose offset would not fit is refused", - compileRegion(code, 0, 4, high(int32)) == nil + compileRegion(code, 0, 4, high(int32), Slots) == nil + ) + +block: + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: LoadGlobalOp, a: int32(Slots), b: 1), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + report( + "a register slot past the frame is refused", + compileRegion(code, 0, 4, Globals, Slots) == nil + ) + +block: + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: LoadGlobalOp, a: -1, b: 1), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + report( + "a negative register slot is refused", + compileRegion(code, 0, 4, Globals, Slots) == nil ) ## Scripts, down both paths From 34e87b47a9ae8419cbe15d5d17bcfaf10cf1c05b Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 19:49:55 -0700 Subject: [PATCH 16/51] Compile array reads and writes Coverage was three loops in five, arrays being one of the two that were left. A loop as ordinary as summing cells fell out over a single fused operation the generator did not model. Reading and writing a cell copies the value entire, whatever kind it holds, because that is what the interpreter does: it assigns the value across and never asks what is in it. So these copy sixteen bytes and ask nothing either, which means an array of fixed-point numbers compiles as readily as one of integers. Only the two fused forms that add through a cell need an integer, and those guard for it. The bounds check is the interpreter's: one unsigned comparison covering both ends at once. Out of range hands the offset back rather than raising, so the message still names the array and its real extent, and still comes from the one place that knows them. array read 17.8 -> 0.76 ms 23.4x was not compiled at all array write 20.3 -> 1.13 ms 17.9x was not compiled at all Five loops in six now. Calls are the only thing left, and the only thing left that needs a different machine. Every array named is proved to exist and to sit where a displacement can reach it. Scripts that walk off either end of an array, index one negatively, or fill one with fixed-point values are in the safety tests and agree on both paths. Verified on arm64 macOS and x86-64 Linux, under -d:release and -d:danger, with the interpreter forced, as a wasm32 build under node, and as a Windows cross build. The four budget-escape shapes remain bounded. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 17 +++- src/bassy/amd64.nim | 10 +++ src/bassy/jit.nim | 178 +++++++++++++++++++++++++++++++++++++- tests/test_amd64.nim | 5 ++ tests/test_jit_safety.nim | 99 ++++++++++++++++++--- 5 files changed, 291 insertions(+), 18 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index fbfb2b9..59a1500 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3136,13 +3136,25 @@ proc compileNative*(runtime: var Runtime): int = runtime.regionAt = @[] if not jitSupported(): return 0 + var extents = newSeq[ArrayExtent](runtime.program.arrays.len) + for index, item in runtime.program.arrays: + extents[index] = ArrayExtent(base: item.base, length: item.length) runtime.regionAt = compileLoops( runtime.program.code, runtime.globals.len, - int(runtime.program.maxRegisters) + int(runtime.program.maxRegisters), + extents ) runtime.nativeRegions +proc arrayExtent*(program: Program, id: int32): (int32, int32) {.inline.} = + ## Returns where one array starts and how many cells it has. + (program.arrays[int(id)].base, program.arrays[int(id)].length) + +proc maxRegisterCount*(program: Program): int32 {.inline.} = + ## Returns the most register slots any routine in the program uses. + program.maxRegisters + proc bytecode*(program: Program): lent seq[Instruction] {.inline.} = ## Exposes the metered bytecode for tools and the native compiler. program.code @@ -3536,6 +3548,9 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = registers: if runtime.registers.len == 0: nil else: runtime.registers[int(runtime.base)].addr, + memory: + if runtime.memory.len == 0: nil + else: runtime.memory[0].addr, remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index 430930e..5b61aa7 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -364,3 +364,13 @@ proc setIfCondition*(assembler: var Assembler, target: Register, assembler.emit(0x0F) assembler.emit(0xB6) assembler.directOperand(target, target) + +proc shiftLeftImmediate*(assembler: var Assembler, width: Width, + target: Register, count: int) {.raises: [BasicError].} = + ## Shifts a register left by a constant. + if count < 0 or count > 63: + fail("assembler shift count is out of range") + assembler.prefix(width, Register(4), target) + assembler.emit(0xC1) + assembler.directOperand(Register(4), target) + assembler.emit(byte(count)) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index ed76dde..5efce76 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -41,10 +41,16 @@ type remainingWork*: int64 pc*: int32 registers*: pointer + memory*: pointer NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} + ArrayExtent* = object + ## Where one array sits in the shared cell storage, and how long it is. + base*: int32 + length*: int32 + Region* = ref object ## One compiled loop, addressed by the bytecode offset that enters it. start*: int32 @@ -71,6 +77,7 @@ const ContextWork = 16 ContextOffset = 24 ContextRegisters = 32 + ContextMemory = 40 MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 @@ -119,6 +126,8 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.registers.addr) - origin != ContextRegisters: return false + if cast[int](context.memory.addr) - origin != ContextMemory: + return false true ## Region discovery @@ -132,6 +141,8 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = AddOp, SubtractOp, MultiplyOp, NegateOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, JumpIfZeroOp, + ArrayGetOp, ArraySetOp, ArrayAddGlobalsOp, + AddGlobalArrayGlobalIndexOp, JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, @@ -168,6 +179,12 @@ proc touchedGlobals(item: Instruction, globals: var seq[int32]) note(item.b) of StoreGlobalOp: note(item.a) + of ArrayAddGlobalsOp: + note(item.b) + note(item.c) + of AddGlobalArrayGlobalIndexOp: + note(item.a) + note(item.c) else: discard @@ -211,11 +228,29 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) note(item.a) note(item.b) note(item.c) + of ArrayGetOp: + note(item.a) + note(item.c) + of ArraySetOp: + note(item.b) + note(item.c) else: discard if item.op == NegateOp: note(item.a) +proc namedArray(item: Instruction, id: var int32): bool {.raises: [].} = + ## Reports whether an operation reaches into an array, and which one. + case item.op + of ArrayGetOp, AddGlobalArrayGlobalIndexOp: + id = item.b + true + of ArraySetOp, ArrayAddGlobalsOp: + id = item.a + true + else: + false + proc usesRegisterFile(item: Instruction): bool {.raises: [].} = ## Reports whether an operation reaches into the frame's slots. var slots: seq[int32] @@ -607,6 +642,55 @@ when NativeArm64: ## Branches when a working register holds zero. emitter.branchIfZero(Word32, ValueScratch[scratch], target) + proc elementAddress(emitter: var Assembler, scratch: int, + extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = + ## Bounds checks an index and leaves the cell's address in Scratch. + ## One unsigned comparison covers both ends, exactly as the + ## interpreter's does, and a refusal hands the offset back so the + ## interpreter can raise with the array's own name. + let index = ValueScratch[scratch] + emitter.loadImmediate(Word32, OtherScratch, int64(extent.length)) + emitter.compareRegister(Word32, index, OtherScratch) + emitter.branchIf(CarrySetCondition, leave) + emitter.loadImmediate(Word32, OtherScratch, int64(extent.base)) + emitter.addRegister(Word32, OtherScratch, OtherScratch, index) + emitter.loadDouble(Scratch, Context, ContextMemory) + emitter.addRegister(Word64, Scratch, Scratch, OtherScratch, 4) + + proc copyElementToSlot(emitter: var Assembler, slot: int32) + {.raises: [BasicError].} = + ## Copies a whole cell into a register slot, whatever it holds. The + ## interpreter copies the value entire, so this does too, and neither + ## needs to know what kind it is. + let base = int(slot) * ValueStride + emitter.loadDouble(ValueScratch[0], Scratch, 0) + emitter.loadDouble(ValueScratch[1], Scratch, ValuePayload) + emitter.storeDouble(ValueScratch[0], RegistersBase, base) + emitter.storeDouble(ValueScratch[1], RegistersBase, base + ValuePayload) + + proc copySlotToElement(emitter: var Assembler, slot: int32) + {.raises: [BasicError].} = + ## Copies a whole register slot into a cell, whatever it holds. + let base = int(slot) * ValueStride + emitter.loadDouble(ValueScratch[0], RegistersBase, base) + emitter.loadDouble(ValueScratch[1], RegistersBase, base + ValuePayload) + emitter.storeDouble(ValueScratch[0], Scratch, 0) + emitter.storeDouble(ValueScratch[1], Scratch, ValuePayload) + + proc readElement(emitter: var Assembler, scratch: int, leave: Label) + {.raises: [BasicError].} = + ## Reads a cell as an integer, leaving the region if it holds else. + let target = ValueScratch[scratch] + emitter.loadByte(target, Scratch, 0) + emitter.branchIfNotZero(Word32, target, leave) + emitter.loadWord(target, Scratch, ValuePayload) + + proc writeElement(emitter: var Assembler, scratch: int) + {.raises: [BasicError].} = + ## Writes a cell as an integer. + emitter.storeByte(zeroRegister, Scratch, 0) + emitter.storeWord(ValueScratch[scratch], Scratch, ValuePayload) + proc beginCountedLoop(emitter: var Assembler, instructions, work: int64, refused: Label) {.raises: [BasicError].} = ## Settles the whole loop's budget once: how many passes both budgets @@ -823,6 +907,55 @@ elif NativeAmd64: emitter.pop(Saved[index]) emitter.returnToCaller() + proc elementAddress(emitter: var Assembler, scratch: int, + extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = + ## Bounds checks an index and leaves the cell's address in Scratch. + ## One unsigned comparison covers both ends, exactly as the + ## interpreter's does, and a refusal hands the offset back so the + ## interpreter can raise with the array's own name. + let index = ValueScratch[scratch] + emitter.compareImmediate(Word32, index, extent.length) + emitter.branchIf(AboveEqualCondition, leave) + emitter.addImmediate(Word32, index, extent.base) + emitter.shiftLeftImmediate(Word64, index, 4) + emitter.loadDouble(Scratch, Context, ContextMemory) + emitter.addRegister(Word64, Scratch, index) + + proc copyElementToSlot(emitter: var Assembler, slot: int32) + {.raises: [BasicError].} = + ## Copies a whole cell into a register slot, whatever it holds. The + ## interpreter copies the value entire, so this does too, and neither + ## needs to know what kind it is. + let base = int(slot) * ValueStride + emitter.loadDouble(ValueScratch[0], Scratch, 0) + emitter.loadDouble(ValueScratch[1], Scratch, ValuePayload) + emitter.storeDouble(ValueScratch[0], RegistersBase, base) + emitter.storeDouble(ValueScratch[1], RegistersBase, base + ValuePayload) + + proc copySlotToElement(emitter: var Assembler, slot: int32) + {.raises: [BasicError].} = + ## Copies a whole register slot into a cell, whatever it holds. + let base = int(slot) * ValueStride + emitter.loadDouble(ValueScratch[0], RegistersBase, base) + emitter.loadDouble(ValueScratch[1], RegistersBase, base + ValuePayload) + emitter.storeDouble(ValueScratch[0], Scratch, 0) + emitter.storeDouble(ValueScratch[1], Scratch, ValuePayload) + + proc readElement(emitter: var Assembler, scratch: int, leave: Label) + {.raises: [BasicError].} = + ## Reads a cell as an integer, leaving the region if it holds else. + let target = ValueScratch[scratch] + emitter.loadByteZeroed(target, Scratch, 0) + emitter.testRegister(Word32, target, target) + emitter.branchIf(NotEqualCondition, leave) + emitter.loadWord(target, Scratch, ValuePayload) + + proc writeElement(emitter: var Assembler, scratch: int) + {.raises: [BasicError].} = + ## Writes a cell as an integer. + emitter.storeByteImmediate(Scratch, 0, 0) + emitter.storeWord(ValueScratch[scratch], Scratch, ValuePayload) + proc guardInteger(emitter: var Assembler, base: int, failed: Label) {.raises: [BasicError].} = ## Leaves the region unless the global at this offset holds an integer. @@ -988,7 +1121,8 @@ elif NativeAmd64: emitter.endRegion() proc compileRegion*(code: seq[Instruction], start, stop, globals, - slots: int): Region {.raises: [BasicError].} = + slots: int, extents: seq[ArrayExtent]): Region + {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. ## ## Generated code indexes global storage without checking, so every @@ -1016,6 +1150,19 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, item.touchedGlobals(hoisted) # Slots are read and written where they sit, so each index only has # to be proved in range; nothing is carried across the region. + # Every array named must exist, and its cells must sit where a + # displacement can reach them. + var arrayId = 0'i32 + if item.namedArray(arrayId): + if arrayId < 0 or int(arrayId) >= extents.len: + return nil + let extent = extents[int(arrayId)] + if extent.length <= 0 or extent.base < 0: + return nil + if int(extent.base) + int(extent.length) > + MaxDisplacementBytes div ValueStride: + return nil + usesSlots = true var touched: seq[int32] item.touchedSlots(touched) if touched.len > 0: @@ -1217,6 +1364,28 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.compareScratch(0, 1) emitter.answerCondition(0, comparisonTest(item.op)) emitter.writeSlot(0, item.a) + of ArrayGetOp: + emitter.readSlot(0, item.c, leaveHere) + emitter.elementAddress(0, extents[int(item.b)], leaveHere) + emitter.copyElementToSlot(item.a) + of ArraySetOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.elementAddress(0, extents[int(item.a)], leaveHere) + emitter.copySlotToElement(item.c) + of ArrayAddGlobalsOp: + emitter.scratchFromHoisted(0, slotOf(item.b)) + emitter.elementAddress(0, extents[int(item.a)], leaveHere) + emitter.readElement(0, leaveHere) + emitter.scratchFromHoisted(1, slotOf(item.c)) + emitter.addScratch(0, 1) + emitter.writeElement(0) + of AddGlobalArrayGlobalIndexOp: + emitter.scratchFromHoisted(0, slotOf(item.c)) + emitter.elementAddress(0, extents[int(item.b)], leaveHere) + emitter.readElement(0, leaveHere) + emitter.scratchFromHoisted(1, slotOf(item.a)) + emitter.addScratch(1, 0) + emitter.hoistedFromScratch(slotOf(item.a), 1) of JumpIfZeroOp: emitter.readSlot(0, item.a, leaveHere) if int(item.b) >= start and int(item.b) < stop: @@ -1300,7 +1469,8 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus ## Runs one compiled loop and reports why it returned. NativeStatus(region.call(context.addr)) -proc compileLoops*(code: seq[Instruction], globals, slots: int): seq[Region] +proc compileLoops*(code: seq[Instruction], globals, slots: int, + extents: seq[ArrayExtent] = @[]): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. ## The result is indexed by bytecode offset, so the interpreter reaches @@ -1317,7 +1487,9 @@ proc compileLoops*(code: seq[Instruction], globals, slots: int): seq[Region] continue var region: Region = nil try: - region = compileRegion(code, int(target), index + 1, globals, slots) + region = compileRegion( + code, int(target), index + 1, globals, slots, extents + ) except BasicError: region = nil if region != nil: diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index 857ae22..4bbd039 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -125,6 +125,11 @@ encodes "setl al\n\tmovzx eax, al": encodes "sete dl\n\tmovzx edx, dl": assembler.setIfCondition(rdx, EqualCondition) +encodes "shl rax, 4": + assembler.shiftLeftImmediate(Word64, rax, 4) +encodes "shl r11d, 1": + assembler.shiftLeftImmediate(Word32, r11, 1) + ## Stack and control flow encodes "push rbx": diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 1c73104..c655368 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -57,14 +57,14 @@ block: let code = countingLoop(1, 4) report( "a well formed loop still compiles", - (not jitSupported()) or compileRegion(code, 0, 4, Globals, Slots) != nil + (not jitSupported()) or compileRegion(code, 0, 4, Globals, Slots, @[]) != nil ) block: let code = countingLoop(Globals, 4) report( "a global one past the end is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil, + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil, "an out of range index would become a fixed offset store" ) @@ -72,14 +72,14 @@ block: let code = countingLoop(1_000_000, 4) report( "a far out of range global is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil ) block: let code = countingLoop(-1, 4) report( "a negative global is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil, + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil, "a negative index would address below the globals" ) @@ -87,28 +87,28 @@ block: let code = countingLoop(1, 99) report( "a branch past the end of the code is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil ) block: let code = countingLoop(1, -5) report( "a negative branch target is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil ) block: let code = countingLoop(1, 4) report( "a region reaching past the code is refused", - compileRegion(code, 0, 99, Globals, Slots) == nil + compileRegion(code, 0, 99, Globals, Slots, @[]) == nil ) block: let code = countingLoop(1, 4) report( "a region with no storage behind it is refused", - compileRegion(code, 0, 4, 0, Slots) == nil + compileRegion(code, 0, 4, 0, Slots, @[]) == nil ) block: @@ -122,7 +122,7 @@ block: ] report( "a zero divisor is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil ) block: @@ -139,7 +139,7 @@ block: ] var raised = false try: - discard compileRegion(code, 0, 5, Globals, Slots) + discard compileRegion(code, 0, 5, Globals, Slots, @[]) except BasicError: raised = true # Falling back to the per-block check is a fine outcome here. Refusing @@ -161,7 +161,7 @@ block: ] var survived = false try: - discard compileLoops(code, Globals, Slots) + discard compileLoops(code, Globals, Slots, @[]) survived = true except BasicError: survived = false @@ -173,7 +173,7 @@ block: let code = countingLoop(high(int32) div 8, 4) report( "a global whose offset would not fit is refused", - compileRegion(code, 0, 4, high(int32), Slots) == nil + compileRegion(code, 0, 4, high(int32), Slots, @[]) == nil ) block: @@ -185,7 +185,7 @@ block: ] report( "a register slot past the frame is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil ) block: @@ -197,7 +197,34 @@ block: ] report( "a negative register slot is refused", - compileRegion(code, 0, 4, Globals, Slots) == nil + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + ) + +block: + # An operation naming an array that does not exist must be refused. + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: ArrayGetOp, a: 0, b: 7, c: 1), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + report( + "an array that does not exist is refused", + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + ) + +block: + # Cells reaching past what a displacement covers must be refused. + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: ArrayGetOp, a: 0, b: 0, c: 1), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + let far = @[ArrayExtent(base: high(int32) div 4, length: 16)] + report( + "an array placed out of reach is refused", + compileRegion(code, 0, 4, Globals, Slots, far) == nil ) ## Scripts, down both paths @@ -364,6 +391,50 @@ while j < 16 wend """) +agrees("reading past the end of an array", """ +dim cells(15) +i = 0 +total = 0 +while i < 40 + total = total + cells(i) + i = i + 1 +wend +""") + +agrees("writing past the end of an array", """ +dim cells(15) +i = 0 +while i < 40 + cells(i) = i + i = i + 1 +wend +""") + +agrees("a negative array index", """ +dim cells(15) +i = 5 +total = 0 +while i > -5 + total = total + cells(i) + i = i + -1 +wend +""") + +agrees("an array holding fixed point", """ +dim cells(15) +i = 0 +total = 0 +while i < 16 + cells(i) = i + 0.5 + i = i + 1 +wend +j = 0 +while j < 16 + total = total + cells(j) + j = j + 1 +wend +""") + ## Generated scripts proc generated(seed: int64): string = From e0e9a77712dffb79cf7859154b6e7884fe918677 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:00:14 -0700 Subject: [PATCH 17/51] Compile remainder, integer division, and fixed-point arithmetic Remainder and integer division were missing, which an array benchmark caught by measuring nothing: it indexed with `i mod 1024` and so compiled no loop at all. Both now compile, with the three divisors that are not plain division going back to the interpreter: zero, which it refuses, and minus one, which traps on one of the two architectures. remainder 28.5 -> 2.1 ms 13.4x was not compiled at all integer divide 29.2 -> 1.8 ms 16.2x was not compiled at all Fixed-point numbers turn out to need very little. Adding, subtracting, negating and comparing them is the same instruction on the stored bits as for whole numbers, so one path serves both: a pair of operands whose tags agree needs no further telling apart, and the answer keeps the tag they agreed on. Only multiplication differs, widening to sixty-four bits and rounding as the fixed-point library does. A mixed pair would have to be promoted, which can fail, so it goes back to the interpreter. fixed in cells 46.2 -> 3.7 ms 12.5x was not compiled at all That benchmark says "in cells" because of a limit worth stating plainly. Globals are hoisted into machine registers on the way in and guarded to be whole numbers, so a loop holding fixed-point values in globals still fails that guard and hands itself back. Which is to say the common shape, x = x + 0.5 does not compile yet, while the same arithmetic between array cells does, because only the loop counter beside it has to be hoisted. Letting globals carry fixed-point values means either keeping them in memory for such loops or compiling once their kinds are known. That is the next decision, and it is a real one rather than more of the same. Under -d:fixedChecks the interpreter asserts on fixed-point overflow where this wraps, so fixed point is not modelled in that build at all, and CI runs the safety tests there as well to say so. Verified on arm64 macOS and x86-64 Linux, under -d:release, -d:danger and -d:fixedChecks, as a wasm32 build under node, and as a Windows cross build. 47 AArch64 and 46 x86-64 encodings check against the assembler. Co-Authored-By: Claude Opus 5 --- src/bassy/amd64.nim | 26 ++++ src/bassy/arm64.nim | 21 ++++ src/bassy/jit.nim | 253 +++++++++++++++++++++++++++++++++++--- tests/test_amd64.nim | 12 ++ tests/test_arm64.nim | 7 ++ tests/test_jit_safety.nim | 97 +++++++++++++++ 6 files changed, 397 insertions(+), 19 deletions(-) diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index 5b61aa7..8aa11bf 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -374,3 +374,29 @@ proc shiftLeftImmediate*(assembler: var Assembler, width: Width, assembler.emit(0xC1) assembler.directOperand(Register(4), target) assembler.emit(byte(count)) + +proc signExtendDouble*(assembler: var Assembler, + destination, source: Register) {.raises: [].} = + ## Widens a 32-bit register into a 64-bit one, keeping the sign. + assembler.prefix(Word64, destination, source) + assembler.emit(0x63) + assembler.directOperand(destination, source) + +proc shiftRightImmediate*(assembler: var Assembler, width: Width, + target: Register, count: int) {.raises: [BasicError].} = + ## Shifts right, keeping the sign, by a constant. + if count < 0 or count > 63: + fail("assembler shift count is out of range") + assembler.prefix(width, Register(7), target) + assembler.emit(0xC1) + assembler.directOperand(Register(7), target) + assembler.emit(byte(count)) + +proc storeByteLow*(assembler: var Assembler, base: Register, offset: int, + source: Register) {.raises: [BasicError].} = + ## Stores the low byte of a register through a base register. + ## The REX prefix is forced so the low byte is named, not the high one. + assembler.emit(byte(0x40'u32 or ((source.number shr 3) shl 2) or + (base.number shr 3))) + assembler.emit(0x88) + assembler.memoryOperand(source, base, offset) diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index 4833c32..cdffa31 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -484,3 +484,24 @@ proc testLowBits*(assembler: var Assembler, width: Width, source: Register, (if width == Word64: 1'u32 shl 22 else: 0'u32) or (uint32(count - 1) shl 10) or (source.number shl 5) or 31'u32 ) + +proc signedMultiplyLong*(assembler: var Assembler, + destination, left, right: Register) {.raises: [].} = + ## Multiplies two 32-bit registers into a full 64-bit result. + assembler.emit( + 0x9B207C00'u32 or (right.number shl 16) or (left.number shl 5) or + destination.number + ) + +proc arithmeticShiftRight*(assembler: var Assembler, width: Width, + destination, source: Register, count: int) {.raises: [BasicError].} = + ## Shifts right, keeping the sign, by a constant. + let last = if width == Word64: 63 else: 31 + if count < 0 or count > last: + fail("assembler shift count is out of range") + let base = + if width == Word64: 0x9340_0000'u32 else: 0x1300_0000'u32 + assembler.emit( + base or (uint32(count) shl 16) or (uint32(last) shl 10) or + (source.number shl 5) or destination.number + ) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 5efce76..2f4cffc 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -81,6 +81,14 @@ const MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 + FixedTag = 1 + FixedShift = 16 + FixedRounding = 1'i64 shl (FixedShift - 1) + + ## Fixed-point values are only modelled when overflow is allowed to + ## wrap. Under fixedChecks the interpreter asserts instead, and nothing + ## here would assert with it. + ModelsFixed* = not defined(fixedChecks) MaxDisplacementBytes = int(high(int32)) proc fail(message: string) {.noreturn, raises: [BasicError].} = @@ -141,6 +149,7 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = AddOp, SubtractOp, MultiplyOp, NegateOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, JumpIfZeroOp, + IntegerDivideOp, ArrayGetOp, ArraySetOp, ArrayAddGlobalsOp, AddGlobalArrayGlobalIndexOp, JumpUnlessGlobalEqualImmediateOp, @@ -150,6 +159,8 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: true + of ModuloOp: + true of JumpUnlessGlobalModuloEqualZeroOp: # The interpreter raises on a zero divisor; refuse rather than model it. item.b != 0 @@ -223,7 +234,7 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) of MoveOp: note(item.a) note(item.b) - of AddOp, SubtractOp, MultiplyOp, + of AddOp, SubtractOp, MultiplyOp, ModuloOp, IntegerDivideOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: note(item.a) note(item.b) @@ -642,6 +653,92 @@ when NativeArm64: ## Branches when a working register holds zero. emitter.branchIfZero(Word32, ValueScratch[scratch], target) + proc readNumeric(emitter: var Assembler, scratch: int, slot: int32, + leave: Label) {.raises: [BasicError].} = + ## Reads a slot's tag into Scratch and its payload into a working + ## register, leaving the region for anything that is not a number. + let base = int(slot) * ValueStride + emitter.loadByte(Scratch, RegistersBase, base) + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(UnsignedGreaterCondition, leave) + emitter.loadWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc requireSameKind(emitter: var Assembler, slot: int32, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region unless a second slot carries the same tag as the + ## one already held. Whole numbers and fixed-point ones add, subtract + ## and compare through the very same instructions, so a pair that + ## agrees needs no further telling apart; a mixed pair would have to + ## be promoted, which can fail, so it goes back to the interpreter. + emitter.loadByte(OtherScratch, RegistersBase, int(slot) * ValueStride) + emitter.compareRegister(Word32, Scratch, OtherScratch) + emitter.branchIf(NotEqualCondition, leave) + + proc writeNumeric(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Writes a payload back under the tag the operands carried. + let base = int(slot) * ValueStride + emitter.storeByte(Scratch, RegistersBase, base) + emitter.storeWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc branchIfFixed(emitter: var Assembler, target: Label) + {.raises: [BasicError].} = + ## Branches when the held tag says fixed point. + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(EqualCondition, target) + + proc requireWholeKind(emitter: var Assembler, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region unless the held tag says whole number. + emitter.compareImmediate(Word32, Scratch, 0) + emitter.branchIf(NotEqualCondition, leave) + + proc readSlotValue(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Reads a slot's payload, its tag having already been established. + emitter.loadWord(ValueScratch[scratch], RegistersBase, + int(slot) * ValueStride + ValuePayload) + + proc guardDivisor(emitter: var Assembler, scratch: int, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region for the two divisors that are not plain division: + ## zero, which the interpreter refuses, and minus one, which the other + ## architecture traps on. + emitter.compareImmediate(Word32, ValueScratch[scratch], 0) + emitter.branchIf(EqualCondition, leave) + emitter.loadImmediate(Word32, OtherScratch, -1) + emitter.compareRegister(Word32, ValueScratch[scratch], OtherScratch) + emitter.branchIf(EqualCondition, leave) + + proc quotientScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Divides the first working register by the second, toward zero. + emitter.signedDivide(Word32, ValueScratch[left], ValueScratch[left], + ValueScratch[right]) + + proc remainderScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Leaves what the division of the two working registers left over. + emitter.signedDivide(Word32, OtherScratch, ValueScratch[left], + ValueScratch[right]) + emitter.multiplySubtract(Word32, ValueScratch[left], OtherScratch, + ValueScratch[right], ValueScratch[left]) + + proc multiplyFixed(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers through a widened intermediate, + ## rounding to nearest exactly as the fixed-point library does. + emitter.signedMultiplyLong(OtherScratch, ValueScratch[left], + ValueScratch[right]) + emitter.loadImmediate(Word64, ValueScratch[right], FixedRounding) + emitter.addRegister(Word64, OtherScratch, OtherScratch, + ValueScratch[right]) + emitter.arithmeticShiftRight(Word64, OtherScratch, OtherScratch, + FixedShift) + emitter.moveRegister(Word32, ValueScratch[left], OtherScratch) + proc elementAddress(emitter: var Assembler, scratch: int, extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = ## Bounds checks an index and leaves the cell's address in Scratch. @@ -907,6 +1004,95 @@ elif NativeAmd64: emitter.pop(Saved[index]) emitter.returnToCaller() + proc readNumeric(emitter: var Assembler, scratch: int, slot: int32, + leave: Label) {.raises: [BasicError].} = + ## Reads a slot's tag into Scratch and its payload into a working + ## register, leaving the region for anything that is not a number. + let base = int(slot) * ValueStride + emitter.loadByteZeroed(Scratch, RegistersBase, base) + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(AboveCondition, leave) + emitter.loadWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc requireSameKind(emitter: var Assembler, slot: int32, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region unless a second slot carries the same tag as the + ## one already held. Whole numbers and fixed-point ones add, subtract + ## and compare through the very same instructions, so a pair that + ## agrees needs no further telling apart; a mixed pair would have to + ## be promoted, which can fail, so it goes back to the interpreter. + emitter.loadByteZeroed(ValueScratch[1], RegistersBase, + int(slot) * ValueStride) + emitter.compareRegister(Word32, Scratch, ValueScratch[1]) + emitter.branchIf(NotEqualCondition, leave) + + proc writeNumeric(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Writes a payload back under the tag the operands carried. + let base = int(slot) * ValueStride + emitter.storeByteLow(RegistersBase, base, Scratch) + emitter.storeWord(ValueScratch[scratch], RegistersBase, + base + ValuePayload) + + proc branchIfFixed(emitter: var Assembler, target: Label) + {.raises: [BasicError].} = + ## Branches when the held tag says fixed point. + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(EqualCondition, target) + + proc requireWholeKind(emitter: var Assembler, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region unless the held tag says whole number. + emitter.compareImmediate(Word32, Scratch, 0) + emitter.branchIf(NotEqualCondition, leave) + + proc readSlotValue(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Reads a slot's payload, its tag having already been established. + emitter.loadWord(ValueScratch[scratch], RegistersBase, + int(slot) * ValueStride + ValuePayload) + + proc guardDivisor(emitter: var Assembler, scratch: int, leave: Label) + {.raises: [BasicError].} = + ## Leaves the region for the two divisors that are not plain division: + ## zero, which the interpreter refuses, and minus one, which would + ## trap here on the most negative dividend. + emitter.compareImmediate(Word32, ValueScratch[scratch], 0) + emitter.branchIf(EqualCondition, leave) + emitter.compareImmediate(Word32, ValueScratch[scratch], -1) + emitter.branchIf(EqualCondition, leave) + + proc quotientScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Divides the first working register by the second, toward zero. + ## The divide reads and writes the accumulator pair, so the divisor is + ## moved aside first and the answer moved back afterwards. + emitter.moveRegister(Word32, Scratch, ValueScratch[right]) + emitter.moveRegister(Word32, rax, ValueScratch[left]) + emitter.signExtendToPair(Word32) + emitter.signedDivide(Word32, Scratch) + emitter.moveRegister(Word32, ValueScratch[left], rax) + + proc remainderScratch(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Leaves what the division of the two working registers left over. + emitter.moveRegister(Word32, Scratch, ValueScratch[right]) + emitter.moveRegister(Word32, rax, ValueScratch[left]) + emitter.signExtendToPair(Word32) + emitter.signedDivide(Word32, Scratch) + emitter.moveRegister(Word32, ValueScratch[left], rdx) + + proc multiplyFixed(emitter: var Assembler, left, right: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers through a widened intermediate, + ## rounding to nearest exactly as the fixed-point library does. + emitter.signExtendDouble(ValueScratch[left], ValueScratch[left]) + emitter.signExtendDouble(ValueScratch[right], ValueScratch[right]) + emitter.multiplyRegister(Word64, ValueScratch[left], ValueScratch[right]) + emitter.addImmediate(Word64, ValueScratch[left], int32(FixedRounding)) + emitter.shiftRightImmediate(Word64, ValueScratch[left], FixedShift) + proc elementAddress(emitter: var Assembler, scratch: int, extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = ## Bounds checks an index and leaves the cell's address in Scratch. @@ -1338,32 +1524,61 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, of StoreGlobalOp: emitter.readSlot(0, item.b, leaveHere) emitter.hoistedFromScratch(slotOf(item.a), 0) - of AddOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.readSlot(1, item.c, leaveHere) - emitter.addScratch(0, 1) - emitter.writeSlot(0, item.a) - of SubtractOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.readSlot(1, item.c, leaveHere) - emitter.subtractScratch(0, 1) - emitter.writeSlot(0, item.a) + of AddOp, SubtractOp: + # Whole and fixed-point numbers add and subtract through the same + # instructions, so one path serves both and the answer keeps the + # kind its operands agreed on. + emitter.readNumeric(0, item.b, leaveHere) + emitter.requireSameKind(item.c, leaveHere) + emitter.readSlotValue(1, item.c) + if item.op == AddOp: + emitter.addScratch(0, 1) + else: + emitter.subtractScratch(0, 1) + emitter.writeNumeric(0, item.a) of MultiplyOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.readSlot(1, item.c, leaveHere) - emitter.multiplyScratch(0, 1) - emitter.writeSlot(0, item.a) + emitter.readNumeric(0, item.b, leaveHere) + emitter.requireSameKind(item.c, leaveHere) + emitter.readSlotValue(1, item.c) + when ModelsFixed: + let fixedWay = emitter.label() + let joined = emitter.label() + emitter.branchIfFixed(fixedWay) + emitter.multiplyScratch(0, 1) + emitter.branch(joined) + emitter.place(fixedWay) + emitter.multiplyFixed(0, 1) + emitter.place(joined) + else: + emitter.requireWholeKind(leaveHere) + emitter.multiplyScratch(0, 1) + emitter.writeNumeric(0, item.a) of NegateOp: - emitter.readSlot(0, item.b, leaveHere) + emitter.readNumeric(0, item.b, leaveHere) emitter.negateScratch(0) - emitter.writeSlot(0, item.a) + emitter.writeNumeric(0, item.a) of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.readSlot(1, item.c, leaveHere) + # Ordering is the same on the stored bits either way round, and + # the answer is always a whole number. + emitter.readNumeric(0, item.b, leaveHere) + emitter.requireSameKind(item.c, leaveHere) + emitter.readSlotValue(1, item.c) emitter.compareScratch(0, 1) emitter.answerCondition(0, comparisonTest(item.op)) emitter.writeSlot(0, item.a) + of ModuloOp, IntegerDivideOp: + # Both want whole numbers, both refuse a zero divisor, and minus + # one would trap on one of the two architectures, so all three go + # back to the interpreter rather than being modelled. + emitter.readSlot(0, item.b, leaveHere) + emitter.readSlot(1, item.c, leaveHere) + emitter.guardDivisor(1, leaveHere) + if item.op == ModuloOp: + emitter.remainderScratch(0, 1) + else: + emitter.quotientScratch(0, 1) + emitter.writeSlot(0, item.a) of ArrayGetOp: emitter.readSlot(0, item.c, leaveHere) emitter.elementAddress(0, extents[int(item.b)], leaveHere) diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index 4bbd039..6c60c55 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -83,6 +83,11 @@ encodes "movzx r11d, byte ptr [rbx]": encodes "mov byte ptr [rbx + 48], 0": assembler.storeByteImmediate(rbx, 48, 0) +encodes "mov byte ptr [rbp + 16], r11b": + assembler.storeByteLow(rbp, 16, r11) +encodes "mov byte ptr [rbx], al": + assembler.storeByteLow(rbx, 0, rax) + ## Arithmetic encodes "add esi, ecx": @@ -130,6 +135,13 @@ encodes "shl rax, 4": encodes "shl r11d, 1": assembler.shiftLeftImmediate(Word32, r11, 1) +encodes "movsxd rax, ecx": + assembler.signExtendDouble(rax, rcx) +encodes "movsxd r11, edx": + assembler.signExtendDouble(r11, rdx) +encodes "sar rax, 16": + assembler.shiftRightImmediate(Word64, rax, 16) + ## Stack and control flow encodes "push rbx": diff --git a/tests/test_arm64.nim b/tests/test_arm64.nim index 9aff539..c3e44af 100644 --- a/tests/test_arm64.nim +++ b/tests/test_arm64.nim @@ -99,6 +99,13 @@ encodes "msub w9, w10, w11, w12": encodes "sdiv w1, w2, w3": assembler.signedDivide(Word32, x1, x2, x3) +encodes "smull x1, w2, w3": + assembler.signedMultiplyLong(x1, x2, x3) +encodes "asr x4, x5, #16": + assembler.arithmeticShiftRight(Word64, x4, x5, 16) +encodes "asr w6, w7, #3": + assembler.arithmeticShiftRight(Word32, x6, x7, 3) + ## Logic encodes "and w1, w2, w3": diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index c655368..510d897 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -435,6 +435,103 @@ while j < 16 wend """) +agrees("fixed point arithmetic", """ +x = 0.5 +delta = 0.25 +total = 0.0 +i = 0 +while i < 200 + total = total + x * delta + x = x - delta + i = i + 1 +wend +""") + +agrees("fixed point comparison", """ +x = 0.0 +hits = 0 +i = 0 +while i < 300 + x = x + 0.125 + if x > 10.0 then + hits = hits + 1 + end if + i = i + 1 +wend +""") + +agrees("mixing whole and fixed operands", """ +x = 0.5 +n = 3 +total = 0 +i = 0 +while i < 100 + total = total + n + x = x + 0.25 + i = i + 1 +wend +""") + +agrees("fixed point that wraps", """ +x = 32767.0 +i = 0 +while i < 50 + x = x + 100.0 + i = i + 1 +wend +""") + +agrees("modulo and integer divide", """ +i = 0 +sum = 0 +while i < 400 + sum = sum + (i mod 7) + sum = sum + (i \ 5) + i = i + 1 +wend +""") + +agrees("a divisor that reaches zero", """ +d = 3 +i = 0 +total = 0 +while i < 10 + total = total + (100 mod d) + d = d - 1 + i = i + 1 +wend +""") + +agrees("dividing the most negative by minus one", """ +a = -2147483648 +d = -1 +i = 0 +total = 0 +while i < 5 + total = total + (a \ d) + i = i + 1 +wend +""") + +agrees("fixed point held in array cells", """ +dim cells(63) +i = 0 +while i < 64 + cells(i) = 0.5 + i = i + 1 +wend +i = 0 +while i < 63 + cells(i) = cells(i) + cells(i + 1) + i = i + 1 +wend +j = 0 +while j < 63 + cells(j) = cells(j) * cells(j + 1) + j = j + 1 +wend +""") + ## Generated scripts proc generated(seed: int64): string = From 2090be3247361e04c0019ef4fddef779e080daa6 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:23:17 -0700 Subject: [PATCH 18/51] Compile fixed-point constants, host data, and the last fused forms Five operations that were interpreted for no reason beyond not having been written. A fixed-point literal is a constant the compiler already knows, so it is stored straight into a slot rather than read from a table at run time. Host data is copied entire, like an array cell, because the interpreter copies it entire and neither needs to know its kind. host data in a loop 15.1 -> 0.74 ms 20.2x was not compiled fixed literal in cells 20.7 -> 1.15 ms 18.0x was not compiled Division of one fixed-point number by another is left alone on purpose. Its rounding normalises the signs and then corrects a floor, and getting that subtly wrong would show up as two machines disagreeing rather than as a failure, which is the one kind of bug this VM cannot afford. It is one and a half percent of the raytracer and can wait for its own change. Thirty-nine of sixty-one opcodes now compile. Of what the raytracer executes, calls are all that is left in any quantity, and they are the reason none of its loops compile at all: one operation a region does not model refuses the whole region. Verified on arm64 macOS and x86-64 Linux, under -d:release, -d:danger and -d:fixedChecks, as a wasm32 build under node, and as a Windows cross build. The four budget-escape shapes remain bounded. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 12 ++++- src/bassy/jit.nim | 124 ++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 131 insertions(+), 5 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 59a1500..b5699d2 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3122,6 +3122,11 @@ proc instructions*(program: Program): int {.inline.} = ## Returns the number of metered register-machine instructions. program.code.len +proc fixedConstants*(program: Program): seq[int32] = + ## Returns the raw bits of every fixed-point constant the code names. + for value in program.fixedValues: + result.add(int32(value)) + proc nativeRegions*(runtime: Runtime): int = ## Returns how many compiled loops are still active. for region in runtime.regionAt: @@ -3143,7 +3148,9 @@ proc compileNative*(runtime: var Runtime): int = runtime.program.code, runtime.globals.len, int(runtime.program.maxRegisters), - extents + extents, + runtime.program.fixedConstants, + runtime.hostData.len ) runtime.nativeRegions @@ -3551,6 +3558,9 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = memory: if runtime.memory.len == 0: nil else: runtime.memory[0].addr, + hostData: + if runtime.hostData.len == 0: nil + else: runtime.hostData[0].addr, remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 2f4cffc..69a300e 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -42,6 +42,7 @@ type pc*: int32 registers*: pointer memory*: pointer + hostData*: pointer NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -78,6 +79,7 @@ const ContextOffset = 24 ContextRegisters = 32 ContextMemory = 40 + ContextHostData = 48 MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 @@ -136,6 +138,8 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.memory.addr) - origin != ContextMemory: return false + if cast[int](context.hostData.addr) - origin != ContextHostData: + return false true ## Region discovery @@ -150,6 +154,8 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, JumpIfZeroOp, IntegerDivideOp, + LoadFixedOp, LoadHostDataOp, AddGlobalHostDataOp, + AddGlobalRegisterOp, ModuloGlobalImmediateOp, ArrayGetOp, ArraySetOp, ArrayAddGlobalsOp, AddGlobalArrayGlobalIndexOp, JumpUnlessGlobalEqualImmediateOp, @@ -190,6 +196,11 @@ proc touchedGlobals(item: Instruction, globals: var seq[int32]) note(item.b) of StoreGlobalOp: note(item.a) + of AddGlobalHostDataOp, AddGlobalRegisterOp: + note(item.a) + of ModuloGlobalImmediateOp: + note(item.a) + note(item.b) of ArrayAddGlobalsOp: note(item.b) note(item.c) @@ -239,6 +250,10 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) note(item.a) note(item.b) note(item.c) + of LoadFixedOp, LoadHostDataOp: + note(item.a) + of AddGlobalRegisterOp: + note(item.b) of ArrayGetOp: note(item.a) note(item.c) @@ -938,6 +953,34 @@ when NativeArm64: emitter.storeWord(ResumeOffset, Context, ContextOffset) emitter.loadImmediate(Word32, Context, int64(ord(NativeGuardFailed))) emitter.endRegion() + proc setSlotConstant(emitter: var Assembler, slot: int32, tag: int, + bits: int32) {.raises: [BasicError].} = + ## Writes a constant of a known kind straight into a slot. + let base = int(slot) * ValueStride + emitter.loadImmediate(Word32, Scratch, int64(tag)) + emitter.storeByte(Scratch, RegistersBase, base) + emitter.loadImmediate(Word32, ValueScratch[0], int64(bits)) + emitter.storeWord(ValueScratch[0], RegistersBase, base + ValuePayload) + + proc hostDataAddress(emitter: var Assembler, index: int32) + {.raises: [BasicError].} = + ## Leaves one host data value's address in Scratch. + emitter.loadDouble(Scratch, Context, ContextHostData) + emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) + emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) + + proc copyHostDataToSlot(emitter: var Assembler, index, slot: int32) + {.raises: [BasicError].} = + ## Copies a host value entire into a slot, as the interpreter does. + emitter.hostDataAddress(index) + emitter.copyElementToSlot(slot) + + proc readHostDataInteger(emitter: var Assembler, scratch: int, + index: int32, leave: Label) {.raises: [BasicError].} = + ## Reads a host value as an integer, leaving the region if it is not. + emitter.hostDataAddress(index) + emitter.readElement(scratch, leave) + elif NativeAmd64: ## x86-64 code generation @@ -1305,10 +1348,36 @@ elif NativeAmd64: emitter.storeWordImmediate(Context, ContextOffset, start) emitter.loadImmediate(Word32, rax, int64(ord(NativeGuardFailed))) emitter.endRegion() + proc setSlotConstant(emitter: var Assembler, slot: int32, tag: int, + bits: int32) {.raises: [BasicError].} = + ## Writes a constant of a known kind straight into a slot. + let base = int(slot) * ValueStride + emitter.storeByteImmediate(RegistersBase, base, byte(tag)) + emitter.loadImmediate(Word32, ValueScratch[0], int64(bits)) + emitter.storeWord(ValueScratch[0], RegistersBase, base + ValuePayload) + + proc hostDataAddress(emitter: var Assembler, index: int32) + {.raises: [BasicError].} = + ## Leaves one host data value's address in Scratch. + emitter.loadDouble(Scratch, Context, ContextHostData) + emitter.addImmediate(Word64, Scratch, index * int32(ValueStride)) + + proc copyHostDataToSlot(emitter: var Assembler, index, slot: int32) + {.raises: [BasicError].} = + ## Copies a host value entire into a slot, as the interpreter does. + emitter.hostDataAddress(index) + emitter.copyElementToSlot(slot) + + proc readHostDataInteger(emitter: var Assembler, scratch: int, + index: int32, leave: Label) {.raises: [BasicError].} = + ## Reads a host value as an integer, leaving the region if it is not. + emitter.hostDataAddress(index) + emitter.readElement(scratch, leave) + proc compileRegion*(code: seq[Instruction], start, stop, globals, - slots: int, extents: seq[ArrayExtent]): Region - {.raises: [BasicError].} = + slots: int, extents: seq[ArrayExtent], constants: seq[int32] = @[], + hostData = 0): Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. ## ## Generated code indexes global storage without checking, so every @@ -1320,8 +1389,10 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return nil else: if start < 0 or stop > code.len or start >= stop: + echo "nil range" return nil if code.reachesOutside(start, stop): + echo "nil outside" return nil if globals < 0 or slots < 0: @@ -1332,12 +1403,35 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, for index in start ..< stop: let item = code[index] if not item.isCompilable: + echo "nil op ", item.op, " at ", index return nil item.touchedGlobals(hoisted) # Slots are read and written where they sit, so each index only has # to be proved in range; nothing is carried across the region. # Every array named must exist, and its cells must sit where a # displacement can reach them. + # Every constant and host slot named has to exist, and a divisor + # fixed at compile time has to be one that divides plainly. + case item.op + of LoadFixedOp: + if item.b < 0 or int(item.b) >= constants.len: + return nil + usesSlots = true + of LoadHostDataOp, AddGlobalHostDataOp: + if item.b < 0 or int(item.b) >= hostData: + return nil + if int(item.b) > + (MaxDisplacementBytes - ValuePayload) div ValueStride: + return nil + if item.op == LoadHostDataOp: + usesSlots = true + of AddGlobalRegisterOp: + usesSlots = true + of ModuloGlobalImmediateOp: + if item.c == 0 or item.c == -1: + return nil + else: + discard var arrayId = 0'i32 if item.namedArray(arrayId): if arrayId < 0 or int(arrayId) >= extents.len: @@ -1365,6 +1459,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, if int(target) < 0 or int(target) > code.len: return nil if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: + echo "nil hoisted ", hoisted.len return nil for index in hoisted: if index < 0 or int(index) >= globals: @@ -1579,6 +1674,25 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, else: emitter.quotientScratch(0, 1) emitter.writeSlot(0, item.a) + of LoadFixedOp: + emitter.setSlotConstant(item.a, FixedTag, constants[int(item.b)]) + of LoadHostDataOp: + emitter.copyHostDataToSlot(item.b, item.a) + of AddGlobalHostDataOp: + emitter.readHostDataInteger(0, item.b, leaveHere) + emitter.scratchFromHoisted(1, slotOf(item.a)) + emitter.addScratch(1, 0) + emitter.hoistedFromScratch(slotOf(item.a), 1) + of AddGlobalRegisterOp: + emitter.readSlot(0, item.b, leaveHere) + emitter.scratchFromHoisted(1, slotOf(item.a)) + emitter.addScratch(1, 0) + emitter.hoistedFromScratch(slotOf(item.a), 1) + of ModuloGlobalImmediateOp: + emitter.scratchFromHoisted(0, slotOf(item.b)) + emitter.setScratch(1, item.c) + emitter.remainderScratch(0, 1) + emitter.hoistedFromScratch(slotOf(item.a), 0) of ArrayGetOp: emitter.readSlot(0, item.c, leaveHere) emitter.elementAddress(0, extents[int(item.b)], leaveHere) @@ -1685,7 +1799,8 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus NativeStatus(region.call(context.addr)) proc compileLoops*(code: seq[Instruction], globals, slots: int, - extents: seq[ArrayExtent] = @[]): seq[Region] + extents: seq[ArrayExtent] = @[], constants: seq[int32] = @[], + hostData = 0): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. ## The result is indexed by bytecode offset, so the interpreter reaches @@ -1703,7 +1818,8 @@ proc compileLoops*(code: seq[Instruction], globals, slots: int, var region: Region = nil try: region = compileRegion( - code, int(target), index + 1, globals, slots, extents + code, int(target), index + 1, globals, slots, extents, + constants, hostData ) except BasicError: region = nil From bc2ba3d02fdd94252d348476fc51fc429624c6bd Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:29:21 -0700 Subject: [PATCH 19/51] Let a region be a set of offsets rather than a run of them Compiling a call means taking in the body of whatever is called, and that body sits elsewhere in the code. A region can therefore no longer be a pair of bounds. Membership and the block map are now stated as a set of offsets, and whether a branch stays inside is a question asked of that set rather than of a range. Nothing changes yet: the set is still exactly the loop, every test passes unaltered, and the counting, branching and nested benchmarks hold at 65.6x, 26.3x and 32.8x. This is the shape the next change needs. Co-Authored-By: Claude Opus 5 --- src/bassy/jit.nim | 29 ++++++++++++++++++++++------- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 69a300e..7f14307 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -1503,16 +1503,30 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return slot -1 + ## The offsets this region covers, and where each one's block sits. + var members: seq[int32] + for index in start ..< stop: + members.add(int32(index)) + var placeOf = newSeq[int32](code.len + 1) + for index in 0 ..< placeOf.len: + placeOf[index] = -1 + for place, offset in members: + placeOf[int(offset)] = int32(place) + + proc covers(offset: int32): bool {.closure, raises: [].} = + ## Reports whether an offset is compiled into this region. + offset >= 0 and int(offset) < placeOf.len and placeOf[int(offset)] >= 0 + var emitter = Assembler() var blocks: seq[Label] - for index in start ..< stop: + for index in members: blocks.add(emitter.label()) let guardFailed = emitter.label() let writeback = emitter.label() var exits: seq[(Label, int32, NativeStatus, int)] template blockAt(offset: int32): Label = - blocks[int(offset) - start] + blocks[placeOf[int(offset)]] proc exitLabel(target: int32, status: NativeStatus, leaving: int): Label = @@ -1554,9 +1568,10 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, ) ## Body: one native block per bytecode offset, so branches keep working. - for index in start ..< stop: + for member in members: + let index = int(member) let item = code[index] - emitter.place(blockAt(int32(index))) + emitter.place(blockAt(member)) # A slot holding anything but an integer hands this offset back to # the interpreter, which can work in whatever the slot does hold. @@ -1566,7 +1581,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, template branchOut(target: int32, test: Test) = ## Takes an in-region branch directly, or leaves through a stub. - if int(target) >= start and int(target) < stop: + if covers(target): emitter.branchWhen(test, blockAt(target)) else: emitter.branchWhen( @@ -1717,14 +1732,14 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.hoistedFromScratch(slotOf(item.a), 1) of JumpIfZeroOp: emitter.readSlot(0, item.a, leaveHere) - if int(item.b) >= start and int(item.b) < stop: + if covers(item.b): emitter.branchIfScratchZero(0, blockAt(item.b)) else: emitter.branchIfScratchZero( 0, exitLabel(item.b, NativeCompleted, index) ) of JumpOp: - if int(item.a) >= start and int(item.a) < stop: + if covers(item.a): when CountedLoops: if counted and index == stop - 1: emitter.advanceCounter() From 8849b1850a6ab31fc4fe66da822be85f559bde7d Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:31:18 -0700 Subject: [PATCH 20/51] Carry the call state through to compiled code A call moves the register base, pushes a frame, and changes which routine is running. Compiled code will have to do all three where the interpreter can read the result, so the context now carries the frame array, the argument array, the whole register file, where the current frame starts, how deep the calls are, and which routine is running. Those three counters are read back on every return from compiled code, alongside the budgets and the offset. The frame layout is checked before anything is compiled, the same way the value layout already was. Compiled code will push frames the interpreter then reads, so a layout it does not recognise has to mean nothing is compiled rather than a corrupted call stack. Nothing is compiled differently yet. Every test passes unchanged and the counting and nested benchmarks hold at 60.6x and 30.5x. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 39 +++++++++++++++++++++++++++++++++++++++ src/bassy/jit.nim | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+) diff --git a/src/bassy.nim b/src/bassy.nim index b5699d2..f62015c 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3122,6 +3122,25 @@ proc instructions*(program: Program): int {.inline.} = ## Returns the number of metered register-machine instructions. program.code.len +proc frameLayoutMatches*(): bool = + ## Confirms the frame layout compiled code would write by hand. These + ## offsets were read off this Nim version, and compiled code pushes and + ## pops frames the interpreter then reads, so a quiet change here would + ## corrupt the call stack rather than merely slow something down. + if sizeof(Frame) != FrameStride: + return false + var probe: Frame + let origin = cast[int](probe.addr) + if cast[int](probe.base.addr) - origin != FrameBase: + return false + if cast[int](probe.routine.addr) - origin != FrameRoutine: + return false + if cast[int](probe.returnPc.addr) - origin != FrameReturn: + return false + if cast[int](probe.kind.addr) - origin != FrameTag: + return false + ord(SubFrame) == 0 + proc fixedConstants*(program: Program): seq[int32] = ## Returns the raw bits of every fixed-point constant the code names. for value in program.fixedValues: @@ -3141,6 +3160,10 @@ proc compileNative*(runtime: var Runtime): int = runtime.regionAt = @[] if not jitSupported(): return 0 + if not frameLayoutMatches(): + # Compiled code pushes and pops frames the interpreter then reads, so + # a layout it does not recognise means nothing may be compiled. + return 0 var extents = newSeq[ArrayExtent](runtime.program.arrays.len) for index, item in runtime.program.arrays: extents[index] = ArrayExtent(base: item.base, length: item.length) @@ -3561,6 +3584,19 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = hostData: if runtime.hostData.len == 0: nil else: runtime.hostData[0].addr, + frames: + if runtime.frames.len == 0: nil + else: runtime.frames[0].addr, + arguments: + if runtime.arguments.len == 0: nil + else: runtime.arguments[0].addr, + registerFile: + if runtime.registers.len == 0: nil + else: runtime.registers[0].addr, + returnTable: region.returnTable, + base: runtime.base, + depth: runtime.depth, + routine: runtime.routine, remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc @@ -3569,6 +3605,9 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = runtime.remainingInstructions = context.remainingInstructions runtime.remainingWork = context.remainingWork runtime.pc = context.pc + runtime.base = context.base + runtime.depth = context.depth + runtime.routine = context.routine case status of NativeCompleted: runtime.bypass = -1 diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 7f14307..cfe8ddc 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -43,6 +43,13 @@ type registers*: pointer memory*: pointer hostData*: pointer + frames*: pointer + arguments*: pointer + registerFile*: pointer + returnTable*: pointer + base*: int32 + depth*: int32 + routine*: int32 NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -59,6 +66,7 @@ type hoisted*: seq[int32] size*: int listing*: seq[byte] + returnTable*: pointer buffer: CodeBuffer call: NativeCall @@ -80,6 +88,23 @@ const ContextRegisters = 32 ContextMemory = 40 ContextHostData = 48 + ContextFrames = 56 + ContextArguments = 64 + ContextRegisterFile = 72 + ContextReturnTable = 80 + ContextBase = 88 + ContextDepth = 92 + ContextRoutine = 96 + + ## One frame as the interpreter lays it out: where the caller's slots + ## start, which routine it was in, where to carry on, and whether it + ## came from a call or from a GOSUB. The host checks these against the + ## real thing before any of it is compiled. + FrameStride* = 16 + FrameBase* = 0 + FrameRoutine* = 4 + FrameReturn* = 8 + FrameTag* = 12 MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 @@ -140,6 +165,20 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.hostData.addr) - origin != ContextHostData: return false + if cast[int](context.frames.addr) - origin != ContextFrames: + return false + if cast[int](context.arguments.addr) - origin != ContextArguments: + return false + if cast[int](context.registerFile.addr) - origin != ContextRegisterFile: + return false + if cast[int](context.returnTable.addr) - origin != ContextReturnTable: + return false + if cast[int](context.base.addr) - origin != ContextBase: + return false + if cast[int](context.depth.addr) - origin != ContextDepth: + return false + if cast[int](context.routine.addr) - origin != ContextRoutine: + return false true ## Region discovery From 96fe97895b75a3574f17d7cda66f2d7e141b832f Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:42:09 -0700 Subject: [PATCH 21/51] Compile calls by leaving every part of one in memory A region now takes in the body of whatever it calls, and the bodies of whatever those call in turn, so a loop that calls a subroutine compiles instead of being refused whole. Nothing is called in the machine's sense. The frame goes into the interpreter's own array, the base and depth and current routine into its own fields, and control simply jumps to the callee's compiled code. Every piece of state a call moves therefore stays exactly where the interpreter looks for it, which is what keeps leaving part way through a call free: there is nothing held anywhere else to put back. Returning is the one thing that cannot be decided when compiling, since a subroutine called from two places has two places to go back to. A table from offset to address answers it, and every offset the region did not compile points at the one stub that hands control back, so returning into interpreted code needs no test of its own. Both ceilings are the interpreter's: the call depth, and the register stack running past its limit. Either sends the offset back so the interpreter refuses it with its own message. loop with a call 14.4 -> 1.65 ms 8.7x was not compiled at all two levels of call 12.9 -> 1.89 ms 6.8x was not compiled at all recursion 17.1 -> 2.48 ms 6.9x was not compiled at all Six shapes in six now compile, where it was one in five three changes ago. Modest speedups next to a tight loop's sixty, because a call still writes a frame and clears the callee's slots exactly as the interpreter does; what changed is that these run at all. Finding calls also turned up a real bug. touchedSlots read jump-if-zero's operands the wrong way round, taking the branch target for the slot. The target was being bounds checked as if it were a slot, which refused perfectly good loops, and the slot itself was never checked at all. That is the kind of gap the rest of this pass exists to close, so it is now refused by name in the safety tests. A loop whose region takes in a callee cannot settle its budget by how far a pass has got, since that stops meaning anything once the region spans more than the loop. Such regions charge block by block. Calls are AArch64 only so far. Elsewhere a region containing one is not compiled, exactly as before they were written anywhere, and x86-64 and the Windows cross build are unaffected. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 17 +- src/bassy/amd64.nim | 4 + src/bassy/arm64.nim | 4 + src/bassy/jit.nim | 344 +++++++++++++++++++++++++++++++++++--- tests/test_jit_safety.nim | 119 +++++++++++++ 5 files changed, 461 insertions(+), 27 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index f62015c..358fbe3 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3122,6 +3122,16 @@ proc instructions*(program: Program): int {.inline.} = ## Returns the number of metered register-machine instructions. program.code.len +proc routineExtents*(program: Program): seq[RoutineExtent] = + ## Returns where each routine's code sits and what a call to it needs. + for routine in program.routines: + result.add(RoutineExtent( + entry: routine.entry, + length: routine.codeLength, + registers: routine.registerCount, + parameters: routine.parameterCount + )) + proc frameLayoutMatches*(): bool = ## Confirms the frame layout compiled code would write by hand. These ## offsets were read off this Nim version, and compiled code pushes and @@ -3173,7 +3183,12 @@ proc compileNative*(runtime: var Runtime): int = int(runtime.program.maxRegisters), extents, runtime.program.fixedConstants, - runtime.hostData.len + runtime.hostData.len, + runtime.program.routineExtents, + CallLimits( + frames: int32(runtime.frames.len), + slots: int32(runtime.registers.len) + ) ) runtime.nativeRegions diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index 8aa11bf..e97b876 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -400,3 +400,7 @@ proc storeByteLow*(assembler: var Assembler, base: Register, offset: int, (base.number shr 3))) assembler.emit(0x88) assembler.memoryOperand(source, base, offset) + +proc offsetOf*(assembler: Assembler, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in bytes. + assembler.targets[int(target)] diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index cdffa31..8b32e87 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -505,3 +505,7 @@ proc arithmeticShiftRight*(assembler: var Assembler, width: Width, base or (uint32(count) shl 16) or (uint32(last) shl 10) or (source.number shl 5) or destination.number ) + +proc offsetOf*(assembler: Assembler, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in instruction words. + assembler.targets[int(target)] diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index cfe8ddc..57ece01 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -20,6 +20,11 @@ const NativeArm64* = NativeCode and defined(arm64) NativeAmd64* = NativeCode and defined(amd64) + ## Calls are written for AArch64 so far. Everywhere else a region + ## containing one is simply not compiled, which is what happened before + ## they were written anywhere. + ModelsCalls* = NativeArm64 + when NativeArm64: import arm64 elif NativeAmd64: @@ -54,6 +59,19 @@ type NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} + RoutineExtent* = object + ## Where one routine's code sits, how many slots a call to it needs, + ## and how many of those the caller fills in. + entry*: int32 + length*: int32 + registers*: int32 + parameters*: int32 + + CallLimits* = object + ## The two ceilings a call has to respect, read from the runtime. + frames*: int32 + slots*: int32 + ArrayExtent* = object ## Where one array sits in the shared cell storage, and how long it is. base*: int32 @@ -66,6 +84,7 @@ type hoisted*: seq[int32] size*: int listing*: seq[byte] + returns: seq[pointer] returnTable*: pointer buffer: CodeBuffer call: NativeCall @@ -108,6 +127,9 @@ const MaxHoistedGlobals* = 7 MaxRegionBytes = 32 * 1024 MaxChargeImmediate = 4095 + ## A call clears the callee's slots one at a time, so a routine wanting + ## more than this keeps to the interpreter rather than growing the code. + MaxClearedSlots = 64 FixedTag = 1 FixedShift = 16 FixedRounding = 1'i64 shl (FixedShift - 1) @@ -206,6 +228,9 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = true of ModuloOp: true + of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, + CallOp, ReturnOp, ExitSubOp: + ModelsCalls of JumpUnlessGlobalModuloEqualZeroOp: # The interpreter raises on a zero divisor; refuse rather than model it. item.b != 0 @@ -235,6 +260,8 @@ proc touchedGlobals(item: Instruction, globals: var seq[int32]) note(item.b) of StoreGlobalOp: note(item.a) + of SetArgumentGlobalOp: + note(item.b) of AddGlobalHostDataOp, AddGlobalRegisterOp: note(item.a) of ModuloGlobalImmediateOp: @@ -279,7 +306,10 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) case item.op of LoadImmediateOp, LoadGlobalOp: note(item.a) - of StoreGlobalOp, NegateOp, JumpIfZeroOp: + of JumpIfZeroOp: + # The slot tested is the first operand; the second is where to go. + note(item.a) + of StoreGlobalOp, NegateOp: note(item.b) of MoveOp: note(item.a) @@ -291,7 +321,7 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) note(item.c) of LoadFixedOp, LoadHostDataOp: note(item.a) - of AddGlobalRegisterOp: + of AddGlobalRegisterOp, SetArgumentOp: note(item.b) of ArrayGetOp: note(item.a) @@ -467,6 +497,46 @@ proc anyTarget(item: Instruction, target: var int32): bool {.raises: [].} = else: item.branchTarget(target) +proc calledRoutine(item: Instruction, id: var int32): bool {.raises: [].} = + ## Reports whether an operation calls a routine, and which one. + if item.op == CallOp: + id = item.a + true + else: + false + +proc gatherCalled(code: seq[Instruction], members: var seq[int32], + covered: var seq[bool], routines: seq[RoutineExtent]): bool + {.raises: [].} = + ## Grows a region to take in the body of everything it calls, and the + ## bodies of everything those call in turn. Returns false if any of it + ## cannot be reached or would not fit. + var pending = 0 + while pending < members.len: + let offset = int(members[pending]) + inc pending + var id = 0'i32 + if not code[offset].calledRoutine(id): + continue + if id < 0 or int(id) >= routines.len: + return false + let routine = routines[int(id)] + if routine.length <= 0 or routine.entry < 0: + return false + if int(routine.entry) + int(routine.length) > code.len: + return false + if covered[int(routine.entry)]: + continue + for step in 0 ..< int(routine.length): + let inside = int(routine.entry) + step + if covered[inside]: + # Two routines cannot share code, so an overlap means the table + # says something this does not understand. + return false + covered[inside] = true + members.add(int32(inside)) + true + proc reachesOutside(code: seq[Instruction], start, stop: int): bool {.raises: [].} = ## Reports whether the loop can be entered anywhere but its first offset. @@ -1000,6 +1070,140 @@ when NativeArm64: emitter.storeByte(Scratch, RegistersBase, base) emitter.loadImmediate(Word32, ValueScratch[0], int64(bits)) emitter.storeWord(ValueScratch[0], RegistersBase, base + ValuePayload) + ## Calls + ## + ## Nothing is called in the machine's sense: the frame goes into the + ## interpreter's own array, the base and depth into its own fields, and + ## control simply jumps to the callee's compiled code. Every piece of + ## state a call moves therefore stays where the interpreter looks for + ## it, so leaving part way through a call costs nothing to arrange. + + proc stageArgumentFromSlot(emitter: var Assembler, index: int32, + slot: int32) {.raises: [BasicError].} = + ## Copies a slot into an argument, entire, whatever kind it holds. + emitter.loadDouble(Scratch, Context, ContextArguments) + emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) + emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) + emitter.copySlotToElement(slot) + + proc stageArgumentWhole(emitter: var Assembler, index: int32, + value: int32) {.raises: [BasicError].} = + ## Writes a whole number straight into an argument. + emitter.loadDouble(Scratch, Context, ContextArguments) + emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) + emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) + emitter.storeByte(zeroRegister, Scratch, 0) + emitter.loadImmediate(Word32, ValueScratch[0], int64(value)) + emitter.storeWord(ValueScratch[0], Scratch, ValuePayload) + + proc stageArgumentFromHoisted(emitter: var Assembler, index: int32, + slot: int) {.raises: [BasicError].} = + ## Writes a hoisted global into an argument, always a whole number + ## because every hoisted global was proved to be one on the way in. + emitter.loadDouble(Scratch, Context, ContextArguments) + emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) + emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) + emitter.storeByte(zeroRegister, Scratch, 0) + emitter.storeWord(slotRegister(slot), Scratch, ValuePayload) + + proc enterRoutine(emitter: var Assembler, callee: RoutineExtent, + calleeId, callerRegisters, resumeAt: int32, limits: CallLimits, + leave: Label) {.raises: [BasicError].} = + ## Pushes a frame and moves to the callee, refusing the same two + ## ceilings the interpreter refuses. + emitter.loadWord(Scratch, Context, ContextDepth) + emitter.loadImmediate(Word32, OtherScratch, int64(limits.frames) - 1) + emitter.compareRegister(Word32, Scratch, OtherScratch) + emitter.branchIf(GreaterEqualCondition, leave) + + emitter.loadWord(OtherScratch, Context, ContextBase) + emitter.moveRegister(Word32, ValueScratch[0], OtherScratch) + if callerRegisters > 0: + emitter.addImmediate(Word32, ValueScratch[0], ValueScratch[0], + int(callerRegisters)) + emitter.loadImmediate(Word32, ValueScratch[1], + int64(limits.slots) - int64(callee.registers)) + emitter.compareRegister(Word32, ValueScratch[0], ValueScratch[1]) + emitter.branchIf(GreaterCondition, leave) + + # frames[depth] = { base, routine, resumeAt, SubFrame } + emitter.loadDouble(ValueScratch[1], Context, ContextFrames) + emitter.addRegister(Word64, ValueScratch[1], ValueScratch[1], Scratch, 4) + emitter.storeWord(OtherScratch, ValueScratch[1], FrameBase) + emitter.loadWord(OtherScratch, Context, ContextRoutine) + emitter.storeWord(OtherScratch, ValueScratch[1], FrameRoutine) + emitter.loadImmediate(Word32, OtherScratch, int64(resumeAt)) + emitter.storeWord(OtherScratch, ValueScratch[1], FrameReturn) + emitter.storeWord(zeroRegister, ValueScratch[1], FrameTag) + + emitter.addImmediate(Word32, Scratch, Scratch, 1) + emitter.storeWord(Scratch, Context, ContextDepth) + emitter.storeWord(ValueScratch[0], Context, ContextBase) + emitter.loadImmediate(Word32, OtherScratch, int64(calleeId)) + emitter.storeWord(OtherScratch, Context, ContextRoutine) + + emitter.loadDouble(OtherScratch, Context, ContextRegisterFile) + emitter.addRegister(Word64, RegistersBase, OtherScratch, + ValueScratch[0], 4) + + # The interpreter clears the callee's slots and then lays the + # arguments over the first few, so this does the same in that order. + for slot in 0 ..< int(callee.registers): + emitter.storeDouble(zeroRegister, RegistersBase, slot * ValueStride) + emitter.storeDouble(zeroRegister, RegistersBase, + slot * ValueStride + ValuePayload) + if callee.parameters > 0: + emitter.loadDouble(Scratch, Context, ContextArguments) + for slot in 0 ..< int(callee.parameters): + emitter.loadDouble(ValueScratch[0], Scratch, slot * ValueStride) + emitter.loadDouble(ValueScratch[1], Scratch, + slot * ValueStride + ValuePayload) + emitter.storeDouble(ValueScratch[0], RegistersBase, + slot * ValueStride) + emitter.storeDouble(ValueScratch[1], RegistersBase, + slot * ValueStride + ValuePayload) + + proc resumeAtStoredOffset(emitter: var Assembler, + hoistedFor: seq[int32]) {.raises: [BasicError].} = + ## Hands control back at the offset already written to the context, + ## which is where a return lands when this region did not compile it. + for slot, index in hoistedFor: + let base = int(index) * ValueStride + emitter.storeByte(zeroRegister, GlobalsBase, base) + emitter.storeWord(slotRegister(slot), GlobalsBase, + base + ValuePayload) + emitter.storeDouble(Instructions, Context, ContextInstructions) + emitter.storeDouble(Work, Context, ContextWork) + emitter.loadImmediate(Word32, Context, int64(ord(NativeCompleted))) + emitter.endRegion() + + proc leaveRoutine(emitter: var Assembler, leave: Label) + {.raises: [BasicError].} = + ## Pops a frame and jumps to wherever it said to carry on. The table + ## sends any offset this region did not compile back to the + ## interpreter, so returning into interpreted code needs no test. + emitter.loadWord(Scratch, Context, ContextDepth) + emitter.branchIfZero(Word32, Scratch, leave) + emitter.subtractImmediate(Word32, Scratch, Scratch, 1) + emitter.storeWord(Scratch, Context, ContextDepth) + + emitter.loadDouble(ValueScratch[1], Context, ContextFrames) + emitter.addRegister(Word64, ValueScratch[1], ValueScratch[1], Scratch, 4) + emitter.loadWord(OtherScratch, ValueScratch[1], FrameBase) + emitter.storeWord(OtherScratch, Context, ContextBase) + emitter.loadWord(ValueScratch[0], ValueScratch[1], FrameRoutine) + emitter.storeWord(ValueScratch[0], Context, ContextRoutine) + emitter.loadWord(ValueScratch[0], ValueScratch[1], FrameReturn) + emitter.storeWord(ValueScratch[0], Context, ContextOffset) + + emitter.loadDouble(Scratch, Context, ContextRegisterFile) + emitter.addRegister(Word64, RegistersBase, Scratch, OtherScratch, 4) + + emitter.loadDouble(Scratch, Context, ContextReturnTable) + emitter.addRegister(Word64, Scratch, Scratch, ValueScratch[0], 3) + emitter.loadDouble(Scratch, Scratch, 0) + emitter.jumpRegister(Scratch) + proc hostDataAddress(emitter: var Assembler, index: int32) {.raises: [BasicError].} = @@ -1416,7 +1620,8 @@ elif NativeAmd64: proc compileRegion*(code: seq[Instruction], start, stop, globals, slots: int, extents: seq[ArrayExtent], constants: seq[int32] = @[], - hostData = 0): Region {.raises: [BasicError].} = + hostData = 0, routines: seq[RoutineExtent] = @[], + limits = CallLimits()): Region {.raises: [BasicError].} = ## Compiles one loop, or returns nil when it is outside the modelled set. ## ## Generated code indexes global storage without checking, so every @@ -1428,21 +1633,64 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return nil else: if start < 0 or stop > code.len or start >= stop: - echo "nil range" return nil if code.reachesOutside(start, stop): - echo "nil outside" return nil if globals < 0 or slots < 0: return nil var usesSlots = false + var usesCalls = false - var hoisted: seq[int32] + ## The offsets this region covers, and where each one's block sits. + ## A call brings the callee's whole body in with it. + var members: seq[int32] + var covered = newSeq[bool](code.len) for index in start ..< stop: + members.add(int32(index)) + covered[index] = true + if not gatherCalled(code, members, covered, routines): + return nil + + ## Which routine each offset belongs to, since a call has to record + ## how many slots the caller was using. Routine zero is the program + ## itself, so every offset has an owner. + var ownerOf = newSeq[int32](code.len + 1) + for index in 0 ..< ownerOf.len: + ownerOf[index] = -1 + for id, routine in routines: + if routine.entry < 0 or routine.length < 0: + return nil + for step in 0 ..< int(routine.length): + let offset = int(routine.entry) + step + if offset >= code.len: + return nil + ownerOf[offset] = int32(id) + # Only a call needs to know which routine it sits in, to record how + # many slots the caller was using. + for member in members: + if code[int(member)].op == CallOp and ownerOf[int(member)] < 0: + return nil + if limits.frames <= 0 or limits.slots <= 0: + for offset in members: + if code[int(offset)].op in {CallOp, ReturnOp, ExitSubOp}: + return nil + var placeOf = newSeq[int32](code.len + 1) + for index in 0 ..< placeOf.len: + placeOf[index] = -1 + for place, offset in members: + placeOf[int(offset)] = int32(place) + + proc covers(offset: int32): bool {.closure, raises: [].} = + ## Reports whether an offset is compiled into this region. + offset >= 0 and int(offset) < placeOf.len and placeOf[int(offset)] >= 0 + + + var hoisted: seq[int32] + for member in members: + let index = int(member) let item = code[index] if not item.isCompilable: - echo "nil op ", item.op, " at ", index return nil item.touchedGlobals(hoisted) # Slots are read and written where they sit, so each index only has @@ -1497,8 +1745,18 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, # interpreter stops, but never beyond it. if int(target) < 0 or int(target) > code.len: return nil + if item.op in {CallOp, ReturnOp, ExitSubOp}: + usesCalls = true + usesSlots = true + var calleeId = 0'i32 + if item.calledRoutine(calleeId): + let callee = routines[int(calleeId)] + if callee.registers < 0 or callee.parameters < 0 or + callee.parameters > callee.registers: + return nil + if callee.registers > MaxClearedSlots: + return nil if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: - echo "nil hoisted ", hoisted.len return nil for index in hoisted: if index < 0 or int(index) >= globals: @@ -1529,8 +1787,12 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, const CountedLoops = NativeArm64 let plan = planLoop(code, start, stop) - let counted = CountedLoops and plan.counted - let spending = CountedLoops and plan.spending and not plan.counted + # Both of those settle the budget by how far a pass has got through + # the loop, which stops meaning anything once a region takes in the + # body of something it calls. Such a region charges block by block. + let counted = CountedLoops and plan.counted and not usesCalls + let spending = CountedLoops and plan.spending and not plan.counted and + not usesCalls let pool = if MaxPooled > 0: pooledConstants(code, start, stop, MaxPooled) else: @[] @@ -1542,20 +1804,6 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return slot -1 - ## The offsets this region covers, and where each one's block sits. - var members: seq[int32] - for index in start ..< stop: - members.add(int32(index)) - var placeOf = newSeq[int32](code.len + 1) - for index in 0 ..< placeOf.len: - placeOf[index] = -1 - for place, offset in members: - placeOf[int(offset)] = int32(place) - - proc covers(offset: int32): bool {.closure, raises: [].} = - ## Reports whether an offset is compiled into this region. - offset >= 0 and int(offset) < placeOf.len and placeOf[int(offset)] >= 0 - var emitter = Assembler() var blocks: seq[Label] for index in members: @@ -1747,6 +1995,27 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.setScratch(1, item.c) emitter.remainderScratch(0, 1) emitter.hoistedFromScratch(slotOf(item.a), 0) + of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, + CallOp, ReturnOp, ExitSubOp: + when ModelsCalls: + case item.op + of SetArgumentOp: + emitter.stageArgumentFromSlot(item.a, item.b) + of SetArgumentImmediateOp: + emitter.stageArgumentWhole(item.a, item.b) + of SetArgumentGlobalOp: + emitter.stageArgumentFromHoisted(item.a, slotOf(item.b)) + of CallOp: + let callee = routines[int(item.a)] + emitter.enterRoutine( + callee, item.a, routines[int(ownerOf[index])].registers, + int32(index + 1), limits, leaveHere + ) + emitter.branch(blockAt(callee.entry)) + else: + emitter.leaveRoutine(leaveHere) + else: + return nil of ArrayGetOp: emitter.readSlot(0, item.c, leaveHere) emitter.elementAddress(0, extents[int(item.b)], leaveHere) @@ -1828,6 +2097,12 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, ## Guard failure happens before any global is written, so the loop is ## simply handed back untouched for the interpreter to run. + var resumeElsewhere = emitter.label() + when ModelsCalls: + if usesCalls: + emitter.place(resumeElsewhere) + emitter.resumeAtStoredOffset(hoisted) + emitter.place(guardFailed) emitter.guardExit(int32(start)) @@ -1839,6 +2114,11 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, result = Region( start: int32(start), stop: int32(stop), hoisted: hoisted, size: size ) + ## Where every offset lands. Anything this region did not compile + ## goes to the one stub that hands the offset back, which is how a + ## return into interpreted code needs no test of its own. + if usesCalls: + result.returns = newSeq[pointer](code.len + 1) result.listing = newSeq[byte](size) if size > 0: copyMem(result.listing[0].addr, emitter.code[0].addr, size) @@ -1846,6 +2126,17 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, result.buffer.write(emitter.code) result.buffer.seal() result.call = cast[NativeCall](result.buffer.entry) + if usesCalls: + let origin = cast[int](result.buffer.entry) + let elsewhere = origin + + emitter.offsetOf(resumeElsewhere) * sizeof(emitter.code[0]) + for index in 0 ..< result.returns.len: + result.returns[index] = cast[pointer](elsewhere) + for place, offset in members: + result.returns[int(offset)] = cast[pointer]( + origin + emitter.offsetOf(blocks[place]) * sizeof(emitter.code[0]) + ) + result.returnTable = result.returns[0].addr proc invoke*(region: Region, context: var NativeContext): NativeStatus {.raises: [].} = @@ -1854,7 +2145,8 @@ proc invoke*(region: Region, context: var NativeContext): NativeStatus proc compileLoops*(code: seq[Instruction], globals, slots: int, extents: seq[ArrayExtent] = @[], constants: seq[int32] = @[], - hostData = 0): seq[Region] + hostData = 0, routines: seq[RoutineExtent] = @[], + limits = CallLimits()): seq[Region] {.raises: [BasicError].} = ## Compiles every backward-branching loop the code generator models. ## The result is indexed by bytecode offset, so the interpreter reaches @@ -1873,7 +2165,7 @@ proc compileLoops*(code: seq[Instruction], globals, slots: int, try: region = compileRegion( code, int(target), index + 1, globals, slots, extents, - constants, hostData + constants, hostData, routines, limits ) except BasicError: region = nil diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 510d897..ea8acb6 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -227,6 +227,18 @@ block: compileRegion(code, 0, 4, Globals, Slots, far) == nil ) +block: + let code = @[ + Instruction(op: MeterOp, a: 4, b: 2), + Instruction(op: JumpIfZeroOp, a: int32(Slots), b: 4), + Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), + Instruction(op: JumpOp, a: 0) + ] + report( + "a jump-if-zero slot past the frame is refused", + compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + ) + ## Scripts, down both paths type Outcome = object @@ -532,6 +544,113 @@ while j < 63 wend """) +agrees("a loop that calls a subroutine", """ +sub bump(v) + total = total + v +end sub +i = 0 +total = 0 +while i < 200 + bump(i) + bump(i) + i = i + 1 +wend +""") + +agrees("a subroutine calling another", """ +sub inner(v) + total = total + v +end sub +sub outer(v) + inner(v) + inner(v) +end sub +i = 0 +total = 0 +while i < 200 + outer(i) + i = i + 1 +wend +""") + +agrees("recursion", """ +sub down(n) + if n > 0 then + hits = hits + 1 + down(n - 1) + end if +end sub +i = 0 +hits = 0 +while i < 100 + down(8) + i = i + 1 +wend +""") + +agrees("recursion past the depth limit", """ +sub down(n) + hits = hits + 1 + down(n + 1) +end sub +i = 0 +hits = 0 +while i < 3 + down(1) + i = i + 1 +wend +""") + +agrees("leaving a subroutine early", """ +sub maybe(v) + if v > 50 then + exit sub + end if + total = total + v +end sub +i = 0 +total = 0 +while i < 200 + maybe(i) + i = i + 1 +wend +""") + +agrees("a callee that reaches an array", """ +dim cells(63) +sub store(n) + cells(n) = n * 2 +end sub +i = 0 +while i < 64 + store(i) + i = i + 1 +wend +""") + +agrees("a callee doing something unmodelled", """ +sub shout(v) + print v +end sub +i = 0 +while i < 20 + shout(i) + i = i + 1 +wend +""") + +agrees("the budget running out inside a call", """ +sub bump(v) + total = total + v +end sub +i = 0 +total = 0 +while i < 100000 + bump(i) + i = i + 1 +wend +""", maximum = 977) + ## Generated scripts proc generated(seed: int64): string = From 6eb5150e399f11d4b8ca1db12c10a6458fc314c7 Mon Sep 17 00:00:00 2001 From: treeform Date: Tue, 22 Sep 2026 20:52:37 -0700 Subject: [PATCH 22/51] Compile host calls and fixed-point division Host code is the one thing compiled code leaves for code it did not write, so it is the one place that has to keep the calling convention. The context and the frame base go on the stack because a callee may use those registers; the budgets, the globals base and the hoisted globals are ones a callee must leave alone. Host code can also refuse, and it cannot refuse by raising back through a frame that nothing described. A trampoline catches whatever it raised and reports it as an answer, and the offset goes back with the refusal already made, so the function is not called a second time on the way out. A host function that raises now stops the script with its own message from either path. a host call in a loop 3.3x was not compiled at all Division to a fixed-point answer follows the library exactly: the signs are put right first, half the divisor is added, and the truncating divide is corrected back to a floor. Either kind may appear on either side, so both are widened first, and a whole number too large to widen goes back rather than being approximated. divide between cells 6.2x was not compiled at all divide by negatives 5.5x was not compiled at all Fifty-two AArch64 encodings now, the new ones being the sign extension and the two shifts the widening needs, all checked against the assembler. The raytracer still compiles nothing, and now for one reason only: its loops touch more globals than there are registers to hold them. Every operation in them is modelled. Letting a region keep its globals in memory when there are too many to hoist is the next change, and the last one the raytracer is waiting on. Both are AArch64 only. Elsewhere a region containing either is not compiled, and x86-64, the wasm build and the Windows cross build are unaffected. Co-Authored-By: Claude Opus 5 --- src/bassy.nim | 52 +++++++++++++ src/bassy/arm64.nim | 32 ++++++++ src/bassy/jit.nim | 156 ++++++++++++++++++++++++++++++++++++-- tests/test_arm64.nim | 11 +++ tests/test_jit_safety.nim | 51 +++++++++++++ 5 files changed, 295 insertions(+), 7 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 358fbe3..f57fb09 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -216,6 +216,7 @@ type finished: bool regionAt: seq[Region] bypass: int32 + hostFailure: string Expr = object text: bool @@ -3132,6 +3133,11 @@ proc routineExtents*(program: Program): seq[RoutineExtent] = parameters: routine.parameterCount )) +proc runHostCall(context: ptr NativeContext, functionId, + destination: int32): int32 {.cdecl.} + ## Runs one host function on behalf of compiled code. The body sits + ## further down, beside the checks it needs. + proc frameLayoutMatches*(): bool = ## Confirms the frame layout compiled code would write by hand. These ## offsets were read off this Nim version, and compiled code pushes and @@ -3569,6 +3575,46 @@ proc leaveFrame(runtime: var Runtime) = runtime.routine = frame.routine runtime.pc = frame.returnPc +proc runHostCall(context: ptr NativeContext, functionId, + destination: int32): int32 {.cdecl.} = + ## Runs one host function on behalf of compiled code. + ## + ## Compiled code cannot let a refusal travel back through a frame that + ## nothing described, so whatever the host raises is caught here and + ## reported as an answer instead. The offset then goes back to the + ## interpreter with the refusal already made, so the function is not + ## called a second time on the way out. + let runtime = cast[Runtime](context.runtime) + try: + let + id = int(functionId) + count = int(runtime.program.hostFunctions[id].parameters) + callback = runtime.hostCallbacks[id] + var value: Value + if callback.numeric != nil: + if count == 0: + value = callback.numeric([]) + else: + value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) + else: + for i in 0 ..< count: + runtime.integerArguments[i] = runtime.arguments[i].asInt + if count == 0: + value = callback.integer(EmptyArguments) + else: + value = callback.integer( + runtime.integerArguments.toOpenArray(0, count - 1) + ) + runtime.requireValue(value) + requireType(runtime.program.hostFunctions[id].name, value) + if destination >= 0: + let slots = cast[ptr UncheckedArray[Value]](context.registerFile) + slots[int(context.base) + int(destination)] = value + 0'i32 + except CatchableError as error: + runtime.hostFailure = error.msg + 1'i32 + proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = ## Executes verified bytecode with bounded work, memory, calls, and output. if runtime.finished: @@ -3612,6 +3658,8 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = base: runtime.base, depth: runtime.depth, routine: runtime.routine, + runtime: cast[pointer](runtime), + hostCall: cast[pointer](runHostCall), remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc @@ -3629,6 +3677,10 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = of NativeExhausted: # Let the interpreter re-run the meter and raise the real error. runtime.bypass = context.pc + of NativeFailed: + # Host code already refused, and saying so again would call it + # a second time, so the refusal is repeated rather than remade. + raise newException(BasicError, runtime.hostFailure) of NativeGuardFailed: # A value stopped being an integer, so retire the compiled form. runtime.regionAt[int(region.start)] = nil diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index 8b32e87..cc1849c 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -509,3 +509,35 @@ proc arithmeticShiftRight*(assembler: var Assembler, width: Width, proc offsetOf*(assembler: Assembler, target: Label): int {.raises: [].} = ## Returns where a label ended up, in instruction words. assembler.targets[int(target)] + +proc signExtendWord*(assembler: var Assembler, + destination, source: Register) {.raises: [].} = + ## Widens a 32-bit register into a 64-bit one, keeping the sign. + assembler.emit( + 0x93407C00'u32 or (source.number shl 5) or destination.number + ) + +proc shiftLeftImmediate*(assembler: var Assembler, width: Width, + destination, source: Register, count: int) {.raises: [BasicError].} = + ## Shifts left by a constant, which is a bitfield move underneath. + let size = if width == Word64: 64 else: 32 + if count < 0 or count >= size: + fail("assembler shift count is out of range") + let base = if width == Word64: 0xD3400000'u32 else: 0x53000000'u32 + assembler.emit( + base or (uint32((size - count) mod size) shl 16) or + (uint32(size - 1 - count) shl 10) or (source.number shl 5) or + destination.number + ) + +proc shiftRightImmediate*(assembler: var Assembler, width: Width, + destination, source: Register, count: int) {.raises: [BasicError].} = + ## Shifts right without keeping the sign, by a constant. + let size = if width == Word64: 64 else: 32 + if count < 0 or count >= size: + fail("assembler shift count is out of range") + let base = if width == Word64: 0xD3400000'u32 else: 0x53000000'u32 + assembler.emit( + base or (uint32(count) shl 16) or (uint32(size - 1) shl 10) or + (source.number shl 5) or destination.number + ) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 57ece01..eb6a01b 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -25,6 +25,9 @@ const ## they were written anywhere. ModelsCalls* = NativeArm64 + ## Dividing to a fixed-point answer is written for AArch64 so far. + ModelsDivide* = NativeArm64 + when NativeArm64: import arm64 elif NativeAmd64: @@ -35,7 +38,8 @@ type ## Why compiled code returned control to the interpreter. NativeCompleted, NativeGuardFailed, - NativeExhausted + NativeExhausted, + NativeFailed NativeContext* = object ## The mutable interpreter state compiled code is allowed to touch. @@ -55,6 +59,8 @@ type base*: int32 depth*: int32 routine*: int32 + runtime*: pointer + hostCall*: pointer NativeCall = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -114,6 +120,8 @@ const ContextBase = 88 ContextDepth = 92 ContextRoutine = 96 + ContextRuntime = 104 + ContextHostCall = 112 ## One frame as the interpreter lays it out: where the caller's slots ## start, which routine it was in, where to carry on, and whether it @@ -201,6 +209,10 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.routine.addr) - origin != ContextRoutine: return false + if cast[int](context.runtime.addr) - origin != ContextRuntime: + return false + if cast[int](context.hostCall.addr) - origin != ContextHostCall: + return false true ## Region discovery @@ -228,8 +240,12 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = true of ModuloOp: true + of DivideOp: + # Dividing wraps here and asserts under fixedChecks, so that build + # leaves it alone, as it leaves the rest of fixed point alone. + ModelsFixed and ModelsDivide of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, - CallOp, ReturnOp, ExitSubOp: + CallOp, ReturnOp, ExitSubOp, HostCallOp: ModelsCalls of JumpUnlessGlobalModuloEqualZeroOp: # The interpreter raises on a zero divisor; refuse rather than model it. @@ -314,13 +330,16 @@ proc touchedSlots(item: Instruction, slots: var seq[int32]) of MoveOp: note(item.a) note(item.b) - of AddOp, SubtractOp, MultiplyOp, ModuloOp, IntegerDivideOp, + of AddOp, SubtractOp, MultiplyOp, ModuloOp, IntegerDivideOp, DivideOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: note(item.a) note(item.b) note(item.c) of LoadFixedOp, LoadHostDataOp: note(item.a) + of HostCallOp: + if item.a >= 0: + note(item.a) of AddGlobalRegisterOp, SetArgumentOp: note(item.b) of ArrayGetOp: @@ -581,7 +600,10 @@ when NativeArm64: LimitInstructions = x7 LimitWork = x8 FirstPooled = 1 - FrameBytes = 96 + FrameBytes = 112 + ## Where the context and the frame base are kept while host code runs, + ## both of them being registers a callee is free to use. + AcrossHostCall = 96 MaxDisplacement = 4095 const MaxPooled* = 4 @@ -1177,6 +1199,100 @@ when NativeArm64: emitter.loadImmediate(Word32, Context, int64(ord(NativeCompleted))) emitter.endRegion() + proc setSlotKind(emitter: var Assembler, slot: int32, tag: int) + {.raises: [BasicError].} = + ## Says what kind a slot now holds, leaving its payload alone. + emitter.loadImmediate(Word32, OtherScratch, int64(tag)) + emitter.storeByte(OtherScratch, RegistersBase, int(slot) * ValueStride) + + proc writeSlotPayload(emitter: var Assembler, scratch: int, slot: int32) + {.raises: [BasicError].} = + ## Writes a slot's payload, its kind having just been said. + emitter.storeWord(ValueScratch[scratch], RegistersBase, + int(slot) * ValueStride + ValuePayload) + + proc widenToFixed(emitter: var Assembler, scratch: int, leave: Label) + {.raises: [BasicError].} = + ## Turns a number of either kind into its Q16.16 bits, widened to + ## sixty-four. A whole number outside the fixed-point range cannot be + ## turned into one at all, which is what the interpreter refuses, so + ## that goes back rather than being approximated. + let value = ValueScratch[scratch] + let already = emitter.label() + let ready = emitter.label() + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(EqualCondition, already) + emitter.loadImmediate(Word32, OtherScratch, 32767) + emitter.compareRegister(Word32, value, OtherScratch) + emitter.branchIf(GreaterCondition, leave) + emitter.loadImmediate(Word32, OtherScratch, -32768) + emitter.compareRegister(Word32, value, OtherScratch) + emitter.branchIf(LessCondition, leave) + emitter.signExtendWord(value, value) + emitter.shiftLeftImmediate(Word64, value, value, FixedShift) + emitter.branch(ready) + emitter.place(already) + emitter.signExtendWord(value, value) + emitter.place(ready) + + proc divideFixed(emitter: var Assembler, leave: Label) + {.raises: [BasicError].} = + ## Divides one Q16.16 number by another, rounding to nearest with + ## halves going up, for either sign, exactly as the fixed-point + ## library does: the signs are put right first, half the divisor is + ## added, and the truncating divide is corrected back to a floor. + let numerator = ValueScratch[0] + let denominator = ValueScratch[1] + emitter.compareImmediate(Word64, denominator, 0) + emitter.branchIf(EqualCondition, leave) + + let signsSettled = emitter.label() + emitter.compareImmediate(Word64, denominator, 0) + emitter.branchIf(GreaterCondition, signsSettled) + emitter.negate(Word64, numerator, numerator) + emitter.negate(Word64, denominator, denominator) + emitter.place(signsSettled) + + emitter.shiftLeftImmediate(Word64, numerator, numerator, FixedShift) + emitter.shiftRightImmediate(Word64, Scratch, denominator, 1) + emitter.addRegister(Word64, numerator, numerator, Scratch) + + emitter.signedDivide(Word64, Scratch, numerator, denominator) + emitter.multiplySubtract(Word64, OtherScratch, Scratch, denominator, + numerator) + + let done = emitter.label() + emitter.compareImmediate(Word64, OtherScratch, 0) + emitter.branchIf(EqualCondition, done) + emitter.compareImmediate(Word64, numerator, 0) + emitter.branchIf(GreaterEqualCondition, done) + emitter.subtractImmediate(Word64, Scratch, Scratch, 1) + emitter.place(done) + emitter.moveRegister(Word32, numerator, Scratch) + + proc callHost(emitter: var Assembler, functionId, destination: int32, + failed: Label) {.raises: [BasicError].} = + ## Runs one host function through a trampoline. + ## + ## This is the one place compiled code leaves for code it did not + ## write, so it is also the one place that has to keep the calling + ## convention. The context and the frame base are registers a callee + ## may use, so they go on the stack; the budgets, the globals base and + ## the hoisted globals are ones a callee must leave alone. + ## + ## Host code can refuse. It cannot refuse by raising through a frame + ## nothing described, so the trampoline catches whatever it raised and + ## says so in its answer, and the offset goes back with the failure + ## already made rather than to be made again. + emitter.storePair(Context, RegistersBase, stackPointer, AcrossHostCall) + emitter.loadDouble(Scratch, Context, ContextHostCall) + emitter.loadImmediate(Word32, x1, int64(functionId)) + emitter.loadImmediate(Word32, x2, int64(destination)) + emitter.callRegister(Scratch) + emitter.moveRegister(Word32, Scratch, x0) + emitter.loadPair(Context, RegistersBase, stackPointer, AcrossHostCall) + emitter.branchIfNotZero(Word32, Scratch, failed) + proc leaveRoutine(emitter: var Assembler, leave: Label) {.raises: [BasicError].} = ## Pops a frame and jumps to wherever it said to carry on. The table @@ -1641,6 +1757,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return nil var usesSlots = false var usesCalls = false + var usesHostCalls = false ## The offsets this region covers, and where each one's block sits. ## A call brings the callee's whole body in with it. @@ -1745,9 +1862,15 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, # interpreter stops, but never beyond it. if int(target) < 0 or int(target) > code.len: return nil - if item.op in {CallOp, ReturnOp, ExitSubOp}: + if item.op in {CallOp, ReturnOp, ExitSubOp, HostCallOp}: usesCalls = true usesSlots = true + if item.op == HostCallOp: + # A host function may use any register a callee is allowed to, + # so nothing loop-invariant may be left in one across the call. + usesHostCalls = true + if item.a >= 0 and (item.a < 0 or int(item.a) >= slots): + return nil var calleeId = 0'i32 if item.calledRoutine(calleeId): let callee = routines[int(calleeId)] @@ -1794,7 +1917,8 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, let spending = CountedLoops and plan.spending and not plan.counted and not usesCalls let pool = - if MaxPooled > 0: pooledConstants(code, start, stop, MaxPooled) + if MaxPooled > 0 and not usesHostCalls: + pooledConstants(code, start, stop, MaxPooled) else: @[] proc poolSlot(value: int32): int {.closure, raises: [].} = @@ -1964,6 +2088,19 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.compareScratch(0, 1) emitter.answerCondition(0, comparisonTest(item.op)) emitter.writeSlot(0, item.a) + of DivideOp: + when ModelsFixed and ModelsDivide: + # Either kind may appear on either side here, unlike adding, + # because both are widened to the same thing before dividing. + emitter.readNumeric(0, item.b, leaveHere) + emitter.widenToFixed(0, leaveHere) + emitter.readNumeric(1, item.c, leaveHere) + emitter.widenToFixed(1, leaveHere) + emitter.divideFixed(leaveHere) + emitter.setSlotKind(item.a, FixedTag) + emitter.writeSlotPayload(0, item.a) + else: + return nil of ModuloOp, IntegerDivideOp: # Both want whole numbers, both refuse a zero divisor, and minus # one would trap on one of the two architectures, so all three go @@ -1996,7 +2133,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.remainderScratch(0, 1) emitter.hoistedFromScratch(slotOf(item.a), 0) of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, - CallOp, ReturnOp, ExitSubOp: + CallOp, ReturnOp, ExitSubOp, HostCallOp: when ModelsCalls: case item.op of SetArgumentOp: @@ -2012,6 +2149,11 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, int32(index + 1), limits, leaveHere ) emitter.branch(blockAt(callee.entry)) + of HostCallOp: + emitter.callHost( + item.b, item.a, + exitLabel(int32(index), NativeFailed, index) + ) else: emitter.leaveRoutine(leaveHere) else: diff --git a/tests/test_arm64.nim b/tests/test_arm64.nim index c3e44af..b5ac47b 100644 --- a/tests/test_arm64.nim +++ b/tests/test_arm64.nim @@ -130,6 +130,17 @@ encodes "tst w3, #7": encodes "tst x4, #0xffff": assembler.testLowBits(Word64, x4, 16) +encodes "sxtw x5, w6": + assembler.signExtendWord(x5, x6) +encodes "lsl x7, x8, #16": + assembler.shiftLeftImmediate(Word64, x7, x8, 16) +encodes "lsl w9, w10, #3": + assembler.shiftLeftImmediate(Word32, x9, x10, 3) +encodes "lsr x11, x12, #1": + assembler.shiftRightImmediate(Word64, x11, x12, 1) +encodes "lsr w13, w14, #8": + assembler.shiftRightImmediate(Word32, x13, x14, 8) + ## Memory encodes "ldr w5, [x6, #12]": diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index ea8acb6..9943f43 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -651,6 +651,57 @@ while i < 100000 wend """, maximum = 977) +agrees("dividing between array cells", """ +dim cells(63) +i = 0 +while i < 64 + cells(i) = 100.0 + i = i + 1 +wend +i = 1 +while i < 64 + cells(i) = cells(i) / cells(i - 1) + cells(i) = cells(i) + 99.5 + i = i + 1 +wend +""") + +agrees("dividing negatives between cells", """ +dim cells(63) +i = 0 +while i < 64 + cells(i) = 0.0 - 7.25 + i = i + 1 +wend +i = 1 +while i < 64 + cells(i) = cells(i) / cells(i - 1) + cells(i) = cells(i) - 8.25 + i = i + 1 +wend +""") + +agrees("dividing by zero part way", """ +d = 3 +total = 0.0 +i = 0 +while i < 10 + total = total + 100 / d + d = d - 1 + i = i + 1 +wend +""") + +agrees("dividing a whole too large to widen", """ +n = 40000 +total = 0.0 +i = 0 +while i < 10 + total = total + n / 2 + i = i + 1 +wend +""") + ## Generated scripts proc generated(seed: int64): string = From 4e25b627e6b772c2359fa7ac5a811481a1d74054 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 06:53:12 -0700 Subject: [PATCH 23/51] reach globals in memory when a loop has too many to hoist --- src/bassy/jit.nim | 204 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 184 insertions(+), 20 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index eb6a01b..f08f501 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -28,6 +28,9 @@ const ## Dividing to a fixed-point answer is written for AArch64 so far. ModelsDivide* = NativeArm64 + ## Reaching globals where they sit is written for AArch64 so far. + ModelsMemoryGlobals* = NativeArm64 + when NativeArm64: import arm64 elif NativeAmd64: @@ -253,6 +256,26 @@ proc isCompilable(item: Instruction): bool {.raises: [].} = else: false +proc suitsMemoryGlobals(item: Instruction): bool {.raises: [].} = + ## Reports whether an operation has a form that reaches globals where + ## they sit. Anything touching a global without one keeps the loop out. + case item.op + of LoadGlobalOp, StoreGlobalOp, MoveGlobalOp, SetArgumentGlobalOp, + StoreGlobalImmediateOp, AddGlobalImmediateOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + true + of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp, + ModuloGlobalImmediateOp, ArrayAddGlobalsOp, + AddGlobalArrayGlobalIndexOp, JumpUnlessGlobalModuloEqualZeroOp: + false + else: + true + proc touchedGlobals(item: Instruction, globals: var seq[int32]) {.raises: [].} = ## Records every global one operation reads or writes. @@ -1187,6 +1210,8 @@ when NativeArm64: proc resumeAtStoredOffset(emitter: var Assembler, hoistedFor: seq[int32]) {.raises: [BasicError].} = + ## The list is empty when globals were left in memory, so nothing is + ## written back; the registers it would name were never filled. ## Hands control back at the offset already written to the context, ## which is where a return lands when this region did not compile it. for slot, index in hoistedFor: @@ -1199,6 +1224,100 @@ when NativeArm64: emitter.loadImmediate(Word32, Context, int64(ord(NativeCompleted))) emitter.endRegion() + ## Globals left where they are + ## + ## A loop touching more globals than there are registers to hold them + ## keeps them all in memory instead. Every access then costs a load or + ## a store, but the loop compiles at all, which the alternative did not. + + proc copyValue(emitter: var Assembler, fromBase: Register, fromOffset: int, + toBase: Register, toOffset: int) {.raises: [BasicError].} = + ## Moves one value entire, whatever kind it holds. + emitter.loadDouble(ValueScratch[0], fromBase, fromOffset) + emitter.loadDouble(ValueScratch[1], fromBase, fromOffset + ValuePayload) + emitter.storeDouble(ValueScratch[0], toBase, toOffset) + emitter.storeDouble(ValueScratch[1], toBase, toOffset + ValuePayload) + + proc copyGlobalToSlot(emitter: var Assembler, index, slot: int32) + {.raises: [BasicError].} = + ## Reads a global into a frame slot. + emitter.copyValue(GlobalsBase, int(index) * ValueStride, + RegistersBase, int(slot) * ValueStride) + + proc copySlotToGlobal(emitter: var Assembler, slot, index: int32) + {.raises: [BasicError].} = + ## Writes a frame slot into a global. + emitter.copyValue(RegistersBase, int(slot) * ValueStride, + GlobalsBase, int(index) * ValueStride) + + proc copyGlobalToGlobal(emitter: var Assembler, destination, source: int32) + {.raises: [BasicError].} = + ## Copies one global into another. + emitter.copyValue(GlobalsBase, int(source) * ValueStride, + GlobalsBase, int(destination) * ValueStride) + + proc copyGlobalToArgument(emitter: var Assembler, argument, index: int32) + {.raises: [BasicError].} = + ## Stages a global as an argument. + emitter.loadDouble(Scratch, Context, ContextArguments) + emitter.copyValue(GlobalsBase, int(index) * ValueStride, + Scratch, int(argument) * ValueStride) + + proc setGlobalWhole(emitter: var Assembler, index: int32, value: int32) + {.raises: [BasicError].} = + ## Writes a whole number into a global. + let base = int(index) * ValueStride + emitter.storeByte(zeroRegister, GlobalsBase, base) + emitter.loadImmediate(Word32, ValueScratch[0], int64(value)) + emitter.storeWord(ValueScratch[0], GlobalsBase, base + ValuePayload) + + proc readGlobalWhole(emitter: var Assembler, scratch: int, index: int32, + leave: Label) {.raises: [BasicError].} = + ## Reads a global as a whole number, leaving the region if it is not. + let base = int(index) * ValueStride + emitter.loadByte(Scratch, GlobalsBase, base) + emitter.branchIfNotZero(Word32, Scratch, leave) + emitter.loadWord(ValueScratch[scratch], GlobalsBase, base + ValuePayload) + + proc addToGlobal(emitter: var Assembler, index: int32, value: int32, + leave: Label) {.raises: [BasicError].} = + ## Adds a constant to a global of either kind. A whole number added to + ## a fixed-point one has to become fixed point first, which only works + ## inside its range, so outside that only whole numbers are taken. + let base = int(index) * ValueStride + let widens = value >= -32768 and value <= 32767 + emitter.loadByte(Scratch, GlobalsBase, base) + if widens: + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(UnsignedGreaterCondition, leave) + else: + emitter.branchIfNotZero(Word32, Scratch, leave) + emitter.loadWord(ValueScratch[0], GlobalsBase, base + ValuePayload) + if widens: + let fixedWay = emitter.label() + let done = emitter.label() + emitter.compareImmediate(Word32, Scratch, FixedTag) + emitter.branchIf(EqualCondition, fixedWay) + emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) + emitter.branch(done) + emitter.place(fixedWay) + emitter.loadImmediate(Word32, ValueScratch[1], + int64(value) * 65536) + emitter.place(done) + else: + emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) + emitter.addRegister(Word32, ValueScratch[0], ValueScratch[0], + ValueScratch[1]) + emitter.storeWord(ValueScratch[0], GlobalsBase, base + ValuePayload) + + proc compareGlobalWhole(emitter: var Assembler, index: int32, + value: int32, leave: Label) {.raises: [BasicError].} = + ## Sets flags from a global against a whole number, leaving the region + ## unless the global is a whole number too. + emitter.readGlobalWhole(0, index, leave) + emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) + emitter.compareRegister(Word32, ValueScratch[0], ValueScratch[1]) + proc setSlotKind(emitter: var Assembler, slot: int32, tag: int) {.raises: [BasicError].} = ## Says what kind a slot now holds, leaving its payload alone. @@ -1879,9 +1998,24 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, return nil if callee.registers > MaxClearedSlots: return nil - if hoisted.len == 0 or hoisted.len > MaxHoistedGlobals: - return nil - for index in hoisted: + ## Too many globals to hold in registers means holding none of them + ## there. Only the operations written for memory are taken then, and + ## only where the generator has them. + let hoisting = hoisted.len > 0 and hoisted.len <= MaxHoistedGlobals + if not hoisting: + when not ModelsMemoryGlobals: + return nil + else: + for member in members: + if not code[int(member)].suitsMemoryGlobals: + return nil + var reached: seq[int32] + if hoisting: + reached = hoisted + else: + for member in members: + code[int(member)].touchedGlobals(reached) + for index in reached: if index < 0 or int(index) >= globals: return nil if int(index) > (MaxDisplacementBytes - ValuePayload) div ValueStride: @@ -1889,7 +2023,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, when NativeArm64: # The tag is read through a scaled byte offset, which is narrower # than the range the bounds check above already allows. - for index in hoisted: + for index in reached: if int(index) * ValueStride + ValuePayload > 4095: return nil @@ -1958,10 +2092,11 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.startRegion() if usesSlots: emitter.loadRegistersBase() - for slot, index in hoisted: - let base = int(index) * ValueStride - emitter.guardInteger(base, guardFailed) - emitter.loadHoisted(slot, base) + if hoisting: + for slot, index in hoisted: + let base = int(index) * ValueStride + emitter.guardInteger(base, guardFailed) + emitter.loadHoisted(slot, base) when CountedLoops: for slot, value in pool: emitter.loadPooled(slot, value) @@ -2026,11 +2161,23 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, exitLabel(int32(index), NativeExhausted, index) ) of StoreGlobalImmediateOp: - emitter.setSlot(slotOf(item.a), item.b) + if hoisting: + emitter.setSlot(slotOf(item.a), item.b) + else: + when ModelsMemoryGlobals: + emitter.setGlobalWhole(item.a, item.b) of MoveGlobalOp: - emitter.copySlot(slotOf(item.a), slotOf(item.b)) + if hoisting: + emitter.copySlot(slotOf(item.a), slotOf(item.b)) + else: + when ModelsMemoryGlobals: + emitter.copyGlobalToGlobal(item.a, item.b) of AddGlobalImmediateOp: - emitter.addToSlot(slotOf(item.a), item.b) + if hoisting: + emitter.addToSlot(slotOf(item.a), item.b) + else: + when ModelsMemoryGlobals: + emitter.addToGlobal(item.a, item.b, leaveHere) of AddGlobalOp: emitter.addSlots(slotOf(item.a), slotOf(item.b)) of LoadImmediateOp: @@ -2040,11 +2187,19 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, emitter.readSlot(0, item.b, leaveHere) emitter.writeSlot(0, item.a) of LoadGlobalOp: - emitter.scratchFromHoisted(0, slotOf(item.b)) - emitter.writeSlot(0, item.a) + if hoisting: + emitter.scratchFromHoisted(0, slotOf(item.b)) + emitter.writeSlot(0, item.a) + else: + when ModelsMemoryGlobals: + emitter.copyGlobalToSlot(item.b, item.a) of StoreGlobalOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.hoistedFromScratch(slotOf(item.a), 0) + if hoisting: + emitter.readSlot(0, item.b, leaveHere) + emitter.hoistedFromScratch(slotOf(item.a), 0) + else: + when ModelsMemoryGlobals: + emitter.copySlotToGlobal(item.b, item.a) of AddOp, SubtractOp: # Whole and fixed-point numbers add and subtract through the same # instructions, so one path serves both and the answer keeps the @@ -2141,7 +2296,11 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, of SetArgumentImmediateOp: emitter.stageArgumentWhole(item.a, item.b) of SetArgumentGlobalOp: - emitter.stageArgumentFromHoisted(item.a, slotOf(item.b)) + if hoisting: + emitter.stageArgumentFromHoisted(item.a, slotOf(item.b)) + else: + when ModelsMemoryGlobals: + emitter.copyGlobalToArgument(item.a, item.b) of CallOp: let callee = routines[int(item.a)] emitter.enterRoutine( @@ -2202,7 +2361,11 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, JumpUnlessGlobalLessEqualImmediateOp, JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: - emitter.compareSlot(slotOf(item.a), item.b, poolSlot(item.b)) + if hoisting: + emitter.compareSlot(slotOf(item.a), item.b, poolSlot(item.b)) + else: + when ModelsMemoryGlobals: + emitter.compareGlobalWhole(item.a, item.b, leaveHere) branchOut(item.c, takenOn(item.op)) of JumpUnlessGlobalModuloEqualZeroOp: # Dividing by one or minus one always leaves no remainder, and @@ -2232,8 +2395,9 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, ## Shared exit: publish the hoisted globals and budgets, then return. emitter.place(writeback) - for slot, index in hoisted: - emitter.storeHoisted(slot, int(index) * ValueStride) + if hoisting: + for slot, index in hoisted: + emitter.storeHoisted(slot, int(index) * ValueStride) emitter.publishState() emitter.endRegion() @@ -2243,7 +2407,7 @@ proc compileRegion*(code: seq[Instruction], start, stop, globals, when ModelsCalls: if usesCalls: emitter.place(resumeElsewhere) - emitter.resumeAtStoredOffset(hoisted) + emitter.resumeAtStoredOffset(if hoisting: hoisted else: @[]) emitter.place(guardFailed) emitter.guardExit(int32(start)) From 8f662b70cd95620800db32a4966c050b2c8d4326 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 06:56:12 -0700 Subject: [PATCH 24/51] share one instruction body between interpreter and native code --- src/bassy.nim | 670 +++++++++++++++++++++++++------------------------- 1 file changed, 340 insertions(+), 330 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index f57fb09..34765ce 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3615,6 +3615,344 @@ proc runHostCall(context: ptr NativeContext, functionId, runtime.hostFailure = error.msg 1'i32 +template chargeMeter(runtime: Runtime, item: Instruction) = + ## Charges one block's budgets, refusing before charging either. + let + cost = int64(item.a) + instructionCount = int64(item.b) + if runtime.remainingInstructions < instructionCount or + runtime.remainingWork < cost: + if runtime.remainingInstructions < instructionCount: + fail("BASIC instruction limit exceeded") + fail("BASIC work limit exceeded") + runtime.remainingInstructions -= instructionCount + runtime.remainingWork -= cost + +template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = + ## Executes one instruction after its block was charged. The interpreter + ## loop and compiled code's slow path both expand this, so the two run + ## the very same code and cannot come to disagree. + template register(index: int32): untyped = + runtime.registers[int(runtime.base + index)] + case item.op + of MeterOp: + fail("BASIC bytecode contains consecutive meter instructions") + of LoadStringOp: + var handle = runtime.stringLiterals[int(item.b)] + if handle < 0: + let text = runtime.program.literals[int(item.b)] + runtime.chargeWork(int64(text.len)) + handle = runtime.strings.put(text).stringHandle + runtime.stringLiterals[int(item.b)] = handle + register(item.a) = stringValue(runtime.strings.empty.stringOwner, handle) + inc runtime.pc + of TextCallOp: + register(item.a) = runtime.textCall(TextFunction(item.b), int(item.c)) + inc runtime.pc + of LoadFixedOp: + register(item.a) = toValue(runtime.program.fixedValues[int(item.b)]) + inc runtime.pc + of LoadImmediateOp: + register(item.a) = item.b + inc runtime.pc + of MoveOp: + register(item.a) = register(item.b) + inc runtime.pc + of LoadGlobalOp: + register(item.a) = runtime.globals[int(item.b)] + inc runtime.pc + of LoadHostDataOp: + register(item.a) = runtime.hostData[int(item.b)] + inc runtime.pc + of StoreGlobalOp: + runtime.globals[int(item.a)] = register(item.b) + inc runtime.pc + of StoreGlobalImmediateOp: + runtime.globals[int(item.a)] = item.b + inc runtime.pc + of MoveGlobalOp: + runtime.globals[int(item.a)] = runtime.globals[int(item.b)] + inc runtime.pc + of AddGlobalImmediateOp: + runtime.globals[int(item.a)] = + runtime.addValue(runtime.globals[int(item.a)], item.b) + inc runtime.pc + of AddGlobalOp: + runtime.globals[int(item.a)] = + runtime.addValue( + runtime.globals[int(item.a)], + runtime.globals[int(item.b)] + ) + inc runtime.pc + of AddGlobalHostDataOp: + runtime.globals[int(item.a)] = + runtime.addValue( + runtime.globals[int(item.a)], + runtime.hostData[int(item.b)] + ) + inc runtime.pc + of AddGlobalRegisterOp: + runtime.globals[int(item.a)] = + runtime.addValue(runtime.globals[int(item.a)], register(item.b)) + inc runtime.pc + of ModuloGlobalImmediateOp: + runtime.globals[int(item.a)] = `mod`( + runtime.globals[int(item.b)], + item.c + ) + inc runtime.pc + of AddGlobalArrayGlobalIndexOp: + let index = runtime.checkedArrayIndex( + item.b, + runtime.globals[int(item.c)] + ) + runtime.globals[int(item.a)] = + runtime.addValue(runtime.globals[int(item.a)], runtime.memory[index]) + inc runtime.pc + of AddOp: + register(item.a) = runtime.addValue(register(item.b), register(item.c)) + inc runtime.pc + of SubtractOp: + register(item.a) = register(item.b) - register(item.c) + inc runtime.pc + of MultiplyOp: + register(item.a) = register(item.b) * register(item.c) + inc runtime.pc + of DivideOp: + register(item.a) = register(item.b) / register(item.c) + inc runtime.pc + of IntegerDivideOp: + register(item.a) = register(item.b) div register(item.c) + inc runtime.pc + of ModuloOp: + register(item.a) = `mod`(register(item.b), register(item.c)) + inc runtime.pc + of NegateOp: + register(item.a) = -register(item.b) + inc runtime.pc + of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: + register(item.a) = runtime.compareValue( + item.op, + register(item.b), + register(item.c) + ) + inc runtime.pc + of AndOp: + register(item.a) = register(item.b) and register(item.c) + inc runtime.pc + of OrOp: + register(item.a) = register(item.b) or register(item.c) + inc runtime.pc + of XorOp: + register(item.a) = register(item.b) xor register(item.c) + inc runtime.pc + of EqvOp: + register(item.a) = eqv(register(item.b), register(item.c)) + inc runtime.pc + of ImpOp: + register(item.a) = imp(register(item.b), register(item.c)) + inc runtime.pc + of NotOp: + register(item.a) = not register(item.b) + inc runtime.pc + of JumpOp: + runtime.pc = item.a + of JumpIfZeroOp: + if register(item.a) == 0: + runtime.pc = item.b + else: + inc runtime.pc + of JumpUnlessGlobalEqualImmediateOp: + if runtime.globals[int(item.a)] != item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalNotEqualImmediateOp: + if runtime.globals[int(item.a)] == item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalLessImmediateOp: + if runtime.globals[int(item.a)] >= item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalLessEqualImmediateOp: + if runtime.globals[int(item.a)] > item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalGreaterImmediateOp: + if runtime.globals[int(item.a)] <= item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalGreaterEqualImmediateOp: + if runtime.globals[int(item.a)] < item.b: + runtime.pc = item.c + else: + inc runtime.pc + of JumpUnlessGlobalModuloEqualZeroOp: + if `mod`(runtime.globals[int(item.a)], item.b) != 0: + runtime.pc = item.c + else: + inc runtime.pc + of ArrayGetOp: + let index = runtime.checkedArrayIndex(item.b, register(item.c)) + register(item.a) = runtime.memory[index] + inc runtime.pc + of ArraySetOp: + let index = runtime.checkedArrayIndex(item.a, register(item.b)) + runtime.memory[index] = register(item.c) + inc runtime.pc + of ArrayAddGlobalsOp: + let index = runtime.checkedArrayIndex( + item.a, + runtime.globals[int(item.b)] + ) + runtime.memory[index] = + runtime.addValue(runtime.memory[index], runtime.globals[int(item.c)]) + inc runtime.pc + of SetArgumentOp: + runtime.arguments[int(item.a)] = register(item.b) + inc runtime.pc + of SetArgumentImmediateOp: + runtime.arguments[int(item.a)] = item.b + inc runtime.pc + of SetArgumentGlobalOp: + runtime.arguments[int(item.a)] = runtime.globals[int(item.b)] + inc runtime.pc + of HostCallOp: + let + functionId = int(item.b) + count = int( + runtime.program.hostFunctions[functionId].parameters + ) + callback = runtime.hostCallbacks[functionId] + var value: Value + if callback.numeric != nil: + if count == 0: + value = callback.numeric([]) + else: + value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) + else: + for i in 0 ..< count: + runtime.integerArguments[i] = runtime.arguments[i].asInt + if count == 0: + value = callback.integer(EmptyArguments) + else: + value = callback.integer( + runtime.integerArguments.toOpenArray(0, count - 1) + ) + runtime.requireValue(value) + requireType(runtime.program.hostFunctions[functionId].name, value) + if item.a >= 0: + register(item.a) = value + inc runtime.pc + of CallOp, GosubOp: + if runtime.depth + 1 >= int32(runtime.frames.len): + fail("BASIC call depth limit exceeded") + let + callee = + if item.op == GosubOp: + runtime.routine + else: + item.a + calleeRegisters = + runtime.program.routines[int(callee)].registerCount + calleeParameters = + int(runtime.program.routines[int(callee)].parameterCount) + calleeEntry = + if item.op == GosubOp: + item.a + else: + runtime.program.routines[int(callee)].entry + callerRegisters = + runtime.program.routines[int(runtime.routine)].registerCount + nextBase = runtime.base + callerRegisters + nextEnd = nextBase + calleeRegisters + if nextEnd > int32(runtime.registers.len): + fail("BASIC register stack exceeds its memory limit") + runtime.frames[int(runtime.depth)] = Frame( + base: runtime.base, + routine: runtime.routine, + returnPc: runtime.pc + 1, + kind: + if item.op == GosubOp: + GosubFrame + else: + SubFrame + ) + if calleeRegisters > 0: + if item.op == GosubOp: + copyMem( + addr runtime.registers[int(nextBase)], + addr runtime.registers[int(runtime.base)], + int(calleeRegisters) * sizeof(Value) + ) + else: + zeroMem( + addr runtime.registers[int(nextBase)], + int(calleeRegisters) * sizeof(Value) + ) + if item.op == CallOp: + for i in 0 ..< calleeParameters: + runtime.registers[int(nextBase) + i] = runtime.arguments[i] + inc runtime.depth + runtime.base = nextBase + runtime.routine = callee + runtime.pc = calleeEntry + of ReturnOp: + runtime.leaveFrame + of ReturnLabelOp: + if runtime.depth == 0 or + runtime.frames[int(runtime.depth) - 1].kind != GosubFrame: + fail("BASIC RETURN label without GOSUB") + runtime.leaveFrame + runtime.pc = item.a + of ExitSubOp: + while runtime.depth > 0 and + runtime.frames[int(runtime.depth) - 1].kind == GosubFrame: + runtime.leaveFrame + runtime.leaveFrame + of HaltOp: + runtime.finished = true + of PrintTextOp: + runtime.chargePrint( + int64(runtime.program.literals[int(item.a)].len) + ) + if print != nil: + print(PrintEvent( + kind: TextPrint, + text: runtime.program.literals[int(item.a)] + )) + inc runtime.pc + of PrintValueOp: + let value = register(item.a) + if value.kind == StringValue: + let length = int64(runtime.strings.length(value)) + runtime.chargeWork(length) + runtime.chargePrint(length) + if print != nil: + print(PrintEvent(kind: TextPrint, text: runtime.strings.get(value))) + elif value.kind == FixedValue: + let text = $value + runtime.chargePrint(int64(text.len)) + if print != nil: + print(PrintEvent( + kind: FixedPrint, fixedValue: value.asFixed, text: text + )) + else: + runtime.chargePrint(printedIntegerBytes(value.asInt)) + if print != nil: + print(PrintEvent(kind: ValuePrint, value: value.asInt)) + inc runtime.pc + of PrintNewlineOp: + runtime.chargePrint(1) + if print != nil: + print(PrintEvent(kind: NewlinePrint)) + inc runtime.pc + proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = ## Executes verified bytecode with bounded work, memory, calls, and output. if runtime.finished: @@ -3624,8 +3962,6 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = startWork = runtime.remainingWork startBytes = runtime.printedBytes startEvents = runtime.printedEvents - template register(index: int32): untyped = - runtime.registers[int(runtime.base + index)] template fetch(): Instruction = runtime.program.code[int(runtime.pc)] while not runtime.finished: @@ -3686,336 +4022,10 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = runtime.regionAt[int(region.start)] = nil runtime.bypass = -1 continue - let - cost = int64(item.a) - instructionCount = int64(item.b) - if runtime.remainingInstructions < instructionCount or - runtime.remainingWork < cost: - if runtime.remainingInstructions < instructionCount: - fail("BASIC instruction limit exceeded") - fail("BASIC work limit exceeded") - runtime.remainingInstructions -= instructionCount - runtime.remainingWork -= cost + runtime.chargeMeter(item) inc runtime.pc item = fetch() - case item.op - of MeterOp: - fail("BASIC bytecode contains consecutive meter instructions") - of LoadStringOp: - var handle = runtime.stringLiterals[int(item.b)] - if handle < 0: - let text = runtime.program.literals[int(item.b)] - runtime.chargeWork(int64(text.len)) - handle = runtime.strings.put(text).stringHandle - runtime.stringLiterals[int(item.b)] = handle - register(item.a) = stringValue(runtime.strings.empty.stringOwner, handle) - inc runtime.pc - of TextCallOp: - register(item.a) = runtime.textCall(TextFunction(item.b), int(item.c)) - inc runtime.pc - of LoadFixedOp: - register(item.a) = toValue(runtime.program.fixedValues[int(item.b)]) - inc runtime.pc - of LoadImmediateOp: - register(item.a) = item.b - inc runtime.pc - of MoveOp: - register(item.a) = register(item.b) - inc runtime.pc - of LoadGlobalOp: - register(item.a) = runtime.globals[int(item.b)] - inc runtime.pc - of LoadHostDataOp: - register(item.a) = runtime.hostData[int(item.b)] - inc runtime.pc - of StoreGlobalOp: - runtime.globals[int(item.a)] = register(item.b) - inc runtime.pc - of StoreGlobalImmediateOp: - runtime.globals[int(item.a)] = item.b - inc runtime.pc - of MoveGlobalOp: - runtime.globals[int(item.a)] = runtime.globals[int(item.b)] - inc runtime.pc - of AddGlobalImmediateOp: - runtime.globals[int(item.a)] = - runtime.addValue(runtime.globals[int(item.a)], item.b) - inc runtime.pc - of AddGlobalOp: - runtime.globals[int(item.a)] = - runtime.addValue( - runtime.globals[int(item.a)], - runtime.globals[int(item.b)] - ) - inc runtime.pc - of AddGlobalHostDataOp: - runtime.globals[int(item.a)] = - runtime.addValue( - runtime.globals[int(item.a)], - runtime.hostData[int(item.b)] - ) - inc runtime.pc - of AddGlobalRegisterOp: - runtime.globals[int(item.a)] = - runtime.addValue(runtime.globals[int(item.a)], register(item.b)) - inc runtime.pc - of ModuloGlobalImmediateOp: - runtime.globals[int(item.a)] = `mod`( - runtime.globals[int(item.b)], - item.c - ) - inc runtime.pc - of AddGlobalArrayGlobalIndexOp: - let index = runtime.checkedArrayIndex( - item.b, - runtime.globals[int(item.c)] - ) - runtime.globals[int(item.a)] = - runtime.addValue(runtime.globals[int(item.a)], runtime.memory[index]) - inc runtime.pc - of AddOp: - register(item.a) = runtime.addValue(register(item.b), register(item.c)) - inc runtime.pc - of SubtractOp: - register(item.a) = register(item.b) - register(item.c) - inc runtime.pc - of MultiplyOp: - register(item.a) = register(item.b) * register(item.c) - inc runtime.pc - of DivideOp: - register(item.a) = register(item.b) / register(item.c) - inc runtime.pc - of IntegerDivideOp: - register(item.a) = register(item.b) div register(item.c) - inc runtime.pc - of ModuloOp: - register(item.a) = `mod`(register(item.b), register(item.c)) - inc runtime.pc - of NegateOp: - register(item.a) = -register(item.b) - inc runtime.pc - of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: - register(item.a) = runtime.compareValue( - item.op, - register(item.b), - register(item.c) - ) - inc runtime.pc - of AndOp: - register(item.a) = register(item.b) and register(item.c) - inc runtime.pc - of OrOp: - register(item.a) = register(item.b) or register(item.c) - inc runtime.pc - of XorOp: - register(item.a) = register(item.b) xor register(item.c) - inc runtime.pc - of EqvOp: - register(item.a) = eqv(register(item.b), register(item.c)) - inc runtime.pc - of ImpOp: - register(item.a) = imp(register(item.b), register(item.c)) - inc runtime.pc - of NotOp: - register(item.a) = not register(item.b) - inc runtime.pc - of JumpOp: - runtime.pc = item.a - of JumpIfZeroOp: - if register(item.a) == 0: - runtime.pc = item.b - else: - inc runtime.pc - of JumpUnlessGlobalEqualImmediateOp: - if runtime.globals[int(item.a)] != item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalNotEqualImmediateOp: - if runtime.globals[int(item.a)] == item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalLessImmediateOp: - if runtime.globals[int(item.a)] >= item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalLessEqualImmediateOp: - if runtime.globals[int(item.a)] > item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalGreaterImmediateOp: - if runtime.globals[int(item.a)] <= item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalGreaterEqualImmediateOp: - if runtime.globals[int(item.a)] < item.b: - runtime.pc = item.c - else: - inc runtime.pc - of JumpUnlessGlobalModuloEqualZeroOp: - if `mod`(runtime.globals[int(item.a)], item.b) != 0: - runtime.pc = item.c - else: - inc runtime.pc - of ArrayGetOp: - let index = runtime.checkedArrayIndex(item.b, register(item.c)) - register(item.a) = runtime.memory[index] - inc runtime.pc - of ArraySetOp: - let index = runtime.checkedArrayIndex(item.a, register(item.b)) - runtime.memory[index] = register(item.c) - inc runtime.pc - of ArrayAddGlobalsOp: - let index = runtime.checkedArrayIndex( - item.a, - runtime.globals[int(item.b)] - ) - runtime.memory[index] = - runtime.addValue(runtime.memory[index], runtime.globals[int(item.c)]) - inc runtime.pc - of SetArgumentOp: - runtime.arguments[int(item.a)] = register(item.b) - inc runtime.pc - of SetArgumentImmediateOp: - runtime.arguments[int(item.a)] = item.b - inc runtime.pc - of SetArgumentGlobalOp: - runtime.arguments[int(item.a)] = runtime.globals[int(item.b)] - inc runtime.pc - of HostCallOp: - let - functionId = int(item.b) - count = int( - runtime.program.hostFunctions[functionId].parameters - ) - callback = runtime.hostCallbacks[functionId] - var value: Value - if callback.numeric != nil: - if count == 0: - value = callback.numeric([]) - else: - value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) - else: - for i in 0 ..< count: - runtime.integerArguments[i] = runtime.arguments[i].asInt - if count == 0: - value = callback.integer(EmptyArguments) - else: - value = callback.integer( - runtime.integerArguments.toOpenArray(0, count - 1) - ) - runtime.requireValue(value) - requireType(runtime.program.hostFunctions[functionId].name, value) - if item.a >= 0: - register(item.a) = value - inc runtime.pc - of CallOp, GosubOp: - if runtime.depth + 1 >= int32(runtime.frames.len): - fail("BASIC call depth limit exceeded") - let - callee = - if item.op == GosubOp: - runtime.routine - else: - item.a - calleeRegisters = - runtime.program.routines[int(callee)].registerCount - calleeParameters = - int(runtime.program.routines[int(callee)].parameterCount) - calleeEntry = - if item.op == GosubOp: - item.a - else: - runtime.program.routines[int(callee)].entry - callerRegisters = - runtime.program.routines[int(runtime.routine)].registerCount - nextBase = runtime.base + callerRegisters - nextEnd = nextBase + calleeRegisters - if nextEnd > int32(runtime.registers.len): - fail("BASIC register stack exceeds its memory limit") - runtime.frames[int(runtime.depth)] = Frame( - base: runtime.base, - routine: runtime.routine, - returnPc: runtime.pc + 1, - kind: - if item.op == GosubOp: - GosubFrame - else: - SubFrame - ) - if calleeRegisters > 0: - if item.op == GosubOp: - copyMem( - addr runtime.registers[int(nextBase)], - addr runtime.registers[int(runtime.base)], - int(calleeRegisters) * sizeof(Value) - ) - else: - zeroMem( - addr runtime.registers[int(nextBase)], - int(calleeRegisters) * sizeof(Value) - ) - if item.op == CallOp: - for i in 0 ..< calleeParameters: - runtime.registers[int(nextBase) + i] = runtime.arguments[i] - inc runtime.depth - runtime.base = nextBase - runtime.routine = callee - runtime.pc = calleeEntry - of ReturnOp: - runtime.leaveFrame - of ReturnLabelOp: - if runtime.depth == 0 or - runtime.frames[int(runtime.depth) - 1].kind != GosubFrame: - fail("BASIC RETURN label without GOSUB") - runtime.leaveFrame - runtime.pc = item.a - of ExitSubOp: - while runtime.depth > 0 and - runtime.frames[int(runtime.depth) - 1].kind == GosubFrame: - runtime.leaveFrame - runtime.leaveFrame - of HaltOp: - runtime.finished = true - of PrintTextOp: - runtime.chargePrint( - int64(runtime.program.literals[int(item.a)].len) - ) - if print != nil: - print(PrintEvent( - kind: TextPrint, - text: runtime.program.literals[int(item.a)] - )) - inc runtime.pc - of PrintValueOp: - let value = register(item.a) - if value.kind == StringValue: - let length = int64(runtime.strings.length(value)) - runtime.chargeWork(length) - runtime.chargePrint(length) - if print != nil: - print(PrintEvent(kind: TextPrint, text: runtime.strings.get(value))) - elif value.kind == FixedValue: - let text = $value - runtime.chargePrint(int64(text.len)) - if print != nil: - print(PrintEvent( - kind: FixedPrint, fixedValue: value.asFixed, text: text - )) - else: - runtime.chargePrint(printedIntegerBytes(value.asInt)) - if print != nil: - print(PrintEvent(kind: ValuePrint, value: value.asInt)) - inc runtime.pc - of PrintNewlineOp: - runtime.chargePrint(1) - if print != nil: - print(PrintEvent(kind: NewlinePrint)) - inc runtime.pc + runtime.performOp(item, print) result = RunStats( instructions: startInstructions - runtime.remainingInstructions, workUnits: startWork - runtime.remainingWork, From c6f0f5543d7f203862b9f69498a6372a6e0dcc52 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:05:47 -0700 Subject: [PATCH 25/51] compile whole programs to arm64 machine code --- src/bassy.nim | 131 ++++- src/bassy/arm64.nim | 7 + src/bassy/jit.nim | 46 +- src/bassy/programs.nim | 1204 ++++++++++++++++++++++++++++++++++++++++ tests/test_native.nim | 578 +++++++++++++++++++ 5 files changed, 1941 insertions(+), 25 deletions(-) create mode 100644 src/bassy/programs.nim create mode 100644 tests/test_native.nim diff --git a/src/bassy.nim b/src/bassy.nim index 34765ce..79a2dc6 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -5,7 +5,7 @@ import std/[strutils, tables], - bassy/[bytecode, jit, numbers, texts] + bassy/[bytecode, jit, numbers, programs, texts] export bytecode, jit, numbers @@ -217,6 +217,10 @@ type regionAt: seq[Region] bypass: int32 hostFailure: string + machine: Machine + printer: PrintProc + nativeError: ref Exception + handedBack: int64 Expr = object text: bool @@ -2935,6 +2939,9 @@ proc clear(values: var seq[Value]) = if values.len > 0: zeroMem(addr values[0], values.len * sizeof(Value)) +proc compileNative*(runtime: var Runtime): int + ## Compiles this program to machine code; the body sits further down. + proc initRuntimeState( program: Program, host: Host, @@ -3052,6 +3059,12 @@ proc initRuntimeState( for i, function in program.hostFunctions: let id = host.functionIds.getOrDefault(function.name, -1'i32) result.hostCallbacks[i] = host.functions[int(id)].callback + when defined(bassyNative): + # Every runtime runs as machine code, so a whole test suite checks the + # two paths agree. A program the compiler refuses fails loudly here. + if jitSupported(): + doAssert result.compileNative() == program.code.len, + "native compilation refused this program" proc initRuntime*(program: Program, limits = defaultLimits()): Runtime = ## Allocates a runtime for a program without host bindings. @@ -3162,6 +3175,12 @@ proc fixedConstants*(program: Program): seq[int32] = for value in program.fixedValues: result.add(int32(value)) +proc handedBack*(runtime: Runtime): int64 {.inline.} = + ## Returns how many instructions compiled code has handed to the + ## interpreter's own code to run, because they were strings, printing, + ## host calls, or about to fail. Everything else ran as machine code. + runtime.handedBack + proc nativeRegions*(runtime: Runtime): int = ## Returns how many compiled loops are still active. for region in runtime.regionAt: @@ -3169,11 +3188,14 @@ proc nativeRegions*(runtime: Runtime): int = inc result proc compileNative*(runtime: var Runtime): int = - ## Compiles the hot integer loops of this program to machine code and - ## returns how many were accepted. Loops the compiler does not model are - ## left to the interpreter, so behavior never depends on the result. + ## Compiles this program to machine code and returns how many bytecode + ## offsets now run natively. The whole program is compiled where the + ## target has a backend for it, and otherwise only its hot loops. + ## Whatever is not compiled is left to the interpreter, so behavior + ## never depends on the result. runtime.bypass = -1 runtime.regionAt = @[] + runtime.machine = nil if not jitSupported(): return 0 if not frameLayoutMatches(): @@ -3183,6 +3205,22 @@ proc compileNative*(runtime: var Runtime): int = var extents = newSeq[ArrayExtent](runtime.program.arrays.len) for index, item in runtime.program.arrays: extents[index] = ArrayExtent(base: item.base, length: item.length) + let limits = CallLimits( + frames: int32(runtime.frames.len), + slots: int32(runtime.registers.len) + ) + runtime.machine = compileProgram( + runtime.program.code, + runtime.program.routineExtents, + extents, + runtime.program.fixedConstants, + runtime.globals.len, + runtime.hostData.len, + runtime.arguments.len, + limits + ) + if runtime.machine != nil: + return runtime.program.code.len runtime.regionAt = compileLoops( runtime.program.code, runtime.globals.len, @@ -3953,6 +3991,89 @@ template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = print(PrintEvent(kind: NewlinePrint)) inc runtime.pc +proc nativeStep(context: ptr NativeContext, pc: int32): int32 {.cdecl.} = + ## Runs one instruction for compiled code, through the very code the + ## interpreter runs. Compiled code keeps the frame and budgets in the + ## context, so they are carried in and back out around it. A failure + ## cannot travel back through frames compiled code built, so it is kept + ## here and raised again once compiled code has returned. + var runtime = cast[Runtime](context.runtime) + inc runtime.handedBack + runtime.pc = pc + runtime.base = context.base + runtime.depth = context.depth + runtime.routine = context.routine + runtime.remainingInstructions = context.remainingInstructions + runtime.remainingWork = context.remainingWork + try: + let item = runtime.program.code[int(pc)] + if item.op == MeterOp: + runtime.chargeMeter(item) + inc runtime.pc + else: + runtime.performOp(item, runtime.printer) + except Exception as error: + runtime.nativeError = error + return 1 + context.pc = runtime.pc + context.base = runtime.base + context.depth = runtime.depth + context.routine = runtime.routine + context.remainingInstructions = runtime.remainingInstructions + context.remainingWork = runtime.remainingWork + 0 + +proc runMachine(runtime: var Runtime, print: PrintProc) = + ## Runs the compiled program from wherever the runtime stands until it + ## halts, or raises whatever the interpreter's code raised on its way. + var context = NativeContext( + globals: + if runtime.globals.len == 0: nil + else: runtime.globals[0].addr, + registers: + if runtime.registers.len == 0: nil + else: runtime.registers[int(runtime.base)].addr, + memory: + if runtime.memory.len == 0: nil + else: runtime.memory[0].addr, + hostData: + if runtime.hostData.len == 0: nil + else: runtime.hostData[0].addr, + frames: + if runtime.frames.len == 0: nil + else: runtime.frames[0].addr, + arguments: + if runtime.arguments.len == 0: nil + else: runtime.arguments[0].addr, + registerFile: + if runtime.registers.len == 0: nil + else: runtime.registers[0].addr, + returnTable: runtime.machine.tableAddress, + base: runtime.base, + depth: runtime.depth, + routine: runtime.routine, + runtime: cast[pointer](runtime), + step: cast[pointer](nativeStep), + remainingInstructions: runtime.remainingInstructions, + remainingWork: runtime.remainingWork, + pc: runtime.pc + ) + runtime.printer = print + let status = runtime.machine.invoke(context) + runtime.printer = nil + if status == NativeFailed: + # The interpreter's code left the runtime exactly as it failed. + let error = runtime.nativeError + runtime.nativeError = nil + raise error + runtime.remainingInstructions = context.remainingInstructions + runtime.remainingWork = context.remainingWork + runtime.pc = context.pc + runtime.base = context.base + runtime.depth = context.depth + runtime.routine = context.routine + runtime.finished = true + proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = ## Executes verified bytecode with bounded work, memory, calls, and output. if runtime.finished: @@ -3964,6 +4085,8 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = startEvents = runtime.printedEvents template fetch(): Instruction = runtime.program.code[int(runtime.pc)] + if runtime.machine != nil: + runtime.runMachine(print) while not runtime.finished: var item = fetch() if item.op == MeterOp: diff --git a/src/bassy/arm64.nim b/src/bassy/arm64.nim index cc1849c..51409ad 100644 --- a/src/bassy/arm64.nim +++ b/src/bassy/arm64.nim @@ -541,3 +541,10 @@ proc shiftRightImmediate*(assembler: var Assembler, width: Width, base or (uint32(count) shl 16) or (uint32(size - 1) shl 10) or (source.number shl 5) or destination.number ) + +proc branchLink*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Calls a label, leaving the return address in the link register. + assembler.fixups.add( + Fixup(kind: Branch26Fixup, at: assembler.code.len, label: int(target)) + ) + assembler.emit(0x94000000'u32) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index f08f501..afa4203 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -64,8 +64,9 @@ type routine*: int32 runtime*: pointer hostCall*: pointer + step*: pointer - NativeCall = proc(context: ptr NativeContext): int32 + NativeCall* = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} RoutineExtent* = object @@ -108,23 +109,24 @@ type GreaterEqualTest const - ValueStride = 16 - ValuePayload = 8 - ContextInstructions = 8 - ContextWork = 16 - ContextOffset = 24 - ContextRegisters = 32 - ContextMemory = 40 - ContextHostData = 48 - ContextFrames = 56 - ContextArguments = 64 - ContextRegisterFile = 72 - ContextReturnTable = 80 - ContextBase = 88 - ContextDepth = 92 - ContextRoutine = 96 - ContextRuntime = 104 - ContextHostCall = 112 + ValueStride* = 16 + ValuePayload* = 8 + ContextInstructions* = 8 + ContextWork* = 16 + ContextOffset* = 24 + ContextRegisters* = 32 + ContextMemory* = 40 + ContextHostData* = 48 + ContextFrames* = 56 + ContextArguments* = 64 + ContextRegisterFile* = 72 + ContextReturnTable* = 80 + ContextBase* = 88 + ContextDepth* = 92 + ContextRoutine* = 96 + ContextRuntime* = 104 + ContextHostCall* = 112 + ContextStep* = 120 ## One frame as the interpreter lays it out: where the caller's slots ## start, which routine it was in, where to carry on, and whether it @@ -141,9 +143,9 @@ const ## A call clears the callee's slots one at a time, so a routine wanting ## more than this keeps to the interpreter rather than growing the code. MaxClearedSlots = 64 - FixedTag = 1 - FixedShift = 16 - FixedRounding = 1'i64 shl (FixedShift - 1) + FixedTag* = 1 + FixedShift* = 16 + FixedRounding* = 1'i64 shl (FixedShift - 1) ## Fixed-point values are only modelled when overflow is allowed to ## wrap. Under fixedChecks the interpreter asserts instead, and nothing @@ -216,6 +218,8 @@ proc layoutMatches*(): bool {.raises: [].} = return false if cast[int](context.hostCall.addr) - origin != ContextHostCall: return false + if cast[int](context.step.addr) - origin != ContextStep: + return false true ## Region discovery diff --git a/src/bassy/programs.nim b/src/bassy/programs.nim new file mode 100644 index 0000000..fcf1fb6 --- /dev/null +++ b/src/bassy/programs.nim @@ -0,0 +1,1204 @@ +## Compiles a whole program from the register bytecode to machine code. +## +## Every bytecode offset becomes a native block, so jumps, calls and +## returns go straight from one block to the next and the interpreter +## loop never runs. Values stay exactly where the interpreter keeps them, +## in the globals, the register file, the arguments and the array cells, +## which is what lets the two agree on every result, every budget and +## every failure. +## +## The common cases run inline: whole-number and fixed-point arithmetic, +## comparisons, branches, moves, array cells, calls, returns and budget +## meters. Everything else, such as strings, printing and host functions, +## and every operation about to fail, calls the one routine the +## interpreter itself runs for that instruction. Nothing is written +## before such a call, so the instruction is simply run there instead. + +import + bytecode, jit, machine, numbers + +when NativeArm64: + import arm64 +elif NativeAmd64: + import amd64 + +type + Machine* = ref object + ## One whole program compiled to machine code. + size*: int + listing*: seq[byte] + table: seq[pointer] + buffer: CodeBuffer + call: NativeCall + + Home = enum + ## Where a value lives. + SlotHome, + GlobalHome, + ArgumentHome, + HostHome, + CellHome + + Place = object + ## One value's address, as a home and an index into it. + home: Home + index: int32 + + Branching = enum + ## Where a slow path carries on once the interpreter's code has run. + ToNext, + ToOffset + + Stub = object + ## A slow path, placed after the block its operation sits in. + label: Label + offset: int32 + carry: Branching + + Check = enum + ## An architecture-neutral comparison outcome. + EqualCheck, + NotEqualCheck, + LessCheck, + LessEqualCheck, + GreaterCheck, + GreaterEqualCheck + +const + MaxProgramBytes = 64 * 1024 * 1024 + ## Leaving a routine without one of these would run on into the next + ## routine's code, which no call set up. + Terminators = {JumpOp, ReturnOp, ReturnLabelOp, ExitSubOp, HaltOp} + +proc slot(index: int32): Place {.inline, raises: [].} = + ## Names a register slot in the current frame. + Place(home: SlotHome, index: index) + +proc global(index: int32): Place {.inline, raises: [].} = + ## Names a scalar global. + Place(home: GlobalHome, index: index) + +proc argument(index: int32): Place {.inline, raises: [].} = + ## Names a staged call argument. + Place(home: ArgumentHome, index: index) + +proc host(index: int32): Place {.inline, raises: [].} = + ## Names a host data value. + Place(home: HostHome, index: index) + +proc cell(): Place {.inline, raises: [].} = + ## Names the array cell whose address was just worked out. + Place(home: CellHome) + +proc comparisonCheck(op: Op): Check {.raises: [].} = + ## Returns the outcome a comparison answers true on. + case op + of EqualOp: EqualCheck + of NotEqualOp: NotEqualCheck + of LessOp: LessCheck + of LessEqualOp: LessEqualCheck + of GreaterOp: GreaterCheck + else: GreaterEqualCheck + +proc takenOn(op: Op): Check {.raises: [].} = + ## Returns the outcome on which a fused test takes its branch. + case op + of JumpUnlessGlobalEqualImmediateOp: NotEqualCheck + of JumpUnlessGlobalNotEqualImmediateOp: EqualCheck + of JumpUnlessGlobalLessImmediateOp: GreaterEqualCheck + of JumpUnlessGlobalLessEqualImmediateOp: GreaterCheck + of JumpUnlessGlobalGreaterImmediateOp: LessEqualCheck + else: LessCheck + +when NativeArm64: + ## AArch64 code generation + ## + ## x19 context x20 instruction budget x21 work budget + ## x22 globals x23 current frame x24 offset table + ## x25 array cells x26 arguments x27 frames + ## x28 register file + ## x9 .. x15 working registers; x16 far addresses; x17 one cell + ## + ## Everything long lived sits in a register the platform's convention + ## keeps across a call, so calling back into the interpreter's code + ## costs no saving beyond the two budgets it may charge. + + const + Context = x19 + Instructions = x20 + Work = x21 + GlobalsBase = x22 + RegistersBase = x23 + TableBase = x24 + MemoryBase = x25 + ArgumentsBase = x26 + FramesBase = x27 + FileBase = x28 + Temps = [x9, x10, x11, x12, x13, x14, x15] + Far = x16 + Cell = x17 + FrameBytes = 96 + NearBytes = 4095 - ValuePayload + + proc temp(index: int): Register {.inline, raises: [].} = + ## Returns one working register. + Temps[index] + + proc nativeCondition(check: Check): Condition {.raises: [].} = + ## Maps a neutral outcome onto the architecture's encoding. + case check + of EqualCheck: EqualCondition + of NotEqualCheck: NotEqualCondition + of LessCheck: LessCondition + of LessEqualCheck: LessEqualCondition + of GreaterCheck: GreaterCondition + of GreaterEqualCheck: GreaterEqualCondition + + proc inverse(condition: Condition): Condition {.raises: [].} = + ## Returns the condition that holds exactly when this one does not. + Condition(ord(condition) xor 1) + + type + Emitter = object + ## The assembler plus whether branches must reach anywhere at all. + code: Assembler + far: bool + + proc label(e: var Emitter): Label {.inline, raises: [].} = + ## Reserves a label. + e.code.label() + + proc place(e: var Emitter, target: Label) {.inline, raises: [].} = + ## Places a label here. + e.code.place(target) + + proc jump(e: var Emitter, target: Label) {.raises: [].} = + ## Jumps unconditionally. + e.code.branch(target) + + proc jumpWhen(e: var Emitter, condition: Condition, target: Label) + {.raises: [].} = + ## Jumps when a condition holds, however far away the target is. + if e.far: + let skip = e.code.label() + e.code.branchIf(condition.inverse, skip) + e.code.branch(target) + e.code.place(skip) + else: + e.code.branchIf(condition, target) + + proc jumpIfZero(e: var Emitter, register: Register, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + if e.far: + let skip = e.code.label() + e.code.branchIfNotZero(Word32, register, skip) + e.code.branch(target) + e.code.place(skip) + else: + e.code.branchIfZero(Word32, register, target) + + proc jumpIfNotZero(e: var Emitter, register: Register, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + if e.far: + let skip = e.code.label() + e.code.branchIfZero(Word32, register, skip) + e.code.branch(target) + e.code.place(skip) + else: + e.code.branchIfNotZero(Word32, register, target) + + proc reach(e: var Emitter, place: Place): (Register, int) + {.raises: [BasicError].} = + ## Returns a base register and byte offset for a value, working the + ## address out in full when the offset is too wide to encode. + var base = Cell + case place.home + of SlotHome: base = RegistersBase + of GlobalHome: base = GlobalsBase + of ArgumentHome: base = ArgumentsBase + of HostHome: + e.code.loadDouble(Cell, Context, ContextHostData) + of CellHome: + return (Cell, 0) + let offset = int(place.index) * ValueStride + if offset <= NearBytes: + return (base, offset) + e.code.loadImmediate(Word64, Far, int64(offset)) + e.code.addRegister(Word64, Far, base, Far) + (Far, 0) + + proc readValue(e: var Emitter, value, tag: int, place: Place) + {.raises: [BasicError].} = + ## Reads a value's kind and its 32-bit payload. + let (base, offset) = e.reach(place) + e.code.loadByte(temp(tag), base, offset) + e.code.loadWord(temp(value), base, offset + ValuePayload) + + proc writeWhole(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a whole number. + let (base, offset) = e.reach(place) + e.code.storeByte(zeroRegister, base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeKind(e: var Emitter, place: Place, tag, value: int) + {.raises: [BasicError].} = + ## Writes a payload under the kind held in a working register. + let (base, offset) = e.reach(place) + e.code.storeByte(temp(tag), base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeFixed(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a fixed-point payload. + let (base, offset) = e.reach(place) + e.code.loadImmediate(Word32, temp(5), FixedTag) + e.code.storeByte(temp(5), base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) + {.raises: [BasicError].} = + ## Writes a constant of a known kind. + e.code.loadImmediate(Word32, temp(6), int64(bits)) + let (base, offset) = e.reach(place) + if tag == 0: + e.code.storeByte(zeroRegister, base, offset) + else: + e.code.loadImmediate(Word32, temp(5), int64(tag)) + e.code.storeByte(temp(5), base, offset) + e.code.storeWord(temp(6), base, offset + ValuePayload) + + proc copyValue(e: var Emitter, destination, source: Place) + {.raises: [BasicError].} = + ## Copies a value entire, whatever kind it holds, as the interpreter + ## does. + let (fromBase, fromOffset) = e.reach(source) + e.code.loadDouble(temp(5), fromBase, fromOffset) + e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) + let (toBase, toOffset) = e.reach(destination) + e.code.storeDouble(temp(5), toBase, toOffset) + e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) + + proc unlessWhole(e: var Emitter, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless a kind says whole number. + e.jumpIfNotZero(temp(tag), slow) + + proc unlessNumeric(e: var Emitter, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless a kind says number of either sort. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(UnsignedGreaterCondition, slow) + + proc unlessSame(e: var Emitter, tag, other: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(NotEqualCondition, slow) + + proc whenFixed(e: var Emitter, tag: int, target: Label) + {.raises: [BasicError].} = + ## Jumps when a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.code.branchIf(EqualCondition, target) + + proc loadConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = + ## Loads a constant into a working register. + e.code.loadImmediate(Word32, temp(value), int64(bits)) + + proc add(e: var Emitter, left, right: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, temp(left), temp(left), temp(right)) + + proc subtract(e: var Emitter, left, right: int) {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, temp(left), temp(left), temp(right)) + + proc multiply(e: var Emitter, left, right: int) {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiply(Word32, temp(left), temp(left), temp(right)) + + proc negate(e: var Emitter, value: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negate(Word32, temp(value), temp(value)) + + proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits both hold. + e.code.andRegister(Word32, temp(left), temp(left), temp(right)) + + proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits either holds. + e.code.orRegister(Word32, temp(left), temp(left), temp(right)) + + proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits exactly one holds. + e.code.xorRegister(Word32, temp(left), temp(left), temp(right)) + + proc bitNot(e: var Emitter, value: int) {.raises: [].} = + ## Flips every bit. + e.code.notRegister(Word32, temp(value), temp(value)) + + proc quotient(e: var Emitter, left, right: int) {.raises: [].} = + ## Divides toward zero; the divisor is known not to be zero. The most + ## negative number over minus one wraps back to itself here, which is + ## the answer the interpreter defines. + e.code.signedDivide(Word32, temp(left), temp(left), temp(right)) + + proc remainder(e: var Emitter, left, right: int) {.raises: [].} = + ## Leaves what dividing left over, with the sign of the dividend. + e.code.signedDivide(Word32, temp(6), temp(left), temp(right)) + e.code.multiplySubtract(Word32, temp(left), temp(6), temp(right), + temp(left)) + + proc multiplyFixed(e: var Emitter, left, right: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers through a widened intermediate, + ## rounding to nearest exactly as the fixed-point library does. + e.code.signedMultiplyLong(temp(left), temp(left), temp(right)) + e.code.loadImmediate(Word64, temp(6), jit.FixedRounding) + e.code.addRegister(Word64, temp(left), temp(left), temp(6)) + e.code.arithmeticShiftRight(Word64, temp(left), temp(left), jit.FixedShift) + e.code.moveRegister(Word32, temp(left), temp(left)) + + proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Turns a number of either kind into its Q16.16 bits, widened to + ## sixty-four. A whole number outside the fixed-point range cannot + ## become one, which the interpreter refuses, so that goes slow. + let register = temp(value) + let already = e.label() + let ready = e.label() + e.whenFixed(tag, already) + e.code.loadImmediate(Word32, temp(6), 32767) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(GreaterCondition, slow) + e.code.loadImmediate(Word32, temp(6), -32768) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(LessCondition, slow) + e.code.signExtendWord(register, register) + e.code.shiftLeftImmediate(Word64, register, register, jit.FixedShift) + e.jump(ready) + e.place(already) + e.code.signExtendWord(register, register) + e.place(ready) + + proc divideFixed(e: var Emitter, left, right: int, slow: Label) + {.raises: [BasicError].} = + ## Divides two widened Q16.16 numbers, rounding to nearest with halves + ## going up, for either sign, exactly as the fixed-point library + ## does: the signs are put right first, half the divisor is added, + ## and the truncating divide is corrected back to a floor. + let numerator = temp(left) + let denominator = temp(right) + let answer = temp(5) + let leftOver = temp(6) + e.code.compareImmediate(Word64, denominator, 0) + e.jumpWhen(EqualCondition, slow) + let signsSettled = e.label() + e.code.branchIf(GreaterCondition, signsSettled) + e.code.negate(Word64, numerator, numerator) + e.code.negate(Word64, denominator, denominator) + e.place(signsSettled) + e.code.shiftLeftImmediate(Word64, numerator, numerator, jit.FixedShift) + e.code.shiftRightImmediate(Word64, answer, denominator, 1) + e.code.addRegister(Word64, numerator, numerator, answer) + e.code.signedDivide(Word64, answer, numerator, denominator) + e.code.multiplySubtract(Word64, leftOver, answer, denominator, + numerator) + let done = e.label() + e.code.compareImmediate(Word64, leftOver, 0) + e.code.branchIf(EqualCondition, done) + e.code.compareImmediate(Word64, numerator, 0) + e.code.branchIf(GreaterEqualCondition, done) + e.code.subtractImmediate(Word64, answer, answer, 1) + e.place(done) + e.code.moveRegister(Word32, numerator, answer) + + proc compare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two working registers. + e.code.compareRegister(Word32, temp(left), temp(right)) + + proc compareConstant(e: var Emitter, value: int, bits: int32) + {.raises: [BasicError].} = + ## Sets flags from a working register against a constant. + if bits >= 0 and bits <= 4095: + e.code.compareImmediate(Word32, temp(value), int(bits)) + else: + e.code.loadImmediate(Word32, temp(6), int64(bits)) + e.code.compareRegister(Word32, temp(value), temp(6)) + + proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. + e.code.setOnCondition(Word32, temp(value), nativeCondition(check)) + + proc jumpOn(e: var Emitter, check: Check, target: Label) + {.raises: [].} = + ## Jumps on a comparison outcome. + e.jumpWhen(nativeCondition(check), target) + + proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + e.jumpIfZero(temp(value), target) + + proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + e.jumpIfNotZero(temp(value), target) + + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, + slow: Label) {.raises: [BasicError].} = + ## Bounds checks an index and leaves the cell's address in Cell. One + ## unsigned comparison covers both ends, as the interpreter's does. + let position = temp(index) + e.code.loadImmediate(Word32, temp(6), int64(extent.length)) + e.code.compareRegister(Word32, position, temp(6)) + e.jumpWhen(CarrySetCondition, slow) + e.code.loadImmediate(Word32, temp(6), int64(extent.base)) + e.code.addRegister(Word32, temp(6), temp(6), position) + e.code.addRegister(Word64, Cell, MemoryBase, temp(6), 4) + + proc meter(e: var Emitter, instructions, work: int32, slow: Label) + {.raises: [BasicError].} = + ## Checks both budgets before charging either, as the interpreter does. + if instructions <= 4095: + e.code.compareImmediate(Word64, Instructions, int(instructions)) + else: + e.code.loadImmediate(Word64, temp(5), int64(instructions)) + e.code.compareRegister(Word64, Instructions, temp(5)) + e.jumpWhen(LessCondition, slow) + if work <= 4095: + e.code.compareImmediate(Word64, Work, int(work)) + else: + e.code.loadImmediate(Word64, temp(6), int64(work)) + e.code.compareRegister(Word64, Work, temp(6)) + e.jumpWhen(LessCondition, slow) + if instructions <= 4095: + e.code.subtractImmediate(Word64, Instructions, Instructions, + int(instructions)) + else: + e.code.subtractRegister(Word64, Instructions, Instructions, temp(5)) + if work <= 4095: + e.code.subtractImmediate(Word64, Work, Work, int(work)) + else: + e.code.subtractRegister(Word64, Work, Work, temp(6)) + + proc frameOf(e: var Emitter, base: Register, index: Register) + {.raises: [].} = + ## Points a register at one register-file slot by its absolute index. + e.code.addRegister(Word64, base, FileBase, index, 4) + + proc copyValues(e: var Emitter, destination, source: Register, + count: int) {.raises: [BasicError].} = + ## Copies a run of whole values, in a loop once there are many. + if count <= 8: + for index in 0 ..< count: + e.code.loadDouble(temp(5), source, index * ValueStride) + e.code.loadDouble(temp(6), source, index * ValueStride + ValuePayload) + e.code.storeDouble(temp(5), destination, index * ValueStride) + e.code.storeDouble(temp(6), destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, temp(2), source) + e.code.moveRegister(Word64, temp(3), destination) + e.code.loadImmediate(Word32, temp(4), int64(count)) + let again = e.label() + e.place(again) + e.code.loadDouble(temp(5), temp(2), 0) + e.code.loadDouble(temp(6), temp(2), ValuePayload) + e.code.storeDouble(temp(5), temp(3), 0) + e.code.storeDouble(temp(6), temp(3), ValuePayload) + e.code.addImmediate(Word64, temp(2), temp(2), ValueStride) + e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) + e.code.subtractImmediate(Word32, temp(4), temp(4), 1) + e.code.branchIfNotZero(Word32, temp(4), again) + + proc clearValues(e: var Emitter, destination: Register, count: int) + {.raises: [BasicError].} = + ## Zeroes a run of values, in a loop once there are many. + if count <= 8: + for index in 0 ..< count: + e.code.storeDouble(zeroRegister, destination, index * ValueStride) + e.code.storeDouble(zeroRegister, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, temp(3), destination) + e.code.loadImmediate(Word32, temp(4), int64(count)) + let again = e.label() + e.place(again) + e.code.storeDouble(zeroRegister, temp(3), 0) + e.code.storeDouble(zeroRegister, temp(3), ValuePayload) + e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) + e.code.subtractImmediate(Word32, temp(4), temp(4), 1) + e.code.branchIfNotZero(Word32, temp(4), again) + + proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, + calleeRegisters, calleeParameters, callerRegisters: int32, + resumeAt: int32, limits: CallLimits, slow: Label) + {.raises: [BasicError].} = + ## Pushes a frame into the interpreter's own array and moves the + ## current frame on, refusing the same two ceilings it refuses. + let depth = temp(0) + let oldBase = temp(1) + let newBase = temp(2) + let frame = temp(3) + e.code.loadWord(depth, Context, ContextDepth) + e.code.loadImmediate(Word32, temp(6), int64(limits.frames) - 1) + e.code.compareRegister(Word32, depth, temp(6)) + e.jumpWhen(GreaterEqualCondition, slow) + e.code.loadWord(oldBase, Context, ContextBase) + e.code.loadImmediate(Word32, temp(6), int64(callerRegisters)) + e.code.addRegister(Word32, newBase, oldBase, temp(6)) + e.code.loadImmediate(Word32, temp(6), + int64(limits.slots) - int64(calleeRegisters)) + e.code.compareRegister(Word32, newBase, temp(6)) + e.jumpWhen(GreaterCondition, slow) + + e.code.addRegister(Word64, frame, FramesBase, depth, 4) + e.code.storeWord(oldBase, frame, FrameBase) + e.code.loadWord(temp(4), Context, ContextRoutine) + e.code.storeWord(temp(4), frame, FrameRoutine) + e.code.loadImmediate(Word32, temp(4), int64(resumeAt)) + e.code.storeWord(temp(4), frame, FrameReturn) + if gosub: + e.code.loadImmediate(Word32, temp(4), 1) + e.code.storeWord(temp(4), frame, FrameTag) + else: + e.code.storeWord(zeroRegister, frame, FrameTag) + + e.code.addImmediate(Word32, depth, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.storeWord(newBase, Context, ContextBase) + e.code.loadImmediate(Word32, temp(4), int64(calleeId)) + e.code.storeWord(temp(4), Context, ContextRoutine) + + # A GOSUB hands the callee a copy of the caller's slots; a call clears + # them and lays the arguments over the first few, in that order. + e.code.moveRegister(Word64, frame, RegistersBase) + e.frameOf(RegistersBase, newBase) + if gosub: + e.copyValues(RegistersBase, frame, int(calleeRegisters)) + else: + e.clearValues(RegistersBase, int(calleeRegisters)) + e.copyValues(RegistersBase, ArgumentsBase, int(calleeParameters)) + + proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) + {.raises: [BasicError].} = + ## Pops a frame and jumps to wherever it said to carry on. A GOSUB + ## frame first hands the shared parameters back to the caller. + let depth = temp(0) + let frame = temp(1) + let base = temp(2) + let resume = temp(3) + e.code.loadWord(depth, Context, ContextDepth) + e.jumpIfZero(depth, slow) + e.code.subtractImmediate(Word32, depth, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.addRegister(Word64, frame, FramesBase, depth, 4) + e.code.loadWord(base, frame, FrameBase) + if parameters > 0: + let plain = e.label() + e.code.loadByte(temp(4), frame, FrameTag) + e.code.compareImmediate(Word32, temp(4), 1) + e.code.branchIf(NotEqualCondition, plain) + e.frameOf(Far, base) + e.copyValues(Far, RegistersBase, int(parameters)) + e.place(plain) + e.code.storeWord(base, Context, ContextBase) + e.code.loadWord(temp(4), frame, FrameRoutine) + e.code.storeWord(temp(4), Context, ContextRoutine) + e.code.loadWord(resume, frame, FrameReturn) + e.code.storeWord(resume, Context, ContextOffset) + e.frameOf(RegistersBase, base) + e.code.addRegister(Word64, temp(4), TableBase, resume, 3) + e.code.loadDouble(temp(4), temp(4), 0) + e.code.jumpRegister(temp(4)) + + proc callSlow(e: var Emitter, offset: int32, routine: Label) + {.raises: [BasicError].} = + ## Runs the interpreter's own code for one instruction. + e.code.loadImmediate(Word32, x1, int64(offset)) + e.code.branchLink(routine) + + proc slowRoutine(e: var Emitter, failed: Label) + {.raises: [BasicError].} = + ## The one place compiled code calls out. The budgets go into the + ## context for the interpreter's code to charge, and come back from it + ## along with the frame, since a call or a return may have moved it. + e.code.storePair(framePointer, linkRegister, stackPointer, -16, true) + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.moveRegister(Word64, x0, Context) + e.code.loadDouble(temp(0), Context, ContextStep) + e.code.callRegister(temp(0)) + e.code.moveRegister(Word32, temp(0), x0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadWord(temp(1), Context, ContextBase) + e.frameOf(RegistersBase, temp(1)) + e.code.loadPair(framePointer, linkRegister, stackPointer, 16, true) + e.code.branchIfNotZero(Word32, temp(0), failed) + e.code.returnToCaller() + + proc dispatch(e: var Emitter) {.raises: [BasicError].} = + ## Jumps to the block for whatever offset the context names. + e.code.loadWord(temp(0), Context, ContextOffset) + e.code.addRegister(Word64, temp(1), TableBase, temp(0), 3) + e.code.loadDouble(temp(1), temp(1), 0) + e.code.jumpRegister(temp(1)) + + proc prologue(e: var Emitter) {.raises: [BasicError].} = + ## Saves what the platform says to keep and loads the machine state. + e.code.storePair(framePointer, linkRegister, stackPointer, -FrameBytes, + true) + e.code.storePair(x19, x20, stackPointer, 16) + e.code.storePair(x21, x22, stackPointer, 32) + e.code.storePair(x23, x24, stackPointer, 48) + e.code.storePair(x25, x26, stackPointer, 64) + e.code.storePair(x27, x28, stackPointer, 80) + e.code.moveRegister(Word64, Context, x0) + e.code.loadDouble(GlobalsBase, Context, 0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadDouble(FileBase, Context, ContextRegisterFile) + e.code.loadDouble(TableBase, Context, ContextReturnTable) + e.code.loadDouble(MemoryBase, Context, ContextMemory) + e.code.loadDouble(ArgumentsBase, Context, ContextArguments) + e.code.loadDouble(FramesBase, Context, ContextFrames) + e.code.loadWord(temp(0), Context, ContextBase) + e.frameOf(RegistersBase, temp(0)) + + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [BasicError].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, x0, int64(ord(status))) + e.code.loadPair(x19, x20, stackPointer, 16) + e.code.loadPair(x21, x22, stackPointer, 32) + e.code.loadPair(x23, x24, stackPointer, 48) + e.code.loadPair(x25, x26, stackPointer, 64) + e.code.loadPair(x27, x28, stackPointer, 80) + e.code.loadPair(framePointer, linkRegister, stackPointer, FrameBytes, + true) + e.code.returnToCaller() + + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = + ## Publishes the budgets and where the program stopped, then returns. + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.loadImmediate(Word32, temp(0), int64(offset)) + e.code.storeWord(temp(0), Context, ContextOffset) + e.epilogue(NativeCompleted) + + proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = + ## Resolves every branch and returns the finished bytes. + e.code.resolve() + result = newSeq[byte](e.code.code.len * 4) + if result.len > 0: + copyMem(result[0].addr, e.code.code[0].addr, result.len) + + proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in bytes. + e.code.offsetOf(target) * 4 + +proc invoke*(machine: Machine, context: var NativeContext): NativeStatus + {.raises: [].} = + ## Runs the compiled program from the offset the context names. + NativeStatus(machine.call(context.addr)) + +proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], + ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], + limits: CallLimits, far: bool): (seq[byte], seq[int]) + {.raises: [BasicError].} = + ## Emits the whole program and returns its bytes along with where each + ## offset's block starts. + when not NativeArm64: + raise newException(BasicError, "BASIC has no whole-program backend here") + else: + var e = Emitter(far: far) + var blocks = newSeq[Label](code.len + 1) + for index in 0 .. code.len: + blocks[index] = e.label() + let dispatchLabel = e.label() + let slowLabel = e.label() + let failedLabel = e.label() + + e.prologue() + e.jump(dispatchLabel) + + var stubs: seq[Stub] + for index in 0 ..< code.len: + let item = code[index] + let offset = int32(index) + e.place(blocks[index]) + + template slowFor(after: Branching): Label = + ## Names a slow path for this instruction, emitted after the block. + let stub = Stub(label: e.label(), offset: offset, carry: after) + stubs.add(stub) + stub.label + + template runSlow() = + ## Runs this instruction through the interpreter's code in line. + e.callSlow(offset, slowLabel) + + var fallsThrough = true + case item.op + of MeterOp: + e.meter(item.b, item.a, slowFor(ToNext)) + of LoadImmediateOp: + e.writeConstant(slot(item.a), 0, item.b) + of LoadFixedOp: + e.writeConstant(slot(item.a), FixedTag, constants[int(item.b)]) + of MoveOp: + e.copyValue(slot(item.a), slot(item.b)) + of LoadGlobalOp: + e.copyValue(slot(item.a), global(item.b)) + of LoadHostDataOp: + e.copyValue(slot(item.a), host(item.b)) + of StoreGlobalOp: + e.copyValue(global(item.a), slot(item.b)) + of StoreGlobalImmediateOp: + e.writeConstant(global(item.a), 0, item.b) + of MoveGlobalOp: + e.copyValue(global(item.a), global(item.b)) + of SetArgumentOp: + e.copyValue(argument(item.a), slot(item.b)) + of SetArgumentImmediateOp: + e.writeConstant(argument(item.a), 0, item.b) + of SetArgumentGlobalOp: + e.copyValue(argument(item.a), global(item.b)) + of AddGlobalImmediateOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.b) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp: + let slow = slowFor(ToNext) + let source = + case item.op + of AddGlobalOp: global(item.b) + of AddGlobalHostDataOp: host(item.b) + else: slot(item.b) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.readValue(1, 3, source) + e.unlessWhole(3, slow) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of ModuloGlobalImmediateOp: + if item.c == 0: + runSlow() + else: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.c) + e.remainder(0, 1) + e.writeWhole(global(item.a), 0) + of AddGlobalArrayGlobalIndexOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.b)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of ArrayAddGlobalsOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.a)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) + e.add(1, 0) + e.writeWhole(cell(), 1) + of AddOp, SubtractOp, MultiplyOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.readValue(1, 3, slot(item.c)) + e.unlessSame(2, 3, slow) + when ModelsFixed: + e.unlessNumeric(2, slow) + else: + e.unlessWhole(2, slow) + case item.op + of AddOp: + e.add(0, 1) + of SubtractOp: + e.subtract(0, 1) + else: + when ModelsFixed: + let fixedWay = e.label() + let joined = e.label() + e.whenFixed(2, fixedWay) + e.multiply(0, 1) + e.jump(joined) + e.place(fixedWay) + e.multiplyFixed(0, 1) + e.place(joined) + else: + e.multiply(0, 1) + e.writeKind(slot(item.a), 2, 0) + of DivideOp: + when ModelsFixed: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessNumeric(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessNumeric(3, slow) + e.widenToFixed(0, 2, slow) + e.widenToFixed(1, 3, slow) + e.divideFixed(0, 1, slow) + e.writeFixed(slot(item.a), 0) + else: + runSlow() + of IntegerDivideOp, ModuloOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessWhole(3, slow) + e.jumpIfZeroValue(1, slow) + if item.op == IntegerDivideOp: + e.quotient(0, 1) + else: + e.remainder(0, 1) + e.writeWhole(slot(item.a), 0) + of NegateOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + when ModelsFixed: + e.unlessNumeric(2, slow) + else: + e.unlessWhole(2, slow) + e.negate(0) + e.writeKind(slot(item.a), 2, 0) + of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp: + # The same kind on both sides orders the same on the stored bits, + # and the answer is always a whole number. + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.readValue(1, 3, slot(item.c)) + e.unlessSame(2, 3, slow) + e.unlessNumeric(2, slow) + e.compare(0, 1) + e.answer(0, comparisonCheck(item.op)) + e.writeWhole(slot(item.a), 0) + of AndOp, OrOp, XorOp, EqvOp, ImpOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessWhole(3, slow) + case item.op + of AndOp: + e.bitAnd(0, 1) + of OrOp: + e.bitOr(0, 1) + of XorOp: + e.bitXor(0, 1) + of EqvOp: + e.bitXor(0, 1) + e.bitNot(0) + else: + e.bitNot(0) + e.bitOr(0, 1) + e.writeWhole(slot(item.a), 0) + of NotOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.bitNot(0) + e.writeWhole(slot(item.a), 0) + of JumpOp: + e.jump(blocks[int(item.a)]) + fallsThrough = false + of JumpIfZeroOp: + # A fixed-point zero is all zero bits too, so either kind tests + # the same way. + let slow = slowFor(ToOffset) + e.readValue(0, 2, slot(item.a)) + e.unlessNumeric(2, slow) + e.jumpIfZeroValue(0, blocks[int(item.b)]) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + let slow = slowFor(ToOffset) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.compareConstant(0, item.b) + e.jumpOn(takenOn(item.op), blocks[int(item.c)]) + of JumpUnlessGlobalModuloEqualZeroOp: + if item.b == 0: + runSlow() + e.jump(dispatchLabel) + fallsThrough = false + else: + let slow = slowFor(ToOffset) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.b) + e.remainder(0, 1) + e.jumpIfNotZeroValue(0, blocks[int(item.c)]) + of ArrayGetOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.c)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.b)], slow) + e.copyValue(slot(item.a), cell()) + of ArraySetOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.a)], slow) + e.copyValue(cell(), slot(item.c)) + of CallOp, GosubOp: + let owner = routines[int(ownerOf[index])] + let slow = slowFor(ToOffset) + if item.op == CallOp: + let callee = routines[int(item.a)] + e.enterRoutine(false, item.a, callee.registers, callee.parameters, + owner.registers, offset + 1, limits, slow) + e.jump(blocks[int(callee.entry)]) + else: + e.enterRoutine(true, ownerOf[index], owner.registers, 0, + owner.registers, offset + 1, limits, slow) + e.jump(blocks[int(item.a)]) + fallsThrough = false + of ReturnOp: + let owner = routines[int(ownerOf[index])] + e.leaveRoutine(owner.parameters, slowFor(ToOffset)) + fallsThrough = false + of HaltOp: + e.halt(offset) + fallsThrough = false + of ReturnLabelOp, ExitSubOp: + runSlow() + e.jump(dispatchLabel) + fallsThrough = false + of LoadStringOp, TextCallOp, HostCallOp, PrintTextOp, PrintValueOp, + PrintNewlineOp: + runSlow() + + # Slow paths go after the block, out of the way of the fast ones. + let blockEnds = index + 1 == code.len or code[index + 1].op == MeterOp + if blockEnds and stubs.len > 0: + if fallsThrough: + e.jump(blocks[index + 1]) + for stub in stubs: + e.place(stub.label) + e.callSlow(stub.offset, slowLabel) + case stub.carry + of ToNext: + e.jump(blocks[int(stub.offset) + 1]) + of ToOffset: + e.jump(dispatchLabel) + stubs.setLen(0) + + # One past the end holds nothing to run. The interpreter's code is + # left to refuse it the way it would. + e.place(blocks[code.len]) + e.callSlow(int32(code.len), slowLabel) + e.jump(dispatchLabel) + + e.place(dispatchLabel) + e.dispatch() + e.place(slowLabel) + e.slowRoutine(failedLabel) + e.place(failedLabel) + e.epilogue(NativeFailed) + + let bytes = e.finish() + var starts = newSeq[int](code.len + 1) + for index in 0 .. code.len: + starts[index] = e.offsetBytes(blocks[index]) + (bytes, starts) + +proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], + extents: seq[ArrayExtent], constants: seq[int32], globals, hostData, + arguments: int, limits: CallLimits): Machine {.raises: [BasicError].} = + ## Compiles every offset of a program to machine code, or returns nil + ## when this target has no backend or the program is outside what the + ## generator is sure of. Generated code indexes storage without + ## checking, so every index it will use is proved in range here first. + when not NativeArm64: + return nil + else: + if not layoutMatches() or code.len == 0 or routines.len == 0: + return nil + if limits.frames <= 0 or limits.slots < 0: + return nil + + # Every offset belongs to exactly one routine, and none runs on into + # the next, so the routine an instruction runs in is known here. + var ownerOf = newSeq[int32](code.len) + for index in 0 ..< ownerOf.len: + ownerOf[index] = -1 + for id, routine in routines: + if routine.entry < 0 or routine.length <= 0 or + int(routine.entry) + int(routine.length) > code.len: + return nil + if routine.registers < 0 or routine.parameters < 0 or + routine.parameters > routine.registers or + routine.parameters > int32(arguments): + return nil + for step in 0 ..< int(routine.length): + let offset = int(routine.entry) + step + if ownerOf[offset] >= 0: + return nil + ownerOf[offset] = int32(id) + let last = code[int(routine.entry) + int(routine.length) - 1] + if last.op notin Terminators: + return nil + for index in 0 ..< code.len: + if ownerOf[index] < 0: + return nil + + for index, item in code: + let owner = routines[int(ownerOf[index])] + template requireSlot(value: int32) = + if value < 0 or value >= owner.registers: + return nil + template requireGlobal(value: int32) = + if value < 0 or int(value) >= globals: + return nil + template requireArray(value: int32) = + if value < 0 or int(value) >= extents.len: + return nil + let extent = extents[int(value)] + if extent.base < 0 or extent.length < 0: + return nil + template requireHost(value: int32) = + if value < 0 or int(value) >= hostData: + return nil + template requireArgument(value: int32) = + if value < 0 or int(value) >= arguments: + return nil + template requireTarget(value: int32) = + if value < 0 or int(value) >= code.len or + ownerOf[int(value)] != ownerOf[index]: + return nil + case item.op + of MeterOp: + if item.a < 0 or item.b < 0: + return nil + of LoadImmediateOp: + requireSlot(item.a) + of LoadFixedOp: + requireSlot(item.a) + if item.b < 0 or int(item.b) >= constants.len: + return nil + of MoveOp, NegateOp, NotOp: + requireSlot(item.a) + requireSlot(item.b) + of LoadGlobalOp: + requireSlot(item.a) + requireGlobal(item.b) + of LoadHostDataOp: + requireSlot(item.a) + requireHost(item.b) + of StoreGlobalOp: + requireGlobal(item.a) + requireSlot(item.b) + of StoreGlobalImmediateOp, AddGlobalImmediateOp: + requireGlobal(item.a) + of MoveGlobalOp, AddGlobalOp, ModuloGlobalImmediateOp: + requireGlobal(item.a) + requireGlobal(item.b) + of AddGlobalHostDataOp: + requireGlobal(item.a) + requireHost(item.b) + of AddGlobalRegisterOp: + requireGlobal(item.a) + requireSlot(item.b) + of AddGlobalArrayGlobalIndexOp: + requireGlobal(item.a) + requireArray(item.b) + requireGlobal(item.c) + of ArrayAddGlobalsOp: + requireArray(item.a) + requireGlobal(item.b) + requireGlobal(item.c) + of AddOp, SubtractOp, MultiplyOp, DivideOp, IntegerDivideOp, + ModuloOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp, AndOp, OrOp, XorOp, EqvOp, ImpOp: + requireSlot(item.a) + requireSlot(item.b) + requireSlot(item.c) + of JumpOp, GosubOp, ReturnLabelOp: + requireTarget(item.a) + of JumpIfZeroOp: + requireSlot(item.a) + requireTarget(item.b) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp: + requireGlobal(item.a) + requireTarget(item.c) + of ArrayGetOp: + requireSlot(item.a) + requireArray(item.b) + requireSlot(item.c) + of ArraySetOp: + requireArray(item.a) + requireSlot(item.b) + requireSlot(item.c) + of SetArgumentOp: + requireArgument(item.a) + requireSlot(item.b) + of SetArgumentImmediateOp: + requireArgument(item.a) + of SetArgumentGlobalOp: + requireArgument(item.a) + requireGlobal(item.b) + of CallOp: + if item.a <= 0 or int(item.a) >= routines.len: + return nil + else: + discard + if item.op in {CallOp, GosubOp} and index + 1 >= code.len: + return nil + + var emitted: (seq[byte], seq[int]) + try: + emitted = emitProgram(code, routines, ownerOf, extents, constants, + limits, false) + except BasicError: + # Some branch could not reach; every branch then goes the long way. + emitted = emitProgram(code, routines, ownerOf, extents, constants, + limits, true) + let (bytes, starts) = emitted + if bytes.len > MaxProgramBytes: + return nil + + result = Machine(size: bytes.len, listing: bytes) + result.buffer = initCodeBuffer(bytes.len) + result.buffer.write(bytes) + result.buffer.seal() + result.call = cast[NativeCall](result.buffer.entry) + let origin = cast[int](result.buffer.entry) + result.table = newSeq[pointer](code.len + 1) + for index in 0 .. code.len: + result.table[index] = cast[pointer](origin + starts[index]) + +proc tableAddress*(machine: Machine): pointer {.raises: [].} = + ## Returns the table of native addresses indexed by bytecode offset. + machine.table[0].addr diff --git a/tests/test_native.nim b/tests/test_native.nim new file mode 100644 index 0000000..583ea10 --- /dev/null +++ b/tests/test_native.nim @@ -0,0 +1,578 @@ +## Checks that a whole program compiled to machine code is +## indistinguishable from the same program interpreted. +## +## Generated programs mix whole numbers, fixed point and strings, arrays, +## subs, GOSUB, host functions, printing, and every way a script can fail: +## bad indices, zero divisors, wrong kinds, exhausted budgets, deep calls, +## and host code that raises. Each runs down both paths and everything a +## host could observe afterwards is compared: every global and array cell, +## every print event, both budgets, the offset it stopped at, the string +## storage, and the exact failure, including its exception type. + +import + std/[random, strformat, strutils, tables], + bassy + +const + Scalars = ["a", "b", "c", "d"] + Decimals = ["x", "y"] + Texts = ["s$", "t$"] + Cells = 6 + TextCells = 3 + +var + failures = 0 + compiled = 0 + handed = 0'i64 + executed = 0'i64 + +proc render(runtime: Runtime, value: Value): string = + ## Renders one value with its kind, and a string by its content. + case value.kind + of IntegerValue: "i" & $value.asInt + of FixedValue: "f" & $int32(value.asFixed) + of StringValue: + try: + "s" & runtime.getString(value).escape + except CatchableError: + "s" + +proc makeHost(): Host = + ## Builds host data and functions of every kind a script can call. + result = initHost() + discard result.addData("seed", toValue(7'i32)) + discard result.addData("scale", toValue(fixed(3'i32) / fixed(2'i32))) + discard result.addData("mail$", "hello") + discard result.addFunction("twice", 1, + proc(arguments: openArray[int32]): int32 = + if arguments[0] == 13: + raise newException(ValueError, "host refuses thirteen") + arguments[0] *% 2 + ) + discard result.addFunction("halve", 1, + proc(arguments: openArray[Value]): Value = + if arguments[0].kind == FixedValue: + toValue(arguments[0].asFixed / fixed(2'i32)) + else: + toValue(arguments[0].asInt div 2) + ) + discard result.addFunction("pick", 2, + proc(arguments: openArray[int32]): int32 = + if arguments[0] > arguments[1]: arguments[0] else: arguments[1] + , 3) + +proc observe(source: string, native: bool, limits: Limits, + runs: int): string = + ## Runs a program and writes down everything a host could observe. + var transcript: seq[string] + let host = makeHost() + let program = compile(source, host, limits) + var runtime = initRuntime(program, host, limits) + if native: + let count = runtime.compileNative() + if jitSupported(): + doAssert count == program.instructions, + "the whole program should compile" + inc compiled + for run in 0 ..< runs: + if run > 0: + runtime.restart + var events: seq[string] + let print = proc(event: PrintEvent) = + events.add(&"{event.kind}:{event.text.escape}:{event.value}:" & + &"{int32(event.fixedValue)}") + try: + let stats = runtime.run(print) + transcript.add(&"stats {stats.instructions} {stats.workUnits} " & + &"{stats.printBytes} {stats.printEvents}") + except CatchableError as error: + transcript.add(&"raised {error.name}: {error.msg}") + transcript.add("printed " & events.join(" ")) + if native: + executed += runtime.instructionsUsed + let (instructions, work) = runtime.remainingBudget + transcript.add(&"budget {instructions} {work} at {runtime.offset}") + transcript.add(&"strings {runtime.stringCount} {runtime.stringBytes}") + for index in 0 ..< program.globals: + transcript.add(&"global {index} " & + runtime.render(runtime.globalValue(int32(index)))) + for index in 0 ..< Cells: + transcript.add(&"cell {index} " & + runtime.render(runtime.getArrayValue("cells", int32(index)))) + for index in 0 ..< TextCells: + transcript.add(&"text {index} " & + runtime.render(runtime.getArrayValue("words$", int32(index)))) + if native: + handed += runtime.handedBack + transcript.join("\n") + +proc agree(name, source: string, limits = defaultLimits(), runs = 2) = + ## Requires both paths to leave exactly the same trail. + let plain = observe(source, false, limits, runs) + let fast = observe(source, true, limits, runs) + if plain != fast: + inc failures + echo &"FAIL {name}" + echo source + let plainLines = plain.splitLines + let fastLines = fast.splitLines + for index in 0 ..< max(plainLines.len, fastLines.len): + let left = if index < plainLines.len: plainLines[index] else: "" + let right = if index < fastLines.len: fastLines[index] else: "" + if left != right: + echo &" interpreted {left}" + echo &" native {right}" + else: + echo &" ok {name}" + +## Programs worth naming + +const Preamble = """ +dim cells(5) +dim words$(2) +""" + +agree("arithmetic of every kind", Preamble & """ +a = 7 +b = -3 +x = 1.5 +y = -0.25 +c = a * b + a \ b - a mod b +d = x * y + x / y - x +e = -x +f = a / b +g = (a < b) + (x >= y) * 2 + (a = 7) +h = not a and 12 or 3 xor 5 +i = a eqv b +j = a imp b +k = x + 2 +l = 2 * y +""") + +agree("strings through every function", Preamble & """ +s$ = "Hello, World" +t$ = s$ + " again" +a = len(t$) +words$(0) = left$(s$, 5) +words$(1) = right$(s$, 5) +words$(2) = mid$(s$, 3, 4) +s$ = ucase$(s$) + lcase$(t$) + trim$(" x ") + ltrim$(" y") + rtrim$("z ") +b = asc("A") +t$ = chr$(66) + space$(2) + string$(3, "q") + str$(42) + str$(-1.5) +c = instr(s$, "WORLD") +d = s$ < t$ +e = s$ = s$ +print s$; t$ +print a, b, c +""") + +agree("subs, recursion, and GOSUB", Preamble & """ +sub fact(n) + if n <= 1 then + a = a + 1 + exit sub + end if + b = b + n + fact(n - 1) +end sub +sub shared(n) + gosub bump + gosub bump + c = c + n + exit sub +bump: + n = n + 1 + return +end sub +fact(10) +shared(5) +gosub outer +d = 99 +end +outer: + cells(1) = cells(1) + 1 + return +""") + +agree("an array index out of range", Preamble & """ +a = 3 +cells(a) = 4 +a = a + 3 +cells(a) = 5 +""") + +agree("a fixed-point index names a whole cell", Preamble & """ +x = 2.0 +cells(x) = 9 +y = 2.5 +cells(y) = 1 +""") + +agree("dividing by zero", Preamble & """ +a = 5 +b = 0 +c = a \ b +""") + +agree("fixed-point division by zero", Preamble & """ +x = 5.5 +y = 0.0 +c = x / y +""") + +agree("a whole number too wide for fixed point", Preamble & """ +a = 40000 +x = 0.5 +y = a + x +""") + +agree("adding a string to a number", Preamble & """ +s$ = "a" +a = len(s$) +t$ = s$ + str$(a) +b = a + len(t$) +""") + +agree("host code that raises", Preamble & """ +a = twice(6) +b = halve(a) +x = halve(3.0) +c = pick(a, b) +d = twice(13) +e = 1 +""") + +agree("host data", Preamble & """ +a = seed + seed +x = scale * 2 +s$ = mail$ + "!" +b = a + seed +""") + +agree("printing", Preamble & """ +print "a"; 1; -2 +print 1.5, -0.125 +s$ = "text" +print s$ +print +""") + +agree("call depth runs out", Preamble & """ +sub deep(n) + a = a + 1 + deep(n + 1) +end sub +deep(0) +""") + +agree("GOSUB depth runs out", Preamble & """ +again: +a = a + 1 +gosub again +""") + +block: + var limits = defaultLimits() + limits.maxInstructions = 777 + agree("the instruction budget runs out part way", Preamble & """ +while 1 + a = a + 1 + cells(a mod 6) = a +wend +""", limits) + +block: + var limits = defaultLimits() + limits.maxWorkUnits = 901 + agree("the work budget runs out part way", Preamble & """ +while 1 + a = a + 1 + b = a \ 3 +wend +""", limits) + +block: + var limits = defaultLimits() + limits.maxPrintEvents = 5 + agree("the print budget runs out part way", Preamble & """ +while 1 + a = a + 1 + print a +wend +""", limits) + +block: + var limits = defaultLimits() + limits.maxStrings = 6 + agree("string storage runs out part way", Preamble & """ +while 1 + s$ = s$ + "x" + a = a + 1 +wend +""", limits) + +agree("wrapping at both ends", Preamble & """ +a = 2147483647 +a = a + 1 +b = -2147483647 - 1 +c = b \ -1 +d = b mod -1 +e = b * -1 +f = -b +x = 32767.5 +x = x + 1 +y = -32768 +y = y - 0.5 +""") + +agree("select, for, do, and on-goto", Preamble & """ +for i = 1 to 10 step 3 + select case i + case 1, 4 + a = a + i + case 7 to 9 + b = b + i + case else + c = c + i + end select +next +do while d < 5 + d = d + 1 + if d = 3 then exit do +loop +e = 2 +on e goto first, second, third +first: + f = 1 +second: + g = 2 +third: + h = 3 +""") + +## Generated programs + +type Generator = object + random: Rand + depth: int + labels: int + +proc pick[T](g: var Generator, items: openArray[T]): T = + ## Picks one item. + items[g.random.rand(0 ..< items.len)] + +proc chance(g: var Generator, percent: int): bool = + ## Reports true about this often. + g.random.rand(0 ..< 100) < percent + +proc literal(g: var Generator): string = + ## A whole-number or fixed-point constant, with the edges favoured. + case g.random.rand(0 .. 9) + of 0: $g.pick([0, 1, -1, 2147483647, -2147483647, 32767, -32768, 13]) + of 1, 2: &"{g.random.rand(-40 .. 40)}.{g.pick([0, 5, 25, 125, 75])}" + else: $g.random.rand(-20 .. 20) + +proc divisor(g: var Generator): string = + ## A divisor that is usually safe, and now and then zero or minus one. + case g.random.rand(0 .. 19) + of 0: "0" + of 1: "-1" + else: $g.pick([2, 3, 4, 5, 7, 8, 16, -3, -8, 1000]) + +proc whole(g: var Generator): string = + ## A whole-number expression of bounded depth, safe to index and divide. + inc g.depth + defer: dec g.depth + if g.depth > 3 or g.chance(35): + case g.random.rand(0 .. 6) + of 0, 1: return $g.random.rand(-20 .. 20) + of 2: return $g.pick([0, 1, -1, 2147483647, -2147483647, 13, 65536]) + of 3, 4, 5: return g.pick(Scalars) + else: return "cells(" & $g.random.rand(0 ..< Cells) & ")" + case g.random.rand(0 .. 11) + of 0 .. 3: + let op = g.pick(["+", "-", "*", "+", "-"]) + "(" & g.whole() & " " & op & " " & g.whole() & ")" + of 4, 5: + let op = g.pick(["\\", "mod"]) + "(" & g.whole() & " " & op & " " & g.divisor() & ")" + of 6: + let op = g.pick(["=", "<>", "<", "<=", ">", ">="]) + "(" & g.whole() & " " & op & " " & g.whole() & ")" + of 7: + let op = g.pick(["and", "or", "xor", "eqv", "imp"]) + "(" & g.whole() & " " & op & " " & g.whole() & ")" + of 8: "(not " & g.whole() & ")" + of 9: "-" & g.whole() + of 10: "twice(" & g.whole() & ")" + else: "pick(" & g.whole() & ", " & g.whole() & ")" + +proc numeric(g: var Generator): string + +proc cellIndex(g: var Generator): string = + ## An index that is usually in range and sometimes not. + if g.chance(90): + $g.random.rand(0 ..< Cells) + elif g.chance(50): + "(" & g.whole() & " mod 6)" + else: + g.numeric() + +proc numeric(g: var Generator): string = + ## A numeric expression of bounded depth, either kind. + inc g.depth + defer: dec g.depth + if g.depth > 3 or g.chance(30): + case g.random.rand(0 .. 5) + of 0, 1: return g.literal() + of 2, 3: return g.pick(Scalars) + of 4: return g.pick(Decimals) + else: return "cells(" & g.cellIndex() & ")" + case g.random.rand(0 .. 16) + of 0 .. 4: + let op = g.pick(["+", "-", "*", "+", "-"]) + "(" & g.numeric() & " " & op & " " & g.numeric() & ")" + of 5: + "(" & g.numeric() & " / " & g.pick(["2", "0.5", "-4", "3", "0"]) & ")" + of 6: + "(" & g.numeric() & " / " & g.numeric() & ")" + of 7: + let op = g.pick(["\\", "mod"]) + "(" & g.numeric() & " " & op & " " & g.divisor() & ")" + of 8, 9: + let op = g.pick(["=", "<>", "<", "<=", ">", ">="]) + "(" & g.numeric() & " " & op & " " & g.numeric() & ")" + of 10: + let op = g.pick(["and", "or", "xor", "eqv", "imp"]) + "(" & g.numeric() & " " & op & " " & g.numeric() & ")" + of 11: "(not " & g.numeric() & ")" + of 12: "-" & g.numeric() + of 13: "halve(" & g.numeric() & ")" + of 14: "len(" & g.pick(Texts) & ")" + of 15: g.whole() + else: "twice(" & g.whole() & ")" + +proc text(g: var Generator): string = + ## A string expression. + case g.random.rand(0 .. 8) + of 0: "\"" & g.pick(["", "a", "abc", " pad ", "Mixed"]) & "\"" + of 1: g.pick(Texts) + of 2: g.pick(Texts) & " + " & g.pick(Texts) + of 3: "left$(" & g.pick(Texts) & ", " & $g.random.rand(0 .. 4) & ")" + of 4: "mid$(" & g.pick(Texts) & ", " & $g.random.rand(1 .. 4) & ")" + of 5: "str$(" & g.numeric() & ")" + of 6: "words$(" & $g.random.rand(0 ..< TextCells) & ")" + of 7: "mid$(" & g.pick(Texts) & ", " & g.whole() & ", " & g.whole() & ")" + else: "chr$(" & $g.random.rand(60 .. 90) & ")" + +proc statement(g: var Generator, indent: string, room: int): string + +proc body(g: var Generator, indent: string, room: int): string = + ## A few statements. + for _ in 0 ..< g.random.rand(1 .. 4): + result.add(g.statement(indent, room)) + +proc statement(g: var Generator, indent: string, room: int): string = + ## One statement, nesting only while there is room. + let choice = g.random.rand(0 .. (if room > 0: 16 else: 9)) + case choice + of 0 .. 3: + indent & g.pick(Scalars) & " = " & + (if g.chance(80): g.whole() else: g.numeric()) & "\n" + of 4: + indent & g.pick(Decimals) & " = " & g.numeric() & "\n" + of 5: + indent & "cells(" & g.cellIndex() & ") = " & + (if g.chance(70): g.whole() else: g.numeric()) & "\n" + of 6: + indent & g.pick(Texts) & " = " & g.text() & "\n" + of 7: + indent & "words$(" & $g.random.rand(0 ..< TextCells) & ") = " & + g.text() & "\n" + of 8: + indent & "print " & g.numeric() & "; " & g.text() & "\n" + of 9: + indent & g.pick(["bump(" & g.whole() & ")", "gosub tally", + "a = a + 1", "b = b - 1"]) & "\n" + of 10 .. 12: + let condition = g.numeric() + var text = indent & "if " & condition & " then\n" & + g.body(indent & " ", room - 1) + if g.chance(50): + text.add(indent & "else\n" & g.body(indent & " ", room - 1)) + text & indent & "end if\n" + of 13, 14: + let counter = g.pick(Scalars) + indent & "for " & counter & " = 0 to " & $g.random.rand(0 .. 12) & + "\n" & g.body(indent & " ", room - 1) & indent & "next\n" + else: + let counter = g.pick(Scalars) + indent & "while " & counter & " < " & $g.random.rand(-5 .. 30) & "\n" & + indent & " " & counter & " = " & counter & " + 1\n" & + g.body(indent & " ", room - 1) & indent & "wend\n" + +proc generated(seed: int64): string = + ## Builds one program out of everything the language offers. + var g = Generator(random: initRand(seed)) + result = Preamble + for name in Scalars: + if g.chance(70): + result.add(name & " = " & g.literal() & "\n") + for name in Decimals: + if g.chance(70): + result.add(name & " = " & g.literal() & "\n") + result.add(g.body("", 2)) + result.add(g.body("", 2)) + result.add("end\n") + result.add("tally:\n c = c + " & g.literal() & "\n return\n") + result.add("sub bump(n)\n d = d + n\n" & g.body(" ", 1) & + " if n < 3 then bump(n + 1)\nend sub\n") + +var + tried = 0 + disagreed = 0 + outcomes: CountTable[string] +for seed in 1'i64 .. 1500'i64: + let source = generated(seed) + var limits = defaultLimits() + limits.maxCallDepth = 16 + limits.maxInstructions = [300'i64, 5_000, 200_000][int(seed mod 3)] + limits.maxStrings = 64 + try: + discard compile(source, makeHost(), limits) + except BasicError: + continue + inc tried + let plain = observe(source, false, limits, 2) + for line in plain.splitLines: + if line.startsWith("raised "): + outcomes.inc(line[0 ..< min(line.len, 60)]) + elif line.startsWith("stats "): + outcomes.inc("finished") + let fast = observe(source, true, limits, 2) + if plain != fast: + inc disagreed + if disagreed <= 2: + echo &"FAIL generated seed {seed}" + echo source + let plainLines = plain.splitLines + let fastLines = fast.splitLines + for index in 0 ..< max(plainLines.len, fastLines.len): + let left = if index < plainLines.len: plainLines[index] else: "" + let right = if index < fastLines.len: fastLines[index] else: "" + if left != right: + echo &" interpreted {left}" + echo &" native {right}" +if disagreed > 0: + inc failures + echo &"FAIL {disagreed} of {tried} generated programs disagreed" +else: + echo &" ok {tried} generated programs agree" +for outcome, count in outcomes: + echo &" {count:>5} {outcome}" +echo &" {handed} of {executed} instructions handed back to interpreter code" + +if failures > 0: + quit(&"{failures} native checks failed") +echo &"whole programs compiled natively: {compiled}" +echo "compiled programs are indistinguishable from interpreted ones" From 021738fac91edab46c9241b0c8875b51e28e3bbd Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:11:55 -0700 Subject: [PATCH 26/51] compile whole programs to x86-64 machine code --- src/bassy/amd64.nim | 54 ++++ src/bassy/programs.nim | 582 ++++++++++++++++++++++++++++++++++++++++- tests/test_amd64.nim | 30 +++ 3 files changed, 664 insertions(+), 2 deletions(-) diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index e97b876..8c7d734 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -404,3 +404,57 @@ proc storeByteLow*(assembler: var Assembler, base: Register, offset: int, proc offsetOf*(assembler: Assembler, target: Label): int {.raises: [].} = ## Returns where a label ended up, in bytes. assembler.targets[int(target)] + +## Logic and indirect control flow + +proc logical(assembler: var Assembler, opcode: byte, width: Width, + destination, source: Register) {.raises: [].} = + ## Encodes one register-to-register logical instruction. + assembler.prefix(width, source, destination) + assembler.emit(opcode) + assembler.directOperand(source, destination) + +proc andRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Keeps the bits both registers hold. + assembler.logical(0x21, width, destination, source) + +proc orRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Keeps the bits either register holds. + assembler.logical(0x09, width, destination, source) + +proc xorRegister*(assembler: var Assembler, width: Width, + destination, source: Register) {.raises: [].} = + ## Keeps the bits exactly one register holds. + assembler.logical(0x31, width, destination, source) + +proc notRegister*(assembler: var Assembler, width: Width, + target: Register) {.raises: [].} = + ## Flips every bit of a register. + assembler.prefix(width, Register(2), target) + assembler.emit(0xF7) + assembler.directOperand(Register(2), target) + +proc callLabel*(assembler: var Assembler, target: Label) {.raises: [].} = + ## Calls a label, pushing the return address. + assembler.emit(0xE8) + assembler.fixups.add( + Fixup(at: assembler.code.len, next: assembler.code.len + 4, + label: int(target)) + ) + assembler.emitDouble(0) + +proc callRegister*(assembler: var Assembler, target: Register) + {.raises: [].} = + ## Calls the address held in a register. + assembler.prefix(Word32, Register(2), target) + assembler.emit(0xFF) + assembler.directOperand(Register(2), target) + +proc jumpRegister*(assembler: var Assembler, target: Register) + {.raises: [].} = + ## Jumps to the address held in a register. + assembler.prefix(Word32, Register(4), target) + assembler.emit(0xFF) + assembler.directOperand(Register(4), target) diff --git a/src/bassy/programs.nim b/src/bassy/programs.nim index fcf1fb6..99b80bb 100644 --- a/src/bassy/programs.nim +++ b/src/bassy/programs.nim @@ -703,6 +703,584 @@ when NativeArm64: ## Returns where a label ended up, in bytes. e.code.offsetOf(target) * 4 +elif NativeAmd64: + ## x86-64 code generation + ## + ## r15 context r12 instruction budget r13 work budget + ## rbx globals rbp current frame r14 array cells + ## rax rcx rsi rdi r8 r9 r10 working registers; r11 one cell; + ## rdx the divide's high half and a spare + ## + ## The six long-lived registers are the ones both platform conventions + ## keep across a call. Windows also keeps rsi and rdi, so those are + ## saved on the way in there. Everything else the program touches + ## rarely is read from the context when it is needed. + + const + Context = r15 + Instructions = r12 + Work = r13 + GlobalsBase = rbx + RegistersBase = rbp + MemoryBase = r14 + Temps = [rax, rcx, rsi, rdi, r8, r9, r10] + Cell = r11 + Spare = rdx + + when defined(windows): + const + FirstArgument = rcx + SecondArgument = rdx + Saved = [rbx, rbp, r12, r13, r14, r15, rsi, rdi] + ## Four shadow slots for the callee, plus eight to realign. + Padding = 40 + else: + const + FirstArgument = rdi + SecondArgument = rsi + Saved = [rbx, rbp, r12, r13, r14, r15] + Padding = 8 + + proc temp(index: int): Register {.inline, raises: [].} = + ## Returns one working register. + Temps[index] + + proc nativeCondition(check: Check): Condition {.raises: [].} = + ## Maps a neutral outcome onto the architecture's encoding. + case check + of EqualCheck: EqualCondition + of NotEqualCheck: NotEqualCondition + of LessCheck: LessCondition + of LessEqualCheck: LessEqualCondition + of GreaterCheck: GreaterCondition + of GreaterEqualCheck: GreaterEqualCondition + + type + Emitter = object + ## The assembler. Every branch here reaches anywhere already, so + ## there is nothing to widen. + code: Assembler + far: bool + + proc label(e: var Emitter): Label {.inline, raises: [].} = + ## Reserves a label. + e.code.label() + + proc place(e: var Emitter, target: Label) {.inline, raises: [].} = + ## Places a label here. + e.code.place(target) + + proc jump(e: var Emitter, target: Label) {.raises: [].} = + ## Jumps unconditionally. + e.code.branch(target) + + proc jumpWhen(e: var Emitter, condition: Condition, target: Label) + {.raises: [].} = + ## Jumps when a condition holds. + e.code.branchIf(condition, target) + + proc contextField(e: var Emitter, destination: Register, offset: int) + {.raises: [BasicError].} = + ## Loads one pointer from the context. + e.code.loadDouble(destination, Context, offset) + + proc reach(e: var Emitter, place: Place): (Register, int) + {.raises: [BasicError].} = + ## Returns a base register and byte offset for a value. Displacements + ## are thirty-two bits wide here, so every index is reached directly. + let offset = int(place.index) * ValueStride + case place.home + of SlotHome: (RegistersBase, offset) + of GlobalHome: (GlobalsBase, offset) + of ArgumentHome: + e.contextField(Cell, ContextArguments) + (Cell, offset) + of HostHome: + e.contextField(Cell, ContextHostData) + (Cell, offset) + of CellHome: (Cell, 0) + + proc readValue(e: var Emitter, value, tag: int, place: Place) + {.raises: [BasicError].} = + ## Reads a value's kind and its 32-bit payload. + let (base, offset) = e.reach(place) + e.code.loadByteZeroed(temp(tag), base, offset) + e.code.loadWord(temp(value), base, offset + ValuePayload) + + proc writeWhole(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a whole number. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, 0) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeKind(e: var Emitter, place: Place, tag, value: int) + {.raises: [BasicError].} = + ## Writes a payload under the kind held in a working register. + let (base, offset) = e.reach(place) + e.code.storeByteLow(base, offset, temp(tag)) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeFixed(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a fixed-point payload. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, byte(FixedTag)) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) + {.raises: [BasicError].} = + ## Writes a constant of a known kind. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, byte(tag)) + e.code.storeWordImmediate(base, offset + ValuePayload, bits) + + proc copyValue(e: var Emitter, destination, source: Place) + {.raises: [BasicError].} = + ## Copies a value entire, whatever kind it holds, as the interpreter + ## does. + let (fromBase, fromOffset) = e.reach(source) + e.code.loadDouble(temp(5), fromBase, fromOffset) + e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) + let (toBase, toOffset) = e.reach(destination) + e.code.storeDouble(temp(5), toBase, toOffset) + e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) + + proc unlessWhole(e: var Emitter, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless a kind says whole number. + e.code.testRegister(Word32, temp(tag), temp(tag)) + e.jumpWhen(NotEqualCondition, slow) + + proc unlessNumeric(e: var Emitter, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless a kind says number of either sort. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(AboveCondition, slow) + + proc unlessSame(e: var Emitter, tag, other: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(NotEqualCondition, slow) + + proc whenFixed(e: var Emitter, tag: int, target: Label) + {.raises: [BasicError].} = + ## Jumps when a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(EqualCondition, target) + + proc loadConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = + ## Loads a constant into a working register. + e.code.loadImmediate(Word32, temp(value), int64(bits)) + + proc add(e: var Emitter, left, right: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, temp(left), temp(right)) + + proc subtract(e: var Emitter, left, right: int) {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, temp(left), temp(right)) + + proc multiply(e: var Emitter, left, right: int) {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiplyRegister(Word32, temp(left), temp(right)) + + proc negate(e: var Emitter, value: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negateRegister(Word32, temp(value)) + + proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits both hold. + e.code.andRegister(Word32, temp(left), temp(right)) + + proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits either holds. + e.code.orRegister(Word32, temp(left), temp(right)) + + proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits exactly one holds. + e.code.xorRegister(Word32, temp(left), temp(right)) + + proc bitNot(e: var Emitter, value: int) {.raises: [].} = + ## Flips every bit. + e.code.notRegister(Word32, temp(value)) + + proc divide(e: var Emitter, left, right: int, keepRemainder: bool) + {.raises: [BasicError].} = + ## Divides toward zero; the divisor is known not to be zero. The + ## hardware traps on the most negative number over minus one, which + ## the interpreter defines, so minus one is answered without dividing: + ## the quotient is the negation, wrapping, and nothing is left over. + let normal = e.label() + let done = e.label() + e.code.compareImmediate(Word32, temp(right), -1) + e.jumpWhen(NotEqualCondition, normal) + if keepRemainder: + e.code.loadImmediate(Word32, temp(left), 0) + else: + e.code.negateRegister(Word32, temp(left)) + e.jump(done) + e.place(normal) + e.code.moveRegister(Word32, rax, temp(left)) + e.code.signExtendToPair(Word32) + e.code.signedDivide(Word32, temp(right)) + if keepRemainder: + e.code.moveRegister(Word32, temp(left), Spare) + else: + e.code.moveRegister(Word32, temp(left), rax) + e.place(done) + + proc quotient(e: var Emitter, left, right: int) {.raises: [BasicError].} = + ## Divides toward zero. + e.divide(left, right, false) + + proc remainder(e: var Emitter, left, right: int) + {.raises: [BasicError].} = + ## Leaves what dividing left over, with the sign of the dividend. + e.divide(left, right, true) + + proc multiplyFixed(e: var Emitter, left, right: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers through a widened intermediate, + ## rounding to nearest exactly as the fixed-point library does. + e.code.signExtendDouble(temp(left), temp(left)) + e.code.signExtendDouble(temp(6), temp(right)) + e.code.multiplyRegister(Word64, temp(left), temp(6)) + e.code.addImmediate(Word64, temp(left), int32(jit.FixedRounding)) + e.code.shiftRightImmediate(Word64, temp(left), jit.FixedShift) + e.code.moveRegister(Word32, temp(left), temp(left)) + + proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Turns a number of either kind into its Q16.16 bits, widened to + ## sixty-four. A whole number outside the fixed-point range cannot + ## become one, which the interpreter refuses, so that goes slow. + let register = temp(value) + let already = e.label() + let ready = e.label() + e.whenFixed(tag, already) + e.code.compareImmediate(Word32, register, 32767) + e.jumpWhen(GreaterCondition, slow) + e.code.compareImmediate(Word32, register, -32768) + e.jumpWhen(LessCondition, slow) + e.code.signExtendDouble(register, register) + e.code.shiftLeftImmediate(Word64, register, jit.FixedShift) + e.jump(ready) + e.place(already) + e.code.signExtendDouble(register, register) + e.place(ready) + + proc divideFixed(e: var Emitter, left, right: int, slow: Label) + {.raises: [BasicError].} = + ## Divides two widened Q16.16 numbers, rounding to nearest with halves + ## going up, for either sign, exactly as the fixed-point library + ## does: the signs are put right first, half the divisor is added, + ## and the truncating divide is corrected back to a floor. The divide + ## works in rax and rdx, so the numerator is moved there. + let denominator = temp(right) + let half = temp(5) + let numerator = temp(6) + e.code.moveRegister(Word64, rax, temp(left)) + e.code.compareImmediate(Word64, denominator, 0) + e.jumpWhen(EqualCondition, slow) + let signsSettled = e.label() + e.jumpWhen(GreaterCondition, signsSettled) + e.code.negateRegister(Word64, rax) + e.code.negateRegister(Word64, denominator) + e.place(signsSettled) + e.code.shiftLeftImmediate(Word64, rax, jit.FixedShift) + e.code.moveRegister(Word64, half, denominator) + e.code.shiftRightImmediate(Word64, half, 1) + e.code.addRegister(Word64, rax, half) + e.code.moveRegister(Word64, numerator, rax) + e.code.signExtendToPair(Word64) + e.code.signedDivide(Word64, denominator) + let done = e.label() + e.code.testRegister(Word64, Spare, Spare) + e.jumpWhen(EqualCondition, done) + e.code.testRegister(Word64, numerator, numerator) + e.jumpWhen(GreaterEqualCondition, done) + e.code.subtractImmediate(Word64, rax, 1) + e.place(done) + e.code.moveRegister(Word32, temp(left), rax) + + proc compare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two working registers. + e.code.compareRegister(Word32, temp(left), temp(right)) + + proc compareConstant(e: var Emitter, value: int, bits: int32) + {.raises: [BasicError].} = + ## Sets flags from a working register against a constant. + e.code.compareImmediate(Word32, temp(value), bits) + + proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. The byte form only + ## names the low byte of rax, rcx, rdx and rbx without a prefix, so + ## this is only ever asked of the first working register. + e.code.setIfCondition(temp(value), nativeCondition(check)) + e.code.negateRegister(Word32, temp(value)) + + proc jumpOn(e: var Emitter, check: Check, target: Label) + {.raises: [].} = + ## Jumps on a comparison outcome. + e.jumpWhen(nativeCondition(check), target) + + proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + e.code.testRegister(Word32, temp(value), temp(value)) + e.jumpWhen(EqualCondition, target) + + proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + e.code.testRegister(Word32, temp(value), temp(value)) + e.jumpWhen(NotEqualCondition, target) + + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, + slow: Label) {.raises: [BasicError].} = + ## Bounds checks an index and leaves the cell's address in Cell. One + ## unsigned comparison covers both ends, as the interpreter's does. + e.code.compareImmediate(Word32, temp(index), extent.length) + e.jumpWhen(AboveEqualCondition, slow) + e.code.moveRegister(Word32, Cell, temp(index)) + e.code.addImmediate(Word32, Cell, extent.base) + e.code.shiftLeftImmediate(Word64, Cell, 4) + e.code.addRegister(Word64, Cell, MemoryBase) + + proc meter(e: var Emitter, instructions, work: int32, slow: Label) + {.raises: [BasicError].} = + ## Checks both budgets before charging either, as the interpreter does. + e.code.compareImmediate(Word64, Instructions, instructions) + e.jumpWhen(LessCondition, slow) + e.code.compareImmediate(Word64, Work, work) + e.jumpWhen(LessCondition, slow) + e.code.subtractImmediate(Word64, Instructions, instructions) + e.code.subtractImmediate(Word64, Work, work) + + proc slotAddress(e: var Emitter, destination, index: Register) + {.raises: [BasicError].} = + ## Points a register at one register-file slot by its absolute index. + ## The index register is left scaled. + e.contextField(destination, ContextRegisterFile) + e.code.shiftLeftImmediate(Word64, index, 4) + e.code.addRegister(Word64, destination, index) + + proc copyValues(e: var Emitter, destination, source: Register, + count: int) {.raises: [BasicError].} = + ## Copies a run of whole values, in a loop once there are many. Works + ## in rax, rdx, r8, r9 and r10, so neither end may be one of those. + if count <= 8: + for index in 0 ..< count: + e.code.loadDouble(rax, source, index * ValueStride) + e.code.loadDouble(Spare, source, index * ValueStride + ValuePayload) + e.code.storeDouble(rax, destination, index * ValueStride) + e.code.storeDouble(Spare, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, r10, source) + e.code.moveRegister(Word64, r9, destination) + e.code.loadImmediate(Word32, r8, int64(count)) + let again = e.label() + e.place(again) + e.code.loadDouble(rax, r10, 0) + e.code.loadDouble(Spare, r10, ValuePayload) + e.code.storeDouble(rax, r9, 0) + e.code.storeDouble(Spare, r9, ValuePayload) + e.code.addImmediate(Word64, r10, ValueStride) + e.code.addImmediate(Word64, r9, ValueStride) + e.code.subtractImmediate(Word32, r8, 1) + e.jumpWhen(NotEqualCondition, again) + + proc clearValues(e: var Emitter, destination: Register, count: int) + {.raises: [BasicError].} = + ## Zeroes a run of values, in a loop once there are many. + e.code.loadImmediate(Word32, rax, 0) + if count <= 8: + for index in 0 ..< count: + e.code.storeDouble(rax, destination, index * ValueStride) + e.code.storeDouble(rax, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, r9, destination) + e.code.loadImmediate(Word32, r8, int64(count)) + let again = e.label() + e.place(again) + e.code.storeDouble(rax, r9, 0) + e.code.storeDouble(rax, r9, ValuePayload) + e.code.addImmediate(Word64, r9, ValueStride) + e.code.subtractImmediate(Word32, r8, 1) + e.jumpWhen(NotEqualCondition, again) + + proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, + calleeRegisters, calleeParameters, callerRegisters: int32, + resumeAt: int32, limits: CallLimits, slow: Label) + {.raises: [BasicError].} = + ## Pushes a frame into the interpreter's own array and moves the + ## current frame on, refusing the same two ceilings it refuses. + let depth = rax + let oldBase = rcx + let newBase = rsi + let frame = rdi + e.code.loadWord(depth, Context, ContextDepth) + e.code.compareImmediate(Word32, depth, limits.frames - 1) + e.jumpWhen(GreaterEqualCondition, slow) + e.code.loadWord(oldBase, Context, ContextBase) + e.code.moveRegister(Word32, newBase, oldBase) + e.code.addImmediate(Word32, newBase, callerRegisters) + e.code.compareImmediate(Word32, newBase, + limits.slots - calleeRegisters) + e.jumpWhen(GreaterCondition, slow) + + e.contextField(frame, ContextFrames) + e.code.moveRegister(Word32, r8, depth) + e.code.shiftLeftImmediate(Word64, r8, 4) + e.code.addRegister(Word64, frame, r8) + e.code.storeWord(oldBase, frame, FrameBase) + e.code.loadWord(r8, Context, ContextRoutine) + e.code.storeWord(r8, frame, FrameRoutine) + e.code.storeWordImmediate(frame, FrameReturn, resumeAt) + e.code.storeWordImmediate(frame, FrameTag, if gosub: 1 else: 0) + + e.code.addImmediate(Word32, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.storeWord(newBase, Context, ContextBase) + e.code.storeWordImmediate(Context, ContextRoutine, calleeId) + + # A GOSUB hands the callee a copy of the caller's slots; a call clears + # them and lays the arguments over the first few, in that order. + e.code.moveRegister(Word64, frame, RegistersBase) + e.code.moveRegister(Word32, rcx, newBase) + e.slotAddress(RegistersBase, rcx) + if gosub: + e.copyValues(RegistersBase, frame, int(calleeRegisters)) + else: + e.clearValues(RegistersBase, int(calleeRegisters)) + if calleeParameters > 0: + e.contextField(Cell, ContextArguments) + e.copyValues(RegistersBase, Cell, int(calleeParameters)) + + proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) + {.raises: [BasicError].} = + ## Pops a frame and jumps to wherever it said to carry on. A GOSUB + ## frame first hands the shared parameters back to the caller. + let depth = rax + let frame = rcx + let base = rsi + e.code.loadWord(depth, Context, ContextDepth) + e.code.testRegister(Word32, depth, depth) + e.jumpWhen(EqualCondition, slow) + e.code.subtractImmediate(Word32, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.contextField(frame, ContextFrames) + e.code.moveRegister(Word32, r8, depth) + e.code.shiftLeftImmediate(Word64, r8, 4) + e.code.addRegister(Word64, frame, r8) + e.code.loadWord(base, frame, FrameBase) + if parameters > 0: + let plain = e.label() + e.code.loadByteZeroed(Spare, frame, FrameTag) + e.code.compareImmediate(Word32, Spare, 1) + e.jumpWhen(NotEqualCondition, plain) + e.code.moveRegister(Word32, r8, base) + e.slotAddress(rdi, r8) + e.copyValues(rdi, RegistersBase, int(parameters)) + e.place(plain) + e.code.storeWord(base, Context, ContextBase) + e.code.loadWord(Spare, frame, FrameRoutine) + e.code.storeWord(Spare, Context, ContextRoutine) + e.code.loadWord(Spare, frame, FrameReturn) + e.code.storeWord(Spare, Context, ContextOffset) + e.code.moveRegister(Word32, r8, base) + e.slotAddress(RegistersBase, r8) + e.contextField(rax, ContextReturnTable) + e.code.shiftLeftImmediate(Word64, Spare, 3) + e.code.addRegister(Word64, rax, Spare) + e.code.loadDouble(rax, rax, 0) + e.code.jumpRegister(rax) + + proc callSlow(e: var Emitter, offset: int32, routine: Label) + {.raises: [BasicError].} = + ## Runs the interpreter's own code for one instruction. + e.code.loadImmediate(Word32, SecondArgument, int64(offset)) + e.code.callLabel(routine) + + proc slowRoutine(e: var Emitter, failed: Label) + {.raises: [BasicError].} = + ## The one place compiled code calls out. The budgets go into the + ## context for the interpreter's code to charge, and come back from it + ## along with the frame, since a call or a return may have moved it. + ## A failure leaves through the shared exit, dropping the return + ## address this routine was called with on the way. + let refused = e.label() + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.moveRegister(Word64, FirstArgument, Context) + e.code.subtractImmediate(Word64, rsp, Padding) + e.contextField(rax, ContextStep) + e.code.callRegister(rax) + e.code.addImmediate(Word64, rsp, Padding) + e.code.moveRegister(Word32, r10, rax) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadWord(rcx, Context, ContextBase) + e.slotAddress(RegistersBase, rcx) + e.code.testRegister(Word32, r10, r10) + e.jumpWhen(NotEqualCondition, refused) + e.code.returnToCaller() + e.place(refused) + e.code.addImmediate(Word64, rsp, 8) + e.jump(failed) + + proc dispatch(e: var Emitter) {.raises: [BasicError].} = + ## Jumps to the block for whatever offset the context names. + e.code.loadWord(rax, Context, ContextOffset) + e.code.shiftLeftImmediate(Word64, rax, 3) + e.contextField(rcx, ContextReturnTable) + e.code.addRegister(Word64, rcx, rax) + e.code.loadDouble(rcx, rcx, 0) + e.code.jumpRegister(rcx) + + proc prologue(e: var Emitter) {.raises: [BasicError].} = + ## Saves what the platform says to keep and loads the machine state. + for register in Saved: + e.code.push(register) + e.code.subtractImmediate(Word64, rsp, Padding) + e.code.moveRegister(Word64, Context, FirstArgument) + e.code.loadDouble(GlobalsBase, Context, 0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.contextField(MemoryBase, ContextMemory) + e.code.loadWord(rcx, Context, ContextBase) + e.slotAddress(RegistersBase, rcx) + + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [BasicError].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, rax, int64(ord(status))) + e.code.addImmediate(Word64, rsp, Padding) + for index in countdown(Saved.len - 1, 0): + e.code.pop(Saved[index]) + e.code.returnToCaller() + + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = + ## Publishes the budgets and where the program stopped, then returns. + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.storeWordImmediate(Context, ContextOffset, offset) + e.epilogue(NativeCompleted) + + proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = + ## Resolves every branch and returns the finished bytes. + e.code.resolve() + e.code.code + + proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in bytes. + e.code.offsetOf(target) + proc invoke*(machine: Machine, context: var NativeContext): NativeStatus {.raises: [].} = ## Runs the compiled program from the offset the context names. @@ -714,7 +1292,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], {.raises: [BasicError].} = ## Emits the whole program and returns its bytes along with where each ## offset's block starts. - when not NativeArm64: + when not (NativeArm64 or NativeAmd64): raise newException(BasicError, "BASIC has no whole-program backend here") else: var e = Emitter(far: far) @@ -1035,7 +1613,7 @@ proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], ## when this target has no backend or the program is outside what the ## generator is sure of. Generated code indexes storage without ## checking, so every index it will use is proved in range here first. - when not NativeArm64: + when not (NativeArm64 or NativeAmd64): return nil else: if not layoutMatches() or code.len == 0 or routines.len == 0: diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index 6c60c55..879840c 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -152,6 +152,36 @@ encodes "pop r12": assembler.pop(r12) encodes "pop rbp": assembler.pop(rbp) +encodes "and eax, ecx": + assembler.andRegister(Word32, rax, rcx) +encodes "and r9d, esi": + assembler.andRegister(Word32, r9, rsi) +encodes "or edi, r10d": + assembler.orRegister(Word32, rdi, r10) +encodes "xor r8d, r9d": + assembler.xorRegister(Word32, r8, r9) +encodes "xor rax, rdx": + assembler.xorRegister(Word64, rax, rdx) +encodes "not ecx": + assembler.notRegister(Word32, rcx) +encodes "not r10d": + assembler.notRegister(Word32, r10) +encodes "call rax": + assembler.callRegister(rax) +encodes "call r11": + assembler.callRegister(r11) +encodes "jmp rcx": + assembler.jumpRegister(rcx) +encodes "jmp r11": + assembler.jumpRegister(r11) +block: + var assembler = Assembler() + let target = assembler.label() + assembler.callLabel(target) + assembler.place(target) + assembler.returnToCaller() + assembler.resolve() + cases.add(("call 1f\n1:\tret", assembler.code)) encodes "ret": assembler.returnToCaller() From 2a03a57ecef487cc19cc5424182f62eda49f309e Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:16:23 -0700 Subject: [PATCH 27/51] replace loop regions with whole-program compilation --- .github/workflows/build.yml | 15 +- src/bassy.nim | 140 +- src/bassy/jit.nim | 3925 +++++++++++++++-------------------- src/bassy/programs.nim | 1782 ---------------- tests/bench_jit.nim | 8 +- tests/bench_raytracer.nim | 14 +- tests/test_jit.nim | 24 +- tests/test_jit_safety.nim | 258 +-- 8 files changed, 1833 insertions(+), 4333 deletions(-) delete mode 100644 src/bassy/programs.nim diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccd510e..4a12b6a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,9 +55,18 @@ jobs: run: | nim r -d:release tests/test_arm64.nim nim r -d:release tests/test_amd64.nim - - name: Test compiled loops against the interpreter - run: nim r -d:release tests/test_jit.nim - - name: Test that compiled loops cannot outrun the sandbox + - name: Test compiled programs against the interpreter + run: | + nim r -d:release tests/test_jit.nim + nim r -d:release tests/test_native.nim + nim r -d:danger tests/test_native.nim + nim r -d:fixedChecks tests/test_native.nim + - name: Run the whole suite as machine code + run: | + nim r -d:release -d:bassyNative tests/tests.nim + nim r -d:danger -d:bassyNative tests/tests.nim + nim r -d:fixedChecks -d:bassyNative tests/tests.nim + - name: Test that compiled programs cannot outrun the sandbox run: | nim r -d:release tests/test_jit_safety.nim nim r -d:danger tests/test_jit_safety.nim diff --git a/src/bassy.nim b/src/bassy.nim index 79a2dc6..c6206c2 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -5,7 +5,7 @@ import std/[strutils, tables], - bassy/[bytecode, jit, numbers, programs, texts] + bassy/[bytecode, jit, numbers, texts] export bytecode, jit, numbers @@ -214,9 +214,6 @@ type printedEvents: int64 allocatedBytes: int64 finished: bool - regionAt: seq[Region] - bypass: int32 - hostFailure: string machine: Machine printer: PrintProc nativeError: ref Exception @@ -3146,11 +3143,6 @@ proc routineExtents*(program: Program): seq[RoutineExtent] = parameters: routine.parameterCount )) -proc runHostCall(context: ptr NativeContext, functionId, - destination: int32): int32 {.cdecl.} - ## Runs one host function on behalf of compiled code. The body sits - ## further down, beside the checks it needs. - proc frameLayoutMatches*(): bool = ## Confirms the frame layout compiled code would write by hand. These ## offsets were read off this Nim version, and compiled code pushes and @@ -3181,20 +3173,12 @@ proc handedBack*(runtime: Runtime): int64 {.inline.} = ## host calls, or about to fail. Everything else ran as machine code. runtime.handedBack -proc nativeRegions*(runtime: Runtime): int = - ## Returns how many compiled loops are still active. - for region in runtime.regionAt: - if region != nil: - inc result - proc compileNative*(runtime: var Runtime): int = ## Compiles this program to machine code and returns how many bytecode - ## offsets now run natively. The whole program is compiled where the - ## target has a backend for it, and otherwise only its hot loops. - ## Whatever is not compiled is left to the interpreter, so behavior - ## never depends on the result. - runtime.bypass = -1 - runtime.regionAt = @[] + ## offsets now run natively: all of them, or none where the target has + ## no backend or the compiler is not sure of the program. The + ## interpreter runs whatever is not compiled, and the two cannot be told + ## apart, so behavior never depends on the result. runtime.machine = nil if not jitSupported(): return 0 @@ -3221,20 +3205,6 @@ proc compileNative*(runtime: var Runtime): int = ) if runtime.machine != nil: return runtime.program.code.len - runtime.regionAt = compileLoops( - runtime.program.code, - runtime.globals.len, - int(runtime.program.maxRegisters), - extents, - runtime.program.fixedConstants, - runtime.hostData.len, - runtime.program.routineExtents, - CallLimits( - frames: int32(runtime.frames.len), - slots: int32(runtime.registers.len) - ) - ) - runtime.nativeRegions proc arrayExtent*(program: Program, id: int32): (int32, int32) {.inline.} = ## Returns where one array starts and how many cells it has. @@ -3613,46 +3583,6 @@ proc leaveFrame(runtime: var Runtime) = runtime.routine = frame.routine runtime.pc = frame.returnPc -proc runHostCall(context: ptr NativeContext, functionId, - destination: int32): int32 {.cdecl.} = - ## Runs one host function on behalf of compiled code. - ## - ## Compiled code cannot let a refusal travel back through a frame that - ## nothing described, so whatever the host raises is caught here and - ## reported as an answer instead. The offset then goes back to the - ## interpreter with the refusal already made, so the function is not - ## called a second time on the way out. - let runtime = cast[Runtime](context.runtime) - try: - let - id = int(functionId) - count = int(runtime.program.hostFunctions[id].parameters) - callback = runtime.hostCallbacks[id] - var value: Value - if callback.numeric != nil: - if count == 0: - value = callback.numeric([]) - else: - value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) - else: - for i in 0 ..< count: - runtime.integerArguments[i] = runtime.arguments[i].asInt - if count == 0: - value = callback.integer(EmptyArguments) - else: - value = callback.integer( - runtime.integerArguments.toOpenArray(0, count - 1) - ) - runtime.requireValue(value) - requireType(runtime.program.hostFunctions[id].name, value) - if destination >= 0: - let slots = cast[ptr UncheckedArray[Value]](context.registerFile) - slots[int(context.base) + int(destination)] = value - 0'i32 - except CatchableError as error: - runtime.hostFailure = error.msg - 1'i32 - template chargeMeter(runtime: Runtime, item: Instruction) = ## Charges one block's budgets, refusing before charging either. let @@ -4030,9 +3960,6 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = globals: if runtime.globals.len == 0: nil else: runtime.globals[0].addr, - registers: - if runtime.registers.len == 0: nil - else: runtime.registers[int(runtime.base)].addr, memory: if runtime.memory.len == 0: nil else: runtime.memory[0].addr, @@ -4048,7 +3975,7 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = registerFile: if runtime.registers.len == 0: nil else: runtime.registers[0].addr, - returnTable: runtime.machine.tableAddress, + table: runtime.machine.tableAddress, base: runtime.base, depth: runtime.depth, routine: runtime.routine, @@ -4090,61 +4017,6 @@ proc run*(runtime: var Runtime, print: PrintProc = nil): RunStats = while not runtime.finished: var item = fetch() if item.op == MeterOp: - if runtime.regionAt.len > 0 and runtime.pc != runtime.bypass: - let region = runtime.regionAt[int(runtime.pc)] - if region != nil: - var context = NativeContext( - globals: runtime.globals[0].addr, - registers: - if runtime.registers.len == 0: nil - else: runtime.registers[int(runtime.base)].addr, - memory: - if runtime.memory.len == 0: nil - else: runtime.memory[0].addr, - hostData: - if runtime.hostData.len == 0: nil - else: runtime.hostData[0].addr, - frames: - if runtime.frames.len == 0: nil - else: runtime.frames[0].addr, - arguments: - if runtime.arguments.len == 0: nil - else: runtime.arguments[0].addr, - registerFile: - if runtime.registers.len == 0: nil - else: runtime.registers[0].addr, - returnTable: region.returnTable, - base: runtime.base, - depth: runtime.depth, - routine: runtime.routine, - runtime: cast[pointer](runtime), - hostCall: cast[pointer](runHostCall), - remainingInstructions: runtime.remainingInstructions, - remainingWork: runtime.remainingWork, - pc: runtime.pc - ) - let status = region.invoke(context) - runtime.remainingInstructions = context.remainingInstructions - runtime.remainingWork = context.remainingWork - runtime.pc = context.pc - runtime.base = context.base - runtime.depth = context.depth - runtime.routine = context.routine - case status - of NativeCompleted: - runtime.bypass = -1 - of NativeExhausted: - # Let the interpreter re-run the meter and raise the real error. - runtime.bypass = context.pc - of NativeFailed: - # Host code already refused, and saying so again would call it - # a second time, so the refusal is repeated rather than remade. - raise newException(BasicError, runtime.hostFailure) - of NativeGuardFailed: - # A value stopped being an integer, so retire the compiled form. - runtime.regionAt[int(region.start)] = nil - runtime.bypass = -1 - continue runtime.chargeMeter(item) inc runtime.pc item = fetch() diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index afa4203..6abbb46 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -1,15 +1,21 @@ -## Compiles hot integer loops from the register bytecode to machine code. +## Compiles a whole program from the register bytecode to machine code. ## -## A region is one backward-branching loop whose every operation is an -## integer operation on global variables. On entry the compiled code proves -## each participating global still holds an integer, hoists it into a -## machine register, and from then on runs without tags, without memory -## traffic, and without dispatch. Any operation the compiler does not -## model, and any value that is not an integer, leaves the loop to the -## interpreter, so the two always agree on results and on budgets. +## Every bytecode offset becomes a native block, so jumps, calls and +## returns go straight from one block to the next and the interpreter +## loop never runs. Values stay exactly where the interpreter keeps them, +## in the globals, the register file, the arguments and the array cells, +## which is what lets the two agree on every result, every budget and +## every failure. ## -## The region walker below is written once. Each architecture supplies the -## same small set of emitters, so AArch64 and x86-64 stay in step. +## The common cases run inline: whole-number and fixed-point arithmetic, +## comparisons, branches, moves, array cells, calls, returns and budget +## meters. Everything else, such as strings, printing and host functions, +## and every operation about to fail, calls the one routine the +## interpreter itself runs for that instruction. Nothing is written +## before such a call, so the instruction is simply run there instead. +## +## The walker below is written once. Each architecture supplies the same +## small set of emitters, so AArch64 and x86-64 stay in step. import bytecode, machine, numbers @@ -20,17 +26,6 @@ const NativeArm64* = NativeCode and defined(arm64) NativeAmd64* = NativeCode and defined(amd64) - ## Calls are written for AArch64 so far. Everywhere else a region - ## containing one is simply not compiled, which is what happened before - ## they were written anywhere. - ModelsCalls* = NativeArm64 - - ## Dividing to a fixed-point answer is written for AArch64 so far. - ModelsDivide* = NativeArm64 - - ## Reaching globals where they sit is written for AArch64 so far. - ModelsMemoryGlobals* = NativeArm64 - when NativeArm64: import arm64 elif NativeAmd64: @@ -38,32 +33,28 @@ elif NativeAmd64: type NativeStatus* = enum - ## Why compiled code returned control to the interpreter. + ## Why compiled code returned to its caller. NativeCompleted, - NativeGuardFailed, - NativeExhausted, NativeFailed NativeContext* = object - ## The mutable interpreter state compiled code is allowed to touch. - ## The register field points at the current frame's first slot, which - ## cannot move while a region runs because a region contains no call. + ## The interpreter state compiled code reads and writes. The frame and + ## the budgets live here whenever the interpreter's own code may need + ## them, and go back into the runtime once compiled code returns. globals*: pointer remainingInstructions*: int64 remainingWork*: int64 pc*: int32 - registers*: pointer memory*: pointer hostData*: pointer frames*: pointer arguments*: pointer registerFile*: pointer - returnTable*: pointer + table*: pointer base*: int32 depth*: int32 routine*: int32 runtime*: pointer - hostCall*: pointer step*: pointer NativeCall* = proc(context: ptr NativeContext): int32 @@ -87,46 +78,23 @@ type base*: int32 length*: int32 - Region* = ref object - ## One compiled loop, addressed by the bytecode offset that enters it. - start*: int32 - stop*: int32 - hoisted*: seq[int32] - size*: int - listing*: seq[byte] - returns: seq[pointer] - returnTable*: pointer - buffer: CodeBuffer - call: NativeCall - - Test = enum - ## An architecture-neutral branch condition. - EqualTest, - NotEqualTest, - LessTest, - LessEqualTest, - GreaterTest, - GreaterEqualTest - const - ValueStride* = 16 - ValuePayload* = 8 - ContextInstructions* = 8 - ContextWork* = 16 - ContextOffset* = 24 - ContextRegisters* = 32 - ContextMemory* = 40 - ContextHostData* = 48 - ContextFrames* = 56 - ContextArguments* = 64 - ContextRegisterFile* = 72 - ContextReturnTable* = 80 - ContextBase* = 88 - ContextDepth* = 92 - ContextRoutine* = 96 - ContextRuntime* = 104 - ContextHostCall* = 112 - ContextStep* = 120 + ValueStride = 16 + ValuePayload = 8 + ContextInstructions = 8 + ContextWork = 16 + ContextOffset = 24 + ContextMemory = 32 + ContextHostData = 40 + ContextFrames = 48 + ContextArguments = 56 + ContextRegisterFile = 64 + ContextTable = 72 + ContextBase = 80 + ContextDepth = 84 + ContextRoutine = 88 + ContextRuntime = 96 + ContextStep = 104 ## One frame as the interpreter lays it out: where the caller's slots ## start, which routine it was in, where to carry on, and whether it @@ -137,25 +105,20 @@ const FrameRoutine* = 4 FrameReturn* = 8 FrameTag* = 12 - MaxHoistedGlobals* = 7 - MaxRegionBytes = 32 * 1024 - MaxChargeImmediate = 4095 - ## A call clears the callee's slots one at a time, so a routine wanting - ## more than this keeps to the interpreter rather than growing the code. - MaxClearedSlots = 64 - FixedTag* = 1 - FixedShift* = 16 - FixedRounding* = 1'i64 shl (FixedShift - 1) - - ## Fixed-point values are only modelled when overflow is allowed to - ## wrap. Under fixedChecks the interpreter asserts instead, and nothing - ## here would assert with it. + + FixedTag = 1 + FixedShift = 16 + FixedRounding = 1'i64 shl (FixedShift - 1) + + ## Fixed-point values are only modelled inline when overflow is allowed + ## to wrap. Under fixedChecks the interpreter asserts instead, so that + ## build hands every fixed-point operation to the interpreter's code. ModelsFixed* = not defined(fixedChecks) - MaxDisplacementBytes = int(high(int32)) -proc fail(message: string) {.noreturn, raises: [BasicError].} = - ## Reports a controlled native compilation failure. - raise newException(BasicError, "BASIC " & message) + MaxProgramBytes = 64 * 1024 * 1024 + ## Leaving a routine without one of these would run on into the next + ## routine's code, which no call set up. + Terminators = {JumpOp, ReturnOp, ReturnLabelOp, ExitSubOp, HaltOp} proc layoutMatches*(): bool {.raises: [].} = ## Confirms the memory layout the code generator writes by hand. @@ -175,2311 +138,1799 @@ proc layoutMatches*(): bool {.raises: [].} = probe[1] = toValue(fixed(1'i32)) var image: array[ValueStride * 2, byte] copyMem(image[0].addr, probe[0].addr, ValueStride * 2) - # An integer must be tagged zero, because the guard tests for zero, and - # a fixed-point number must not be, or the guard would let one through. - if image[0] != byte(ord(IntegerValue)): + # A whole number must be tagged zero and a fixed-point one tagged one, + # because the generated code tests the tag byte for exactly those. + if image[0] != byte(ord(IntegerValue)) or ord(IntegerValue) != 0: return false - if image[ValueStride] == byte(ord(IntegerValue)): + if image[ValueStride] != byte(FixedTag) or ord(FixedValue) != FixedTag: return false var payload = 0'i32 copyMem(payload.addr, image[ValuePayload].addr, sizeof(int32)) if payload != 0x5A6B7C0D'i32: return false + copyMem(payload.addr, image[ValueStride + ValuePayload].addr, + sizeof(int32)) + if payload != int32(fixed(1'i32)): + return false var context: NativeContext let origin = cast[int](context.addr) - if cast[int](context.remainingInstructions.addr) - origin != - ContextInstructions: - return false - if cast[int](context.remainingWork.addr) - origin != ContextWork: - return false - if cast[int](context.pc.addr) - origin != ContextOffset: - return false - if cast[int](context.registers.addr) - origin != ContextRegisters: - return false - if cast[int](context.memory.addr) - origin != ContextMemory: - return false - if cast[int](context.hostData.addr) - origin != ContextHostData: - return false - if cast[int](context.frames.addr) - origin != ContextFrames: - return false - if cast[int](context.arguments.addr) - origin != ContextArguments: - return false - if cast[int](context.registerFile.addr) - origin != ContextRegisterFile: - return false - if cast[int](context.returnTable.addr) - origin != ContextReturnTable: - return false - if cast[int](context.base.addr) - origin != ContextBase: - return false - if cast[int](context.depth.addr) - origin != ContextDepth: - return false - if cast[int](context.routine.addr) - origin != ContextRoutine: - return false - if cast[int](context.runtime.addr) - origin != ContextRuntime: - return false - if cast[int](context.hostCall.addr) - origin != ContextHostCall: - return false - if cast[int](context.step.addr) - origin != ContextStep: - return false - true - -## Region discovery - -proc isCompilable(item: Instruction): bool {.raises: [].} = - ## Reports whether one operation has a modelled integer translation. - case item.op - of MeterOp, JumpOp, StoreGlobalImmediateOp, MoveGlobalOp, - AddGlobalImmediateOp, AddGlobalOp, - LoadImmediateOp, MoveOp, LoadGlobalOp, StoreGlobalOp, - AddOp, SubtractOp, MultiplyOp, NegateOp, - EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, - JumpIfZeroOp, - IntegerDivideOp, - LoadFixedOp, LoadHostDataOp, AddGlobalHostDataOp, - AddGlobalRegisterOp, ModuloGlobalImmediateOp, - ArrayGetOp, ArraySetOp, ArrayAddGlobalsOp, - AddGlobalArrayGlobalIndexOp, - JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp: - true - of ModuloOp: - true - of DivideOp: - # Dividing wraps here and asserts under fixedChecks, so that build - # leaves it alone, as it leaves the rest of fixed point alone. - ModelsFixed and ModelsDivide - of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, - CallOp, ReturnOp, ExitSubOp, HostCallOp: - ModelsCalls - of JumpUnlessGlobalModuloEqualZeroOp: - # The interpreter raises on a zero divisor; refuse rather than model it. - item.b != 0 - else: - false - -proc suitsMemoryGlobals(item: Instruction): bool {.raises: [].} = - ## Reports whether an operation has a form that reaches globals where - ## they sit. Anything touching a global without one keeps the loop out. - case item.op - of LoadGlobalOp, StoreGlobalOp, MoveGlobalOp, SetArgumentGlobalOp, - StoreGlobalImmediateOp, AddGlobalImmediateOp, - JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp: - true - of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp, - ModuloGlobalImmediateOp, ArrayAddGlobalsOp, - AddGlobalArrayGlobalIndexOp, JumpUnlessGlobalModuloEqualZeroOp: - false - else: - true - -proc touchedGlobals(item: Instruction, globals: var seq[int32]) - {.raises: [].} = - ## Records every global one operation reads or writes. - template note(index: int32) = - if index notin globals: - globals.add(index) - case item.op - of StoreGlobalImmediateOp, AddGlobalImmediateOp, - JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp, - JumpUnlessGlobalModuloEqualZeroOp: - note(item.a) - of MoveGlobalOp, AddGlobalOp: - note(item.a) - note(item.b) - of LoadGlobalOp: - note(item.b) - of StoreGlobalOp: - note(item.a) - of SetArgumentGlobalOp: - note(item.b) - of AddGlobalHostDataOp, AddGlobalRegisterOp: - note(item.a) - of ModuloGlobalImmediateOp: - note(item.a) - note(item.b) - of ArrayAddGlobalsOp: - note(item.b) - note(item.c) - of AddGlobalArrayGlobalIndexOp: - note(item.a) - note(item.c) - else: - discard - -proc branchTarget(item: Instruction, target: var int32): bool - {.raises: [].} = - ## Reports whether an operation branches, and to where. - case item.op - of JumpOp: - target = item.a - true - of JumpIfZeroOp: - target = item.b - true - of JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp, - JumpUnlessGlobalModuloEqualZeroOp: - target = item.c - true - else: - false + template at(field: untyped): int = + cast[int](context.field.addr) - origin + at(remainingInstructions) == ContextInstructions and + at(remainingWork) == ContextWork and + at(pc) == ContextOffset and + at(memory) == ContextMemory and + at(hostData) == ContextHostData and + at(frames) == ContextFrames and + at(arguments) == ContextArguments and + at(registerFile) == ContextRegisterFile and + at(table) == ContextTable and + at(base) == ContextBase and + at(depth) == ContextDepth and + at(routine) == ContextRoutine and + at(runtime) == ContextRuntime and + at(step) == ContextStep -proc touchedSlots(item: Instruction, slots: var seq[int32]) - {.raises: [].} = - ## Records every register slot one operation reads or writes. - template note(index: int32) = - slots.add(index) - case item.op - of LoadImmediateOp, LoadGlobalOp: - note(item.a) - of JumpIfZeroOp: - # The slot tested is the first operand; the second is where to go. - note(item.a) - of StoreGlobalOp, NegateOp: - note(item.b) - of MoveOp: - note(item.a) - note(item.b) - of AddOp, SubtractOp, MultiplyOp, ModuloOp, IntegerDivideOp, DivideOp, - EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp: - note(item.a) - note(item.b) - note(item.c) - of LoadFixedOp, LoadHostDataOp: - note(item.a) - of HostCallOp: - if item.a >= 0: - note(item.a) - of AddGlobalRegisterOp, SetArgumentOp: - note(item.b) - of ArrayGetOp: - note(item.a) - note(item.c) - of ArraySetOp: - note(item.b) - note(item.c) - else: - discard - if item.op == NegateOp: - note(item.a) - -proc namedArray(item: Instruction, id: var int32): bool {.raises: [].} = - ## Reports whether an operation reaches into an array, and which one. - case item.op - of ArrayGetOp, AddGlobalArrayGlobalIndexOp: - id = item.b - true - of ArraySetOp, ArrayAddGlobalsOp: - id = item.a - true - else: - false - -proc usesRegisterFile(item: Instruction): bool {.raises: [].} = - ## Reports whether an operation reaches into the frame's slots. - var slots: seq[int32] - item.touchedSlots(slots) - slots.len > 0 +type + Machine* = ref object + ## One whole program compiled to machine code. + size*: int + listing*: seq[byte] + table: seq[pointer] + buffer: CodeBuffer + call: NativeCall -proc comparisonTest(op: Op): Test {.raises: [].} = - ## Returns the condition a comparison answers true on. + Home = enum + ## Where a value lives. + SlotHome, + GlobalHome, + ArgumentHome, + HostHome, + CellHome + + Place = object + ## One value's address, as a home and an index into it. + home: Home + index: int32 + + Branching = enum + ## Where a slow path carries on once the interpreter's code has run. + ToNext, + ToOffset + + Check = enum + ## An architecture-neutral comparison outcome. + EqualCheck, + NotEqualCheck, + LessCheck, + LessEqualCheck, + GreaterCheck, + GreaterEqualCheck + +when NativeArm64 or NativeAmd64: + type + Stub = object + ## A slow path, placed after the block its operation sits in. + label: Label + offset: int32 + carry: Branching + +proc slot(index: int32): Place {.inline, raises: [].} = + ## Names a register slot in the current frame. + Place(home: SlotHome, index: index) + +proc global(index: int32): Place {.inline, raises: [].} = + ## Names a scalar global. + Place(home: GlobalHome, index: index) + +proc argument(index: int32): Place {.inline, raises: [].} = + ## Names a staged call argument. + Place(home: ArgumentHome, index: index) + +proc host(index: int32): Place {.inline, raises: [].} = + ## Names a host data value. + Place(home: HostHome, index: index) + +proc cell(): Place {.inline, raises: [].} = + ## Names the array cell whose address was just worked out. + Place(home: CellHome) + +proc comparisonCheck(op: Op): Check {.raises: [].} = + ## Returns the outcome a comparison answers true on. case op - of EqualOp: EqualTest - of NotEqualOp: NotEqualTest - of LessOp: LessTest - of LessEqualOp: LessEqualTest - of GreaterOp: GreaterTest - else: GreaterEqualTest - -proc takenOn(op: Op): Test {.raises: [].} = - ## Returns the condition on which a fused test takes its branch. + of EqualOp: EqualCheck + of NotEqualOp: NotEqualCheck + of LessOp: LessCheck + of LessEqualOp: LessEqualCheck + of GreaterOp: GreaterCheck + else: GreaterEqualCheck + +proc takenOn(op: Op): Check {.raises: [].} = + ## Returns the outcome on which a fused test takes its branch. case op - of JumpUnlessGlobalEqualImmediateOp: NotEqualTest - of JumpUnlessGlobalNotEqualImmediateOp: EqualTest - of JumpUnlessGlobalLessImmediateOp: GreaterEqualTest - of JumpUnlessGlobalLessEqualImmediateOp: GreaterTest - of JumpUnlessGlobalGreaterImmediateOp: LessEqualTest - of JumpUnlessGlobalGreaterEqualImmediateOp: LessTest - else: NotEqualTest - -type - LoopPlan = object - ## What one pass through a loop costs, when that is knowable. - ## - ## A loop whose body has no internal branch runs the same operations - ## every time around, so its budget can be settled once on entry - ## instead of at every block. The counter below then replaces both - ## budget checks, and the interpreter is handed back a count to charge. - counted: bool - spending: bool - instructions: int64 - work: int64 - passInstructions: int64 - passWork: int64 - partialInstructions: seq[int64] - partialWork: seq[int64] - checkpoints: seq[bool] - -proc planLoop(code: seq[Instruction], start, stop: int): LoopPlan - {.raises: [].} = - ## Measures one pass through a loop and reports whether it is countable. - result.partialInstructions = newSeq[int64](stop - start) - result.partialWork = newSeq[int64](stop - start) - result.checkpoints = newSeq[bool](stop - start) - if stop - start < 2: - return - let last = code[stop - 1] - if last.op != JumpOp or int(last.a) != start: - return - if code[start].op != MeterOp: - return - # Every meter in the region bounds what a single pass can cost, however - # the branches inside it fall. - for index in start ..< stop: - if code[index].op == MeterOp: - result.passInstructions += int64(code[index].b) - result.passWork += int64(code[index].a) - - # Every backward branch is a place the spending must be looked at. An - # inner loop turning under an outer one would otherwise run as long as - # it liked between two looks, because the outer header is only reached - # once for however many times the inner one goes round. What runs - # between two checks then contains no backward branch, so it can charge - # no more than one pass, which is what the limit is set against. - result.checkpoints[0] = true - for index in start ..< stop: - var target = 0'i32 - if code[index].branchTarget(target): - if int(target) >= start and int(target) <= index: - result.checkpoints[int(target) - start] = true - for offset, wanted in result.checkpoints: - if wanted and code[start + offset].op != MeterOp: - # Nowhere to put the look, so this loop keeps the per-block check. - return - # Every pass runs the meter at the loop head, so charging at least one - # instruction there is what stops a pass from spending nothing and - # looping for ever against an unmoving total. - result.spending = result.passInstructions > 0 and result.passWork > 0 and - code[start].b > 0 - for index in start ..< stop: - if code[index].op == MeterOp: - if code[index].b < 0 or code[index].b > MaxChargeImmediate or - code[index].a < 0 or code[index].a > MaxChargeImmediate: - result.spending = false - var - instructions = 0'i64 - work = 0'i64 - for index in start ..< stop: - let item = code[index] - result.partialInstructions[index - start] = instructions - result.partialWork[index - start] = work - if item.op == MeterOp: - instructions += int64(item.b) - work += int64(item.a) - var target = 0'i32 - if item.branchTarget(target): - if index == stop - 1: - continue - # Any branch back into the loop means the cost of a pass depends on - # which way it went, so the count would not be a count. - if int(target) >= start and int(target) < stop: - return - if instructions <= 0 or work <= 0: - return - result.instructions = instructions - result.work = work - result.counted = true - -proc pooledConstants(code: seq[Instruction], start, stop, room: int): - seq[int64] {.raises: [].} = - ## Collects the compare constants worth holding in a register, which are - ## the ones too wide for an immediate and so rebuilt on every pass. - for index in start ..< stop: - let item = code[index] - case item.op - of JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp: - if item.b >= 0 and item.b <= 4095: - continue - let value = int64(item.b) - if value notin result and result.len < room: - result.add(value) - else: - discard - -proc anyTarget(item: Instruction, target: var int32): bool {.raises: [].} = - ## Reports where an operation can send control, for every operation that - ## can send it anywhere. This is wider than the set the code generator - ## models: a subroutine call or a register test the generator refuses to - ## compile can still name an offset inside a loop it did compile. - case item.op - of JumpOp, GosubOp, ReturnLabelOp: - target = item.a - true - of JumpIfZeroOp: - target = item.b - true - else: - item.branchTarget(target) - -proc calledRoutine(item: Instruction, id: var int32): bool {.raises: [].} = - ## Reports whether an operation calls a routine, and which one. - if item.op == CallOp: - id = item.a - true - else: - false - -proc gatherCalled(code: seq[Instruction], members: var seq[int32], - covered: var seq[bool], routines: seq[RoutineExtent]): bool - {.raises: [].} = - ## Grows a region to take in the body of everything it calls, and the - ## bodies of everything those call in turn. Returns false if any of it - ## cannot be reached or would not fit. - var pending = 0 - while pending < members.len: - let offset = int(members[pending]) - inc pending - var id = 0'i32 - if not code[offset].calledRoutine(id): - continue - if id < 0 or int(id) >= routines.len: - return false - let routine = routines[int(id)] - if routine.length <= 0 or routine.entry < 0: - return false - if int(routine.entry) + int(routine.length) > code.len: - return false - if covered[int(routine.entry)]: - continue - for step in 0 ..< int(routine.length): - let inside = int(routine.entry) + step - if covered[inside]: - # Two routines cannot share code, so an overlap means the table - # says something this does not understand. - return false - covered[inside] = true - members.add(int32(inside)) - true - -proc reachesOutside(code: seq[Instruction], start, stop: int): bool - {.raises: [].} = - ## Reports whether the loop can be entered anywhere but its first offset. - ## Compiled code proves its globals are integers and loads them into - ## registers on the way in, so arriving anywhere else would skip the - ## proof and read registers that were never filled. - for index in 0 ..< code.len: - if index >= start and index < stop: - continue - var target = 0'i32 - if code[index].anyTarget(target): - if int(target) > start and int(target) < stop: - return true - false + of JumpUnlessGlobalEqualImmediateOp: NotEqualCheck + of JumpUnlessGlobalNotEqualImmediateOp: EqualCheck + of JumpUnlessGlobalLessImmediateOp: GreaterEqualCheck + of JumpUnlessGlobalLessEqualImmediateOp: GreaterCheck + of JumpUnlessGlobalGreaterImmediateOp: LessEqualCheck + else: LessCheck when NativeArm64: ## AArch64 code generation ## - ## x0 context pointer, live for the whole region - ## x19 base of the globals array - ## x20 remaining instruction budget - ## x21 remaining work budget - ## x22+ hoisted globals, one per entry in the region's list - ## x9, x10 scratch; x11, x12 resume offset and status + ## x19 context x20 instruction budget x21 work budget + ## x22 globals x23 current frame x24 offset table + ## x25 array cells x26 arguments x27 frames + ## x28 register file + ## x9 .. x15 working registers; x16 far addresses; x17 one cell + ## + ## Everything long lived sits in a register the platform's convention + ## keeps across a call, so calling back into the interpreter's code + ## costs no saving beyond the two budgets it may charge. const - Context = x0 - GlobalsBase = x19 + Context = x19 Instructions = x20 Work = x21 - FirstHoisted = 22 - Scratch = x9 - OtherScratch = x10 - ResumeOffset = x11 - ResumeStatus = x12 - Counter = x13 - Allowance = x14 - RegistersBase = x15 - ValueScratch = [x16, x17] - SpentInstructions = x5 - SpentWork = x6 - LimitInstructions = x7 - LimitWork = x8 - FirstPooled = 1 - FrameBytes = 112 - ## Where the context and the frame base are kept while host code runs, - ## both of them being registers a callee is free to use. - AcrossHostCall = 96 - MaxDisplacement = 4095 - - const MaxPooled* = 4 - - proc pooledRegister(slot: int): Register {.raises: [].} = - ## Returns the caller-saved register holding one hoisted constant. - Register(uint32(FirstPooled + slot)) - - proc loadPooled(emitter: var Assembler, slot: int, value: int64) - {.raises: [BasicError].} = - ## Materializes a loop-invariant constant once, before the loop. - emitter.loadImmediate(Word32, pooledRegister(slot), value) - - proc slotRegister(slot: int): Register {.raises: [].} = - ## Returns the callee-saved register holding one hoisted global. - Register(uint32(FirstHoisted + slot)) - - proc nativeCondition(test: Test): Condition {.raises: [].} = - ## Maps a neutral condition onto the architecture's encoding. - case test - of EqualTest: EqualCondition - of NotEqualTest: NotEqualCondition - of LessTest: LessCondition - of LessEqualTest: LessEqualCondition - of GreaterTest: GreaterCondition - of GreaterEqualTest: GreaterEqualCondition - - proc branchWhen(emitter: var Assembler, test: Test, target: Label) + GlobalsBase = x22 + RegistersBase = x23 + TableBase = x24 + MemoryBase = x25 + ArgumentsBase = x26 + FramesBase = x27 + FileBase = x28 + Temps = [x9, x10, x11, x12, x13, x14, x15] + Far = x16 + Cell = x17 + FrameBytes = 96 + NearBytes = 4095 - ValuePayload + + proc temp(index: int): Register {.inline, raises: [].} = + ## Returns one working register. + Temps[index] + + proc nativeCondition(check: Check): Condition {.raises: [].} = + ## Maps a neutral outcome onto the architecture's encoding. + case check + of EqualCheck: EqualCondition + of NotEqualCheck: NotEqualCondition + of LessCheck: LessCondition + of LessEqualCheck: LessEqualCondition + of GreaterCheck: GreaterCondition + of GreaterEqualCheck: GreaterEqualCondition + + proc inverse(condition: Condition): Condition {.raises: [].} = + ## Returns the condition that holds exactly when this one does not. + Condition(ord(condition) xor 1) + + type + Emitter = object + ## The assembler plus whether branches must reach anywhere at all. + code: Assembler + far: bool + + proc label(e: var Emitter): Label {.inline, raises: [].} = + ## Reserves a label. + e.code.label() + + proc place(e: var Emitter, target: Label) {.inline, raises: [].} = + ## Places a label here. + e.code.place(target) + + proc jump(e: var Emitter, target: Label) {.raises: [].} = + ## Jumps unconditionally. + e.code.branch(target) + + proc jumpWhen(e: var Emitter, condition: Condition, target: Label) {.raises: [].} = - ## Branches when the neutral condition holds. - emitter.branchIf(nativeCondition(test), target) - - proc startRegion(emitter: var Assembler) {.raises: [BasicError].} = - ## Saves callee-saved registers and loads the interpreter state. - emitter.storePair( - framePointer, linkRegister, stackPointer, -FrameBytes, true - ) - emitter.storePair(x19, x20, stackPointer, 16) - emitter.storePair(x21, x22, stackPointer, 32) - emitter.storePair(x23, x24, stackPointer, 48) - emitter.storePair(x25, x26, stackPointer, 64) - emitter.storePair(x27, x28, stackPointer, 80) - emitter.loadDouble(GlobalsBase, Context, 0) - emitter.loadDouble(Instructions, Context, ContextInstructions) - emitter.loadDouble(Work, Context, ContextWork) - - proc endRegion(emitter: var Assembler) {.raises: [BasicError].} = - ## Restores callee-saved registers and returns to the interpreter. - emitter.loadPair(x19, x20, stackPointer, 16) - emitter.loadPair(x21, x22, stackPointer, 32) - emitter.loadPair(x23, x24, stackPointer, 48) - emitter.loadPair(x25, x26, stackPointer, 64) - emitter.loadPair(x27, x28, stackPointer, 80) - emitter.loadPair( - framePointer, linkRegister, stackPointer, FrameBytes, true - ) - emitter.returnToCaller() - - proc guardInteger(emitter: var Assembler, base: int, failed: Label) - {.raises: [BasicError].} = - ## Leaves the region unless the global at this offset holds an integer. - emitter.loadByte(Scratch, GlobalsBase, base) - emitter.branchIfNotZero(Word32, Scratch, failed) - - proc loadHoisted(emitter: var Assembler, slot: int, base: int) - {.raises: [BasicError].} = - ## Reads one global into its register. - emitter.loadWord(slotRegister(slot), GlobalsBase, base + ValuePayload) - - proc storeHoisted(emitter: var Assembler, slot: int, base: int) - {.raises: [BasicError].} = - ## Publishes one register back as an integer value. - emitter.storeByte(zeroRegister, GlobalsBase, base) - emitter.storeWord(slotRegister(slot), GlobalsBase, base + ValuePayload) - - proc setSlot(emitter: var Assembler, slot: int, value: int32) - {.raises: [BasicError].} = - ## Loads a constant into a hoisted register. - emitter.loadImmediate(Word32, slotRegister(slot), int64(value)) - - proc copySlot(emitter: var Assembler, destination, source: int) - {.raises: [BasicError].} = - ## Copies one hoisted register into another. - emitter.moveRegister( - Word32, slotRegister(destination), slotRegister(source) - ) + ## Jumps when a condition holds, however far away the target is. + if e.far: + let skip = e.code.label() + e.code.branchIf(condition.inverse, skip) + e.code.branch(target) + e.code.place(skip) + else: + e.code.branchIf(condition, target) - proc addSlots(emitter: var Assembler, destination, source: int) - {.raises: [BasicError].} = - ## Adds one hoisted register into another, wrapping on overflow. - let target = slotRegister(destination) - emitter.addRegister(Word32, target, target, slotRegister(source)) + proc jumpIfZero(e: var Emitter, register: Register, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + if e.far: + let skip = e.code.label() + e.code.branchIfNotZero(Word32, register, skip) + e.code.branch(target) + e.code.place(skip) + else: + e.code.branchIfZero(Word32, register, target) - proc addToSlot(emitter: var Assembler, slot: int, value: int32) - {.raises: [BasicError].} = - ## Adds a constant to a hoisted register, wrapping on overflow. - let target = slotRegister(slot) - if value >= 0 and value <= MaxDisplacement: - emitter.addImmediate(Word32, target, target, int(value)) - elif value < 0 and value >= -MaxDisplacement: - emitter.subtractImmediate(Word32, target, target, int(-value)) + proc jumpIfNotZero(e: var Emitter, register: Register, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + if e.far: + let skip = e.code.label() + e.code.branchIfZero(Word32, register, skip) + e.code.branch(target) + e.code.place(skip) else: - emitter.loadImmediate(Word32, Scratch, int64(value)) - emitter.addRegister(Word32, target, target, Scratch) - - proc compareSlot(emitter: var Assembler, slot: int, value: int32, - pooled = -1) {.raises: [BasicError].} = - ## Sets flags from a hoisted register against a constant, using the - ## register that already holds it when the loop has one. - let target = slotRegister(slot) - if pooled >= 0: - emitter.compareRegister(Word32, target, pooledRegister(pooled)) - elif value >= 0 and value <= MaxDisplacement: - emitter.compareImmediate(Word32, target, int(value)) + e.code.branchIfNotZero(Word32, register, target) + + proc reach(e: var Emitter, place: Place): (Register, int) + {.raises: [BasicError].} = + ## Returns a base register and byte offset for a value, working the + ## address out in full when the offset is too wide to encode. + var base = Cell + case place.home + of SlotHome: base = RegistersBase + of GlobalHome: base = GlobalsBase + of ArgumentHome: base = ArgumentsBase + of HostHome: + e.code.loadDouble(Cell, Context, ContextHostData) + of CellHome: + return (Cell, 0) + let offset = int(place.index) * ValueStride + if offset <= NearBytes: + return (base, offset) + e.code.loadImmediate(Word64, Far, int64(offset)) + e.code.addRegister(Word64, Far, base, Far) + (Far, 0) + + proc readValue(e: var Emitter, value, tag: int, place: Place) + {.raises: [BasicError].} = + ## Reads a value's kind and its 32-bit payload. + let (base, offset) = e.reach(place) + e.code.loadByte(temp(tag), base, offset) + e.code.loadWord(temp(value), base, offset + ValuePayload) + + proc writeWhole(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a whole number. + let (base, offset) = e.reach(place) + e.code.storeByte(zeroRegister, base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeKind(e: var Emitter, place: Place, tag, value: int) + {.raises: [BasicError].} = + ## Writes a payload under the kind held in a working register. + let (base, offset) = e.reach(place) + e.code.storeByte(temp(tag), base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeFixed(e: var Emitter, place: Place, value: int) + {.raises: [BasicError].} = + ## Writes a fixed-point payload. + let (base, offset) = e.reach(place) + e.code.loadImmediate(Word32, temp(5), FixedTag) + e.code.storeByte(temp(5), base, offset) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) + {.raises: [BasicError].} = + ## Writes a constant of a known kind. + e.code.loadImmediate(Word32, temp(6), int64(bits)) + let (base, offset) = e.reach(place) + if tag == 0: + e.code.storeByte(zeroRegister, base, offset) else: - emitter.loadImmediate(Word32, Scratch, int64(value)) - emitter.compareRegister(Word32, target, Scratch) - - proc loadRegistersBase(emitter: var Assembler) {.raises: [BasicError].} = - ## Points at the first slot of the frame the region runs in. - emitter.loadDouble(RegistersBase, Context, ContextRegisters) - - proc readSlot(emitter: var Assembler, scratch: int, slot: int32, - leave: Label) {.raises: [BasicError].} = - ## Reads one register slot as an integer, leaving the region if it - ## holds anything else. The tag is read into the same register the - ## value will land in, so no third register is needed. - let target = ValueScratch[scratch] - let base = int(slot) * ValueStride - emitter.loadByte(target, RegistersBase, base) - emitter.branchIfNotZero(Word32, target, leave) - emitter.loadWord(target, RegistersBase, base + ValuePayload) - - proc writeSlot(emitter: var Assembler, scratch: int, slot: int32) - {.raises: [BasicError].} = - ## Writes one register slot as an integer. - let base = int(slot) * ValueStride - emitter.storeByte(zeroRegister, RegistersBase, base) - emitter.storeWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) + e.code.loadImmediate(Word32, temp(5), int64(tag)) + e.code.storeByte(temp(5), base, offset) + e.code.storeWord(temp(6), base, offset + ValuePayload) + + proc copyValue(e: var Emitter, destination, source: Place) + {.raises: [BasicError].} = + ## Copies a value entire, whatever kind it holds, as the interpreter + ## does. + let (fromBase, fromOffset) = e.reach(source) + e.code.loadDouble(temp(5), fromBase, fromOffset) + e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) + let (toBase, toOffset) = e.reach(destination) + e.code.storeDouble(temp(5), toBase, toOffset) + e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) + + proc unlessWhole(e: var Emitter, tag: int, slow: Label) + {.raises: [].} = + ## Takes the slow path unless a kind says whole number. + e.jumpIfNotZero(temp(tag), slow) - proc setScratch(emitter: var Assembler, scratch: int, value: int32) + proc unlessNumeric(e: var Emitter, tag: int, slow: Label) {.raises: [BasicError].} = - ## Loads a constant into a working register. - emitter.loadImmediate(Word32, ValueScratch[scratch], int64(value)) + ## Takes the slow path unless a kind says number of either sort. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(UnsignedGreaterCondition, slow) - proc addScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Adds the second working register into the first, wrapping. - emitter.addRegister(Word32, ValueScratch[left], ValueScratch[left], - ValueScratch[right]) + proc unlessSame(e: var Emitter, tag, other: int, slow: Label) + {.raises: [].} = + ## Takes the slow path unless two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(NotEqualCondition, slow) - proc subtractScratch(emitter: var Assembler, left, right: int) + proc whenFixed(e: var Emitter, tag: int, target: Label) {.raises: [BasicError].} = - ## Subtracts the second working register from the first, wrapping. - emitter.subtractRegister(Word32, ValueScratch[left], ValueScratch[left], - ValueScratch[right]) + ## Jumps when a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.code.branchIf(EqualCondition, target) - proc multiplyScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Multiplies the first working register by the second, wrapping. - emitter.multiply(Word32, ValueScratch[left], ValueScratch[left], - ValueScratch[right]) + proc loadConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = + ## Loads a constant into a working register. + e.code.loadImmediate(Word32, temp(value), int64(bits)) - proc negateScratch(emitter: var Assembler, scratch: int) - {.raises: [BasicError].} = - ## Replaces a working register with its negation, wrapping. - emitter.negate(Word32, ValueScratch[scratch], ValueScratch[scratch]) + proc add(e: var Emitter, left, right: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, temp(left), temp(left), temp(right)) - proc compareScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Sets flags from two working registers. - emitter.compareRegister(Word32, ValueScratch[left], ValueScratch[right]) + proc subtract(e: var Emitter, left, right: int) {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, temp(left), temp(left), temp(right)) - proc answerCondition(emitter: var Assembler, scratch: int, test: Test) - {.raises: [BasicError].} = - ## Writes BASIC's -1 for true and zero for false. - emitter.setOnCondition(Word32, ValueScratch[scratch], - nativeCondition(test)) + proc multiply(e: var Emitter, left, right: int) {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiply(Word32, temp(left), temp(left), temp(right)) - proc scratchFromHoisted(emitter: var Assembler, scratch, slot: int) - {.raises: [BasicError].} = - ## Copies a hoisted global into a working register. - emitter.moveRegister(Word32, ValueScratch[scratch], slotRegister(slot)) + proc negate(e: var Emitter, value: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negate(Word32, temp(value), temp(value)) - proc hoistedFromScratch(emitter: var Assembler, slot, scratch: int) - {.raises: [BasicError].} = - ## Copies a working register into a hoisted global. - emitter.moveRegister(Word32, slotRegister(slot), ValueScratch[scratch]) - - proc branchIfScratchZero(emitter: var Assembler, scratch: int, - target: Label) {.raises: [BasicError].} = - ## Branches when a working register holds zero. - emitter.branchIfZero(Word32, ValueScratch[scratch], target) - - proc readNumeric(emitter: var Assembler, scratch: int, slot: int32, - leave: Label) {.raises: [BasicError].} = - ## Reads a slot's tag into Scratch and its payload into a working - ## register, leaving the region for anything that is not a number. - let base = int(slot) * ValueStride - emitter.loadByte(Scratch, RegistersBase, base) - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(UnsignedGreaterCondition, leave) - emitter.loadWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) - - proc requireSameKind(emitter: var Assembler, slot: int32, leave: Label) - {.raises: [BasicError].} = - ## Leaves the region unless a second slot carries the same tag as the - ## one already held. Whole numbers and fixed-point ones add, subtract - ## and compare through the very same instructions, so a pair that - ## agrees needs no further telling apart; a mixed pair would have to - ## be promoted, which can fail, so it goes back to the interpreter. - emitter.loadByte(OtherScratch, RegistersBase, int(slot) * ValueStride) - emitter.compareRegister(Word32, Scratch, OtherScratch) - emitter.branchIf(NotEqualCondition, leave) - - proc writeNumeric(emitter: var Assembler, scratch: int, slot: int32) - {.raises: [BasicError].} = - ## Writes a payload back under the tag the operands carried. - let base = int(slot) * ValueStride - emitter.storeByte(Scratch, RegistersBase, base) - emitter.storeWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) + proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits both hold. + e.code.andRegister(Word32, temp(left), temp(left), temp(right)) - proc branchIfFixed(emitter: var Assembler, target: Label) - {.raises: [BasicError].} = - ## Branches when the held tag says fixed point. - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(EqualCondition, target) + proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits either holds. + e.code.orRegister(Word32, temp(left), temp(left), temp(right)) - proc requireWholeKind(emitter: var Assembler, leave: Label) - {.raises: [BasicError].} = - ## Leaves the region unless the held tag says whole number. - emitter.compareImmediate(Word32, Scratch, 0) - emitter.branchIf(NotEqualCondition, leave) + proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits exactly one holds. + e.code.xorRegister(Word32, temp(left), temp(left), temp(right)) - proc readSlotValue(emitter: var Assembler, scratch: int, slot: int32) - {.raises: [BasicError].} = - ## Reads a slot's payload, its tag having already been established. - emitter.loadWord(ValueScratch[scratch], RegistersBase, - int(slot) * ValueStride + ValuePayload) + proc bitNot(e: var Emitter, value: int) {.raises: [].} = + ## Flips every bit. + e.code.notRegister(Word32, temp(value), temp(value)) - proc guardDivisor(emitter: var Assembler, scratch: int, leave: Label) - {.raises: [BasicError].} = - ## Leaves the region for the two divisors that are not plain division: - ## zero, which the interpreter refuses, and minus one, which the other - ## architecture traps on. - emitter.compareImmediate(Word32, ValueScratch[scratch], 0) - emitter.branchIf(EqualCondition, leave) - emitter.loadImmediate(Word32, OtherScratch, -1) - emitter.compareRegister(Word32, ValueScratch[scratch], OtherScratch) - emitter.branchIf(EqualCondition, leave) - - proc quotientScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Divides the first working register by the second, toward zero. - emitter.signedDivide(Word32, ValueScratch[left], ValueScratch[left], - ValueScratch[right]) + proc quotient(e: var Emitter, left, right: int) {.raises: [].} = + ## Divides toward zero; the divisor is known not to be zero. The most + ## negative number over minus one wraps back to itself here, which is + ## the answer the interpreter defines. + e.code.signedDivide(Word32, temp(left), temp(left), temp(right)) - proc remainderScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Leaves what the division of the two working registers left over. - emitter.signedDivide(Word32, OtherScratch, ValueScratch[left], - ValueScratch[right]) - emitter.multiplySubtract(Word32, ValueScratch[left], OtherScratch, - ValueScratch[right], ValueScratch[left]) + proc remainder(e: var Emitter, left, right: int) {.raises: [].} = + ## Leaves what dividing left over, with the sign of the dividend. + e.code.signedDivide(Word32, temp(6), temp(left), temp(right)) + e.code.multiplySubtract(Word32, temp(left), temp(6), temp(right), + temp(left)) - proc multiplyFixed(emitter: var Assembler, left, right: int) + proc multiplyFixed(e: var Emitter, left, right: int) {.raises: [BasicError].} = ## Multiplies two Q16.16 numbers through a widened intermediate, ## rounding to nearest exactly as the fixed-point library does. - emitter.signedMultiplyLong(OtherScratch, ValueScratch[left], - ValueScratch[right]) - emitter.loadImmediate(Word64, ValueScratch[right], FixedRounding) - emitter.addRegister(Word64, OtherScratch, OtherScratch, - ValueScratch[right]) - emitter.arithmeticShiftRight(Word64, OtherScratch, OtherScratch, - FixedShift) - emitter.moveRegister(Word32, ValueScratch[left], OtherScratch) - - proc elementAddress(emitter: var Assembler, scratch: int, - extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = - ## Bounds checks an index and leaves the cell's address in Scratch. - ## One unsigned comparison covers both ends, exactly as the - ## interpreter's does, and a refusal hands the offset back so the - ## interpreter can raise with the array's own name. - let index = ValueScratch[scratch] - emitter.loadImmediate(Word32, OtherScratch, int64(extent.length)) - emitter.compareRegister(Word32, index, OtherScratch) - emitter.branchIf(CarrySetCondition, leave) - emitter.loadImmediate(Word32, OtherScratch, int64(extent.base)) - emitter.addRegister(Word32, OtherScratch, OtherScratch, index) - emitter.loadDouble(Scratch, Context, ContextMemory) - emitter.addRegister(Word64, Scratch, Scratch, OtherScratch, 4) - - proc copyElementToSlot(emitter: var Assembler, slot: int32) - {.raises: [BasicError].} = - ## Copies a whole cell into a register slot, whatever it holds. The - ## interpreter copies the value entire, so this does too, and neither - ## needs to know what kind it is. - let base = int(slot) * ValueStride - emitter.loadDouble(ValueScratch[0], Scratch, 0) - emitter.loadDouble(ValueScratch[1], Scratch, ValuePayload) - emitter.storeDouble(ValueScratch[0], RegistersBase, base) - emitter.storeDouble(ValueScratch[1], RegistersBase, base + ValuePayload) - - proc copySlotToElement(emitter: var Assembler, slot: int32) - {.raises: [BasicError].} = - ## Copies a whole register slot into a cell, whatever it holds. - let base = int(slot) * ValueStride - emitter.loadDouble(ValueScratch[0], RegistersBase, base) - emitter.loadDouble(ValueScratch[1], RegistersBase, base + ValuePayload) - emitter.storeDouble(ValueScratch[0], Scratch, 0) - emitter.storeDouble(ValueScratch[1], Scratch, ValuePayload) - - proc readElement(emitter: var Assembler, scratch: int, leave: Label) - {.raises: [BasicError].} = - ## Reads a cell as an integer, leaving the region if it holds else. - let target = ValueScratch[scratch] - emitter.loadByte(target, Scratch, 0) - emitter.branchIfNotZero(Word32, target, leave) - emitter.loadWord(target, Scratch, ValuePayload) - - proc writeElement(emitter: var Assembler, scratch: int) - {.raises: [BasicError].} = - ## Writes a cell as an integer. - emitter.storeByte(zeroRegister, Scratch, 0) - emitter.storeWord(ValueScratch[scratch], Scratch, ValuePayload) - - proc beginCountedLoop(emitter: var Assembler, instructions, work: int64, - refused: Label) {.raises: [BasicError].} = - ## Settles the whole loop's budget once: how many passes both budgets - ## can certainly afford. One pass is held back so that the pass which - ## finally leaves the loop, charging up to a full pass on the way out, - ## still cannot overrun. - emitter.loadImmediate(Word64, Scratch, instructions) - emitter.signedDivide(Word64, Allowance, Instructions, Scratch) - emitter.loadImmediate(Word64, Scratch, work) - emitter.signedDivide(Word64, OtherScratch, Work, Scratch) - let smaller = emitter.label() - emitter.compareRegister(Word64, Allowance, OtherScratch) - emitter.branchIf(LessEqualCondition, smaller) - emitter.moveRegister(Word64, Allowance, OtherScratch) - emitter.place(smaller) - emitter.subtractImmediate(Word64, Allowance, Allowance, 1) - # Fewer than one affordable pass means the loop must go back to the - # interpreter, which alone can refuse the budget at the right place. - # Accepting zero here would hand the same offset back for ever. - emitter.compareImmediate(Word64, Allowance, 1) - emitter.branchIf(LessCondition, refused) - emitter.loadImmediate(Word64, Counter, 0) - - proc checkCounter(emitter: var Assembler, handBack: Label) - {.raises: [BasicError].} = - ## Leaves the loop once the settled number of passes is used up. - emitter.compareRegister(Word64, Counter, Allowance) - emitter.branchIf(GreaterEqualCondition, handBack) - - proc advanceCounter(emitter: var Assembler) {.raises: [BasicError].} = - ## Records that one more pass finished. - emitter.addImmediate(Word64, Counter, Counter, 1) + e.code.signedMultiplyLong(temp(left), temp(left), temp(right)) + e.code.loadImmediate(Word64, temp(6), FixedRounding) + e.code.addRegister(Word64, temp(left), temp(left), temp(6)) + e.code.arithmeticShiftRight(Word64, temp(left), temp(left), FixedShift) + e.code.moveRegister(Word32, temp(left), temp(left)) - proc beginSpendingLoop(emitter: var Assembler, - passInstructions, passWork: int64, refused: Label) - {.raises: [BasicError].} = - ## Prepares a loop whose passes differ in cost. Rather than refusing - ## the budget block by block, the loop adds up what it spends and asks - ## once a pass whether another pass could still be afforded outright. - emitter.loadImmediate(Word64, SpentInstructions, 0) - emitter.loadImmediate(Word64, SpentWork, 0) - emitter.loadImmediate(Word64, Scratch, passInstructions) - emitter.subtractRegister( - Word64, LimitInstructions, Instructions, Scratch - ) - emitter.loadImmediate(Word64, Scratch, passWork) - emitter.subtractRegister(Word64, LimitWork, Work, Scratch) - emitter.compareImmediate(Word64, LimitInstructions, 0) - emitter.branchIf(LessCondition, refused) - emitter.compareImmediate(Word64, LimitWork, 0) - emitter.branchIf(LessCondition, refused) - - proc checkSpending(emitter: var Assembler, handBack: Label) + proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) {.raises: [BasicError].} = - ## Leaves the loop while another whole pass is still certainly afforded. - emitter.compareRegister(Word64, SpentInstructions, LimitInstructions) - emitter.branchIf(GreaterCondition, handBack) - emitter.compareRegister(Word64, SpentWork, LimitWork) - emitter.branchIf(GreaterCondition, handBack) + ## Turns a number of either kind into its Q16.16 bits, widened to + ## sixty-four. A whole number outside the fixed-point range cannot + ## become one, which the interpreter refuses, so that goes slow. + let register = temp(value) + let already = e.label() + let ready = e.label() + e.whenFixed(tag, already) + e.code.loadImmediate(Word32, temp(6), 32767) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(GreaterCondition, slow) + e.code.loadImmediate(Word32, temp(6), -32768) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(LessCondition, slow) + e.code.signExtendWord(register, register) + e.code.shiftLeftImmediate(Word64, register, register, FixedShift) + e.jump(ready) + e.place(already) + e.code.signExtendWord(register, register) + e.place(ready) + + proc divideFixed(e: var Emitter, left, right: int, slow: Label) + {.raises: [BasicError].} = + ## Divides two widened Q16.16 numbers, rounding to nearest with halves + ## going up, for either sign, exactly as the fixed-point library + ## does: the signs are put right first, half the divisor is added, + ## and the truncating divide is corrected back to a floor. + let numerator = temp(left) + let denominator = temp(right) + let answer = temp(5) + let leftOver = temp(6) + e.code.compareImmediate(Word64, denominator, 0) + e.jumpWhen(EqualCondition, slow) + let signsSettled = e.label() + e.code.branchIf(GreaterCondition, signsSettled) + e.code.negate(Word64, numerator, numerator) + e.code.negate(Word64, denominator, denominator) + e.place(signsSettled) + e.code.shiftLeftImmediate(Word64, numerator, numerator, FixedShift) + e.code.shiftRightImmediate(Word64, answer, denominator, 1) + e.code.addRegister(Word64, numerator, numerator, answer) + e.code.signedDivide(Word64, answer, numerator, denominator) + e.code.multiplySubtract(Word64, leftOver, answer, denominator, + numerator) + let done = e.label() + e.code.compareImmediate(Word64, leftOver, 0) + e.code.branchIf(EqualCondition, done) + e.code.compareImmediate(Word64, numerator, 0) + e.code.branchIf(GreaterEqualCondition, done) + e.code.subtractImmediate(Word64, answer, answer, 1) + e.place(done) + e.code.moveRegister(Word32, numerator, answer) + + proc compare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two working registers. + e.code.compareRegister(Word32, temp(left), temp(right)) - proc recordSpending(emitter: var Assembler, instructions, work: int64) - {.raises: [BasicError].} = - ## Adds one block's charge, with nothing to test and nowhere to branch. - emitter.addImmediate( - Word64, SpentInstructions, SpentInstructions, int(instructions) - ) - emitter.addImmediate(Word64, SpentWork, SpentWork, int(work)) - - proc chargeSpending(emitter: var Assembler) {.raises: [BasicError].} = - ## Hands back exactly what the passes added up to. - emitter.subtractRegister( - Word64, Instructions, Instructions, SpentInstructions - ) - emitter.subtractRegister(Word64, Work, Work, SpentWork) - - proc chargeCounted(emitter: var Assembler, instructions, work: int64, - partialInstructions, partialWork: int64) {.raises: [BasicError].} = - ## Charges whole passes plus however far the last one got. - emitter.loadImmediate(Word64, Scratch, instructions) - emitter.loadImmediate(Word64, OtherScratch, partialInstructions) - emitter.multiplyAdd(Word64, Scratch, Counter, Scratch, OtherScratch) - emitter.subtractRegister(Word64, Instructions, Instructions, Scratch) - emitter.loadImmediate(Word64, Scratch, work) - emitter.loadImmediate(Word64, OtherScratch, partialWork) - emitter.multiplyAdd(Word64, Scratch, Counter, Scratch, OtherScratch) - emitter.subtractRegister(Word64, Work, Work, Scratch) - - proc lowBitCount(divisor: int32): int {.raises: [].} = - ## Returns how many low bits decide divisibility, when the divisor is - ## a power of two and so only those bits matter. - var magnitude = int64(divisor) - if magnitude < 0: - magnitude = -magnitude - if magnitude < 2 or (magnitude and (magnitude - 1)) != 0: - return 0 - while magnitude > 1: - magnitude = magnitude shr 1 - inc result - - proc remainderTest(emitter: var Assembler, slot: int, divisor: int32) - {.raises: [BasicError].} = - ## Sets flags so NotEqualTest means the remainder is not zero. - let source = slotRegister(slot) - # Truncating division leaves no remainder against a power of two - # exactly when the low bits are clear, for negative values as well, so - # a bit test stands in for a divide and a multiply. - let bits = lowBitCount(divisor) - if bits > 0: - emitter.testLowBits(Word32, source, bits) - return - emitter.loadImmediate(Word32, Scratch, int64(divisor)) - emitter.signedDivide(Word32, OtherScratch, source, Scratch) - emitter.multiplySubtract( - Word32, OtherScratch, OtherScratch, Scratch, source - ) - emitter.compareImmediate(Word32, OtherScratch, 0) - - proc budgetGate(emitter: var Assembler, instructionCount, workCost: int64, - short: Label) {.raises: [BasicError].} = - ## Checks both budgets before charging either, as the interpreter does. - emitter.loadImmediate(Word64, Scratch, instructionCount) - emitter.compareRegister(Word64, Instructions, Scratch) - emitter.branchIf(LessCondition, short) - emitter.loadImmediate(Word64, OtherScratch, workCost) - emitter.compareRegister(Word64, Work, OtherScratch) - emitter.branchIf(LessCondition, short) - emitter.subtractRegister(Word64, Instructions, Instructions, Scratch) - emitter.subtractRegister(Word64, Work, Work, OtherScratch) - - proc exitStub(emitter: var Assembler, offset: int32, status: int32, - writeback: Label) {.raises: [BasicError].} = - ## Names the resume offset and status, then joins the shared exit. - emitter.loadImmediate(Word32, ResumeOffset, int64(offset)) - emitter.loadImmediate(Word32, ResumeStatus, int64(status)) - emitter.branch(writeback) - - proc publishState(emitter: var Assembler) {.raises: [BasicError].} = - ## Writes the budgets, the resume offset, and the status. - emitter.storeDouble(Instructions, Context, ContextInstructions) - emitter.storeDouble(Work, Context, ContextWork) - emitter.storeWord(ResumeOffset, Context, ContextOffset) - emitter.moveRegister(Word32, Context, ResumeStatus) - - proc guardExit(emitter: var Assembler, start: int32) - {.raises: [BasicError].} = - ## Hands the loop back untouched after a failed guard. - emitter.loadImmediate(Word32, ResumeOffset, int64(start)) - emitter.storeWord(ResumeOffset, Context, ContextOffset) - emitter.loadImmediate(Word32, Context, int64(ord(NativeGuardFailed))) - emitter.endRegion() - proc setSlotConstant(emitter: var Assembler, slot: int32, tag: int, - bits: int32) {.raises: [BasicError].} = - ## Writes a constant of a known kind straight into a slot. - let base = int(slot) * ValueStride - emitter.loadImmediate(Word32, Scratch, int64(tag)) - emitter.storeByte(Scratch, RegistersBase, base) - emitter.loadImmediate(Word32, ValueScratch[0], int64(bits)) - emitter.storeWord(ValueScratch[0], RegistersBase, base + ValuePayload) - ## Calls - ## - ## Nothing is called in the machine's sense: the frame goes into the - ## interpreter's own array, the base and depth into its own fields, and - ## control simply jumps to the callee's compiled code. Every piece of - ## state a call moves therefore stays where the interpreter looks for - ## it, so leaving part way through a call costs nothing to arrange. - - proc stageArgumentFromSlot(emitter: var Assembler, index: int32, - slot: int32) {.raises: [BasicError].} = - ## Copies a slot into an argument, entire, whatever kind it holds. - emitter.loadDouble(Scratch, Context, ContextArguments) - emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) - emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) - emitter.copySlotToElement(slot) - - proc stageArgumentWhole(emitter: var Assembler, index: int32, - value: int32) {.raises: [BasicError].} = - ## Writes a whole number straight into an argument. - emitter.loadDouble(Scratch, Context, ContextArguments) - emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) - emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) - emitter.storeByte(zeroRegister, Scratch, 0) - emitter.loadImmediate(Word32, ValueScratch[0], int64(value)) - emitter.storeWord(ValueScratch[0], Scratch, ValuePayload) - - proc stageArgumentFromHoisted(emitter: var Assembler, index: int32, - slot: int) {.raises: [BasicError].} = - ## Writes a hoisted global into an argument, always a whole number - ## because every hoisted global was proved to be one on the way in. - emitter.loadDouble(Scratch, Context, ContextArguments) - emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) - emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) - emitter.storeByte(zeroRegister, Scratch, 0) - emitter.storeWord(slotRegister(slot), Scratch, ValuePayload) - - proc enterRoutine(emitter: var Assembler, callee: RoutineExtent, - calleeId, callerRegisters, resumeAt: int32, limits: CallLimits, - leave: Label) {.raises: [BasicError].} = - ## Pushes a frame and moves to the callee, refusing the same two - ## ceilings the interpreter refuses. - emitter.loadWord(Scratch, Context, ContextDepth) - emitter.loadImmediate(Word32, OtherScratch, int64(limits.frames) - 1) - emitter.compareRegister(Word32, Scratch, OtherScratch) - emitter.branchIf(GreaterEqualCondition, leave) - - emitter.loadWord(OtherScratch, Context, ContextBase) - emitter.moveRegister(Word32, ValueScratch[0], OtherScratch) - if callerRegisters > 0: - emitter.addImmediate(Word32, ValueScratch[0], ValueScratch[0], - int(callerRegisters)) - emitter.loadImmediate(Word32, ValueScratch[1], - int64(limits.slots) - int64(callee.registers)) - emitter.compareRegister(Word32, ValueScratch[0], ValueScratch[1]) - emitter.branchIf(GreaterCondition, leave) - - # frames[depth] = { base, routine, resumeAt, SubFrame } - emitter.loadDouble(ValueScratch[1], Context, ContextFrames) - emitter.addRegister(Word64, ValueScratch[1], ValueScratch[1], Scratch, 4) - emitter.storeWord(OtherScratch, ValueScratch[1], FrameBase) - emitter.loadWord(OtherScratch, Context, ContextRoutine) - emitter.storeWord(OtherScratch, ValueScratch[1], FrameRoutine) - emitter.loadImmediate(Word32, OtherScratch, int64(resumeAt)) - emitter.storeWord(OtherScratch, ValueScratch[1], FrameReturn) - emitter.storeWord(zeroRegister, ValueScratch[1], FrameTag) - - emitter.addImmediate(Word32, Scratch, Scratch, 1) - emitter.storeWord(Scratch, Context, ContextDepth) - emitter.storeWord(ValueScratch[0], Context, ContextBase) - emitter.loadImmediate(Word32, OtherScratch, int64(calleeId)) - emitter.storeWord(OtherScratch, Context, ContextRoutine) - - emitter.loadDouble(OtherScratch, Context, ContextRegisterFile) - emitter.addRegister(Word64, RegistersBase, OtherScratch, - ValueScratch[0], 4) - - # The interpreter clears the callee's slots and then lays the - # arguments over the first few, so this does the same in that order. - for slot in 0 ..< int(callee.registers): - emitter.storeDouble(zeroRegister, RegistersBase, slot * ValueStride) - emitter.storeDouble(zeroRegister, RegistersBase, - slot * ValueStride + ValuePayload) - if callee.parameters > 0: - emitter.loadDouble(Scratch, Context, ContextArguments) - for slot in 0 ..< int(callee.parameters): - emitter.loadDouble(ValueScratch[0], Scratch, slot * ValueStride) - emitter.loadDouble(ValueScratch[1], Scratch, - slot * ValueStride + ValuePayload) - emitter.storeDouble(ValueScratch[0], RegistersBase, - slot * ValueStride) - emitter.storeDouble(ValueScratch[1], RegistersBase, - slot * ValueStride + ValuePayload) - - proc resumeAtStoredOffset(emitter: var Assembler, - hoistedFor: seq[int32]) {.raises: [BasicError].} = - ## The list is empty when globals were left in memory, so nothing is - ## written back; the registers it would name were never filled. - ## Hands control back at the offset already written to the context, - ## which is where a return lands when this region did not compile it. - for slot, index in hoistedFor: - let base = int(index) * ValueStride - emitter.storeByte(zeroRegister, GlobalsBase, base) - emitter.storeWord(slotRegister(slot), GlobalsBase, - base + ValuePayload) - emitter.storeDouble(Instructions, Context, ContextInstructions) - emitter.storeDouble(Work, Context, ContextWork) - emitter.loadImmediate(Word32, Context, int64(ord(NativeCompleted))) - emitter.endRegion() - - ## Globals left where they are - ## - ## A loop touching more globals than there are registers to hold them - ## keeps them all in memory instead. Every access then costs a load or - ## a store, but the loop compiles at all, which the alternative did not. - - proc copyValue(emitter: var Assembler, fromBase: Register, fromOffset: int, - toBase: Register, toOffset: int) {.raises: [BasicError].} = - ## Moves one value entire, whatever kind it holds. - emitter.loadDouble(ValueScratch[0], fromBase, fromOffset) - emitter.loadDouble(ValueScratch[1], fromBase, fromOffset + ValuePayload) - emitter.storeDouble(ValueScratch[0], toBase, toOffset) - emitter.storeDouble(ValueScratch[1], toBase, toOffset + ValuePayload) - - proc copyGlobalToSlot(emitter: var Assembler, index, slot: int32) + proc compareConstant(e: var Emitter, value: int, bits: int32) {.raises: [BasicError].} = - ## Reads a global into a frame slot. - emitter.copyValue(GlobalsBase, int(index) * ValueStride, - RegistersBase, int(slot) * ValueStride) + ## Sets flags from a working register against a constant. + if bits >= 0 and bits <= 4095: + e.code.compareImmediate(Word32, temp(value), int(bits)) + else: + e.code.loadImmediate(Word32, temp(6), int64(bits)) + e.code.compareRegister(Word32, temp(value), temp(6)) - proc copySlotToGlobal(emitter: var Assembler, slot, index: int32) - {.raises: [BasicError].} = - ## Writes a frame slot into a global. - emitter.copyValue(RegistersBase, int(slot) * ValueStride, - GlobalsBase, int(index) * ValueStride) + proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. + e.code.setOnCondition(Word32, temp(value), nativeCondition(check)) - proc copyGlobalToGlobal(emitter: var Assembler, destination, source: int32) - {.raises: [BasicError].} = - ## Copies one global into another. - emitter.copyValue(GlobalsBase, int(source) * ValueStride, - GlobalsBase, int(destination) * ValueStride) + proc jumpOn(e: var Emitter, check: Check, target: Label) + {.raises: [].} = + ## Jumps on a comparison outcome. + e.jumpWhen(nativeCondition(check), target) - proc copyGlobalToArgument(emitter: var Assembler, argument, index: int32) - {.raises: [BasicError].} = - ## Stages a global as an argument. - emitter.loadDouble(Scratch, Context, ContextArguments) - emitter.copyValue(GlobalsBase, int(index) * ValueStride, - Scratch, int(argument) * ValueStride) + proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + e.jumpIfZero(temp(value), target) - proc setGlobalWhole(emitter: var Assembler, index: int32, value: int32) + proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + e.jumpIfNotZero(temp(value), target) + + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, + slow: Label) {.raises: [].} = + ## Bounds checks an index and leaves the cell's address in Cell. One + ## unsigned comparison covers both ends, as the interpreter's does. + let position = temp(index) + e.code.loadImmediate(Word32, temp(6), int64(extent.length)) + e.code.compareRegister(Word32, position, temp(6)) + e.jumpWhen(CarrySetCondition, slow) + e.code.loadImmediate(Word32, temp(6), int64(extent.base)) + e.code.addRegister(Word32, temp(6), temp(6), position) + e.code.addRegister(Word64, Cell, MemoryBase, temp(6), 4) + + proc meter(e: var Emitter, instructions, work: int32, slow: Label) {.raises: [BasicError].} = - ## Writes a whole number into a global. - let base = int(index) * ValueStride - emitter.storeByte(zeroRegister, GlobalsBase, base) - emitter.loadImmediate(Word32, ValueScratch[0], int64(value)) - emitter.storeWord(ValueScratch[0], GlobalsBase, base + ValuePayload) - - proc readGlobalWhole(emitter: var Assembler, scratch: int, index: int32, - leave: Label) {.raises: [BasicError].} = - ## Reads a global as a whole number, leaving the region if it is not. - let base = int(index) * ValueStride - emitter.loadByte(Scratch, GlobalsBase, base) - emitter.branchIfNotZero(Word32, Scratch, leave) - emitter.loadWord(ValueScratch[scratch], GlobalsBase, base + ValuePayload) - - proc addToGlobal(emitter: var Assembler, index: int32, value: int32, - leave: Label) {.raises: [BasicError].} = - ## Adds a constant to a global of either kind. A whole number added to - ## a fixed-point one has to become fixed point first, which only works - ## inside its range, so outside that only whole numbers are taken. - let base = int(index) * ValueStride - let widens = value >= -32768 and value <= 32767 - emitter.loadByte(Scratch, GlobalsBase, base) - if widens: - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(UnsignedGreaterCondition, leave) + ## Checks both budgets before charging either, as the interpreter does. + if instructions <= 4095: + e.code.compareImmediate(Word64, Instructions, int(instructions)) else: - emitter.branchIfNotZero(Word32, Scratch, leave) - emitter.loadWord(ValueScratch[0], GlobalsBase, base + ValuePayload) - if widens: - let fixedWay = emitter.label() - let done = emitter.label() - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(EqualCondition, fixedWay) - emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) - emitter.branch(done) - emitter.place(fixedWay) - emitter.loadImmediate(Word32, ValueScratch[1], - int64(value) * 65536) - emitter.place(done) + e.code.loadImmediate(Word64, temp(5), int64(instructions)) + e.code.compareRegister(Word64, Instructions, temp(5)) + e.jumpWhen(LessCondition, slow) + if work <= 4095: + e.code.compareImmediate(Word64, Work, int(work)) else: - emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) - emitter.addRegister(Word32, ValueScratch[0], ValueScratch[0], - ValueScratch[1]) - emitter.storeWord(ValueScratch[0], GlobalsBase, base + ValuePayload) - - proc compareGlobalWhole(emitter: var Assembler, index: int32, - value: int32, leave: Label) {.raises: [BasicError].} = - ## Sets flags from a global against a whole number, leaving the region - ## unless the global is a whole number too. - emitter.readGlobalWhole(0, index, leave) - emitter.loadImmediate(Word32, ValueScratch[1], int64(value)) - emitter.compareRegister(Word32, ValueScratch[0], ValueScratch[1]) - - proc setSlotKind(emitter: var Assembler, slot: int32, tag: int) - {.raises: [BasicError].} = - ## Says what kind a slot now holds, leaving its payload alone. - emitter.loadImmediate(Word32, OtherScratch, int64(tag)) - emitter.storeByte(OtherScratch, RegistersBase, int(slot) * ValueStride) - - proc writeSlotPayload(emitter: var Assembler, scratch: int, slot: int32) - {.raises: [BasicError].} = - ## Writes a slot's payload, its kind having just been said. - emitter.storeWord(ValueScratch[scratch], RegistersBase, - int(slot) * ValueStride + ValuePayload) - - proc widenToFixed(emitter: var Assembler, scratch: int, leave: Label) - {.raises: [BasicError].} = - ## Turns a number of either kind into its Q16.16 bits, widened to - ## sixty-four. A whole number outside the fixed-point range cannot be - ## turned into one at all, which is what the interpreter refuses, so - ## that goes back rather than being approximated. - let value = ValueScratch[scratch] - let already = emitter.label() - let ready = emitter.label() - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(EqualCondition, already) - emitter.loadImmediate(Word32, OtherScratch, 32767) - emitter.compareRegister(Word32, value, OtherScratch) - emitter.branchIf(GreaterCondition, leave) - emitter.loadImmediate(Word32, OtherScratch, -32768) - emitter.compareRegister(Word32, value, OtherScratch) - emitter.branchIf(LessCondition, leave) - emitter.signExtendWord(value, value) - emitter.shiftLeftImmediate(Word64, value, value, FixedShift) - emitter.branch(ready) - emitter.place(already) - emitter.signExtendWord(value, value) - emitter.place(ready) - - proc divideFixed(emitter: var Assembler, leave: Label) - {.raises: [BasicError].} = - ## Divides one Q16.16 number by another, rounding to nearest with - ## halves going up, for either sign, exactly as the fixed-point - ## library does: the signs are put right first, half the divisor is - ## added, and the truncating divide is corrected back to a floor. - let numerator = ValueScratch[0] - let denominator = ValueScratch[1] - emitter.compareImmediate(Word64, denominator, 0) - emitter.branchIf(EqualCondition, leave) - - let signsSettled = emitter.label() - emitter.compareImmediate(Word64, denominator, 0) - emitter.branchIf(GreaterCondition, signsSettled) - emitter.negate(Word64, numerator, numerator) - emitter.negate(Word64, denominator, denominator) - emitter.place(signsSettled) - - emitter.shiftLeftImmediate(Word64, numerator, numerator, FixedShift) - emitter.shiftRightImmediate(Word64, Scratch, denominator, 1) - emitter.addRegister(Word64, numerator, numerator, Scratch) - - emitter.signedDivide(Word64, Scratch, numerator, denominator) - emitter.multiplySubtract(Word64, OtherScratch, Scratch, denominator, - numerator) - - let done = emitter.label() - emitter.compareImmediate(Word64, OtherScratch, 0) - emitter.branchIf(EqualCondition, done) - emitter.compareImmediate(Word64, numerator, 0) - emitter.branchIf(GreaterEqualCondition, done) - emitter.subtractImmediate(Word64, Scratch, Scratch, 1) - emitter.place(done) - emitter.moveRegister(Word32, numerator, Scratch) - - proc callHost(emitter: var Assembler, functionId, destination: int32, - failed: Label) {.raises: [BasicError].} = - ## Runs one host function through a trampoline. - ## - ## This is the one place compiled code leaves for code it did not - ## write, so it is also the one place that has to keep the calling - ## convention. The context and the frame base are registers a callee - ## may use, so they go on the stack; the budgets, the globals base and - ## the hoisted globals are ones a callee must leave alone. - ## - ## Host code can refuse. It cannot refuse by raising through a frame - ## nothing described, so the trampoline catches whatever it raised and - ## says so in its answer, and the offset goes back with the failure - ## already made rather than to be made again. - emitter.storePair(Context, RegistersBase, stackPointer, AcrossHostCall) - emitter.loadDouble(Scratch, Context, ContextHostCall) - emitter.loadImmediate(Word32, x1, int64(functionId)) - emitter.loadImmediate(Word32, x2, int64(destination)) - emitter.callRegister(Scratch) - emitter.moveRegister(Word32, Scratch, x0) - emitter.loadPair(Context, RegistersBase, stackPointer, AcrossHostCall) - emitter.branchIfNotZero(Word32, Scratch, failed) - - proc leaveRoutine(emitter: var Assembler, leave: Label) - {.raises: [BasicError].} = - ## Pops a frame and jumps to wherever it said to carry on. The table - ## sends any offset this region did not compile back to the - ## interpreter, so returning into interpreted code needs no test. - emitter.loadWord(Scratch, Context, ContextDepth) - emitter.branchIfZero(Word32, Scratch, leave) - emitter.subtractImmediate(Word32, Scratch, Scratch, 1) - emitter.storeWord(Scratch, Context, ContextDepth) - - emitter.loadDouble(ValueScratch[1], Context, ContextFrames) - emitter.addRegister(Word64, ValueScratch[1], ValueScratch[1], Scratch, 4) - emitter.loadWord(OtherScratch, ValueScratch[1], FrameBase) - emitter.storeWord(OtherScratch, Context, ContextBase) - emitter.loadWord(ValueScratch[0], ValueScratch[1], FrameRoutine) - emitter.storeWord(ValueScratch[0], Context, ContextRoutine) - emitter.loadWord(ValueScratch[0], ValueScratch[1], FrameReturn) - emitter.storeWord(ValueScratch[0], Context, ContextOffset) - - emitter.loadDouble(Scratch, Context, ContextRegisterFile) - emitter.addRegister(Word64, RegistersBase, Scratch, OtherScratch, 4) - - emitter.loadDouble(Scratch, Context, ContextReturnTable) - emitter.addRegister(Word64, Scratch, Scratch, ValueScratch[0], 3) - emitter.loadDouble(Scratch, Scratch, 0) - emitter.jumpRegister(Scratch) - - - proc hostDataAddress(emitter: var Assembler, index: int32) - {.raises: [BasicError].} = - ## Leaves one host data value's address in Scratch. - emitter.loadDouble(Scratch, Context, ContextHostData) - emitter.loadImmediate(Word32, OtherScratch, int64(index) * ValueStride) - emitter.addRegister(Word64, Scratch, Scratch, OtherScratch) - - proc copyHostDataToSlot(emitter: var Assembler, index, slot: int32) - {.raises: [BasicError].} = - ## Copies a host value entire into a slot, as the interpreter does. - emitter.hostDataAddress(index) - emitter.copyElementToSlot(slot) - - proc readHostDataInteger(emitter: var Assembler, scratch: int, - index: int32, leave: Label) {.raises: [BasicError].} = - ## Reads a host value as an integer, leaving the region if it is not. - emitter.hostDataAddress(index) - emitter.readElement(scratch, leave) + e.code.loadImmediate(Word64, temp(6), int64(work)) + e.code.compareRegister(Word64, Work, temp(6)) + e.jumpWhen(LessCondition, slow) + if instructions <= 4095: + e.code.subtractImmediate(Word64, Instructions, Instructions, + int(instructions)) + else: + e.code.subtractRegister(Word64, Instructions, Instructions, temp(5)) + if work <= 4095: + e.code.subtractImmediate(Word64, Work, Work, int(work)) + else: + e.code.subtractRegister(Word64, Work, Work, temp(6)) + proc frameOf(e: var Emitter, base: Register, index: Register) + {.raises: [].} = + ## Points a register at one register-file slot by its absolute index. + e.code.addRegister(Word64, base, FileBase, index, 4) + + proc copyValues(e: var Emitter, destination, source: Register, + count: int) {.raises: [BasicError].} = + ## Copies a run of whole values, in a loop once there are many. + if count <= 8: + for index in 0 ..< count: + e.code.loadDouble(temp(5), source, index * ValueStride) + e.code.loadDouble(temp(6), source, index * ValueStride + ValuePayload) + e.code.storeDouble(temp(5), destination, index * ValueStride) + e.code.storeDouble(temp(6), destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, temp(2), source) + e.code.moveRegister(Word64, temp(3), destination) + e.code.loadImmediate(Word32, temp(4), int64(count)) + let again = e.label() + e.place(again) + e.code.loadDouble(temp(5), temp(2), 0) + e.code.loadDouble(temp(6), temp(2), ValuePayload) + e.code.storeDouble(temp(5), temp(3), 0) + e.code.storeDouble(temp(6), temp(3), ValuePayload) + e.code.addImmediate(Word64, temp(2), temp(2), ValueStride) + e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) + e.code.subtractImmediate(Word32, temp(4), temp(4), 1) + e.code.branchIfNotZero(Word32, temp(4), again) + + proc clearValues(e: var Emitter, destination: Register, count: int) + {.raises: [BasicError].} = + ## Zeroes a run of values, in a loop once there are many. + if count <= 8: + for index in 0 ..< count: + e.code.storeDouble(zeroRegister, destination, index * ValueStride) + e.code.storeDouble(zeroRegister, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, temp(3), destination) + e.code.loadImmediate(Word32, temp(4), int64(count)) + let again = e.label() + e.place(again) + e.code.storeDouble(zeroRegister, temp(3), 0) + e.code.storeDouble(zeroRegister, temp(3), ValuePayload) + e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) + e.code.subtractImmediate(Word32, temp(4), temp(4), 1) + e.code.branchIfNotZero(Word32, temp(4), again) + + proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, + calleeRegisters, calleeParameters, callerRegisters: int32, + resumeAt: int32, limits: CallLimits, slow: Label) + {.raises: [BasicError].} = + ## Pushes a frame into the interpreter's own array and moves the + ## current frame on, refusing the same two ceilings it refuses. + let depth = temp(0) + let oldBase = temp(1) + let newBase = temp(2) + let frame = temp(3) + e.code.loadWord(depth, Context, ContextDepth) + e.code.loadImmediate(Word32, temp(6), int64(limits.frames) - 1) + e.code.compareRegister(Word32, depth, temp(6)) + e.jumpWhen(GreaterEqualCondition, slow) + e.code.loadWord(oldBase, Context, ContextBase) + e.code.loadImmediate(Word32, temp(6), int64(callerRegisters)) + e.code.addRegister(Word32, newBase, oldBase, temp(6)) + e.code.loadImmediate(Word32, temp(6), + int64(limits.slots) - int64(calleeRegisters)) + e.code.compareRegister(Word32, newBase, temp(6)) + e.jumpWhen(GreaterCondition, slow) + + e.code.addRegister(Word64, frame, FramesBase, depth, 4) + e.code.storeWord(oldBase, frame, FrameBase) + e.code.loadWord(temp(4), Context, ContextRoutine) + e.code.storeWord(temp(4), frame, FrameRoutine) + e.code.loadImmediate(Word32, temp(4), int64(resumeAt)) + e.code.storeWord(temp(4), frame, FrameReturn) + if gosub: + e.code.loadImmediate(Word32, temp(4), 1) + e.code.storeWord(temp(4), frame, FrameTag) + else: + e.code.storeWord(zeroRegister, frame, FrameTag) + + e.code.addImmediate(Word32, depth, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.storeWord(newBase, Context, ContextBase) + e.code.loadImmediate(Word32, temp(4), int64(calleeId)) + e.code.storeWord(temp(4), Context, ContextRoutine) + + # A GOSUB hands the callee a copy of the caller's slots; a call clears + # them and lays the arguments over the first few, in that order. + e.code.moveRegister(Word64, frame, RegistersBase) + e.frameOf(RegistersBase, newBase) + if gosub: + e.copyValues(RegistersBase, frame, int(calleeRegisters)) + else: + e.clearValues(RegistersBase, int(calleeRegisters)) + e.copyValues(RegistersBase, ArgumentsBase, int(calleeParameters)) + + proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) + {.raises: [BasicError].} = + ## Pops a frame and jumps to wherever it said to carry on. A GOSUB + ## frame first hands the shared parameters back to the caller. + let depth = temp(0) + let frame = temp(1) + let base = temp(2) + let resume = temp(3) + e.code.loadWord(depth, Context, ContextDepth) + e.jumpIfZero(depth, slow) + e.code.subtractImmediate(Word32, depth, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.addRegister(Word64, frame, FramesBase, depth, 4) + e.code.loadWord(base, frame, FrameBase) + if parameters > 0: + let plain = e.label() + e.code.loadByte(temp(4), frame, FrameTag) + e.code.compareImmediate(Word32, temp(4), 1) + e.code.branchIf(NotEqualCondition, plain) + e.frameOf(Far, base) + e.copyValues(Far, RegistersBase, int(parameters)) + e.place(plain) + e.code.storeWord(base, Context, ContextBase) + e.code.loadWord(temp(4), frame, FrameRoutine) + e.code.storeWord(temp(4), Context, ContextRoutine) + e.code.loadWord(resume, frame, FrameReturn) + e.code.storeWord(resume, Context, ContextOffset) + e.frameOf(RegistersBase, base) + e.code.addRegister(Word64, temp(4), TableBase, resume, 3) + e.code.loadDouble(temp(4), temp(4), 0) + e.code.jumpRegister(temp(4)) + + proc callSlow(e: var Emitter, offset: int32, routine: Label) + {.raises: [].} = + ## Runs the interpreter's own code for one instruction. + e.code.loadImmediate(Word32, x1, int64(offset)) + e.code.branchLink(routine) + + proc slowRoutine(e: var Emitter, failed: Label) + {.raises: [BasicError].} = + ## The one place compiled code calls out. The budgets go into the + ## context for the interpreter's code to charge, and come back from it + ## along with the frame, since a call or a return may have moved it. + e.code.storePair(framePointer, linkRegister, stackPointer, -16, true) + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.moveRegister(Word64, x0, Context) + e.code.loadDouble(temp(0), Context, ContextStep) + e.code.callRegister(temp(0)) + e.code.moveRegister(Word32, temp(0), x0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadWord(temp(1), Context, ContextBase) + e.frameOf(RegistersBase, temp(1)) + e.code.loadPair(framePointer, linkRegister, stackPointer, 16, true) + e.code.branchIfNotZero(Word32, temp(0), failed) + e.code.returnToCaller() + + proc dispatch(e: var Emitter) {.raises: [BasicError].} = + ## Jumps to the block for whatever offset the context names. + e.code.loadWord(temp(0), Context, ContextOffset) + e.code.addRegister(Word64, temp(1), TableBase, temp(0), 3) + e.code.loadDouble(temp(1), temp(1), 0) + e.code.jumpRegister(temp(1)) + + proc prologue(e: var Emitter) {.raises: [BasicError].} = + ## Saves what the platform says to keep and loads the machine state. + e.code.storePair(framePointer, linkRegister, stackPointer, -FrameBytes, + true) + e.code.storePair(x19, x20, stackPointer, 16) + e.code.storePair(x21, x22, stackPointer, 32) + e.code.storePair(x23, x24, stackPointer, 48) + e.code.storePair(x25, x26, stackPointer, 64) + e.code.storePair(x27, x28, stackPointer, 80) + e.code.moveRegister(Word64, Context, x0) + e.code.loadDouble(GlobalsBase, Context, 0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadDouble(FileBase, Context, ContextRegisterFile) + e.code.loadDouble(TableBase, Context, ContextTable) + e.code.loadDouble(MemoryBase, Context, ContextMemory) + e.code.loadDouble(ArgumentsBase, Context, ContextArguments) + e.code.loadDouble(FramesBase, Context, ContextFrames) + e.code.loadWord(temp(0), Context, ContextBase) + e.frameOf(RegistersBase, temp(0)) + + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [BasicError].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, x0, int64(ord(status))) + e.code.loadPair(x19, x20, stackPointer, 16) + e.code.loadPair(x21, x22, stackPointer, 32) + e.code.loadPair(x23, x24, stackPointer, 48) + e.code.loadPair(x25, x26, stackPointer, 64) + e.code.loadPair(x27, x28, stackPointer, 80) + e.code.loadPair(framePointer, linkRegister, stackPointer, FrameBytes, + true) + e.code.returnToCaller() + + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = + ## Publishes the budgets and where the program stopped, then returns. + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.loadImmediate(Word32, temp(0), int64(offset)) + e.code.storeWord(temp(0), Context, ContextOffset) + e.epilogue(NativeCompleted) + + proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = + ## Resolves every branch and returns the finished bytes. + e.code.resolve() + result = newSeq[byte](e.code.code.len * 4) + if result.len > 0: + copyMem(result[0].addr, e.code.code[0].addr, result.len) + + proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in bytes. + e.code.offsetOf(target) * 4 elif NativeAmd64: ## x86-64 code generation ## - ## rbx base of the globals array - ## r12 remaining instruction budget - ## r13 remaining work budget - ## rax and rdx are reserved for the divide; r11 is scratch + ## r15 context r12 instruction budget r13 work budget + ## rbx globals rbp current frame r14 array cells + ## rax rcx rsi rdi r8 r9 r10 working registers; r11 one cell; + ## rdx the divide's high half and a spare ## - ## The two conventions differ only in which register carries the argument - ## and which ones a callee must preserve. System V takes its argument in - ## rdi and may use rsi and rdi freely; Windows takes its argument in rcx - ## and must preserve both rsi and rdi. + ## The six long-lived registers are the ones both platform conventions + ## keep across a call. Windows also keeps rsi and rdi, so those are + ## saved on the way in there. Everything else the program touches + ## rarely is read from the context when it is needed. const - GlobalsBase = rbx - RegistersBase = rbp + Context = r15 Instructions = r12 Work = r13 - Scratch = r11 - ValueScratch = [rax, rdx] + GlobalsBase = rbx + RegistersBase = rbp + MemoryBase = r14 + Temps = [rax, rcx, rsi, rdi, r8, r9, r10] + Cell = r11 + Spare = rdx when defined(windows): const - Context = rcx - Hoisted = [r14, r15, rsi, rdi, r8, r9, r10] + FirstArgument = rcx + SecondArgument = rdx Saved = [rbx, rbp, r12, r13, r14, r15, rsi, rdi] + ## Four shadow slots for the callee, plus eight to realign. + Padding = 40 else: const - Context = rdi - Hoisted = [r14, r15, rsi, rcx, r8, r9, r10] + FirstArgument = rdi + SecondArgument = rsi Saved = [rbx, rbp, r12, r13, r14, r15] - - proc slotRegister(slot: int): Register {.raises: [].} = - ## Returns the register holding one hoisted global. - Hoisted[slot] - - proc nativeCondition(test: Test): Condition {.raises: [].} = - ## Maps a neutral condition onto the architecture's encoding. - case test - of EqualTest: EqualCondition - of NotEqualTest: NotEqualCondition - of LessTest: LessCondition - of LessEqualTest: LessEqualCondition - of GreaterTest: GreaterCondition - of GreaterEqualTest: GreaterEqualCondition - - proc branchWhen(emitter: var Assembler, test: Test, target: Label) + Padding = 8 + + proc temp(index: int): Register {.inline, raises: [].} = + ## Returns one working register. + Temps[index] + + proc nativeCondition(check: Check): Condition {.raises: [].} = + ## Maps a neutral outcome onto the architecture's encoding. + case check + of EqualCheck: EqualCondition + of NotEqualCheck: NotEqualCondition + of LessCheck: LessCondition + of LessEqualCheck: LessEqualCondition + of GreaterCheck: GreaterCondition + of GreaterEqualCheck: GreaterEqualCondition + + type + Emitter = object + ## The assembler. Every branch here reaches anywhere already, so + ## there is nothing to widen. + code: Assembler + far: bool + + proc label(e: var Emitter): Label {.inline, raises: [].} = + ## Reserves a label. + e.code.label() + + proc place(e: var Emitter, target: Label) {.inline, raises: [].} = + ## Places a label here. + e.code.place(target) + + proc jump(e: var Emitter, target: Label) {.raises: [].} = + ## Jumps unconditionally. + e.code.branch(target) + + proc jumpWhen(e: var Emitter, condition: Condition, target: Label) {.raises: [].} = - ## Branches when the neutral condition holds. - emitter.branchIf(nativeCondition(test), target) + ## Jumps when a condition holds. + e.code.branchIf(condition, target) - proc startRegion(emitter: var Assembler) {.raises: [BasicError].} = - ## Saves callee-saved registers and loads the interpreter state. - for register in Saved: - emitter.push(register) - emitter.loadDouble(GlobalsBase, Context, 0) - emitter.loadDouble(Instructions, Context, ContextInstructions) - emitter.loadDouble(Work, Context, ContextWork) - - proc endRegion(emitter: var Assembler) {.raises: [BasicError].} = - ## Restores callee-saved registers and returns to the interpreter. - for index in countdown(Saved.len - 1, 0): - emitter.pop(Saved[index]) - emitter.returnToCaller() - - proc readNumeric(emitter: var Assembler, scratch: int, slot: int32, - leave: Label) {.raises: [BasicError].} = - ## Reads a slot's tag into Scratch and its payload into a working - ## register, leaving the region for anything that is not a number. - let base = int(slot) * ValueStride - emitter.loadByteZeroed(Scratch, RegistersBase, base) - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(AboveCondition, leave) - emitter.loadWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) - - proc requireSameKind(emitter: var Assembler, slot: int32, leave: Label) + proc contextField(e: var Emitter, destination: Register, offset: int) {.raises: [BasicError].} = - ## Leaves the region unless a second slot carries the same tag as the - ## one already held. Whole numbers and fixed-point ones add, subtract - ## and compare through the very same instructions, so a pair that - ## agrees needs no further telling apart; a mixed pair would have to - ## be promoted, which can fail, so it goes back to the interpreter. - emitter.loadByteZeroed(ValueScratch[1], RegistersBase, - int(slot) * ValueStride) - emitter.compareRegister(Word32, Scratch, ValueScratch[1]) - emitter.branchIf(NotEqualCondition, leave) - - proc writeNumeric(emitter: var Assembler, scratch: int, slot: int32) - {.raises: [BasicError].} = - ## Writes a payload back under the tag the operands carried. - let base = int(slot) * ValueStride - emitter.storeByteLow(RegistersBase, base, Scratch) - emitter.storeWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) + ## Loads one pointer from the context. + e.code.loadDouble(destination, Context, offset) - proc branchIfFixed(emitter: var Assembler, target: Label) + proc reach(e: var Emitter, place: Place): (Register, int) {.raises: [BasicError].} = - ## Branches when the held tag says fixed point. - emitter.compareImmediate(Word32, Scratch, FixedTag) - emitter.branchIf(EqualCondition, target) + ## Returns a base register and byte offset for a value. Displacements + ## are thirty-two bits wide here, so every index is reached directly. + let offset = int(place.index) * ValueStride + case place.home + of SlotHome: (RegistersBase, offset) + of GlobalHome: (GlobalsBase, offset) + of ArgumentHome: + e.contextField(Cell, ContextArguments) + (Cell, offset) + of HostHome: + e.contextField(Cell, ContextHostData) + (Cell, offset) + of CellHome: (Cell, 0) - proc requireWholeKind(emitter: var Assembler, leave: Label) + proc readValue(e: var Emitter, value, tag: int, place: Place) {.raises: [BasicError].} = - ## Leaves the region unless the held tag says whole number. - emitter.compareImmediate(Word32, Scratch, 0) - emitter.branchIf(NotEqualCondition, leave) + ## Reads a value's kind and its 32-bit payload. + let (base, offset) = e.reach(place) + e.code.loadByteZeroed(temp(tag), base, offset) + e.code.loadWord(temp(value), base, offset + ValuePayload) - proc readSlotValue(emitter: var Assembler, scratch: int, slot: int32) + proc writeWhole(e: var Emitter, place: Place, value: int) {.raises: [BasicError].} = - ## Reads a slot's payload, its tag having already been established. - emitter.loadWord(ValueScratch[scratch], RegistersBase, - int(slot) * ValueStride + ValuePayload) + ## Writes a whole number. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, 0) + e.code.storeWord(temp(value), base, offset + ValuePayload) - proc guardDivisor(emitter: var Assembler, scratch: int, leave: Label) - {.raises: [BasicError].} = - ## Leaves the region for the two divisors that are not plain division: - ## zero, which the interpreter refuses, and minus one, which would - ## trap here on the most negative dividend. - emitter.compareImmediate(Word32, ValueScratch[scratch], 0) - emitter.branchIf(EqualCondition, leave) - emitter.compareImmediate(Word32, ValueScratch[scratch], -1) - emitter.branchIf(EqualCondition, leave) - - proc quotientScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Divides the first working register by the second, toward zero. - ## The divide reads and writes the accumulator pair, so the divisor is - ## moved aside first and the answer moved back afterwards. - emitter.moveRegister(Word32, Scratch, ValueScratch[right]) - emitter.moveRegister(Word32, rax, ValueScratch[left]) - emitter.signExtendToPair(Word32) - emitter.signedDivide(Word32, Scratch) - emitter.moveRegister(Word32, ValueScratch[left], rax) - - proc remainderScratch(emitter: var Assembler, left, right: int) + proc writeKind(e: var Emitter, place: Place, tag, value: int) {.raises: [BasicError].} = - ## Leaves what the division of the two working registers left over. - emitter.moveRegister(Word32, Scratch, ValueScratch[right]) - emitter.moveRegister(Word32, rax, ValueScratch[left]) - emitter.signExtendToPair(Word32) - emitter.signedDivide(Word32, Scratch) - emitter.moveRegister(Word32, ValueScratch[left], rdx) - - proc multiplyFixed(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Multiplies two Q16.16 numbers through a widened intermediate, - ## rounding to nearest exactly as the fixed-point library does. - emitter.signExtendDouble(ValueScratch[left], ValueScratch[left]) - emitter.signExtendDouble(ValueScratch[right], ValueScratch[right]) - emitter.multiplyRegister(Word64, ValueScratch[left], ValueScratch[right]) - emitter.addImmediate(Word64, ValueScratch[left], int32(FixedRounding)) - emitter.shiftRightImmediate(Word64, ValueScratch[left], FixedShift) - - proc elementAddress(emitter: var Assembler, scratch: int, - extent: ArrayExtent, leave: Label) {.raises: [BasicError].} = - ## Bounds checks an index and leaves the cell's address in Scratch. - ## One unsigned comparison covers both ends, exactly as the - ## interpreter's does, and a refusal hands the offset back so the - ## interpreter can raise with the array's own name. - let index = ValueScratch[scratch] - emitter.compareImmediate(Word32, index, extent.length) - emitter.branchIf(AboveEqualCondition, leave) - emitter.addImmediate(Word32, index, extent.base) - emitter.shiftLeftImmediate(Word64, index, 4) - emitter.loadDouble(Scratch, Context, ContextMemory) - emitter.addRegister(Word64, Scratch, index) - - proc copyElementToSlot(emitter: var Assembler, slot: int32) - {.raises: [BasicError].} = - ## Copies a whole cell into a register slot, whatever it holds. The - ## interpreter copies the value entire, so this does too, and neither - ## needs to know what kind it is. - let base = int(slot) * ValueStride - emitter.loadDouble(ValueScratch[0], Scratch, 0) - emitter.loadDouble(ValueScratch[1], Scratch, ValuePayload) - emitter.storeDouble(ValueScratch[0], RegistersBase, base) - emitter.storeDouble(ValueScratch[1], RegistersBase, base + ValuePayload) - - proc copySlotToElement(emitter: var Assembler, slot: int32) - {.raises: [BasicError].} = - ## Copies a whole register slot into a cell, whatever it holds. - let base = int(slot) * ValueStride - emitter.loadDouble(ValueScratch[0], RegistersBase, base) - emitter.loadDouble(ValueScratch[1], RegistersBase, base + ValuePayload) - emitter.storeDouble(ValueScratch[0], Scratch, 0) - emitter.storeDouble(ValueScratch[1], Scratch, ValuePayload) - - proc readElement(emitter: var Assembler, scratch: int, leave: Label) - {.raises: [BasicError].} = - ## Reads a cell as an integer, leaving the region if it holds else. - let target = ValueScratch[scratch] - emitter.loadByteZeroed(target, Scratch, 0) - emitter.testRegister(Word32, target, target) - emitter.branchIf(NotEqualCondition, leave) - emitter.loadWord(target, Scratch, ValuePayload) - - proc writeElement(emitter: var Assembler, scratch: int) - {.raises: [BasicError].} = - ## Writes a cell as an integer. - emitter.storeByteImmediate(Scratch, 0, 0) - emitter.storeWord(ValueScratch[scratch], Scratch, ValuePayload) + ## Writes a payload under the kind held in a working register. + let (base, offset) = e.reach(place) + e.code.storeByteLow(base, offset, temp(tag)) + e.code.storeWord(temp(value), base, offset + ValuePayload) - proc guardInteger(emitter: var Assembler, base: int, failed: Label) + proc writeFixed(e: var Emitter, place: Place, value: int) {.raises: [BasicError].} = - ## Leaves the region unless the global at this offset holds an integer. - emitter.loadByteZeroed(Scratch, GlobalsBase, base) - emitter.testRegister(Word32, Scratch, Scratch) - emitter.branchIf(NotEqualCondition, failed) - - proc loadRegistersBase(emitter: var Assembler) {.raises: [BasicError].} = - ## Points at the first slot of the frame the region runs in. - emitter.loadDouble(RegistersBase, Context, ContextRegisters) - - proc readSlot(emitter: var Assembler, scratch: int, slot: int32, - leave: Label) {.raises: [BasicError].} = - ## Reads one register slot as an integer, leaving the region if it - ## holds anything else. The tag is read into the same register the - ## value will land in, so no third register is needed. - let target = ValueScratch[scratch] - let base = int(slot) * ValueStride - emitter.loadByteZeroed(target, RegistersBase, base) - emitter.testRegister(Word32, target, target) - emitter.branchIf(NotEqualCondition, leave) - emitter.loadWord(target, RegistersBase, base + ValuePayload) - - proc writeSlot(emitter: var Assembler, scratch: int, slot: int32) + ## Writes a fixed-point payload. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, byte(FixedTag)) + e.code.storeWord(temp(value), base, offset + ValuePayload) + + proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) {.raises: [BasicError].} = - ## Writes one register slot as an integer. - let base = int(slot) * ValueStride - emitter.storeByteImmediate(RegistersBase, base, 0) - emitter.storeWord(ValueScratch[scratch], RegistersBase, - base + ValuePayload) + ## Writes a constant of a known kind. + let (base, offset) = e.reach(place) + e.code.storeByteImmediate(base, offset, byte(tag)) + e.code.storeWordImmediate(base, offset + ValuePayload, bits) - proc setScratch(emitter: var Assembler, scratch: int, value: int32) + proc copyValue(e: var Emitter, destination, source: Place) {.raises: [BasicError].} = + ## Copies a value entire, whatever kind it holds, as the interpreter + ## does. + let (fromBase, fromOffset) = e.reach(source) + e.code.loadDouble(temp(5), fromBase, fromOffset) + e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) + let (toBase, toOffset) = e.reach(destination) + e.code.storeDouble(temp(5), toBase, toOffset) + e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) + + proc unlessWhole(e: var Emitter, tag: int, slow: Label) + {.raises: [].} = + ## Takes the slow path unless a kind says whole number. + e.code.testRegister(Word32, temp(tag), temp(tag)) + e.jumpWhen(NotEqualCondition, slow) + + proc unlessNumeric(e: var Emitter, tag: int, slow: Label) + {.raises: [].} = + ## Takes the slow path unless a kind says number of either sort. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(AboveCondition, slow) + + proc unlessSame(e: var Emitter, tag, other: int, slow: Label) + {.raises: [].} = + ## Takes the slow path unless two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(NotEqualCondition, slow) + + proc whenFixed(e: var Emitter, tag: int, target: Label) + {.raises: [].} = + ## Jumps when a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(EqualCondition, target) + + proc loadConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = ## Loads a constant into a working register. - emitter.loadImmediate(Word32, ValueScratch[scratch], int64(value)) + e.code.loadImmediate(Word32, temp(value), int64(bits)) - proc addScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Adds the second working register into the first, wrapping. - emitter.addRegister(Word32, ValueScratch[left], ValueScratch[right]) + proc add(e: var Emitter, left, right: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, temp(left), temp(right)) - proc subtractScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Subtracts the second working register from the first, wrapping. - emitter.subtractRegister(Word32, ValueScratch[left], ValueScratch[right]) + proc subtract(e: var Emitter, left, right: int) {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, temp(left), temp(right)) - proc multiplyScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Multiplies the first working register by the second, wrapping. - emitter.multiplyRegister(Word32, ValueScratch[left], ValueScratch[right]) + proc multiply(e: var Emitter, left, right: int) {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiplyRegister(Word32, temp(left), temp(right)) - proc negateScratch(emitter: var Assembler, scratch: int) - {.raises: [BasicError].} = - ## Replaces a working register with its negation, wrapping. - emitter.negateRegister(Word32, ValueScratch[scratch]) + proc negate(e: var Emitter, value: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negateRegister(Word32, temp(value)) - proc compareScratch(emitter: var Assembler, left, right: int) - {.raises: [BasicError].} = - ## Sets flags from two working registers. - emitter.compareRegister(Word32, ValueScratch[left], ValueScratch[right]) + proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits both hold. + e.code.andRegister(Word32, temp(left), temp(right)) - proc answerCondition(emitter: var Assembler, scratch: int, test: Test) - {.raises: [BasicError].} = - ## Writes BASIC's -1 for true and zero for false. - emitter.setIfCondition(ValueScratch[scratch], nativeCondition(test)) - emitter.negateRegister(Word32, ValueScratch[scratch]) + proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits either holds. + e.code.orRegister(Word32, temp(left), temp(right)) - proc scratchFromHoisted(emitter: var Assembler, scratch, slot: int) - {.raises: [BasicError].} = - ## Copies a hoisted global into a working register. - emitter.moveRegister(Word32, ValueScratch[scratch], slotRegister(slot)) + proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = + ## Keeps the bits exactly one holds. + e.code.xorRegister(Word32, temp(left), temp(right)) - proc hoistedFromScratch(emitter: var Assembler, slot, scratch: int) - {.raises: [BasicError].} = - ## Copies a working register into a hoisted global. - emitter.moveRegister(Word32, slotRegister(slot), ValueScratch[scratch]) + proc bitNot(e: var Emitter, value: int) {.raises: [].} = + ## Flips every bit. + e.code.notRegister(Word32, temp(value)) - proc branchIfScratchZero(emitter: var Assembler, scratch: int, - target: Label) {.raises: [BasicError].} = - ## Branches when a working register holds zero. - emitter.testRegister(Word32, ValueScratch[scratch], ValueScratch[scratch]) - emitter.branchIf(EqualCondition, target) + proc divide(e: var Emitter, left, right: int, keepRemainder: bool) + {.raises: [].} = + ## Divides toward zero; the divisor is known not to be zero. The + ## hardware traps on the most negative number over minus one, which + ## the interpreter defines, so minus one is answered without dividing: + ## the quotient is the negation, wrapping, and nothing is left over. + let normal = e.label() + let done = e.label() + e.code.compareImmediate(Word32, temp(right), -1) + e.jumpWhen(NotEqualCondition, normal) + if keepRemainder: + e.code.loadImmediate(Word32, temp(left), 0) + else: + e.code.negateRegister(Word32, temp(left)) + e.jump(done) + e.place(normal) + e.code.moveRegister(Word32, rax, temp(left)) + e.code.signExtendToPair(Word32) + e.code.signedDivide(Word32, temp(right)) + if keepRemainder: + e.code.moveRegister(Word32, temp(left), Spare) + else: + e.code.moveRegister(Word32, temp(left), rax) + e.place(done) - proc loadHoisted(emitter: var Assembler, slot: int, base: int) - {.raises: [BasicError].} = - ## Reads one global into its register. - emitter.loadWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + proc quotient(e: var Emitter, left, right: int) {.raises: [].} = + ## Divides toward zero. + e.divide(left, right, false) - proc storeHoisted(emitter: var Assembler, slot: int, base: int) - {.raises: [BasicError].} = - ## Publishes one register back as an integer value. - emitter.storeByteImmediate(GlobalsBase, base, 0) - emitter.storeWord(slotRegister(slot), GlobalsBase, base + ValuePayload) + proc remainder(e: var Emitter, left, right: int) + {.raises: [].} = + ## Leaves what dividing left over, with the sign of the dividend. + e.divide(left, right, true) - proc setSlot(emitter: var Assembler, slot: int, value: int32) + proc multiplyFixed(e: var Emitter, left, right: int) {.raises: [BasicError].} = - ## Loads a constant into a hoisted register. - emitter.loadImmediate(Word32, slotRegister(slot), int64(value)) + ## Multiplies two Q16.16 numbers through a widened intermediate, + ## rounding to nearest exactly as the fixed-point library does. + e.code.signExtendDouble(temp(left), temp(left)) + e.code.signExtendDouble(temp(6), temp(right)) + e.code.multiplyRegister(Word64, temp(left), temp(6)) + e.code.addImmediate(Word64, temp(left), int32(FixedRounding)) + e.code.shiftRightImmediate(Word64, temp(left), FixedShift) + e.code.moveRegister(Word32, temp(left), temp(left)) - proc copySlot(emitter: var Assembler, destination, source: int) + proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) {.raises: [BasicError].} = - ## Copies one hoisted register into another. - emitter.moveRegister( - Word32, slotRegister(destination), slotRegister(source) - ) + ## Turns a number of either kind into its Q16.16 bits, widened to + ## sixty-four. A whole number outside the fixed-point range cannot + ## become one, which the interpreter refuses, so that goes slow. + let register = temp(value) + let already = e.label() + let ready = e.label() + e.whenFixed(tag, already) + e.code.compareImmediate(Word32, register, 32767) + e.jumpWhen(GreaterCondition, slow) + e.code.compareImmediate(Word32, register, -32768) + e.jumpWhen(LessCondition, slow) + e.code.signExtendDouble(register, register) + e.code.shiftLeftImmediate(Word64, register, FixedShift) + e.jump(ready) + e.place(already) + e.code.signExtendDouble(register, register) + e.place(ready) + + proc divideFixed(e: var Emitter, left, right: int, slow: Label) + {.raises: [BasicError].} = + ## Divides two widened Q16.16 numbers, rounding to nearest with halves + ## going up, for either sign, exactly as the fixed-point library + ## does: the signs are put right first, half the divisor is added, + ## and the truncating divide is corrected back to a floor. The divide + ## works in rax and rdx, so the numerator is moved there. + let denominator = temp(right) + let half = temp(5) + let numerator = temp(6) + e.code.moveRegister(Word64, rax, temp(left)) + e.code.compareImmediate(Word64, denominator, 0) + e.jumpWhen(EqualCondition, slow) + let signsSettled = e.label() + e.jumpWhen(GreaterCondition, signsSettled) + e.code.negateRegister(Word64, rax) + e.code.negateRegister(Word64, denominator) + e.place(signsSettled) + e.code.shiftLeftImmediate(Word64, rax, FixedShift) + e.code.moveRegister(Word64, half, denominator) + e.code.shiftRightImmediate(Word64, half, 1) + e.code.addRegister(Word64, rax, half) + e.code.moveRegister(Word64, numerator, rax) + e.code.signExtendToPair(Word64) + e.code.signedDivide(Word64, denominator) + let done = e.label() + e.code.testRegister(Word64, Spare, Spare) + e.jumpWhen(EqualCondition, done) + e.code.testRegister(Word64, numerator, numerator) + e.jumpWhen(GreaterEqualCondition, done) + e.code.subtractImmediate(Word64, rax, 1) + e.place(done) + e.code.moveRegister(Word32, temp(left), rax) + + proc compare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two working registers. + e.code.compareRegister(Word32, temp(left), temp(right)) - proc addSlots(emitter: var Assembler, destination, source: int) - {.raises: [BasicError].} = - ## Adds one hoisted register into another, wrapping on overflow. - emitter.addRegister( - Word32, slotRegister(destination), slotRegister(source) - ) + proc compareConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = + ## Sets flags from a working register against a constant. + e.code.compareImmediate(Word32, temp(value), bits) - proc addToSlot(emitter: var Assembler, slot: int, value: int32) - {.raises: [BasicError].} = - ## Adds a constant to a hoisted register, wrapping on overflow. - emitter.addImmediate(Word32, slotRegister(slot), value) + proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. The byte form only + ## names the low byte of rax, rcx, rdx and rbx without a prefix, so + ## this is only ever asked of the first working register. + e.code.setIfCondition(temp(value), nativeCondition(check)) + e.code.negateRegister(Word32, temp(value)) - const MaxPooled* = 0 + proc jumpOn(e: var Emitter, check: Check, target: Label) + {.raises: [].} = + ## Jumps on a comparison outcome. + e.jumpWhen(nativeCondition(check), target) - proc compareSlot(emitter: var Assembler, slot: int, value: int32, - pooled = -1) {.raises: [BasicError].} = - ## Sets flags from a hoisted register against a constant. - emitter.compareImmediate(Word32, slotRegister(slot), value) + proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds zero. + e.code.testRegister(Word32, temp(value), temp(value)) + e.jumpWhen(EqualCondition, target) - proc remainderTest(emitter: var Assembler, slot: int, divisor: int32) - {.raises: [BasicError].} = - ## Sets flags so NotEqualTest means the remainder is not zero. - emitter.moveRegister(Word32, rax, slotRegister(slot)) - emitter.signExtendToPair(Word32) - emitter.loadImmediate(Word32, Scratch, int64(divisor)) - emitter.signedDivide(Word32, Scratch) - emitter.testRegister(Word32, rdx, rdx) - - proc budgetGate(emitter: var Assembler, instructionCount, workCost: int64, - short: Label) {.raises: [BasicError].} = + proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a working register holds anything but zero. + e.code.testRegister(Word32, temp(value), temp(value)) + e.jumpWhen(NotEqualCondition, target) + + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, + slow: Label) {.raises: [BasicError].} = + ## Bounds checks an index and leaves the cell's address in Cell. One + ## unsigned comparison covers both ends, as the interpreter's does. + e.code.compareImmediate(Word32, temp(index), extent.length) + e.jumpWhen(AboveEqualCondition, slow) + e.code.moveRegister(Word32, Cell, temp(index)) + e.code.addImmediate(Word32, Cell, extent.base) + e.code.shiftLeftImmediate(Word64, Cell, 4) + e.code.addRegister(Word64, Cell, MemoryBase) + + proc meter(e: var Emitter, instructions, work: int32, slow: Label) + {.raises: [].} = ## Checks both budgets before charging either, as the interpreter does. - ## Both counts come from int32 operands, so they fit the immediate form. - emitter.compareImmediate(Word64, Instructions, int32(instructionCount)) - emitter.branchIf(LessCondition, short) - emitter.compareImmediate(Word64, Work, int32(workCost)) - emitter.branchIf(LessCondition, short) - emitter.subtractImmediate(Word64, Instructions, int32(instructionCount)) - emitter.subtractImmediate(Word64, Work, int32(workCost)) - - proc exitStub(emitter: var Assembler, offset: int32, status: int32, - writeback: Label) {.raises: [BasicError].} = - ## Names the resume offset and status, then joins the shared exit. - emitter.storeWordImmediate(Context, ContextOffset, offset) - emitter.loadImmediate(Word32, rax, int64(status)) - emitter.branch(writeback) - - proc publishState(emitter: var Assembler) {.raises: [BasicError].} = - ## Writes the budgets back; the status already sits in the result. - emitter.storeDouble(Instructions, Context, ContextInstructions) - emitter.storeDouble(Work, Context, ContextWork) - - proc guardExit(emitter: var Assembler, start: int32) - {.raises: [BasicError].} = - ## Hands the loop back untouched after a failed guard. - emitter.storeWordImmediate(Context, ContextOffset, start) - emitter.loadImmediate(Word32, rax, int64(ord(NativeGuardFailed))) - emitter.endRegion() - proc setSlotConstant(emitter: var Assembler, slot: int32, tag: int, - bits: int32) {.raises: [BasicError].} = - ## Writes a constant of a known kind straight into a slot. - let base = int(slot) * ValueStride - emitter.storeByteImmediate(RegistersBase, base, byte(tag)) - emitter.loadImmediate(Word32, ValueScratch[0], int64(bits)) - emitter.storeWord(ValueScratch[0], RegistersBase, base + ValuePayload) - - proc hostDataAddress(emitter: var Assembler, index: int32) - {.raises: [BasicError].} = - ## Leaves one host data value's address in Scratch. - emitter.loadDouble(Scratch, Context, ContextHostData) - emitter.addImmediate(Word64, Scratch, index * int32(ValueStride)) + e.code.compareImmediate(Word64, Instructions, instructions) + e.jumpWhen(LessCondition, slow) + e.code.compareImmediate(Word64, Work, work) + e.jumpWhen(LessCondition, slow) + e.code.subtractImmediate(Word64, Instructions, instructions) + e.code.subtractImmediate(Word64, Work, work) + + proc slotAddress(e: var Emitter, destination, index: Register) + {.raises: [BasicError].} = + ## Points a register at one register-file slot by its absolute index. + ## The index register is left scaled. + e.contextField(destination, ContextRegisterFile) + e.code.shiftLeftImmediate(Word64, index, 4) + e.code.addRegister(Word64, destination, index) + + proc copyValues(e: var Emitter, destination, source: Register, + count: int) {.raises: [BasicError].} = + ## Copies a run of whole values, in a loop once there are many. Works + ## in rax, rdx, r8, r9 and r10, so neither end may be one of those. + if count <= 8: + for index in 0 ..< count: + e.code.loadDouble(rax, source, index * ValueStride) + e.code.loadDouble(Spare, source, index * ValueStride + ValuePayload) + e.code.storeDouble(rax, destination, index * ValueStride) + e.code.storeDouble(Spare, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, r10, source) + e.code.moveRegister(Word64, r9, destination) + e.code.loadImmediate(Word32, r8, int64(count)) + let again = e.label() + e.place(again) + e.code.loadDouble(rax, r10, 0) + e.code.loadDouble(Spare, r10, ValuePayload) + e.code.storeDouble(rax, r9, 0) + e.code.storeDouble(Spare, r9, ValuePayload) + e.code.addImmediate(Word64, r10, ValueStride) + e.code.addImmediate(Word64, r9, ValueStride) + e.code.subtractImmediate(Word32, r8, 1) + e.jumpWhen(NotEqualCondition, again) + + proc clearValues(e: var Emitter, destination: Register, count: int) + {.raises: [BasicError].} = + ## Zeroes a run of values, in a loop once there are many. + e.code.loadImmediate(Word32, rax, 0) + if count <= 8: + for index in 0 ..< count: + e.code.storeDouble(rax, destination, index * ValueStride) + e.code.storeDouble(rax, destination, + index * ValueStride + ValuePayload) + return + e.code.moveRegister(Word64, r9, destination) + e.code.loadImmediate(Word32, r8, int64(count)) + let again = e.label() + e.place(again) + e.code.storeDouble(rax, r9, 0) + e.code.storeDouble(rax, r9, ValuePayload) + e.code.addImmediate(Word64, r9, ValueStride) + e.code.subtractImmediate(Word32, r8, 1) + e.jumpWhen(NotEqualCondition, again) + + proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, + calleeRegisters, calleeParameters, callerRegisters: int32, + resumeAt: int32, limits: CallLimits, slow: Label) + {.raises: [BasicError].} = + ## Pushes a frame into the interpreter's own array and moves the + ## current frame on, refusing the same two ceilings it refuses. + let depth = rax + let oldBase = rcx + let newBase = rsi + let frame = rdi + e.code.loadWord(depth, Context, ContextDepth) + e.code.compareImmediate(Word32, depth, limits.frames - 1) + e.jumpWhen(GreaterEqualCondition, slow) + e.code.loadWord(oldBase, Context, ContextBase) + e.code.moveRegister(Word32, newBase, oldBase) + e.code.addImmediate(Word32, newBase, callerRegisters) + e.code.compareImmediate(Word32, newBase, + limits.slots - calleeRegisters) + e.jumpWhen(GreaterCondition, slow) + + e.contextField(frame, ContextFrames) + e.code.moveRegister(Word32, r8, depth) + e.code.shiftLeftImmediate(Word64, r8, 4) + e.code.addRegister(Word64, frame, r8) + e.code.storeWord(oldBase, frame, FrameBase) + e.code.loadWord(r8, Context, ContextRoutine) + e.code.storeWord(r8, frame, FrameRoutine) + e.code.storeWordImmediate(frame, FrameReturn, resumeAt) + e.code.storeWordImmediate(frame, FrameTag, if gosub: 1 else: 0) + + e.code.addImmediate(Word32, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.code.storeWord(newBase, Context, ContextBase) + e.code.storeWordImmediate(Context, ContextRoutine, calleeId) + + # A GOSUB hands the callee a copy of the caller's slots; a call clears + # them and lays the arguments over the first few, in that order. + e.code.moveRegister(Word64, frame, RegistersBase) + e.code.moveRegister(Word32, rcx, newBase) + e.slotAddress(RegistersBase, rcx) + if gosub: + e.copyValues(RegistersBase, frame, int(calleeRegisters)) + else: + e.clearValues(RegistersBase, int(calleeRegisters)) + if calleeParameters > 0: + e.contextField(Cell, ContextArguments) + e.copyValues(RegistersBase, Cell, int(calleeParameters)) + + proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) + {.raises: [BasicError].} = + ## Pops a frame and jumps to wherever it said to carry on. A GOSUB + ## frame first hands the shared parameters back to the caller. + let depth = rax + let frame = rcx + let base = rsi + e.code.loadWord(depth, Context, ContextDepth) + e.code.testRegister(Word32, depth, depth) + e.jumpWhen(EqualCondition, slow) + e.code.subtractImmediate(Word32, depth, 1) + e.code.storeWord(depth, Context, ContextDepth) + e.contextField(frame, ContextFrames) + e.code.moveRegister(Word32, r8, depth) + e.code.shiftLeftImmediate(Word64, r8, 4) + e.code.addRegister(Word64, frame, r8) + e.code.loadWord(base, frame, FrameBase) + if parameters > 0: + let plain = e.label() + e.code.loadByteZeroed(Spare, frame, FrameTag) + e.code.compareImmediate(Word32, Spare, 1) + e.jumpWhen(NotEqualCondition, plain) + e.code.moveRegister(Word32, r8, base) + e.slotAddress(rdi, r8) + e.copyValues(rdi, RegistersBase, int(parameters)) + e.place(plain) + e.code.storeWord(base, Context, ContextBase) + e.code.loadWord(Spare, frame, FrameRoutine) + e.code.storeWord(Spare, Context, ContextRoutine) + e.code.loadWord(Spare, frame, FrameReturn) + e.code.storeWord(Spare, Context, ContextOffset) + e.code.moveRegister(Word32, r8, base) + e.slotAddress(RegistersBase, r8) + e.contextField(rax, ContextTable) + e.code.shiftLeftImmediate(Word64, Spare, 3) + e.code.addRegister(Word64, rax, Spare) + e.code.loadDouble(rax, rax, 0) + e.code.jumpRegister(rax) + + proc callSlow(e: var Emitter, offset: int32, routine: Label) + {.raises: [].} = + ## Runs the interpreter's own code for one instruction. + e.code.loadImmediate(Word32, SecondArgument, int64(offset)) + e.code.callLabel(routine) + + proc slowRoutine(e: var Emitter, failed: Label) + {.raises: [BasicError].} = + ## The one place compiled code calls out. The budgets go into the + ## context for the interpreter's code to charge, and come back from it + ## along with the frame, since a call or a return may have moved it. + ## A failure leaves through the shared exit, dropping the return + ## address this routine was called with on the way. + let refused = e.label() + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.moveRegister(Word64, FirstArgument, Context) + e.code.subtractImmediate(Word64, rsp, Padding) + e.contextField(rax, ContextStep) + e.code.callRegister(rax) + e.code.addImmediate(Word64, rsp, Padding) + e.code.moveRegister(Word32, r10, rax) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.code.loadWord(rcx, Context, ContextBase) + e.slotAddress(RegistersBase, rcx) + e.code.testRegister(Word32, r10, r10) + e.jumpWhen(NotEqualCondition, refused) + e.code.returnToCaller() + e.place(refused) + e.code.addImmediate(Word64, rsp, 8) + e.jump(failed) + + proc dispatch(e: var Emitter) {.raises: [BasicError].} = + ## Jumps to the block for whatever offset the context names. + e.code.loadWord(rax, Context, ContextOffset) + e.code.shiftLeftImmediate(Word64, rax, 3) + e.contextField(rcx, ContextTable) + e.code.addRegister(Word64, rcx, rax) + e.code.loadDouble(rcx, rcx, 0) + e.code.jumpRegister(rcx) + + proc prologue(e: var Emitter) {.raises: [BasicError].} = + ## Saves what the platform says to keep and loads the machine state. + for register in Saved: + e.code.push(register) + e.code.subtractImmediate(Word64, rsp, Padding) + e.code.moveRegister(Word64, Context, FirstArgument) + e.code.loadDouble(GlobalsBase, Context, 0) + e.code.loadDouble(Instructions, Context, ContextInstructions) + e.code.loadDouble(Work, Context, ContextWork) + e.contextField(MemoryBase, ContextMemory) + e.code.loadWord(rcx, Context, ContextBase) + e.slotAddress(RegistersBase, rcx) + + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, rax, int64(ord(status))) + e.code.addImmediate(Word64, rsp, Padding) + for index in countdown(Saved.len - 1, 0): + e.code.pop(Saved[index]) + e.code.returnToCaller() + + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = + ## Publishes the budgets and where the program stopped, then returns. + e.code.storeDouble(Instructions, Context, ContextInstructions) + e.code.storeDouble(Work, Context, ContextWork) + e.code.storeWordImmediate(Context, ContextOffset, offset) + e.epilogue(NativeCompleted) + + proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = + ## Resolves every branch and returns the finished bytes. + e.code.resolve() + e.code.code + + proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = + ## Returns where a label ended up, in bytes. + e.code.offsetOf(target) + +proc invoke*(machine: Machine, context: var NativeContext): NativeStatus + {.raises: [].} = + ## Runs the compiled program from the offset the context names. + NativeStatus(machine.call(context.addr)) - proc copyHostDataToSlot(emitter: var Assembler, index, slot: int32) - {.raises: [BasicError].} = - ## Copies a host value entire into a slot, as the interpreter does. - emitter.hostDataAddress(index) - emitter.copyElementToSlot(slot) - - proc readHostDataInteger(emitter: var Assembler, scratch: int, - index: int32, leave: Label) {.raises: [BasicError].} = - ## Reads a host value as an integer, leaving the region if it is not. - emitter.hostDataAddress(index) - emitter.readElement(scratch, leave) - - -proc compileRegion*(code: seq[Instruction], start, stop, globals, - slots: int, extents: seq[ArrayExtent], constants: seq[int32] = @[], - hostData = 0, routines: seq[RoutineExtent] = @[], - limits = CallLimits()): Region {.raises: [BasicError].} = - ## Compiles one loop, or returns nil when it is outside the modelled set. - ## - ## Generated code indexes global storage without checking, so every - ## index it will use is proved to be in range here, before any of it is - ## emitted. The interpreter checks each access as it runs; compiled code - ## cannot, which is exactly why this pass has to be exhaustive. +proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], + ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], + limits: CallLimits, far: bool): (seq[byte], seq[int]) + {.raises: [BasicError].} = + ## Emits the whole program and returns its bytes along with where each + ## offset's block starts. + when not (NativeArm64 or NativeAmd64): + raise newException(BasicError, "BASIC has no whole-program backend here") + else: + var e = Emitter(far: far) + var blocks = newSeq[Label](code.len + 1) + for index in 0 .. code.len: + blocks[index] = e.label() + let dispatchLabel = e.label() + let slowLabel = e.label() + let failedLabel = e.label() + + e.prologue() + e.jump(dispatchLabel) + + var stubs: seq[Stub] + for index in 0 ..< code.len: + let item = code[index] + let offset = int32(index) + e.place(blocks[index]) + + template slowFor(after: Branching): Label = + ## Names a slow path for this instruction, emitted after the block. + let stub = Stub(label: e.label(), offset: offset, carry: after) + stubs.add(stub) + stub.label + + template runSlow() = + ## Runs this instruction through the interpreter's code in line. + e.callSlow(offset, slowLabel) + + var fallsThrough = true + case item.op + of MeterOp: + e.meter(item.b, item.a, slowFor(ToNext)) + of LoadImmediateOp: + e.writeConstant(slot(item.a), 0, item.b) + of LoadFixedOp: + e.writeConstant(slot(item.a), FixedTag, constants[int(item.b)]) + of MoveOp: + e.copyValue(slot(item.a), slot(item.b)) + of LoadGlobalOp: + e.copyValue(slot(item.a), global(item.b)) + of LoadHostDataOp: + e.copyValue(slot(item.a), host(item.b)) + of StoreGlobalOp: + e.copyValue(global(item.a), slot(item.b)) + of StoreGlobalImmediateOp: + e.writeConstant(global(item.a), 0, item.b) + of MoveGlobalOp: + e.copyValue(global(item.a), global(item.b)) + of SetArgumentOp: + e.copyValue(argument(item.a), slot(item.b)) + of SetArgumentImmediateOp: + e.writeConstant(argument(item.a), 0, item.b) + of SetArgumentGlobalOp: + e.copyValue(argument(item.a), global(item.b)) + of AddGlobalImmediateOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.b) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp: + let slow = slowFor(ToNext) + let source = + case item.op + of AddGlobalOp: global(item.b) + of AddGlobalHostDataOp: host(item.b) + else: slot(item.b) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.readValue(1, 3, source) + e.unlessWhole(3, slow) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of ModuloGlobalImmediateOp: + if item.c == 0: + runSlow() + else: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.c) + e.remainder(0, 1) + e.writeWhole(global(item.a), 0) + of AddGlobalArrayGlobalIndexOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.b)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.add(0, 1) + e.writeWhole(global(item.a), 0) + of ArrayAddGlobalsOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.a)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) + e.add(1, 0) + e.writeWhole(cell(), 1) + of AddOp, SubtractOp, MultiplyOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.readValue(1, 3, slot(item.c)) + e.unlessSame(2, 3, slow) + when ModelsFixed: + e.unlessNumeric(2, slow) + else: + e.unlessWhole(2, slow) + case item.op + of AddOp: + e.add(0, 1) + of SubtractOp: + e.subtract(0, 1) + else: + when ModelsFixed: + let fixedWay = e.label() + let joined = e.label() + e.whenFixed(2, fixedWay) + e.multiply(0, 1) + e.jump(joined) + e.place(fixedWay) + e.multiplyFixed(0, 1) + e.place(joined) + else: + e.multiply(0, 1) + e.writeKind(slot(item.a), 2, 0) + of DivideOp: + when ModelsFixed: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessNumeric(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessNumeric(3, slow) + e.widenToFixed(0, 2, slow) + e.widenToFixed(1, 3, slow) + e.divideFixed(0, 1, slow) + e.writeFixed(slot(item.a), 0) + else: + runSlow() + of IntegerDivideOp, ModuloOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessWhole(3, slow) + e.jumpIfZeroValue(1, slow) + if item.op == IntegerDivideOp: + e.quotient(0, 1) + else: + e.remainder(0, 1) + e.writeWhole(slot(item.a), 0) + of NegateOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + when ModelsFixed: + e.unlessNumeric(2, slow) + else: + e.unlessWhole(2, slow) + e.negate(0) + e.writeKind(slot(item.a), 2, 0) + of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp: + # The same kind on both sides orders the same on the stored bits, + # and the answer is always a whole number. + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.readValue(1, 3, slot(item.c)) + e.unlessSame(2, 3, slow) + e.unlessNumeric(2, slow) + e.compare(0, 1) + e.answer(0, comparisonCheck(item.op)) + e.writeWhole(slot(item.a), 0) + of AndOp, OrOp, XorOp, EqvOp, ImpOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.readValue(1, 3, slot(item.c)) + e.unlessWhole(3, slow) + case item.op + of AndOp: + e.bitAnd(0, 1) + of OrOp: + e.bitOr(0, 1) + of XorOp: + e.bitXor(0, 1) + of EqvOp: + e.bitXor(0, 1) + e.bitNot(0) + else: + e.bitNot(0) + e.bitOr(0, 1) + e.writeWhole(slot(item.a), 0) + of NotOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.bitNot(0) + e.writeWhole(slot(item.a), 0) + of JumpOp: + e.jump(blocks[int(item.a)]) + fallsThrough = false + of JumpIfZeroOp: + # A fixed-point zero is all zero bits too, so either kind tests + # the same way. + let slow = slowFor(ToOffset) + e.readValue(0, 2, slot(item.a)) + e.unlessNumeric(2, slow) + e.jumpIfZeroValue(0, blocks[int(item.b)]) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + let slow = slowFor(ToOffset) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.compareConstant(0, item.b) + e.jumpOn(takenOn(item.op), blocks[int(item.c)]) + of JumpUnlessGlobalModuloEqualZeroOp: + if item.b == 0: + runSlow() + e.jump(dispatchLabel) + fallsThrough = false + else: + let slow = slowFor(ToOffset) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.b) + e.remainder(0, 1) + e.jumpIfNotZeroValue(0, blocks[int(item.c)]) + of ArrayGetOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.c)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.b)], slow) + e.copyValue(slot(item.a), cell()) + of ArraySetOp: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.a)], slow) + e.copyValue(cell(), slot(item.c)) + of CallOp, GosubOp: + let owner = routines[int(ownerOf[index])] + let slow = slowFor(ToOffset) + if item.op == CallOp: + let callee = routines[int(item.a)] + e.enterRoutine(false, item.a, callee.registers, callee.parameters, + owner.registers, offset + 1, limits, slow) + e.jump(blocks[int(callee.entry)]) + else: + e.enterRoutine(true, ownerOf[index], owner.registers, 0, + owner.registers, offset + 1, limits, slow) + e.jump(blocks[int(item.a)]) + fallsThrough = false + of ReturnOp: + let owner = routines[int(ownerOf[index])] + e.leaveRoutine(owner.parameters, slowFor(ToOffset)) + fallsThrough = false + of HaltOp: + e.halt(offset) + fallsThrough = false + of ReturnLabelOp, ExitSubOp: + runSlow() + e.jump(dispatchLabel) + fallsThrough = false + of LoadStringOp, TextCallOp, HostCallOp, PrintTextOp, PrintValueOp, + PrintNewlineOp: + runSlow() + + # Slow paths go after the block, out of the way of the fast ones. + let blockEnds = index + 1 == code.len or code[index + 1].op == MeterOp + if blockEnds and stubs.len > 0: + if fallsThrough: + e.jump(blocks[index + 1]) + for stub in stubs: + e.place(stub.label) + e.callSlow(stub.offset, slowLabel) + case stub.carry + of ToNext: + e.jump(blocks[int(stub.offset) + 1]) + of ToOffset: + e.jump(dispatchLabel) + stubs.setLen(0) + + # One past the end holds nothing to run. The interpreter's code is + # left to refuse it the way it would. + e.place(blocks[code.len]) + e.callSlow(int32(code.len), slowLabel) + e.jump(dispatchLabel) + + e.place(dispatchLabel) + e.dispatch() + e.place(slowLabel) + e.slowRoutine(failedLabel) + e.place(failedLabel) + e.epilogue(NativeFailed) + + let bytes = e.finish() + var starts = newSeq[int](code.len + 1) + for index in 0 .. code.len: + starts[index] = e.offsetBytes(blocks[index]) + (bytes, starts) + +proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], + extents: seq[ArrayExtent], constants: seq[int32], globals, hostData, + arguments: int, limits: CallLimits): Machine {.raises: [BasicError].} = + ## Compiles every offset of a program to machine code, or returns nil + ## when this target has no backend or the program is outside what the + ## generator is sure of. Generated code indexes storage without + ## checking, so every index it will use is proved in range here first. when not (NativeArm64 or NativeAmd64): - # No backend for this target: the interpreter is the only path. return nil else: - if start < 0 or stop > code.len or start >= stop: + if not layoutMatches() or code.len == 0 or routines.len == 0: return nil - if code.reachesOutside(start, stop): + if limits.frames <= 0 or limits.slots < 0: return nil - - if globals < 0 or slots < 0: - return nil - var usesSlots = false - var usesCalls = false - var usesHostCalls = false - - ## The offsets this region covers, and where each one's block sits. - ## A call brings the callee's whole body in with it. - var members: seq[int32] - var covered = newSeq[bool](code.len) - for index in start ..< stop: - members.add(int32(index)) - covered[index] = true - if not gatherCalled(code, members, covered, routines): + # Every value is reached through a displacement worked out here, so + # each store of values must end where one of thirty-two bits still + # reaches, whichever architecture this is. + const Reachable = (int(high(int32)) - ValueStride) div ValueStride + if globals > Reachable or hostData > Reachable or + arguments > Reachable or int(limits.slots) > Reachable: return nil - ## Which routine each offset belongs to, since a call has to record - ## how many slots the caller was using. Routine zero is the program - ## itself, so every offset has an owner. - var ownerOf = newSeq[int32](code.len + 1) + # Every offset belongs to exactly one routine, and none runs on into + # the next, so the routine an instruction runs in is known here. + var ownerOf = newSeq[int32](code.len) for index in 0 ..< ownerOf.len: ownerOf[index] = -1 for id, routine in routines: - if routine.entry < 0 or routine.length < 0: + if routine.entry < 0 or routine.length <= 0 or + int(routine.entry) + int(routine.length) > code.len: + return nil + if routine.registers < 0 or int(routine.registers) > Reachable or + routine.parameters < 0 or + routine.parameters > routine.registers or + routine.parameters > int32(arguments): return nil for step in 0 ..< int(routine.length): let offset = int(routine.entry) + step - if offset >= code.len: + if ownerOf[offset] >= 0: return nil ownerOf[offset] = int32(id) - # Only a call needs to know which routine it sits in, to record how - # many slots the caller was using. - for member in members: - if code[int(member)].op == CallOp and ownerOf[int(member)] < 0: + let last = code[int(routine.entry) + int(routine.length) - 1] + if last.op notin Terminators: return nil - if limits.frames <= 0 or limits.slots <= 0: - for offset in members: - if code[int(offset)].op in {CallOp, ReturnOp, ExitSubOp}: - return nil - var placeOf = newSeq[int32](code.len + 1) - for index in 0 ..< placeOf.len: - placeOf[index] = -1 - for place, offset in members: - placeOf[int(offset)] = int32(place) - - proc covers(offset: int32): bool {.closure, raises: [].} = - ## Reports whether an offset is compiled into this region. - offset >= 0 and int(offset) < placeOf.len and placeOf[int(offset)] >= 0 - - - var hoisted: seq[int32] - for member in members: - let index = int(member) - let item = code[index] - if not item.isCompilable: + for index in 0 ..< code.len: + if ownerOf[index] < 0: return nil - item.touchedGlobals(hoisted) - # Slots are read and written where they sit, so each index only has - # to be proved in range; nothing is carried across the region. - # Every array named must exist, and its cells must sit where a - # displacement can reach them. - # Every constant and host slot named has to exist, and a divisor - # fixed at compile time has to be one that divides plainly. - case item.op - of LoadFixedOp: - if item.b < 0 or int(item.b) >= constants.len: - return nil - usesSlots = true - of LoadHostDataOp, AddGlobalHostDataOp: - if item.b < 0 or int(item.b) >= hostData: - return nil - if int(item.b) > - (MaxDisplacementBytes - ValuePayload) div ValueStride: - return nil - if item.op == LoadHostDataOp: - usesSlots = true - of AddGlobalRegisterOp: - usesSlots = true - of ModuloGlobalImmediateOp: - if item.c == 0 or item.c == -1: - return nil - else: - discard - var arrayId = 0'i32 - if item.namedArray(arrayId): - if arrayId < 0 or int(arrayId) >= extents.len: + + for index, item in code: + let owner = routines[int(ownerOf[index])] + template requireSlot(value: int32) = + if value < 0 or value >= owner.registers: return nil - let extent = extents[int(arrayId)] - if extent.length <= 0 or extent.base < 0: + template requireGlobal(value: int32) = + if value < 0 or int(value) >= globals: return nil - if int(extent.base) + int(extent.length) > - MaxDisplacementBytes div ValueStride: + template requireArray(value: int32) = + if value < 0 or int(value) >= extents.len: return nil - usesSlots = true - var touched: seq[int32] - item.touchedSlots(touched) - if touched.len > 0: - usesSlots = true - for slot in touched: - if slot < 0 or int(slot) >= slots: - return nil - if int(slot) > (MaxDisplacementBytes - ValuePayload) div ValueStride: - return nil - var target = 0'i32 - if item.branchTarget(target): - # A branch may land one past the last offset, where the - # interpreter stops, but never beyond it. - if int(target) < 0 or int(target) > code.len: + let extent = extents[int(value)] + if extent.base < 0 or extent.length < 0 or + int(extent.base) + int(extent.length) > Reachable: return nil - if item.op in {CallOp, ReturnOp, ExitSubOp, HostCallOp}: - usesCalls = true - usesSlots = true - if item.op == HostCallOp: - # A host function may use any register a callee is allowed to, - # so nothing loop-invariant may be left in one across the call. - usesHostCalls = true - if item.a >= 0 and (item.a < 0 or int(item.a) >= slots): + template requireHost(value: int32) = + if value < 0 or int(value) >= hostData: return nil - var calleeId = 0'i32 - if item.calledRoutine(calleeId): - let callee = routines[int(calleeId)] - if callee.registers < 0 or callee.parameters < 0 or - callee.parameters > callee.registers: + template requireArgument(value: int32) = + if value < 0 or int(value) >= arguments: return nil - if callee.registers > MaxClearedSlots: + template requireTarget(value: int32) = + if value < 0 or int(value) >= code.len or + ownerOf[int(value)] != ownerOf[index]: return nil - ## Too many globals to hold in registers means holding none of them - ## there. Only the operations written for memory are taken then, and - ## only where the generator has them. - let hoisting = hoisted.len > 0 and hoisted.len <= MaxHoistedGlobals - if not hoisting: - when not ModelsMemoryGlobals: - return nil - else: - for member in members: - if not code[int(member)].suitsMemoryGlobals: - return nil - var reached: seq[int32] - if hoisting: - reached = hoisted - else: - for member in members: - code[int(member)].touchedGlobals(reached) - for index in reached: - if index < 0 or int(index) >= globals: - return nil - if int(index) > (MaxDisplacementBytes - ValuePayload) div ValueStride: - return nil - when NativeArm64: - # The tag is read through a scaled byte offset, which is narrower - # than the range the bounds check above already allows. - for index in reached: - if int(index) * ValueStride + ValuePayload > 4095: - return nil - - proc slotOf(index: int32): int {.closure, raises: [BasicError].} = - ## Returns which hoisted register holds one global. Reaching the end - ## would mean an operation reads a global that was never gathered, - ## and so never bounds checked, so it refuses rather than picking a - ## register that happens to be next. - result = -1 - for slot, candidate in hoisted: - if candidate == index: - result = slot - break - if result < 0: - raise newException( - BasicError, "BASIC native compiler met an ungathered global" - ) - - const CountedLoops = NativeArm64 - let plan = planLoop(code, start, stop) - # Both of those settle the budget by how far a pass has got through - # the loop, which stops meaning anything once a region takes in the - # body of something it calls. Such a region charges block by block. - let counted = CountedLoops and plan.counted and not usesCalls - let spending = CountedLoops and plan.spending and not plan.counted and - not usesCalls - let pool = - if MaxPooled > 0 and not usesHostCalls: - pooledConstants(code, start, stop, MaxPooled) - else: @[] - - proc poolSlot(value: int32): int {.closure, raises: [].} = - ## Returns which register already holds a constant, if one does. - for slot, held in pool: - if held == int64(value): - return slot - -1 - - var emitter = Assembler() - var blocks: seq[Label] - for index in members: - blocks.add(emitter.label()) - let guardFailed = emitter.label() - let writeback = emitter.label() - var exits: seq[(Label, int32, NativeStatus, int)] - - template blockAt(offset: int32): Label = - blocks[placeOf[int(offset)]] - - proc exitLabel(target: int32, status: NativeStatus, - leaving: int): Label = - ## Names the out-of-line stub that resumes the interpreter here. - ## A countable loop charges for how far the leaving pass got, so - ## stubs differ by where they leave as well as where they go. - for (stub, existing, kind, origin) in exits: - if existing == target and kind == status and origin == leaving: - return stub - result = emitter.label() - exits.add((result, target, status, leaving)) - - template leaveFor(target: int32, status: NativeStatus, leaving: int) = - ## Jumps to the stub that resumes the interpreter at an offset. - emitter.branch(exitLabel(target, status, leaving)) - - ## Entry: prove every participating global is an integer, then hoist it. - emitter.startRegion() - if usesSlots: - emitter.loadRegistersBase() - if hoisting: - for slot, index in hoisted: - let base = int(index) * ValueStride - emitter.guardInteger(base, guardFailed) - emitter.loadHoisted(slot, base) - when CountedLoops: - for slot, value in pool: - emitter.loadPooled(slot, value) - # Nothing has been written yet, so a refusal here hands the loop - # back exactly as it was found. - if counted: - emitter.beginCountedLoop( - plan.instructions, plan.work, - exitLabel(int32(start), NativeExhausted, start) - ) - elif spending: - emitter.beginSpendingLoop( - plan.passInstructions, plan.passWork, - exitLabel(int32(start), NativeExhausted, start) - ) - - ## Body: one native block per bytecode offset, so branches keep working. - for member in members: - let index = int(member) - let item = code[index] - emitter.place(blockAt(member)) - - # A slot holding anything but an integer hands this offset back to - # the interpreter, which can work in whatever the slot does hold. - # Nothing has been written for this operation yet, and the charge - # for the pass so far is the one every other exit uses. - let leaveHere = exitLabel(int32(index), NativeCompleted, index) - - template branchOut(target: int32, test: Test) = - ## Takes an in-region branch directly, or leaves through a stub. - if covers(target): - emitter.branchWhen(test, blockAt(target)) - else: - emitter.branchWhen( - test, exitLabel(target, NativeCompleted, index) - ) - case item.op of MeterOp: - when CountedLoops: - if counted: - # The budget was settled on entry, so a countable loop needs - # only to know it has passes left. - if index == start: - emitter.checkCounter( - exitLabel(int32(start), NativeCompleted, start) - ) - elif spending: - if plan.checkpoints[index - start]: - emitter.checkSpending( - exitLabel(int32(index), NativeCompleted, index) - ) - emitter.recordSpending(int64(item.b), int64(item.a)) - else: - emitter.budgetGate( - int64(item.b), int64(item.a), - exitLabel(int32(index), NativeExhausted, index) - ) - else: - emitter.budgetGate( - int64(item.b), int64(item.a), - exitLabel(int32(index), NativeExhausted, index) - ) - of StoreGlobalImmediateOp: - if hoisting: - emitter.setSlot(slotOf(item.a), item.b) - else: - when ModelsMemoryGlobals: - emitter.setGlobalWhole(item.a, item.b) - of MoveGlobalOp: - if hoisting: - emitter.copySlot(slotOf(item.a), slotOf(item.b)) - else: - when ModelsMemoryGlobals: - emitter.copyGlobalToGlobal(item.a, item.b) - of AddGlobalImmediateOp: - if hoisting: - emitter.addToSlot(slotOf(item.a), item.b) - else: - when ModelsMemoryGlobals: - emitter.addToGlobal(item.a, item.b, leaveHere) - of AddGlobalOp: - emitter.addSlots(slotOf(item.a), slotOf(item.b)) - of LoadImmediateOp: - emitter.setScratch(0, item.b) - emitter.writeSlot(0, item.a) - of MoveOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.writeSlot(0, item.a) - of LoadGlobalOp: - if hoisting: - emitter.scratchFromHoisted(0, slotOf(item.b)) - emitter.writeSlot(0, item.a) - else: - when ModelsMemoryGlobals: - emitter.copyGlobalToSlot(item.b, item.a) - of StoreGlobalOp: - if hoisting: - emitter.readSlot(0, item.b, leaveHere) - emitter.hoistedFromScratch(slotOf(item.a), 0) - else: - when ModelsMemoryGlobals: - emitter.copySlotToGlobal(item.b, item.a) - of AddOp, SubtractOp: - # Whole and fixed-point numbers add and subtract through the same - # instructions, so one path serves both and the answer keeps the - # kind its operands agreed on. - emitter.readNumeric(0, item.b, leaveHere) - emitter.requireSameKind(item.c, leaveHere) - emitter.readSlotValue(1, item.c) - if item.op == AddOp: - emitter.addScratch(0, 1) - else: - emitter.subtractScratch(0, 1) - emitter.writeNumeric(0, item.a) - of MultiplyOp: - emitter.readNumeric(0, item.b, leaveHere) - emitter.requireSameKind(item.c, leaveHere) - emitter.readSlotValue(1, item.c) - when ModelsFixed: - let fixedWay = emitter.label() - let joined = emitter.label() - emitter.branchIfFixed(fixedWay) - emitter.multiplyScratch(0, 1) - emitter.branch(joined) - emitter.place(fixedWay) - emitter.multiplyFixed(0, 1) - emitter.place(joined) - else: - emitter.requireWholeKind(leaveHere) - emitter.multiplyScratch(0, 1) - emitter.writeNumeric(0, item.a) - of NegateOp: - emitter.readNumeric(0, item.b, leaveHere) - emitter.negateScratch(0) - emitter.writeNumeric(0, item.a) - of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, - GreaterEqualOp: - # Ordering is the same on the stored bits either way round, and - # the answer is always a whole number. - emitter.readNumeric(0, item.b, leaveHere) - emitter.requireSameKind(item.c, leaveHere) - emitter.readSlotValue(1, item.c) - emitter.compareScratch(0, 1) - emitter.answerCondition(0, comparisonTest(item.op)) - emitter.writeSlot(0, item.a) - of DivideOp: - when ModelsFixed and ModelsDivide: - # Either kind may appear on either side here, unlike adding, - # because both are widened to the same thing before dividing. - emitter.readNumeric(0, item.b, leaveHere) - emitter.widenToFixed(0, leaveHere) - emitter.readNumeric(1, item.c, leaveHere) - emitter.widenToFixed(1, leaveHere) - emitter.divideFixed(leaveHere) - emitter.setSlotKind(item.a, FixedTag) - emitter.writeSlotPayload(0, item.a) - else: + if item.a < 0 or item.b < 0: return nil - of ModuloOp, IntegerDivideOp: - # Both want whole numbers, both refuse a zero divisor, and minus - # one would trap on one of the two architectures, so all three go - # back to the interpreter rather than being modelled. - emitter.readSlot(0, item.b, leaveHere) - emitter.readSlot(1, item.c, leaveHere) - emitter.guardDivisor(1, leaveHere) - if item.op == ModuloOp: - emitter.remainderScratch(0, 1) - else: - emitter.quotientScratch(0, 1) - emitter.writeSlot(0, item.a) + of LoadImmediateOp: + requireSlot(item.a) of LoadFixedOp: - emitter.setSlotConstant(item.a, FixedTag, constants[int(item.b)]) + requireSlot(item.a) + if item.b < 0 or int(item.b) >= constants.len: + return nil + of MoveOp, NegateOp, NotOp: + requireSlot(item.a) + requireSlot(item.b) + of LoadGlobalOp: + requireSlot(item.a) + requireGlobal(item.b) of LoadHostDataOp: - emitter.copyHostDataToSlot(item.b, item.a) + requireSlot(item.a) + requireHost(item.b) + of StoreGlobalOp: + requireGlobal(item.a) + requireSlot(item.b) + of StoreGlobalImmediateOp, AddGlobalImmediateOp: + requireGlobal(item.a) + of MoveGlobalOp, AddGlobalOp, ModuloGlobalImmediateOp: + requireGlobal(item.a) + requireGlobal(item.b) of AddGlobalHostDataOp: - emitter.readHostDataInteger(0, item.b, leaveHere) - emitter.scratchFromHoisted(1, slotOf(item.a)) - emitter.addScratch(1, 0) - emitter.hoistedFromScratch(slotOf(item.a), 1) + requireGlobal(item.a) + requireHost(item.b) of AddGlobalRegisterOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.scratchFromHoisted(1, slotOf(item.a)) - emitter.addScratch(1, 0) - emitter.hoistedFromScratch(slotOf(item.a), 1) - of ModuloGlobalImmediateOp: - emitter.scratchFromHoisted(0, slotOf(item.b)) - emitter.setScratch(1, item.c) - emitter.remainderScratch(0, 1) - emitter.hoistedFromScratch(slotOf(item.a), 0) - of SetArgumentOp, SetArgumentImmediateOp, SetArgumentGlobalOp, - CallOp, ReturnOp, ExitSubOp, HostCallOp: - when ModelsCalls: - case item.op - of SetArgumentOp: - emitter.stageArgumentFromSlot(item.a, item.b) - of SetArgumentImmediateOp: - emitter.stageArgumentWhole(item.a, item.b) - of SetArgumentGlobalOp: - if hoisting: - emitter.stageArgumentFromHoisted(item.a, slotOf(item.b)) - else: - when ModelsMemoryGlobals: - emitter.copyGlobalToArgument(item.a, item.b) - of CallOp: - let callee = routines[int(item.a)] - emitter.enterRoutine( - callee, item.a, routines[int(ownerOf[index])].registers, - int32(index + 1), limits, leaveHere - ) - emitter.branch(blockAt(callee.entry)) - of HostCallOp: - emitter.callHost( - item.b, item.a, - exitLabel(int32(index), NativeFailed, index) - ) - else: - emitter.leaveRoutine(leaveHere) - else: - return nil - of ArrayGetOp: - emitter.readSlot(0, item.c, leaveHere) - emitter.elementAddress(0, extents[int(item.b)], leaveHere) - emitter.copyElementToSlot(item.a) - of ArraySetOp: - emitter.readSlot(0, item.b, leaveHere) - emitter.elementAddress(0, extents[int(item.a)], leaveHere) - emitter.copySlotToElement(item.c) - of ArrayAddGlobalsOp: - emitter.scratchFromHoisted(0, slotOf(item.b)) - emitter.elementAddress(0, extents[int(item.a)], leaveHere) - emitter.readElement(0, leaveHere) - emitter.scratchFromHoisted(1, slotOf(item.c)) - emitter.addScratch(0, 1) - emitter.writeElement(0) + requireGlobal(item.a) + requireSlot(item.b) of AddGlobalArrayGlobalIndexOp: - emitter.scratchFromHoisted(0, slotOf(item.c)) - emitter.elementAddress(0, extents[int(item.b)], leaveHere) - emitter.readElement(0, leaveHere) - emitter.scratchFromHoisted(1, slotOf(item.a)) - emitter.addScratch(1, 0) - emitter.hoistedFromScratch(slotOf(item.a), 1) + requireGlobal(item.a) + requireArray(item.b) + requireGlobal(item.c) + of ArrayAddGlobalsOp: + requireArray(item.a) + requireGlobal(item.b) + requireGlobal(item.c) + of AddOp, SubtractOp, MultiplyOp, DivideOp, IntegerDivideOp, + ModuloOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp, AndOp, OrOp, XorOp, EqvOp, ImpOp: + requireSlot(item.a) + requireSlot(item.b) + requireSlot(item.c) + of JumpOp, GosubOp, ReturnLabelOp: + requireTarget(item.a) of JumpIfZeroOp: - emitter.readSlot(0, item.a, leaveHere) - if covers(item.b): - emitter.branchIfScratchZero(0, blockAt(item.b)) - else: - emitter.branchIfScratchZero( - 0, exitLabel(item.b, NativeCompleted, index) - ) - of JumpOp: - if covers(item.a): - when CountedLoops: - if counted and index == stop - 1: - emitter.advanceCounter() - emitter.branch(blockAt(item.a)) - else: - leaveFor(item.a, NativeCompleted, index) + requireSlot(item.a) + requireTarget(item.b) of JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, JumpUnlessGlobalLessEqualImmediateOp, JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp: - if hoisting: - emitter.compareSlot(slotOf(item.a), item.b, poolSlot(item.b)) - else: - when ModelsMemoryGlobals: - emitter.compareGlobalWhole(item.a, item.b, leaveHere) - branchOut(item.c, takenOn(item.op)) - of JumpUnlessGlobalModuloEqualZeroOp: - # Dividing by one or minus one always leaves no remainder, and - # minus one would trap on x86, so never emit the divide for those. - if item.b != 1 and item.b != -1: - emitter.remainderTest(slotOf(item.a), item.b) - branchOut(item.c, NotEqualTest) + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp: + requireGlobal(item.a) + requireTarget(item.c) + of ArrayGetOp: + requireSlot(item.a) + requireArray(item.b) + requireSlot(item.c) + of ArraySetOp: + requireArray(item.a) + requireSlot(item.b) + requireSlot(item.c) + of SetArgumentOp: + requireArgument(item.a) + requireSlot(item.b) + of SetArgumentImmediateOp: + requireArgument(item.a) + of SetArgumentGlobalOp: + requireArgument(item.a) + requireGlobal(item.b) + of CallOp: + if item.a <= 0 or int(item.a) >= routines.len: + return nil else: + discard + if item.op in {CallOp, GosubOp} and index + 1 >= code.len: return nil - ## Falling off the last offset resumes the interpreter at the next one. - leaveFor(int32(stop), NativeCompleted, stop - 1) - - for (stub, target, status, leaving) in exits: - emitter.place(stub) - when CountedLoops: - if counted and status == NativeCompleted: - # Charge the passes that ran, plus how far the leaving one got. - emitter.chargeCounted( - plan.instructions, plan.work, - plan.partialInstructions[leaving - start], - plan.partialWork[leaving - start] - ) - elif spending and status == NativeCompleted: - emitter.chargeSpending() - emitter.exitStub(target, int32(ord(status)), writeback) - - ## Shared exit: publish the hoisted globals and budgets, then return. - emitter.place(writeback) - if hoisting: - for slot, index in hoisted: - emitter.storeHoisted(slot, int(index) * ValueStride) - emitter.publishState() - emitter.endRegion() - - ## Guard failure happens before any global is written, so the loop is - ## simply handed back untouched for the interpreter to run. - var resumeElsewhere = emitter.label() - when ModelsCalls: - if usesCalls: - emitter.place(resumeElsewhere) - emitter.resumeAtStoredOffset(if hoisting: hoisted else: @[]) - - emitter.place(guardFailed) - emitter.guardExit(int32(start)) - - emitter.resolve() - let size = emitter.code.len * sizeof(emitter.code[0]) - if size > MaxRegionBytes: + var emitted: (seq[byte], seq[int]) + try: + emitted = emitProgram(code, routines, ownerOf, extents, constants, + limits, false) + except BasicError: + # Some branch could not reach; every branch then goes the long way. + emitted = emitProgram(code, routines, ownerOf, extents, constants, + limits, true) + let (bytes, starts) = emitted + if bytes.len > MaxProgramBytes: return nil - result = Region( - start: int32(start), stop: int32(stop), hoisted: hoisted, size: size - ) - ## Where every offset lands. Anything this region did not compile - ## goes to the one stub that hands the offset back, which is how a - ## return into interpreted code needs no test of its own. - if usesCalls: - result.returns = newSeq[pointer](code.len + 1) - result.listing = newSeq[byte](size) - if size > 0: - copyMem(result.listing[0].addr, emitter.code[0].addr, size) - result.buffer = initCodeBuffer(size) - result.buffer.write(emitter.code) + result = Machine(size: bytes.len, listing: bytes) + result.buffer = initCodeBuffer(bytes.len) + result.buffer.write(bytes) result.buffer.seal() result.call = cast[NativeCall](result.buffer.entry) - if usesCalls: - let origin = cast[int](result.buffer.entry) - let elsewhere = origin + - emitter.offsetOf(resumeElsewhere) * sizeof(emitter.code[0]) - for index in 0 ..< result.returns.len: - result.returns[index] = cast[pointer](elsewhere) - for place, offset in members: - result.returns[int(offset)] = cast[pointer]( - origin + emitter.offsetOf(blocks[place]) * sizeof(emitter.code[0]) - ) - result.returnTable = result.returns[0].addr - -proc invoke*(region: Region, context: var NativeContext): NativeStatus - {.raises: [].} = - ## Runs one compiled loop and reports why it returned. - NativeStatus(region.call(context.addr)) - -proc compileLoops*(code: seq[Instruction], globals, slots: int, - extents: seq[ArrayExtent] = @[], constants: seq[int32] = @[], - hostData = 0, routines: seq[RoutineExtent] = @[], - limits = CallLimits()): seq[Region] - {.raises: [BasicError].} = - ## Compiles every backward-branching loop the code generator models. - ## The result is indexed by bytecode offset, so the interpreter reaches - ## a compiled loop with one load rather than a lookup. - if not layoutMatches(): - return - for index in 0 ..< code.len: - var target = 0'i32 - if not code[index].branchTarget(target): - continue - if int(target) > index or int(target) < 0: - continue - if result.len > 0 and result[int(target)] != nil: - continue - var region: Region = nil - try: - region = compileRegion( - code, int(target), index + 1, globals, slots, extents, - constants, hostData, routines, limits - ) - except BasicError: - region = nil - if region != nil: - if result.len == 0: - result = newSeq[Region](code.len) - result[int(target)] = region + let origin = cast[int](result.buffer.entry) + result.table = newSeq[pointer](code.len + 1) + for index in 0 .. code.len: + result.table[index] = cast[pointer](origin + starts[index]) + +proc tableAddress*(machine: Machine): pointer {.raises: [].} = + ## Returns the table of native addresses indexed by bytecode offset. + machine.table[0].addr diff --git a/src/bassy/programs.nim b/src/bassy/programs.nim deleted file mode 100644 index 99b80bb..0000000 --- a/src/bassy/programs.nim +++ /dev/null @@ -1,1782 +0,0 @@ -## Compiles a whole program from the register bytecode to machine code. -## -## Every bytecode offset becomes a native block, so jumps, calls and -## returns go straight from one block to the next and the interpreter -## loop never runs. Values stay exactly where the interpreter keeps them, -## in the globals, the register file, the arguments and the array cells, -## which is what lets the two agree on every result, every budget and -## every failure. -## -## The common cases run inline: whole-number and fixed-point arithmetic, -## comparisons, branches, moves, array cells, calls, returns and budget -## meters. Everything else, such as strings, printing and host functions, -## and every operation about to fail, calls the one routine the -## interpreter itself runs for that instruction. Nothing is written -## before such a call, so the instruction is simply run there instead. - -import - bytecode, jit, machine, numbers - -when NativeArm64: - import arm64 -elif NativeAmd64: - import amd64 - -type - Machine* = ref object - ## One whole program compiled to machine code. - size*: int - listing*: seq[byte] - table: seq[pointer] - buffer: CodeBuffer - call: NativeCall - - Home = enum - ## Where a value lives. - SlotHome, - GlobalHome, - ArgumentHome, - HostHome, - CellHome - - Place = object - ## One value's address, as a home and an index into it. - home: Home - index: int32 - - Branching = enum - ## Where a slow path carries on once the interpreter's code has run. - ToNext, - ToOffset - - Stub = object - ## A slow path, placed after the block its operation sits in. - label: Label - offset: int32 - carry: Branching - - Check = enum - ## An architecture-neutral comparison outcome. - EqualCheck, - NotEqualCheck, - LessCheck, - LessEqualCheck, - GreaterCheck, - GreaterEqualCheck - -const - MaxProgramBytes = 64 * 1024 * 1024 - ## Leaving a routine without one of these would run on into the next - ## routine's code, which no call set up. - Terminators = {JumpOp, ReturnOp, ReturnLabelOp, ExitSubOp, HaltOp} - -proc slot(index: int32): Place {.inline, raises: [].} = - ## Names a register slot in the current frame. - Place(home: SlotHome, index: index) - -proc global(index: int32): Place {.inline, raises: [].} = - ## Names a scalar global. - Place(home: GlobalHome, index: index) - -proc argument(index: int32): Place {.inline, raises: [].} = - ## Names a staged call argument. - Place(home: ArgumentHome, index: index) - -proc host(index: int32): Place {.inline, raises: [].} = - ## Names a host data value. - Place(home: HostHome, index: index) - -proc cell(): Place {.inline, raises: [].} = - ## Names the array cell whose address was just worked out. - Place(home: CellHome) - -proc comparisonCheck(op: Op): Check {.raises: [].} = - ## Returns the outcome a comparison answers true on. - case op - of EqualOp: EqualCheck - of NotEqualOp: NotEqualCheck - of LessOp: LessCheck - of LessEqualOp: LessEqualCheck - of GreaterOp: GreaterCheck - else: GreaterEqualCheck - -proc takenOn(op: Op): Check {.raises: [].} = - ## Returns the outcome on which a fused test takes its branch. - case op - of JumpUnlessGlobalEqualImmediateOp: NotEqualCheck - of JumpUnlessGlobalNotEqualImmediateOp: EqualCheck - of JumpUnlessGlobalLessImmediateOp: GreaterEqualCheck - of JumpUnlessGlobalLessEqualImmediateOp: GreaterCheck - of JumpUnlessGlobalGreaterImmediateOp: LessEqualCheck - else: LessCheck - -when NativeArm64: - ## AArch64 code generation - ## - ## x19 context x20 instruction budget x21 work budget - ## x22 globals x23 current frame x24 offset table - ## x25 array cells x26 arguments x27 frames - ## x28 register file - ## x9 .. x15 working registers; x16 far addresses; x17 one cell - ## - ## Everything long lived sits in a register the platform's convention - ## keeps across a call, so calling back into the interpreter's code - ## costs no saving beyond the two budgets it may charge. - - const - Context = x19 - Instructions = x20 - Work = x21 - GlobalsBase = x22 - RegistersBase = x23 - TableBase = x24 - MemoryBase = x25 - ArgumentsBase = x26 - FramesBase = x27 - FileBase = x28 - Temps = [x9, x10, x11, x12, x13, x14, x15] - Far = x16 - Cell = x17 - FrameBytes = 96 - NearBytes = 4095 - ValuePayload - - proc temp(index: int): Register {.inline, raises: [].} = - ## Returns one working register. - Temps[index] - - proc nativeCondition(check: Check): Condition {.raises: [].} = - ## Maps a neutral outcome onto the architecture's encoding. - case check - of EqualCheck: EqualCondition - of NotEqualCheck: NotEqualCondition - of LessCheck: LessCondition - of LessEqualCheck: LessEqualCondition - of GreaterCheck: GreaterCondition - of GreaterEqualCheck: GreaterEqualCondition - - proc inverse(condition: Condition): Condition {.raises: [].} = - ## Returns the condition that holds exactly when this one does not. - Condition(ord(condition) xor 1) - - type - Emitter = object - ## The assembler plus whether branches must reach anywhere at all. - code: Assembler - far: bool - - proc label(e: var Emitter): Label {.inline, raises: [].} = - ## Reserves a label. - e.code.label() - - proc place(e: var Emitter, target: Label) {.inline, raises: [].} = - ## Places a label here. - e.code.place(target) - - proc jump(e: var Emitter, target: Label) {.raises: [].} = - ## Jumps unconditionally. - e.code.branch(target) - - proc jumpWhen(e: var Emitter, condition: Condition, target: Label) - {.raises: [].} = - ## Jumps when a condition holds, however far away the target is. - if e.far: - let skip = e.code.label() - e.code.branchIf(condition.inverse, skip) - e.code.branch(target) - e.code.place(skip) - else: - e.code.branchIf(condition, target) - - proc jumpIfZero(e: var Emitter, register: Register, target: Label) - {.raises: [].} = - ## Jumps when a working register holds zero. - if e.far: - let skip = e.code.label() - e.code.branchIfNotZero(Word32, register, skip) - e.code.branch(target) - e.code.place(skip) - else: - e.code.branchIfZero(Word32, register, target) - - proc jumpIfNotZero(e: var Emitter, register: Register, target: Label) - {.raises: [].} = - ## Jumps when a working register holds anything but zero. - if e.far: - let skip = e.code.label() - e.code.branchIfZero(Word32, register, skip) - e.code.branch(target) - e.code.place(skip) - else: - e.code.branchIfNotZero(Word32, register, target) - - proc reach(e: var Emitter, place: Place): (Register, int) - {.raises: [BasicError].} = - ## Returns a base register and byte offset for a value, working the - ## address out in full when the offset is too wide to encode. - var base = Cell - case place.home - of SlotHome: base = RegistersBase - of GlobalHome: base = GlobalsBase - of ArgumentHome: base = ArgumentsBase - of HostHome: - e.code.loadDouble(Cell, Context, ContextHostData) - of CellHome: - return (Cell, 0) - let offset = int(place.index) * ValueStride - if offset <= NearBytes: - return (base, offset) - e.code.loadImmediate(Word64, Far, int64(offset)) - e.code.addRegister(Word64, Far, base, Far) - (Far, 0) - - proc readValue(e: var Emitter, value, tag: int, place: Place) - {.raises: [BasicError].} = - ## Reads a value's kind and its 32-bit payload. - let (base, offset) = e.reach(place) - e.code.loadByte(temp(tag), base, offset) - e.code.loadWord(temp(value), base, offset + ValuePayload) - - proc writeWhole(e: var Emitter, place: Place, value: int) - {.raises: [BasicError].} = - ## Writes a whole number. - let (base, offset) = e.reach(place) - e.code.storeByte(zeroRegister, base, offset) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeKind(e: var Emitter, place: Place, tag, value: int) - {.raises: [BasicError].} = - ## Writes a payload under the kind held in a working register. - let (base, offset) = e.reach(place) - e.code.storeByte(temp(tag), base, offset) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeFixed(e: var Emitter, place: Place, value: int) - {.raises: [BasicError].} = - ## Writes a fixed-point payload. - let (base, offset) = e.reach(place) - e.code.loadImmediate(Word32, temp(5), FixedTag) - e.code.storeByte(temp(5), base, offset) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) - {.raises: [BasicError].} = - ## Writes a constant of a known kind. - e.code.loadImmediate(Word32, temp(6), int64(bits)) - let (base, offset) = e.reach(place) - if tag == 0: - e.code.storeByte(zeroRegister, base, offset) - else: - e.code.loadImmediate(Word32, temp(5), int64(tag)) - e.code.storeByte(temp(5), base, offset) - e.code.storeWord(temp(6), base, offset + ValuePayload) - - proc copyValue(e: var Emitter, destination, source: Place) - {.raises: [BasicError].} = - ## Copies a value entire, whatever kind it holds, as the interpreter - ## does. - let (fromBase, fromOffset) = e.reach(source) - e.code.loadDouble(temp(5), fromBase, fromOffset) - e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) - let (toBase, toOffset) = e.reach(destination) - e.code.storeDouble(temp(5), toBase, toOffset) - e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) - - proc unlessWhole(e: var Emitter, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless a kind says whole number. - e.jumpIfNotZero(temp(tag), slow) - - proc unlessNumeric(e: var Emitter, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless a kind says number of either sort. - e.code.compareImmediate(Word32, temp(tag), FixedTag) - e.jumpWhen(UnsignedGreaterCondition, slow) - - proc unlessSame(e: var Emitter, tag, other: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless two kinds agree. - e.code.compareRegister(Word32, temp(tag), temp(other)) - e.jumpWhen(NotEqualCondition, slow) - - proc whenFixed(e: var Emitter, tag: int, target: Label) - {.raises: [BasicError].} = - ## Jumps when a kind says fixed point. - e.code.compareImmediate(Word32, temp(tag), FixedTag) - e.code.branchIf(EqualCondition, target) - - proc loadConstant(e: var Emitter, value: int, bits: int32) - {.raises: [].} = - ## Loads a constant into a working register. - e.code.loadImmediate(Word32, temp(value), int64(bits)) - - proc add(e: var Emitter, left, right: int) {.raises: [].} = - ## Adds, wrapping. - e.code.addRegister(Word32, temp(left), temp(left), temp(right)) - - proc subtract(e: var Emitter, left, right: int) {.raises: [].} = - ## Subtracts, wrapping. - e.code.subtractRegister(Word32, temp(left), temp(left), temp(right)) - - proc multiply(e: var Emitter, left, right: int) {.raises: [].} = - ## Multiplies, wrapping. - e.code.multiply(Word32, temp(left), temp(left), temp(right)) - - proc negate(e: var Emitter, value: int) {.raises: [].} = - ## Negates, wrapping. - e.code.negate(Word32, temp(value), temp(value)) - - proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits both hold. - e.code.andRegister(Word32, temp(left), temp(left), temp(right)) - - proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits either holds. - e.code.orRegister(Word32, temp(left), temp(left), temp(right)) - - proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits exactly one holds. - e.code.xorRegister(Word32, temp(left), temp(left), temp(right)) - - proc bitNot(e: var Emitter, value: int) {.raises: [].} = - ## Flips every bit. - e.code.notRegister(Word32, temp(value), temp(value)) - - proc quotient(e: var Emitter, left, right: int) {.raises: [].} = - ## Divides toward zero; the divisor is known not to be zero. The most - ## negative number over minus one wraps back to itself here, which is - ## the answer the interpreter defines. - e.code.signedDivide(Word32, temp(left), temp(left), temp(right)) - - proc remainder(e: var Emitter, left, right: int) {.raises: [].} = - ## Leaves what dividing left over, with the sign of the dividend. - e.code.signedDivide(Word32, temp(6), temp(left), temp(right)) - e.code.multiplySubtract(Word32, temp(left), temp(6), temp(right), - temp(left)) - - proc multiplyFixed(e: var Emitter, left, right: int) - {.raises: [BasicError].} = - ## Multiplies two Q16.16 numbers through a widened intermediate, - ## rounding to nearest exactly as the fixed-point library does. - e.code.signedMultiplyLong(temp(left), temp(left), temp(right)) - e.code.loadImmediate(Word64, temp(6), jit.FixedRounding) - e.code.addRegister(Word64, temp(left), temp(left), temp(6)) - e.code.arithmeticShiftRight(Word64, temp(left), temp(left), jit.FixedShift) - e.code.moveRegister(Word32, temp(left), temp(left)) - - proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Turns a number of either kind into its Q16.16 bits, widened to - ## sixty-four. A whole number outside the fixed-point range cannot - ## become one, which the interpreter refuses, so that goes slow. - let register = temp(value) - let already = e.label() - let ready = e.label() - e.whenFixed(tag, already) - e.code.loadImmediate(Word32, temp(6), 32767) - e.code.compareRegister(Word32, register, temp(6)) - e.jumpWhen(GreaterCondition, slow) - e.code.loadImmediate(Word32, temp(6), -32768) - e.code.compareRegister(Word32, register, temp(6)) - e.jumpWhen(LessCondition, slow) - e.code.signExtendWord(register, register) - e.code.shiftLeftImmediate(Word64, register, register, jit.FixedShift) - e.jump(ready) - e.place(already) - e.code.signExtendWord(register, register) - e.place(ready) - - proc divideFixed(e: var Emitter, left, right: int, slow: Label) - {.raises: [BasicError].} = - ## Divides two widened Q16.16 numbers, rounding to nearest with halves - ## going up, for either sign, exactly as the fixed-point library - ## does: the signs are put right first, half the divisor is added, - ## and the truncating divide is corrected back to a floor. - let numerator = temp(left) - let denominator = temp(right) - let answer = temp(5) - let leftOver = temp(6) - e.code.compareImmediate(Word64, denominator, 0) - e.jumpWhen(EqualCondition, slow) - let signsSettled = e.label() - e.code.branchIf(GreaterCondition, signsSettled) - e.code.negate(Word64, numerator, numerator) - e.code.negate(Word64, denominator, denominator) - e.place(signsSettled) - e.code.shiftLeftImmediate(Word64, numerator, numerator, jit.FixedShift) - e.code.shiftRightImmediate(Word64, answer, denominator, 1) - e.code.addRegister(Word64, numerator, numerator, answer) - e.code.signedDivide(Word64, answer, numerator, denominator) - e.code.multiplySubtract(Word64, leftOver, answer, denominator, - numerator) - let done = e.label() - e.code.compareImmediate(Word64, leftOver, 0) - e.code.branchIf(EqualCondition, done) - e.code.compareImmediate(Word64, numerator, 0) - e.code.branchIf(GreaterEqualCondition, done) - e.code.subtractImmediate(Word64, answer, answer, 1) - e.place(done) - e.code.moveRegister(Word32, numerator, answer) - - proc compare(e: var Emitter, left, right: int) {.raises: [].} = - ## Sets flags from two working registers. - e.code.compareRegister(Word32, temp(left), temp(right)) - - proc compareConstant(e: var Emitter, value: int, bits: int32) - {.raises: [BasicError].} = - ## Sets flags from a working register against a constant. - if bits >= 0 and bits <= 4095: - e.code.compareImmediate(Word32, temp(value), int(bits)) - else: - e.code.loadImmediate(Word32, temp(6), int64(bits)) - e.code.compareRegister(Word32, temp(value), temp(6)) - - proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = - ## Writes BASIC's -1 for true and zero for false. - e.code.setOnCondition(Word32, temp(value), nativeCondition(check)) - - proc jumpOn(e: var Emitter, check: Check, target: Label) - {.raises: [].} = - ## Jumps on a comparison outcome. - e.jumpWhen(nativeCondition(check), target) - - proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) - {.raises: [].} = - ## Jumps when a working register holds zero. - e.jumpIfZero(temp(value), target) - - proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) - {.raises: [].} = - ## Jumps when a working register holds anything but zero. - e.jumpIfNotZero(temp(value), target) - - proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, - slow: Label) {.raises: [BasicError].} = - ## Bounds checks an index and leaves the cell's address in Cell. One - ## unsigned comparison covers both ends, as the interpreter's does. - let position = temp(index) - e.code.loadImmediate(Word32, temp(6), int64(extent.length)) - e.code.compareRegister(Word32, position, temp(6)) - e.jumpWhen(CarrySetCondition, slow) - e.code.loadImmediate(Word32, temp(6), int64(extent.base)) - e.code.addRegister(Word32, temp(6), temp(6), position) - e.code.addRegister(Word64, Cell, MemoryBase, temp(6), 4) - - proc meter(e: var Emitter, instructions, work: int32, slow: Label) - {.raises: [BasicError].} = - ## Checks both budgets before charging either, as the interpreter does. - if instructions <= 4095: - e.code.compareImmediate(Word64, Instructions, int(instructions)) - else: - e.code.loadImmediate(Word64, temp(5), int64(instructions)) - e.code.compareRegister(Word64, Instructions, temp(5)) - e.jumpWhen(LessCondition, slow) - if work <= 4095: - e.code.compareImmediate(Word64, Work, int(work)) - else: - e.code.loadImmediate(Word64, temp(6), int64(work)) - e.code.compareRegister(Word64, Work, temp(6)) - e.jumpWhen(LessCondition, slow) - if instructions <= 4095: - e.code.subtractImmediate(Word64, Instructions, Instructions, - int(instructions)) - else: - e.code.subtractRegister(Word64, Instructions, Instructions, temp(5)) - if work <= 4095: - e.code.subtractImmediate(Word64, Work, Work, int(work)) - else: - e.code.subtractRegister(Word64, Work, Work, temp(6)) - - proc frameOf(e: var Emitter, base: Register, index: Register) - {.raises: [].} = - ## Points a register at one register-file slot by its absolute index. - e.code.addRegister(Word64, base, FileBase, index, 4) - - proc copyValues(e: var Emitter, destination, source: Register, - count: int) {.raises: [BasicError].} = - ## Copies a run of whole values, in a loop once there are many. - if count <= 8: - for index in 0 ..< count: - e.code.loadDouble(temp(5), source, index * ValueStride) - e.code.loadDouble(temp(6), source, index * ValueStride + ValuePayload) - e.code.storeDouble(temp(5), destination, index * ValueStride) - e.code.storeDouble(temp(6), destination, - index * ValueStride + ValuePayload) - return - e.code.moveRegister(Word64, temp(2), source) - e.code.moveRegister(Word64, temp(3), destination) - e.code.loadImmediate(Word32, temp(4), int64(count)) - let again = e.label() - e.place(again) - e.code.loadDouble(temp(5), temp(2), 0) - e.code.loadDouble(temp(6), temp(2), ValuePayload) - e.code.storeDouble(temp(5), temp(3), 0) - e.code.storeDouble(temp(6), temp(3), ValuePayload) - e.code.addImmediate(Word64, temp(2), temp(2), ValueStride) - e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) - e.code.subtractImmediate(Word32, temp(4), temp(4), 1) - e.code.branchIfNotZero(Word32, temp(4), again) - - proc clearValues(e: var Emitter, destination: Register, count: int) - {.raises: [BasicError].} = - ## Zeroes a run of values, in a loop once there are many. - if count <= 8: - for index in 0 ..< count: - e.code.storeDouble(zeroRegister, destination, index * ValueStride) - e.code.storeDouble(zeroRegister, destination, - index * ValueStride + ValuePayload) - return - e.code.moveRegister(Word64, temp(3), destination) - e.code.loadImmediate(Word32, temp(4), int64(count)) - let again = e.label() - e.place(again) - e.code.storeDouble(zeroRegister, temp(3), 0) - e.code.storeDouble(zeroRegister, temp(3), ValuePayload) - e.code.addImmediate(Word64, temp(3), temp(3), ValueStride) - e.code.subtractImmediate(Word32, temp(4), temp(4), 1) - e.code.branchIfNotZero(Word32, temp(4), again) - - proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, - calleeRegisters, calleeParameters, callerRegisters: int32, - resumeAt: int32, limits: CallLimits, slow: Label) - {.raises: [BasicError].} = - ## Pushes a frame into the interpreter's own array and moves the - ## current frame on, refusing the same two ceilings it refuses. - let depth = temp(0) - let oldBase = temp(1) - let newBase = temp(2) - let frame = temp(3) - e.code.loadWord(depth, Context, ContextDepth) - e.code.loadImmediate(Word32, temp(6), int64(limits.frames) - 1) - e.code.compareRegister(Word32, depth, temp(6)) - e.jumpWhen(GreaterEqualCondition, slow) - e.code.loadWord(oldBase, Context, ContextBase) - e.code.loadImmediate(Word32, temp(6), int64(callerRegisters)) - e.code.addRegister(Word32, newBase, oldBase, temp(6)) - e.code.loadImmediate(Word32, temp(6), - int64(limits.slots) - int64(calleeRegisters)) - e.code.compareRegister(Word32, newBase, temp(6)) - e.jumpWhen(GreaterCondition, slow) - - e.code.addRegister(Word64, frame, FramesBase, depth, 4) - e.code.storeWord(oldBase, frame, FrameBase) - e.code.loadWord(temp(4), Context, ContextRoutine) - e.code.storeWord(temp(4), frame, FrameRoutine) - e.code.loadImmediate(Word32, temp(4), int64(resumeAt)) - e.code.storeWord(temp(4), frame, FrameReturn) - if gosub: - e.code.loadImmediate(Word32, temp(4), 1) - e.code.storeWord(temp(4), frame, FrameTag) - else: - e.code.storeWord(zeroRegister, frame, FrameTag) - - e.code.addImmediate(Word32, depth, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) - e.code.storeWord(newBase, Context, ContextBase) - e.code.loadImmediate(Word32, temp(4), int64(calleeId)) - e.code.storeWord(temp(4), Context, ContextRoutine) - - # A GOSUB hands the callee a copy of the caller's slots; a call clears - # them and lays the arguments over the first few, in that order. - e.code.moveRegister(Word64, frame, RegistersBase) - e.frameOf(RegistersBase, newBase) - if gosub: - e.copyValues(RegistersBase, frame, int(calleeRegisters)) - else: - e.clearValues(RegistersBase, int(calleeRegisters)) - e.copyValues(RegistersBase, ArgumentsBase, int(calleeParameters)) - - proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) - {.raises: [BasicError].} = - ## Pops a frame and jumps to wherever it said to carry on. A GOSUB - ## frame first hands the shared parameters back to the caller. - let depth = temp(0) - let frame = temp(1) - let base = temp(2) - let resume = temp(3) - e.code.loadWord(depth, Context, ContextDepth) - e.jumpIfZero(depth, slow) - e.code.subtractImmediate(Word32, depth, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) - e.code.addRegister(Word64, frame, FramesBase, depth, 4) - e.code.loadWord(base, frame, FrameBase) - if parameters > 0: - let plain = e.label() - e.code.loadByte(temp(4), frame, FrameTag) - e.code.compareImmediate(Word32, temp(4), 1) - e.code.branchIf(NotEqualCondition, plain) - e.frameOf(Far, base) - e.copyValues(Far, RegistersBase, int(parameters)) - e.place(plain) - e.code.storeWord(base, Context, ContextBase) - e.code.loadWord(temp(4), frame, FrameRoutine) - e.code.storeWord(temp(4), Context, ContextRoutine) - e.code.loadWord(resume, frame, FrameReturn) - e.code.storeWord(resume, Context, ContextOffset) - e.frameOf(RegistersBase, base) - e.code.addRegister(Word64, temp(4), TableBase, resume, 3) - e.code.loadDouble(temp(4), temp(4), 0) - e.code.jumpRegister(temp(4)) - - proc callSlow(e: var Emitter, offset: int32, routine: Label) - {.raises: [BasicError].} = - ## Runs the interpreter's own code for one instruction. - e.code.loadImmediate(Word32, x1, int64(offset)) - e.code.branchLink(routine) - - proc slowRoutine(e: var Emitter, failed: Label) - {.raises: [BasicError].} = - ## The one place compiled code calls out. The budgets go into the - ## context for the interpreter's code to charge, and come back from it - ## along with the frame, since a call or a return may have moved it. - e.code.storePair(framePointer, linkRegister, stackPointer, -16, true) - e.code.storeDouble(Instructions, Context, ContextInstructions) - e.code.storeDouble(Work, Context, ContextWork) - e.code.moveRegister(Word64, x0, Context) - e.code.loadDouble(temp(0), Context, ContextStep) - e.code.callRegister(temp(0)) - e.code.moveRegister(Word32, temp(0), x0) - e.code.loadDouble(Instructions, Context, ContextInstructions) - e.code.loadDouble(Work, Context, ContextWork) - e.code.loadWord(temp(1), Context, ContextBase) - e.frameOf(RegistersBase, temp(1)) - e.code.loadPair(framePointer, linkRegister, stackPointer, 16, true) - e.code.branchIfNotZero(Word32, temp(0), failed) - e.code.returnToCaller() - - proc dispatch(e: var Emitter) {.raises: [BasicError].} = - ## Jumps to the block for whatever offset the context names. - e.code.loadWord(temp(0), Context, ContextOffset) - e.code.addRegister(Word64, temp(1), TableBase, temp(0), 3) - e.code.loadDouble(temp(1), temp(1), 0) - e.code.jumpRegister(temp(1)) - - proc prologue(e: var Emitter) {.raises: [BasicError].} = - ## Saves what the platform says to keep and loads the machine state. - e.code.storePair(framePointer, linkRegister, stackPointer, -FrameBytes, - true) - e.code.storePair(x19, x20, stackPointer, 16) - e.code.storePair(x21, x22, stackPointer, 32) - e.code.storePair(x23, x24, stackPointer, 48) - e.code.storePair(x25, x26, stackPointer, 64) - e.code.storePair(x27, x28, stackPointer, 80) - e.code.moveRegister(Word64, Context, x0) - e.code.loadDouble(GlobalsBase, Context, 0) - e.code.loadDouble(Instructions, Context, ContextInstructions) - e.code.loadDouble(Work, Context, ContextWork) - e.code.loadDouble(FileBase, Context, ContextRegisterFile) - e.code.loadDouble(TableBase, Context, ContextReturnTable) - e.code.loadDouble(MemoryBase, Context, ContextMemory) - e.code.loadDouble(ArgumentsBase, Context, ContextArguments) - e.code.loadDouble(FramesBase, Context, ContextFrames) - e.code.loadWord(temp(0), Context, ContextBase) - e.frameOf(RegistersBase, temp(0)) - - proc epilogue(e: var Emitter, status: NativeStatus) - {.raises: [BasicError].} = - ## Restores what the platform says to keep and returns a status. - e.code.loadImmediate(Word32, x0, int64(ord(status))) - e.code.loadPair(x19, x20, stackPointer, 16) - e.code.loadPair(x21, x22, stackPointer, 32) - e.code.loadPair(x23, x24, stackPointer, 48) - e.code.loadPair(x25, x26, stackPointer, 64) - e.code.loadPair(x27, x28, stackPointer, 80) - e.code.loadPair(framePointer, linkRegister, stackPointer, FrameBytes, - true) - e.code.returnToCaller() - - proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = - ## Publishes the budgets and where the program stopped, then returns. - e.code.storeDouble(Instructions, Context, ContextInstructions) - e.code.storeDouble(Work, Context, ContextWork) - e.code.loadImmediate(Word32, temp(0), int64(offset)) - e.code.storeWord(temp(0), Context, ContextOffset) - e.epilogue(NativeCompleted) - - proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = - ## Resolves every branch and returns the finished bytes. - e.code.resolve() - result = newSeq[byte](e.code.code.len * 4) - if result.len > 0: - copyMem(result[0].addr, e.code.code[0].addr, result.len) - - proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = - ## Returns where a label ended up, in bytes. - e.code.offsetOf(target) * 4 - -elif NativeAmd64: - ## x86-64 code generation - ## - ## r15 context r12 instruction budget r13 work budget - ## rbx globals rbp current frame r14 array cells - ## rax rcx rsi rdi r8 r9 r10 working registers; r11 one cell; - ## rdx the divide's high half and a spare - ## - ## The six long-lived registers are the ones both platform conventions - ## keep across a call. Windows also keeps rsi and rdi, so those are - ## saved on the way in there. Everything else the program touches - ## rarely is read from the context when it is needed. - - const - Context = r15 - Instructions = r12 - Work = r13 - GlobalsBase = rbx - RegistersBase = rbp - MemoryBase = r14 - Temps = [rax, rcx, rsi, rdi, r8, r9, r10] - Cell = r11 - Spare = rdx - - when defined(windows): - const - FirstArgument = rcx - SecondArgument = rdx - Saved = [rbx, rbp, r12, r13, r14, r15, rsi, rdi] - ## Four shadow slots for the callee, plus eight to realign. - Padding = 40 - else: - const - FirstArgument = rdi - SecondArgument = rsi - Saved = [rbx, rbp, r12, r13, r14, r15] - Padding = 8 - - proc temp(index: int): Register {.inline, raises: [].} = - ## Returns one working register. - Temps[index] - - proc nativeCondition(check: Check): Condition {.raises: [].} = - ## Maps a neutral outcome onto the architecture's encoding. - case check - of EqualCheck: EqualCondition - of NotEqualCheck: NotEqualCondition - of LessCheck: LessCondition - of LessEqualCheck: LessEqualCondition - of GreaterCheck: GreaterCondition - of GreaterEqualCheck: GreaterEqualCondition - - type - Emitter = object - ## The assembler. Every branch here reaches anywhere already, so - ## there is nothing to widen. - code: Assembler - far: bool - - proc label(e: var Emitter): Label {.inline, raises: [].} = - ## Reserves a label. - e.code.label() - - proc place(e: var Emitter, target: Label) {.inline, raises: [].} = - ## Places a label here. - e.code.place(target) - - proc jump(e: var Emitter, target: Label) {.raises: [].} = - ## Jumps unconditionally. - e.code.branch(target) - - proc jumpWhen(e: var Emitter, condition: Condition, target: Label) - {.raises: [].} = - ## Jumps when a condition holds. - e.code.branchIf(condition, target) - - proc contextField(e: var Emitter, destination: Register, offset: int) - {.raises: [BasicError].} = - ## Loads one pointer from the context. - e.code.loadDouble(destination, Context, offset) - - proc reach(e: var Emitter, place: Place): (Register, int) - {.raises: [BasicError].} = - ## Returns a base register and byte offset for a value. Displacements - ## are thirty-two bits wide here, so every index is reached directly. - let offset = int(place.index) * ValueStride - case place.home - of SlotHome: (RegistersBase, offset) - of GlobalHome: (GlobalsBase, offset) - of ArgumentHome: - e.contextField(Cell, ContextArguments) - (Cell, offset) - of HostHome: - e.contextField(Cell, ContextHostData) - (Cell, offset) - of CellHome: (Cell, 0) - - proc readValue(e: var Emitter, value, tag: int, place: Place) - {.raises: [BasicError].} = - ## Reads a value's kind and its 32-bit payload. - let (base, offset) = e.reach(place) - e.code.loadByteZeroed(temp(tag), base, offset) - e.code.loadWord(temp(value), base, offset + ValuePayload) - - proc writeWhole(e: var Emitter, place: Place, value: int) - {.raises: [BasicError].} = - ## Writes a whole number. - let (base, offset) = e.reach(place) - e.code.storeByteImmediate(base, offset, 0) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeKind(e: var Emitter, place: Place, tag, value: int) - {.raises: [BasicError].} = - ## Writes a payload under the kind held in a working register. - let (base, offset) = e.reach(place) - e.code.storeByteLow(base, offset, temp(tag)) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeFixed(e: var Emitter, place: Place, value: int) - {.raises: [BasicError].} = - ## Writes a fixed-point payload. - let (base, offset) = e.reach(place) - e.code.storeByteImmediate(base, offset, byte(FixedTag)) - e.code.storeWord(temp(value), base, offset + ValuePayload) - - proc writeConstant(e: var Emitter, place: Place, tag: int, bits: int32) - {.raises: [BasicError].} = - ## Writes a constant of a known kind. - let (base, offset) = e.reach(place) - e.code.storeByteImmediate(base, offset, byte(tag)) - e.code.storeWordImmediate(base, offset + ValuePayload, bits) - - proc copyValue(e: var Emitter, destination, source: Place) - {.raises: [BasicError].} = - ## Copies a value entire, whatever kind it holds, as the interpreter - ## does. - let (fromBase, fromOffset) = e.reach(source) - e.code.loadDouble(temp(5), fromBase, fromOffset) - e.code.loadDouble(temp(6), fromBase, fromOffset + ValuePayload) - let (toBase, toOffset) = e.reach(destination) - e.code.storeDouble(temp(5), toBase, toOffset) - e.code.storeDouble(temp(6), toBase, toOffset + ValuePayload) - - proc unlessWhole(e: var Emitter, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless a kind says whole number. - e.code.testRegister(Word32, temp(tag), temp(tag)) - e.jumpWhen(NotEqualCondition, slow) - - proc unlessNumeric(e: var Emitter, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless a kind says number of either sort. - e.code.compareImmediate(Word32, temp(tag), FixedTag) - e.jumpWhen(AboveCondition, slow) - - proc unlessSame(e: var Emitter, tag, other: int, slow: Label) - {.raises: [BasicError].} = - ## Takes the slow path unless two kinds agree. - e.code.compareRegister(Word32, temp(tag), temp(other)) - e.jumpWhen(NotEqualCondition, slow) - - proc whenFixed(e: var Emitter, tag: int, target: Label) - {.raises: [BasicError].} = - ## Jumps when a kind says fixed point. - e.code.compareImmediate(Word32, temp(tag), FixedTag) - e.jumpWhen(EqualCondition, target) - - proc loadConstant(e: var Emitter, value: int, bits: int32) - {.raises: [].} = - ## Loads a constant into a working register. - e.code.loadImmediate(Word32, temp(value), int64(bits)) - - proc add(e: var Emitter, left, right: int) {.raises: [].} = - ## Adds, wrapping. - e.code.addRegister(Word32, temp(left), temp(right)) - - proc subtract(e: var Emitter, left, right: int) {.raises: [].} = - ## Subtracts, wrapping. - e.code.subtractRegister(Word32, temp(left), temp(right)) - - proc multiply(e: var Emitter, left, right: int) {.raises: [].} = - ## Multiplies, wrapping. - e.code.multiplyRegister(Word32, temp(left), temp(right)) - - proc negate(e: var Emitter, value: int) {.raises: [].} = - ## Negates, wrapping. - e.code.negateRegister(Word32, temp(value)) - - proc bitAnd(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits both hold. - e.code.andRegister(Word32, temp(left), temp(right)) - - proc bitOr(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits either holds. - e.code.orRegister(Word32, temp(left), temp(right)) - - proc bitXor(e: var Emitter, left, right: int) {.raises: [].} = - ## Keeps the bits exactly one holds. - e.code.xorRegister(Word32, temp(left), temp(right)) - - proc bitNot(e: var Emitter, value: int) {.raises: [].} = - ## Flips every bit. - e.code.notRegister(Word32, temp(value)) - - proc divide(e: var Emitter, left, right: int, keepRemainder: bool) - {.raises: [BasicError].} = - ## Divides toward zero; the divisor is known not to be zero. The - ## hardware traps on the most negative number over minus one, which - ## the interpreter defines, so minus one is answered without dividing: - ## the quotient is the negation, wrapping, and nothing is left over. - let normal = e.label() - let done = e.label() - e.code.compareImmediate(Word32, temp(right), -1) - e.jumpWhen(NotEqualCondition, normal) - if keepRemainder: - e.code.loadImmediate(Word32, temp(left), 0) - else: - e.code.negateRegister(Word32, temp(left)) - e.jump(done) - e.place(normal) - e.code.moveRegister(Word32, rax, temp(left)) - e.code.signExtendToPair(Word32) - e.code.signedDivide(Word32, temp(right)) - if keepRemainder: - e.code.moveRegister(Word32, temp(left), Spare) - else: - e.code.moveRegister(Word32, temp(left), rax) - e.place(done) - - proc quotient(e: var Emitter, left, right: int) {.raises: [BasicError].} = - ## Divides toward zero. - e.divide(left, right, false) - - proc remainder(e: var Emitter, left, right: int) - {.raises: [BasicError].} = - ## Leaves what dividing left over, with the sign of the dividend. - e.divide(left, right, true) - - proc multiplyFixed(e: var Emitter, left, right: int) - {.raises: [BasicError].} = - ## Multiplies two Q16.16 numbers through a widened intermediate, - ## rounding to nearest exactly as the fixed-point library does. - e.code.signExtendDouble(temp(left), temp(left)) - e.code.signExtendDouble(temp(6), temp(right)) - e.code.multiplyRegister(Word64, temp(left), temp(6)) - e.code.addImmediate(Word64, temp(left), int32(jit.FixedRounding)) - e.code.shiftRightImmediate(Word64, temp(left), jit.FixedShift) - e.code.moveRegister(Word32, temp(left), temp(left)) - - proc widenToFixed(e: var Emitter, value, tag: int, slow: Label) - {.raises: [BasicError].} = - ## Turns a number of either kind into its Q16.16 bits, widened to - ## sixty-four. A whole number outside the fixed-point range cannot - ## become one, which the interpreter refuses, so that goes slow. - let register = temp(value) - let already = e.label() - let ready = e.label() - e.whenFixed(tag, already) - e.code.compareImmediate(Word32, register, 32767) - e.jumpWhen(GreaterCondition, slow) - e.code.compareImmediate(Word32, register, -32768) - e.jumpWhen(LessCondition, slow) - e.code.signExtendDouble(register, register) - e.code.shiftLeftImmediate(Word64, register, jit.FixedShift) - e.jump(ready) - e.place(already) - e.code.signExtendDouble(register, register) - e.place(ready) - - proc divideFixed(e: var Emitter, left, right: int, slow: Label) - {.raises: [BasicError].} = - ## Divides two widened Q16.16 numbers, rounding to nearest with halves - ## going up, for either sign, exactly as the fixed-point library - ## does: the signs are put right first, half the divisor is added, - ## and the truncating divide is corrected back to a floor. The divide - ## works in rax and rdx, so the numerator is moved there. - let denominator = temp(right) - let half = temp(5) - let numerator = temp(6) - e.code.moveRegister(Word64, rax, temp(left)) - e.code.compareImmediate(Word64, denominator, 0) - e.jumpWhen(EqualCondition, slow) - let signsSettled = e.label() - e.jumpWhen(GreaterCondition, signsSettled) - e.code.negateRegister(Word64, rax) - e.code.negateRegister(Word64, denominator) - e.place(signsSettled) - e.code.shiftLeftImmediate(Word64, rax, jit.FixedShift) - e.code.moveRegister(Word64, half, denominator) - e.code.shiftRightImmediate(Word64, half, 1) - e.code.addRegister(Word64, rax, half) - e.code.moveRegister(Word64, numerator, rax) - e.code.signExtendToPair(Word64) - e.code.signedDivide(Word64, denominator) - let done = e.label() - e.code.testRegister(Word64, Spare, Spare) - e.jumpWhen(EqualCondition, done) - e.code.testRegister(Word64, numerator, numerator) - e.jumpWhen(GreaterEqualCondition, done) - e.code.subtractImmediate(Word64, rax, 1) - e.place(done) - e.code.moveRegister(Word32, temp(left), rax) - - proc compare(e: var Emitter, left, right: int) {.raises: [].} = - ## Sets flags from two working registers. - e.code.compareRegister(Word32, temp(left), temp(right)) - - proc compareConstant(e: var Emitter, value: int, bits: int32) - {.raises: [BasicError].} = - ## Sets flags from a working register against a constant. - e.code.compareImmediate(Word32, temp(value), bits) - - proc answer(e: var Emitter, value: int, check: Check) {.raises: [].} = - ## Writes BASIC's -1 for true and zero for false. The byte form only - ## names the low byte of rax, rcx, rdx and rbx without a prefix, so - ## this is only ever asked of the first working register. - e.code.setIfCondition(temp(value), nativeCondition(check)) - e.code.negateRegister(Word32, temp(value)) - - proc jumpOn(e: var Emitter, check: Check, target: Label) - {.raises: [].} = - ## Jumps on a comparison outcome. - e.jumpWhen(nativeCondition(check), target) - - proc jumpIfZeroValue(e: var Emitter, value: int, target: Label) - {.raises: [].} = - ## Jumps when a working register holds zero. - e.code.testRegister(Word32, temp(value), temp(value)) - e.jumpWhen(EqualCondition, target) - - proc jumpIfNotZeroValue(e: var Emitter, value: int, target: Label) - {.raises: [].} = - ## Jumps when a working register holds anything but zero. - e.code.testRegister(Word32, temp(value), temp(value)) - e.jumpWhen(NotEqualCondition, target) - - proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, - slow: Label) {.raises: [BasicError].} = - ## Bounds checks an index and leaves the cell's address in Cell. One - ## unsigned comparison covers both ends, as the interpreter's does. - e.code.compareImmediate(Word32, temp(index), extent.length) - e.jumpWhen(AboveEqualCondition, slow) - e.code.moveRegister(Word32, Cell, temp(index)) - e.code.addImmediate(Word32, Cell, extent.base) - e.code.shiftLeftImmediate(Word64, Cell, 4) - e.code.addRegister(Word64, Cell, MemoryBase) - - proc meter(e: var Emitter, instructions, work: int32, slow: Label) - {.raises: [BasicError].} = - ## Checks both budgets before charging either, as the interpreter does. - e.code.compareImmediate(Word64, Instructions, instructions) - e.jumpWhen(LessCondition, slow) - e.code.compareImmediate(Word64, Work, work) - e.jumpWhen(LessCondition, slow) - e.code.subtractImmediate(Word64, Instructions, instructions) - e.code.subtractImmediate(Word64, Work, work) - - proc slotAddress(e: var Emitter, destination, index: Register) - {.raises: [BasicError].} = - ## Points a register at one register-file slot by its absolute index. - ## The index register is left scaled. - e.contextField(destination, ContextRegisterFile) - e.code.shiftLeftImmediate(Word64, index, 4) - e.code.addRegister(Word64, destination, index) - - proc copyValues(e: var Emitter, destination, source: Register, - count: int) {.raises: [BasicError].} = - ## Copies a run of whole values, in a loop once there are many. Works - ## in rax, rdx, r8, r9 and r10, so neither end may be one of those. - if count <= 8: - for index in 0 ..< count: - e.code.loadDouble(rax, source, index * ValueStride) - e.code.loadDouble(Spare, source, index * ValueStride + ValuePayload) - e.code.storeDouble(rax, destination, index * ValueStride) - e.code.storeDouble(Spare, destination, - index * ValueStride + ValuePayload) - return - e.code.moveRegister(Word64, r10, source) - e.code.moveRegister(Word64, r9, destination) - e.code.loadImmediate(Word32, r8, int64(count)) - let again = e.label() - e.place(again) - e.code.loadDouble(rax, r10, 0) - e.code.loadDouble(Spare, r10, ValuePayload) - e.code.storeDouble(rax, r9, 0) - e.code.storeDouble(Spare, r9, ValuePayload) - e.code.addImmediate(Word64, r10, ValueStride) - e.code.addImmediate(Word64, r9, ValueStride) - e.code.subtractImmediate(Word32, r8, 1) - e.jumpWhen(NotEqualCondition, again) - - proc clearValues(e: var Emitter, destination: Register, count: int) - {.raises: [BasicError].} = - ## Zeroes a run of values, in a loop once there are many. - e.code.loadImmediate(Word32, rax, 0) - if count <= 8: - for index in 0 ..< count: - e.code.storeDouble(rax, destination, index * ValueStride) - e.code.storeDouble(rax, destination, - index * ValueStride + ValuePayload) - return - e.code.moveRegister(Word64, r9, destination) - e.code.loadImmediate(Word32, r8, int64(count)) - let again = e.label() - e.place(again) - e.code.storeDouble(rax, r9, 0) - e.code.storeDouble(rax, r9, ValuePayload) - e.code.addImmediate(Word64, r9, ValueStride) - e.code.subtractImmediate(Word32, r8, 1) - e.jumpWhen(NotEqualCondition, again) - - proc enterRoutine(e: var Emitter, gosub: bool, calleeId: int32, - calleeRegisters, calleeParameters, callerRegisters: int32, - resumeAt: int32, limits: CallLimits, slow: Label) - {.raises: [BasicError].} = - ## Pushes a frame into the interpreter's own array and moves the - ## current frame on, refusing the same two ceilings it refuses. - let depth = rax - let oldBase = rcx - let newBase = rsi - let frame = rdi - e.code.loadWord(depth, Context, ContextDepth) - e.code.compareImmediate(Word32, depth, limits.frames - 1) - e.jumpWhen(GreaterEqualCondition, slow) - e.code.loadWord(oldBase, Context, ContextBase) - e.code.moveRegister(Word32, newBase, oldBase) - e.code.addImmediate(Word32, newBase, callerRegisters) - e.code.compareImmediate(Word32, newBase, - limits.slots - calleeRegisters) - e.jumpWhen(GreaterCondition, slow) - - e.contextField(frame, ContextFrames) - e.code.moveRegister(Word32, r8, depth) - e.code.shiftLeftImmediate(Word64, r8, 4) - e.code.addRegister(Word64, frame, r8) - e.code.storeWord(oldBase, frame, FrameBase) - e.code.loadWord(r8, Context, ContextRoutine) - e.code.storeWord(r8, frame, FrameRoutine) - e.code.storeWordImmediate(frame, FrameReturn, resumeAt) - e.code.storeWordImmediate(frame, FrameTag, if gosub: 1 else: 0) - - e.code.addImmediate(Word32, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) - e.code.storeWord(newBase, Context, ContextBase) - e.code.storeWordImmediate(Context, ContextRoutine, calleeId) - - # A GOSUB hands the callee a copy of the caller's slots; a call clears - # them and lays the arguments over the first few, in that order. - e.code.moveRegister(Word64, frame, RegistersBase) - e.code.moveRegister(Word32, rcx, newBase) - e.slotAddress(RegistersBase, rcx) - if gosub: - e.copyValues(RegistersBase, frame, int(calleeRegisters)) - else: - e.clearValues(RegistersBase, int(calleeRegisters)) - if calleeParameters > 0: - e.contextField(Cell, ContextArguments) - e.copyValues(RegistersBase, Cell, int(calleeParameters)) - - proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) - {.raises: [BasicError].} = - ## Pops a frame and jumps to wherever it said to carry on. A GOSUB - ## frame first hands the shared parameters back to the caller. - let depth = rax - let frame = rcx - let base = rsi - e.code.loadWord(depth, Context, ContextDepth) - e.code.testRegister(Word32, depth, depth) - e.jumpWhen(EqualCondition, slow) - e.code.subtractImmediate(Word32, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) - e.contextField(frame, ContextFrames) - e.code.moveRegister(Word32, r8, depth) - e.code.shiftLeftImmediate(Word64, r8, 4) - e.code.addRegister(Word64, frame, r8) - e.code.loadWord(base, frame, FrameBase) - if parameters > 0: - let plain = e.label() - e.code.loadByteZeroed(Spare, frame, FrameTag) - e.code.compareImmediate(Word32, Spare, 1) - e.jumpWhen(NotEqualCondition, plain) - e.code.moveRegister(Word32, r8, base) - e.slotAddress(rdi, r8) - e.copyValues(rdi, RegistersBase, int(parameters)) - e.place(plain) - e.code.storeWord(base, Context, ContextBase) - e.code.loadWord(Spare, frame, FrameRoutine) - e.code.storeWord(Spare, Context, ContextRoutine) - e.code.loadWord(Spare, frame, FrameReturn) - e.code.storeWord(Spare, Context, ContextOffset) - e.code.moveRegister(Word32, r8, base) - e.slotAddress(RegistersBase, r8) - e.contextField(rax, ContextReturnTable) - e.code.shiftLeftImmediate(Word64, Spare, 3) - e.code.addRegister(Word64, rax, Spare) - e.code.loadDouble(rax, rax, 0) - e.code.jumpRegister(rax) - - proc callSlow(e: var Emitter, offset: int32, routine: Label) - {.raises: [BasicError].} = - ## Runs the interpreter's own code for one instruction. - e.code.loadImmediate(Word32, SecondArgument, int64(offset)) - e.code.callLabel(routine) - - proc slowRoutine(e: var Emitter, failed: Label) - {.raises: [BasicError].} = - ## The one place compiled code calls out. The budgets go into the - ## context for the interpreter's code to charge, and come back from it - ## along with the frame, since a call or a return may have moved it. - ## A failure leaves through the shared exit, dropping the return - ## address this routine was called with on the way. - let refused = e.label() - e.code.storeDouble(Instructions, Context, ContextInstructions) - e.code.storeDouble(Work, Context, ContextWork) - e.code.moveRegister(Word64, FirstArgument, Context) - e.code.subtractImmediate(Word64, rsp, Padding) - e.contextField(rax, ContextStep) - e.code.callRegister(rax) - e.code.addImmediate(Word64, rsp, Padding) - e.code.moveRegister(Word32, r10, rax) - e.code.loadDouble(Instructions, Context, ContextInstructions) - e.code.loadDouble(Work, Context, ContextWork) - e.code.loadWord(rcx, Context, ContextBase) - e.slotAddress(RegistersBase, rcx) - e.code.testRegister(Word32, r10, r10) - e.jumpWhen(NotEqualCondition, refused) - e.code.returnToCaller() - e.place(refused) - e.code.addImmediate(Word64, rsp, 8) - e.jump(failed) - - proc dispatch(e: var Emitter) {.raises: [BasicError].} = - ## Jumps to the block for whatever offset the context names. - e.code.loadWord(rax, Context, ContextOffset) - e.code.shiftLeftImmediate(Word64, rax, 3) - e.contextField(rcx, ContextReturnTable) - e.code.addRegister(Word64, rcx, rax) - e.code.loadDouble(rcx, rcx, 0) - e.code.jumpRegister(rcx) - - proc prologue(e: var Emitter) {.raises: [BasicError].} = - ## Saves what the platform says to keep and loads the machine state. - for register in Saved: - e.code.push(register) - e.code.subtractImmediate(Word64, rsp, Padding) - e.code.moveRegister(Word64, Context, FirstArgument) - e.code.loadDouble(GlobalsBase, Context, 0) - e.code.loadDouble(Instructions, Context, ContextInstructions) - e.code.loadDouble(Work, Context, ContextWork) - e.contextField(MemoryBase, ContextMemory) - e.code.loadWord(rcx, Context, ContextBase) - e.slotAddress(RegistersBase, rcx) - - proc epilogue(e: var Emitter, status: NativeStatus) - {.raises: [BasicError].} = - ## Restores what the platform says to keep and returns a status. - e.code.loadImmediate(Word32, rax, int64(ord(status))) - e.code.addImmediate(Word64, rsp, Padding) - for index in countdown(Saved.len - 1, 0): - e.code.pop(Saved[index]) - e.code.returnToCaller() - - proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = - ## Publishes the budgets and where the program stopped, then returns. - e.code.storeDouble(Instructions, Context, ContextInstructions) - e.code.storeDouble(Work, Context, ContextWork) - e.code.storeWordImmediate(Context, ContextOffset, offset) - e.epilogue(NativeCompleted) - - proc finish(e: var Emitter): seq[byte] {.raises: [BasicError].} = - ## Resolves every branch and returns the finished bytes. - e.code.resolve() - e.code.code - - proc offsetBytes(e: Emitter, target: Label): int {.raises: [].} = - ## Returns where a label ended up, in bytes. - e.code.offsetOf(target) - -proc invoke*(machine: Machine, context: var NativeContext): NativeStatus - {.raises: [].} = - ## Runs the compiled program from the offset the context names. - NativeStatus(machine.call(context.addr)) - -proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], - ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], - limits: CallLimits, far: bool): (seq[byte], seq[int]) - {.raises: [BasicError].} = - ## Emits the whole program and returns its bytes along with where each - ## offset's block starts. - when not (NativeArm64 or NativeAmd64): - raise newException(BasicError, "BASIC has no whole-program backend here") - else: - var e = Emitter(far: far) - var blocks = newSeq[Label](code.len + 1) - for index in 0 .. code.len: - blocks[index] = e.label() - let dispatchLabel = e.label() - let slowLabel = e.label() - let failedLabel = e.label() - - e.prologue() - e.jump(dispatchLabel) - - var stubs: seq[Stub] - for index in 0 ..< code.len: - let item = code[index] - let offset = int32(index) - e.place(blocks[index]) - - template slowFor(after: Branching): Label = - ## Names a slow path for this instruction, emitted after the block. - let stub = Stub(label: e.label(), offset: offset, carry: after) - stubs.add(stub) - stub.label - - template runSlow() = - ## Runs this instruction through the interpreter's code in line. - e.callSlow(offset, slowLabel) - - var fallsThrough = true - case item.op - of MeterOp: - e.meter(item.b, item.a, slowFor(ToNext)) - of LoadImmediateOp: - e.writeConstant(slot(item.a), 0, item.b) - of LoadFixedOp: - e.writeConstant(slot(item.a), FixedTag, constants[int(item.b)]) - of MoveOp: - e.copyValue(slot(item.a), slot(item.b)) - of LoadGlobalOp: - e.copyValue(slot(item.a), global(item.b)) - of LoadHostDataOp: - e.copyValue(slot(item.a), host(item.b)) - of StoreGlobalOp: - e.copyValue(global(item.a), slot(item.b)) - of StoreGlobalImmediateOp: - e.writeConstant(global(item.a), 0, item.b) - of MoveGlobalOp: - e.copyValue(global(item.a), global(item.b)) - of SetArgumentOp: - e.copyValue(argument(item.a), slot(item.b)) - of SetArgumentImmediateOp: - e.writeConstant(argument(item.a), 0, item.b) - of SetArgumentGlobalOp: - e.copyValue(argument(item.a), global(item.b)) - of AddGlobalImmediateOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.loadConstant(1, item.b) - e.add(0, 1) - e.writeWhole(global(item.a), 0) - of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp: - let slow = slowFor(ToNext) - let source = - case item.op - of AddGlobalOp: global(item.b) - of AddGlobalHostDataOp: host(item.b) - else: slot(item.b) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.readValue(1, 3, source) - e.unlessWhole(3, slow) - e.add(0, 1) - e.writeWhole(global(item.a), 0) - of ModuloGlobalImmediateOp: - if item.c == 0: - runSlow() - else: - let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.b)) - e.unlessWhole(2, slow) - e.loadConstant(1, item.c) - e.remainder(0, 1) - e.writeWhole(global(item.a), 0) - of AddGlobalArrayGlobalIndexOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.c)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.b)], slow) - e.readValue(1, 3, cell()) - e.unlessWhole(3, slow) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.add(0, 1) - e.writeWhole(global(item.a), 0) - of ArrayAddGlobalsOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.b)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.a)], slow) - e.readValue(1, 3, cell()) - e.unlessWhole(3, slow) - e.readValue(0, 2, global(item.c)) - e.unlessWhole(2, slow) - e.add(1, 0) - e.writeWhole(cell(), 1) - of AddOp, SubtractOp, MultiplyOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.readValue(1, 3, slot(item.c)) - e.unlessSame(2, 3, slow) - when ModelsFixed: - e.unlessNumeric(2, slow) - else: - e.unlessWhole(2, slow) - case item.op - of AddOp: - e.add(0, 1) - of SubtractOp: - e.subtract(0, 1) - else: - when ModelsFixed: - let fixedWay = e.label() - let joined = e.label() - e.whenFixed(2, fixedWay) - e.multiply(0, 1) - e.jump(joined) - e.place(fixedWay) - e.multiplyFixed(0, 1) - e.place(joined) - else: - e.multiply(0, 1) - e.writeKind(slot(item.a), 2, 0) - of DivideOp: - when ModelsFixed: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.unlessNumeric(2, slow) - e.readValue(1, 3, slot(item.c)) - e.unlessNumeric(3, slow) - e.widenToFixed(0, 2, slow) - e.widenToFixed(1, 3, slow) - e.divideFixed(0, 1, slow) - e.writeFixed(slot(item.a), 0) - else: - runSlow() - of IntegerDivideOp, ModuloOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.unlessWhole(2, slow) - e.readValue(1, 3, slot(item.c)) - e.unlessWhole(3, slow) - e.jumpIfZeroValue(1, slow) - if item.op == IntegerDivideOp: - e.quotient(0, 1) - else: - e.remainder(0, 1) - e.writeWhole(slot(item.a), 0) - of NegateOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - when ModelsFixed: - e.unlessNumeric(2, slow) - else: - e.unlessWhole(2, slow) - e.negate(0) - e.writeKind(slot(item.a), 2, 0) - of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, - GreaterEqualOp: - # The same kind on both sides orders the same on the stored bits, - # and the answer is always a whole number. - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.readValue(1, 3, slot(item.c)) - e.unlessSame(2, 3, slow) - e.unlessNumeric(2, slow) - e.compare(0, 1) - e.answer(0, comparisonCheck(item.op)) - e.writeWhole(slot(item.a), 0) - of AndOp, OrOp, XorOp, EqvOp, ImpOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.unlessWhole(2, slow) - e.readValue(1, 3, slot(item.c)) - e.unlessWhole(3, slow) - case item.op - of AndOp: - e.bitAnd(0, 1) - of OrOp: - e.bitOr(0, 1) - of XorOp: - e.bitXor(0, 1) - of EqvOp: - e.bitXor(0, 1) - e.bitNot(0) - else: - e.bitNot(0) - e.bitOr(0, 1) - e.writeWhole(slot(item.a), 0) - of NotOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.unlessWhole(2, slow) - e.bitNot(0) - e.writeWhole(slot(item.a), 0) - of JumpOp: - e.jump(blocks[int(item.a)]) - fallsThrough = false - of JumpIfZeroOp: - # A fixed-point zero is all zero bits too, so either kind tests - # the same way. - let slow = slowFor(ToOffset) - e.readValue(0, 2, slot(item.a)) - e.unlessNumeric(2, slow) - e.jumpIfZeroValue(0, blocks[int(item.b)]) - of JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp: - let slow = slowFor(ToOffset) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.compareConstant(0, item.b) - e.jumpOn(takenOn(item.op), blocks[int(item.c)]) - of JumpUnlessGlobalModuloEqualZeroOp: - if item.b == 0: - runSlow() - e.jump(dispatchLabel) - fallsThrough = false - else: - let slow = slowFor(ToOffset) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.loadConstant(1, item.b) - e.remainder(0, 1) - e.jumpIfNotZeroValue(0, blocks[int(item.c)]) - of ArrayGetOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.c)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.b)], slow) - e.copyValue(slot(item.a), cell()) - of ArraySetOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, slot(item.b)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.a)], slow) - e.copyValue(cell(), slot(item.c)) - of CallOp, GosubOp: - let owner = routines[int(ownerOf[index])] - let slow = slowFor(ToOffset) - if item.op == CallOp: - let callee = routines[int(item.a)] - e.enterRoutine(false, item.a, callee.registers, callee.parameters, - owner.registers, offset + 1, limits, slow) - e.jump(blocks[int(callee.entry)]) - else: - e.enterRoutine(true, ownerOf[index], owner.registers, 0, - owner.registers, offset + 1, limits, slow) - e.jump(blocks[int(item.a)]) - fallsThrough = false - of ReturnOp: - let owner = routines[int(ownerOf[index])] - e.leaveRoutine(owner.parameters, slowFor(ToOffset)) - fallsThrough = false - of HaltOp: - e.halt(offset) - fallsThrough = false - of ReturnLabelOp, ExitSubOp: - runSlow() - e.jump(dispatchLabel) - fallsThrough = false - of LoadStringOp, TextCallOp, HostCallOp, PrintTextOp, PrintValueOp, - PrintNewlineOp: - runSlow() - - # Slow paths go after the block, out of the way of the fast ones. - let blockEnds = index + 1 == code.len or code[index + 1].op == MeterOp - if blockEnds and stubs.len > 0: - if fallsThrough: - e.jump(blocks[index + 1]) - for stub in stubs: - e.place(stub.label) - e.callSlow(stub.offset, slowLabel) - case stub.carry - of ToNext: - e.jump(blocks[int(stub.offset) + 1]) - of ToOffset: - e.jump(dispatchLabel) - stubs.setLen(0) - - # One past the end holds nothing to run. The interpreter's code is - # left to refuse it the way it would. - e.place(blocks[code.len]) - e.callSlow(int32(code.len), slowLabel) - e.jump(dispatchLabel) - - e.place(dispatchLabel) - e.dispatch() - e.place(slowLabel) - e.slowRoutine(failedLabel) - e.place(failedLabel) - e.epilogue(NativeFailed) - - let bytes = e.finish() - var starts = newSeq[int](code.len + 1) - for index in 0 .. code.len: - starts[index] = e.offsetBytes(blocks[index]) - (bytes, starts) - -proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], - extents: seq[ArrayExtent], constants: seq[int32], globals, hostData, - arguments: int, limits: CallLimits): Machine {.raises: [BasicError].} = - ## Compiles every offset of a program to machine code, or returns nil - ## when this target has no backend or the program is outside what the - ## generator is sure of. Generated code indexes storage without - ## checking, so every index it will use is proved in range here first. - when not (NativeArm64 or NativeAmd64): - return nil - else: - if not layoutMatches() or code.len == 0 or routines.len == 0: - return nil - if limits.frames <= 0 or limits.slots < 0: - return nil - - # Every offset belongs to exactly one routine, and none runs on into - # the next, so the routine an instruction runs in is known here. - var ownerOf = newSeq[int32](code.len) - for index in 0 ..< ownerOf.len: - ownerOf[index] = -1 - for id, routine in routines: - if routine.entry < 0 or routine.length <= 0 or - int(routine.entry) + int(routine.length) > code.len: - return nil - if routine.registers < 0 or routine.parameters < 0 or - routine.parameters > routine.registers or - routine.parameters > int32(arguments): - return nil - for step in 0 ..< int(routine.length): - let offset = int(routine.entry) + step - if ownerOf[offset] >= 0: - return nil - ownerOf[offset] = int32(id) - let last = code[int(routine.entry) + int(routine.length) - 1] - if last.op notin Terminators: - return nil - for index in 0 ..< code.len: - if ownerOf[index] < 0: - return nil - - for index, item in code: - let owner = routines[int(ownerOf[index])] - template requireSlot(value: int32) = - if value < 0 or value >= owner.registers: - return nil - template requireGlobal(value: int32) = - if value < 0 or int(value) >= globals: - return nil - template requireArray(value: int32) = - if value < 0 or int(value) >= extents.len: - return nil - let extent = extents[int(value)] - if extent.base < 0 or extent.length < 0: - return nil - template requireHost(value: int32) = - if value < 0 or int(value) >= hostData: - return nil - template requireArgument(value: int32) = - if value < 0 or int(value) >= arguments: - return nil - template requireTarget(value: int32) = - if value < 0 or int(value) >= code.len or - ownerOf[int(value)] != ownerOf[index]: - return nil - case item.op - of MeterOp: - if item.a < 0 or item.b < 0: - return nil - of LoadImmediateOp: - requireSlot(item.a) - of LoadFixedOp: - requireSlot(item.a) - if item.b < 0 or int(item.b) >= constants.len: - return nil - of MoveOp, NegateOp, NotOp: - requireSlot(item.a) - requireSlot(item.b) - of LoadGlobalOp: - requireSlot(item.a) - requireGlobal(item.b) - of LoadHostDataOp: - requireSlot(item.a) - requireHost(item.b) - of StoreGlobalOp: - requireGlobal(item.a) - requireSlot(item.b) - of StoreGlobalImmediateOp, AddGlobalImmediateOp: - requireGlobal(item.a) - of MoveGlobalOp, AddGlobalOp, ModuloGlobalImmediateOp: - requireGlobal(item.a) - requireGlobal(item.b) - of AddGlobalHostDataOp: - requireGlobal(item.a) - requireHost(item.b) - of AddGlobalRegisterOp: - requireGlobal(item.a) - requireSlot(item.b) - of AddGlobalArrayGlobalIndexOp: - requireGlobal(item.a) - requireArray(item.b) - requireGlobal(item.c) - of ArrayAddGlobalsOp: - requireArray(item.a) - requireGlobal(item.b) - requireGlobal(item.c) - of AddOp, SubtractOp, MultiplyOp, DivideOp, IntegerDivideOp, - ModuloOp, EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, - GreaterEqualOp, AndOp, OrOp, XorOp, EqvOp, ImpOp: - requireSlot(item.a) - requireSlot(item.b) - requireSlot(item.c) - of JumpOp, GosubOp, ReturnLabelOp: - requireTarget(item.a) - of JumpIfZeroOp: - requireSlot(item.a) - requireTarget(item.b) - of JumpUnlessGlobalEqualImmediateOp, - JumpUnlessGlobalNotEqualImmediateOp, - JumpUnlessGlobalLessImmediateOp, - JumpUnlessGlobalLessEqualImmediateOp, - JumpUnlessGlobalGreaterImmediateOp, - JumpUnlessGlobalGreaterEqualImmediateOp, - JumpUnlessGlobalModuloEqualZeroOp: - requireGlobal(item.a) - requireTarget(item.c) - of ArrayGetOp: - requireSlot(item.a) - requireArray(item.b) - requireSlot(item.c) - of ArraySetOp: - requireArray(item.a) - requireSlot(item.b) - requireSlot(item.c) - of SetArgumentOp: - requireArgument(item.a) - requireSlot(item.b) - of SetArgumentImmediateOp: - requireArgument(item.a) - of SetArgumentGlobalOp: - requireArgument(item.a) - requireGlobal(item.b) - of CallOp: - if item.a <= 0 or int(item.a) >= routines.len: - return nil - else: - discard - if item.op in {CallOp, GosubOp} and index + 1 >= code.len: - return nil - - var emitted: (seq[byte], seq[int]) - try: - emitted = emitProgram(code, routines, ownerOf, extents, constants, - limits, false) - except BasicError: - # Some branch could not reach; every branch then goes the long way. - emitted = emitProgram(code, routines, ownerOf, extents, constants, - limits, true) - let (bytes, starts) = emitted - if bytes.len > MaxProgramBytes: - return nil - - result = Machine(size: bytes.len, listing: bytes) - result.buffer = initCodeBuffer(bytes.len) - result.buffer.write(bytes) - result.buffer.seal() - result.call = cast[NativeCall](result.buffer.entry) - let origin = cast[int](result.buffer.entry) - result.table = newSeq[pointer](code.len + 1) - for index in 0 .. code.len: - result.table[index] = cast[pointer](origin + starts[index]) - -proc tableAddress*(machine: Machine): pointer {.raises: [].} = - ## Returns the table of native addresses indexed by bytecode offset. - machine.table[0].addr diff --git a/tests/bench_jit.nim b/tests/bench_jit.nim index 220d826..aa09d08 100644 --- a/tests/bench_jit.nim +++ b/tests/bench_jit.nim @@ -1,4 +1,4 @@ -## Reports how much faster compiled loops run than the interpreter. +## Reports how much faster compiled programs run than the interpreter. ## Times its own runs so it can execute on any CI machine without pulling ## in a benchmarking dependency. @@ -83,7 +83,7 @@ proc measure(name, source: string, expected: int32) = var plain = initRuntime(program, limits) var fast = initRuntime(program, limits) - let regions = fast.compileNative() + let compiled = fast.compileNative() let plainTime = plain.fastest() let fastTime = fast.fastest() @@ -94,11 +94,11 @@ proc measure(name, source: string, expected: int32) = let charged = plain.instructionsUsed == fast.instructionsUsed let ratio = - if fastTime > 0.0 and regions > 0: &"{plainTime / fastTime:6.1f}x" + if fastTime > 0.0 and compiled > 0: &"{plainTime / fastTime:6.1f}x" else: " --" echo &" {name:<12} interpreted {plainTime:8.3f} ms " & &"native {fastTime:8.3f} ms {ratio} " & - &"loops {regions} results {agree} budget {charged}" + &"offsets {compiled} results {agree} budget {charged}" if not agree: quit(&"{name}: interpreted {plainSum} but native {fastSum}") if not charged: diff --git a/tests/bench_raytracer.nim b/tests/bench_raytracer.nim index aca9337..48aee24 100644 --- a/tests/bench_raytracer.nim +++ b/tests/bench_raytracer.nim @@ -1,10 +1,10 @@ ## Runs the BASIC raytracer on both execution paths. ## ## This is the realistic end of the workload range. Almost all of its time -## goes to fixed-point arithmetic, arrays, and subroutine calls, none of -## which the native compiler models, so it compiles no loops at all. It is -## here to show where the speedup does not yet reach, and to check that a -## script this large still agrees on both paths. +## goes to fixed-point arithmetic, arrays, subroutine calls and host +## functions, and there is no single hot loop to speak of. It shows what +## compiling the whole program buys on real work, and checks that a script +## this large still agrees on both paths. import std/[monotimes, strformat, times], @@ -135,15 +135,15 @@ echo &"image: {Size} by {Size}, bytecode {program.instructions} instructions" var plain = initRuntime(program, host, limits) var fast = initRuntime(program, host, limits) -let regions = fast.compileNative() +let compiled = fast.compileNative() let (plainTime, plainSum) = plain.render() let (fastTime, fastSum) = fast.render() echo &" interpreted {plainTime:9.2f} ms checksum {plainSum}" echo &" native {fastTime:9.2f} ms checksum {fastSum} " & - &"compiled loops {regions}" -if regions > 0 and fastTime > 0.0: + &"compiled offsets {compiled}" +if compiled > 0 and fastTime > 0.0: echo &" ratio {plainTime / fastTime:9.2f}x" echo &" instructions charged: {plain.instructionsUsed} vs " & &"{fast.instructionsUsed}" diff --git a/tests/test_jit.nim b/tests/test_jit.nim index 666ee59..b9ba9e3 100644 --- a/tests/test_jit.nim +++ b/tests/test_jit.nim @@ -1,5 +1,5 @@ -## Checks that compiled loops agree with the interpreter. -## Every script runs twice, once interpreted and once with its hot loops +## Checks that compiled programs agree with the interpreter. +## Every script runs twice, once interpreted and once with the whole program ## executed as machine code. The globals and both budgets must match ## exactly, because a script must not be able to tell which path ran. @@ -12,7 +12,7 @@ type Outcome = object instructions: int64 work: int64 failure: string - regions: int + compiled: int proc describe(value: Value): string = ## Renders a global for comparison output. @@ -26,7 +26,7 @@ proc execute(source: string, native: bool): Outcome = let program = compile(source) var runtime = initRuntime(program) if native: - result.regions = runtime.compileNative() + result.compiled = runtime.compileNative() try: discard runtime.run() except BasicError as error: @@ -37,13 +37,13 @@ proc execute(source: string, native: bool): Outcome = result.instructions = instructions result.work = work -proc check(name, source: string, expectRegions = true) = +proc check(name, source: string) = ## Compares the two execution paths and reports any disagreement. let plain = execute(source, false) let fast = execute(source, true) var problems: seq[string] - if jitSupported() and expectRegions and fast.regions == 0: - problems.add("no loop was compiled") + if jitSupported() and fast.compiled == 0: + problems.add("the program was not compiled") if plain.globals.len != fast.globals.len: problems.add("global count differs") else: @@ -72,7 +72,7 @@ proc check(name, source: string, expectRegions = true) = echo " ", problem quit(1) let note = - if fast.regions > 0: &"{fast.regions} compiled" + if fast.compiled > 0: &"{fast.compiled} compiled" else: "interpreted only" echo &" ok {name:<34} {note}" @@ -139,16 +139,16 @@ while i < 100 wend """ -# A loop whose counter becomes fixed point must fall back to the -# interpreter without changing the answer. -check "fixed point defeats the guard", """ +# A counter that is fixed point from the start takes the other kind's +# path through every comparison and add without changing the answer. +check "a fixed-point counter", """ i = 0.5 total = 0 while i < 10 total = total + 1 i = i + 1 wend -""", expectRegions = false +""" # The instruction budget has to be refused at exactly the same point. proc checkBudget(name: string, source: string, maximum: int64) = diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 9943f43..3390406 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -3,7 +3,7 @@ ## through offsets worked out at compile time, so the checks that make ## that safe are the ones worth attacking. ## -## Two halves. The first hands compileRegion bytecode the language's own +## Two halves. The first hands compileProgram bytecode the language's own ## compiler would never produce, and requires it to refuse rather than ## emit. The second runs generated scripts down both paths and requires ## the results and both budgets to match, because a script that could tell @@ -39,205 +39,155 @@ report( const Globals = 4 Slots = 8 + Limits = CallLimits(frames: 8, slots: 64) proc countingLoop(globalIndex: int32, target: int32): seq[Instruction] = - ## A minimal loop, parameterised so it can be made malformed. + ## A minimal program, parameterised so it can be made malformed. @[ Instruction(op: MeterOp, a: 4, b: 2), Instruction( op: JumpUnlessGlobalLessImmediateOp, a: globalIndex, b: 10, c: target ), Instruction(op: AddGlobalImmediateOp, a: globalIndex, b: 1), - Instruction(op: JumpOp, a: 0) + Instruction(op: JumpOp, a: 0), + Instruction(op: MeterOp, a: 1, b: 1), + Instruction(op: HaltOp) ] +proc main(code: seq[Instruction]): seq[RoutineExtent] = + ## One routine covering the whole program. + @[RoutineExtent(entry: 0, length: int32(code.len), registers: Slots)] + +proc compiles(code: seq[Instruction], globals = Globals, + routines: seq[RoutineExtent] = @[], extents: seq[ArrayExtent] = @[], + constants: seq[int32] = @[], hostData = 0, arguments = 4): bool = + ## Reports whether the whole program was accepted. + let table = if routines.len > 0: routines else: main(code) + compileProgram(code, table, extents, constants, globals, hostData, + arguments, Limits) != nil + block: # The same shape must compile when it is well formed, or the refusals # below would prove nothing. - let code = countingLoop(1, 4) report( - "a well formed loop still compiles", - (not jitSupported()) or compileRegion(code, 0, 4, Globals, Slots, @[]) != nil + "a well formed program still compiles", + (not jitSupported()) or countingLoop(1, 4).compiles ) -block: - let code = countingLoop(Globals, 4) - report( - "a global one past the end is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil, - "an out of range index would become a fixed offset store" - ) +report( + "a global one past the end is refused", + not countingLoop(Globals, 4).compiles, + "an out of range index would become a fixed offset store" +) -block: - let code = countingLoop(1_000_000, 4) - report( - "a far out of range global is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil - ) +report( + "a global far past the end is refused", + not countingLoop(1_000_000, 4).compiles +) -block: - let code = countingLoop(-1, 4) - report( - "a negative global is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil, - "a negative index would address below the globals" - ) +report("a negative global is refused", not countingLoop(-1, 4).compiles) -block: - let code = countingLoop(1, 99) - report( - "a branch past the end of the code is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil - ) +report( + "a branch past the end of the code is refused", + not countingLoop(1, 99).compiles +) -block: - let code = countingLoop(1, -5) - report( - "a negative branch target is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil - ) +report( + "a negative branch target is refused", + not countingLoop(1, -5).compiles +) + +report( + "a program with no storage behind it is refused", + not countingLoop(1, 4).compiles(globals = 0) +) block: - let code = countingLoop(1, 4) + # Nothing may hand the generator a global so far out that its offset + # would not fit the displacement it is reached through. report( - "a region reaching past the code is refused", - compileRegion(code, 0, 99, Globals, Slots, @[]) == nil + "a global whose offset would not fit is refused", + not countingLoop(high(int32) div 8, 4).compiles(globals = high(int32)) ) block: - let code = countingLoop(1, 4) - report( - "a region with no storage behind it is refused", - compileRegion(code, 0, 4, 0, Slots, @[]) == nil - ) + var code = countingLoop(1, 4) + code[2] = Instruction(op: LoadGlobalOp, a: int32(Slots), b: 1) + report("a register slot past the frame is refused", not code.compiles) + code[2] = Instruction(op: LoadGlobalOp, a: -1, b: 1) + report("a negative register slot is refused", not code.compiles) + code[2] = Instruction(op: JumpIfZeroOp, a: int32(Slots), b: 4) + report("a jump-if-zero slot past the frame is refused", not code.compiles) block: - # Dividing by zero raises in the interpreter, so it must never reach a - # divide instruction. - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: JumpUnlessGlobalModuloEqualZeroOp, a: 1, b: 0, c: 4), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] + var code = countingLoop(1, 4) + code[2] = Instruction(op: ArrayGetOp, a: 0, b: 7, c: 1) + report("an array that does not exist is refused", not code.compiles) report( - "a zero divisor is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + "an array with a negative length is refused", + not code.compiles(extents = @[ + ArrayExtent(base: 0, length: 1), ArrayExtent(base: 0, length: 1), + ArrayExtent(base: 0, length: 1), ArrayExtent(base: 0, length: 1), + ArrayExtent(base: 0, length: 1), ArrayExtent(base: 0, length: 1), + ArrayExtent(base: 0, length: 1), ArrayExtent(base: 0, length: -4) + ]) ) block: - # A block whose charge will not fit the instruction that adds it must - # leave that loop interpreted, not abandon the whole compilation. - let code = @[ - Instruction(op: MeterOp, a: 9_000_000, b: 9_000_000), - Instruction( - op: JumpUnlessGlobalLessImmediateOp, a: 1, b: 10, c: 5 - ), - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] - var raised = false - try: - discard compileRegion(code, 0, 5, Globals, Slots, @[]) - except BasicError: - raised = true - # Falling back to the per-block check is a fine outcome here. Refusing - # the whole compilation is not. - report("a charge too wide to add does not abandon compilation", not raised) - -block: - # compileLoops must survive a region it cannot finish, because the - # interpreter can run anything the generator declines. - let code = @[ - Instruction(op: MeterOp, a: 9_000_000, b: 9_000_000), - Instruction( - op: JumpUnlessGlobalLessImmediateOp, a: 1, b: 10, c: 5 - ), - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0), - Instruction(op: HaltOp) - ] - var survived = false - try: - discard compileLoops(code, Globals, Slots, @[]) - survived = true - except BasicError: - survived = false - report("compiling many loops survives one it cannot finish", survived) + var code = countingLoop(1, 4) + code[2] = Instruction(op: LoadFixedOp, a: 0, b: 3) + report("a fixed-point constant that does not exist is refused", + not code.compiles(constants = @[1'i32])) + code[2] = Instruction(op: LoadHostDataOp, a: 0, b: 2) + report("host data that does not exist is refused", + not code.compiles(hostData = 2)) + code[2] = Instruction(op: SetArgumentImmediateOp, a: 4, b: 1) + report("an argument past the staging area is refused", + not code.compiles(arguments = 4)) block: - # Nothing may hand the generator a global so far out that its offset - # would not fit the displacement it is reached through. - let code = countingLoop(high(int32) div 8, 4) + let code = countingLoop(1, 4) report( - "a global whose offset would not fit is refused", - compileRegion(code, 0, 4, high(int32), Slots, @[]) == nil + "a routine table that leaves code uncovered is refused", + not code.compiles(routines = @[ + RoutineExtent(entry: 0, length: 4, registers: Slots) + ]) ) - -block: - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: LoadGlobalOp, a: int32(Slots), b: 1), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] report( - "a register slot past the frame is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + "routines that overlap are refused", + not code.compiles(routines = @[ + RoutineExtent(entry: 0, length: 6, registers: Slots), + RoutineExtent(entry: 4, length: 2, registers: Slots) + ]) ) - -block: - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: LoadGlobalOp, a: -1, b: 1), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] report( - "a negative register slot is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + "a routine reaching past the code is refused", + not code.compiles(routines = @[ + RoutineExtent(entry: 0, length: 9, registers: Slots) + ]) ) - -block: - # An operation naming an array that does not exist must be refused. - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: ArrayGetOp, a: 0, b: 7, c: 1), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] report( - "an array that does not exist is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil + "a routine that runs on into the next one is refused", + not code.compiles(routines = @[ + RoutineExtent(entry: 0, length: 2, registers: Slots), + RoutineExtent(entry: 2, length: 4, registers: Slots) + ]) ) - -block: - # Cells reaching past what a displacement covers must be refused. - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: ArrayGetOp, a: 0, b: 0, c: 1), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] - let far = @[ArrayExtent(base: high(int32) div 4, length: 16)] report( - "an array placed out of reach is refused", - compileRegion(code, 0, 4, Globals, Slots, far) == nil + "a branch into another routine is refused", + not countingLoop(1, 4).compiles(routines = @[ + RoutineExtent(entry: 0, length: 4, registers: Slots), + RoutineExtent(entry: 4, length: 2, registers: Slots) + ]) ) block: - let code = @[ - Instruction(op: MeterOp, a: 4, b: 2), - Instruction(op: JumpIfZeroOp, a: int32(Slots), b: 4), - Instruction(op: AddGlobalImmediateOp, a: 1, b: 1), - Instruction(op: JumpOp, a: 0) - ] - report( - "a jump-if-zero slot past the frame is refused", - compileRegion(code, 0, 4, Globals, Slots, @[]) == nil - ) + var code = countingLoop(1, 4) + code[2] = Instruction(op: CallOp, a: 0) + report("a call to the main program is refused", not code.compiles) + code[2] = Instruction(op: CallOp, a: 5) + report("a call to a routine that does not exist is refused", + not code.compiles) ## Scripts, down both paths From 5433445d9565df44e37417b25f1727a1469f5ac0 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:17:45 -0700 Subject: [PATCH 28/51] fuzz resumed runs and host code writing globals --- tests/test_native.nim | 44 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/tests/test_native.nim b/tests/test_native.nim index 583ea10..c795465 100644 --- a/tests/test_native.nim +++ b/tests/test_native.nim @@ -75,7 +75,9 @@ proc observe(source: string, native: bool, limits: Limits, "the whole program should compile" inc compiled for run in 0 ..< runs: - if run > 0: + # The second run carries on from wherever the first stopped, which + # after a failure is part way through a block; the third restarts. + if run == 2: runtime.restart var events: seq[string] let print = proc(event: PrintEvent) = @@ -106,7 +108,7 @@ proc observe(source: string, native: bool, limits: Limits, handed += runtime.handedBack transcript.join("\n") -proc agree(name, source: string, limits = defaultLimits(), runs = 2) = +proc agree(name, source: string, limits = defaultLimits(), runs = 3) = ## Requires both paths to leave exactly the same trail. let plain = observe(source, false, limits, runs) let fast = observe(source, true, limits, runs) @@ -350,6 +352,40 @@ third: h = 3 """) +block: + # A host function that writes the script's own state from inside a run + # must be seen at once, by both paths, including inside a loop. + proc pokeRun(native: bool): string = + var host = makeHost() + var target: Runtime + discard host.addFunction("poke", 1, + proc(arguments: openArray[int32]): int32 = + target.setGlobal("a", arguments[0] *% 3) + target.setArray("cells", 0, arguments[0]) + 0 + ) + let program = compile(Preamble & """ +while b < 50 + b = b + 1 + a = a + b + c = c + a + cells(0) + if b mod 7 = 0 then d = poke(b) +wend +""", host) + target = initRuntime(program, host) + if native: + discard target.compileNative() + discard target.run() + $target.getGlobal("a") & " " & $target.getGlobal("c") & " " & + $target.getArray("cells", 0) + let plain = pokeRun(false) + let fast = pokeRun(true) + if plain == fast: + echo " ok host code writing globals mid-run" + else: + inc failures + echo &"FAIL host code writing globals mid-run: {plain} then {fast}" + ## Generated programs type Generator = object @@ -543,13 +579,13 @@ for seed in 1'i64 .. 1500'i64: except BasicError: continue inc tried - let plain = observe(source, false, limits, 2) + let plain = observe(source, false, limits, 3) for line in plain.splitLines: if line.startsWith("raised "): outcomes.inc(line[0 ..< min(line.len, 60)]) elif line.startsWith("stats "): outcomes.inc("finished") - let fast = observe(source, true, limits, 2) + let fast = observe(source, true, limits, 3) if plain != fast: inc disagreed if disagreed <= 2: From 18143289840c3f12b8dab3e8af10a5792f5be446 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:20:06 -0700 Subject: [PATCH 29/51] inline mixed-kind arithmetic, fixed-point compares, and exit sub --- src/bassy/jit.nim | 173 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 160 insertions(+), 13 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 6abbb46..620abca 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -443,6 +443,54 @@ when NativeArm64: e.code.compareRegister(Word32, temp(tag), temp(other)) e.jumpWhen(NotEqualCondition, slow) + proc jumpIfSame(e: var Emitter, tag, other: int, target: Label) + {.raises: [].} = + ## Jumps when two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(EqualCondition, target) + + proc whenWhole(e: var Emitter, tag: int, target: Label) {.raises: [].} = + ## Jumps when a kind says whole number. + e.jumpIfZero(temp(tag), target) + + proc unlessFixed(e: var Emitter, tag: int, slow: Label) + {.raises: [BasicError].} = + ## Takes the slow path unless a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(NotEqualCondition, slow) + + proc toFixed(e: var Emitter, value: int, slow: Label) + {.raises: [BasicError].} = + ## Turns a whole number into Q16.16 bits. One outside the fixed-point + ## range cannot be, which the interpreter refuses, so that goes slow. + let register = temp(value) + e.code.loadImmediate(Word32, temp(6), 32767) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(GreaterCondition, slow) + e.code.loadImmediate(Word32, temp(6), -32768) + e.code.compareRegister(Word32, register, temp(6)) + e.jumpWhen(LessCondition, slow) + e.code.shiftLeftImmediate(Word32, register, register, FixedShift) + + proc scaleWide(e: var Emitter, value, tag: int) {.raises: [BasicError].} = + ## Widens a number of either kind to sixty-four bits on the fixed-point + ## scale, where every whole number and every fixed-point one compare + ## exactly, as the interpreter compares them. + let register = temp(value) + let done = e.label() + e.code.signExtendWord(register, register) + e.jumpIfNotZero(temp(tag), done) + e.code.shiftLeftImmediate(Word64, register, register, FixedShift) + e.place(done) + + proc loadWide(e: var Emitter, value: int, bits: int64) {.raises: [].} = + ## Loads a sixty-four bit constant into a working register. + e.code.loadImmediate(Word64, temp(value), bits) + + proc compareWide(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two widened working registers. + e.code.compareRegister(Word64, temp(left), temp(right)) + proc whenFixed(e: var Emitter, tag: int, target: Label) {.raises: [BasicError].} = ## Jumps when a kind says fixed point. @@ -730,10 +778,12 @@ when NativeArm64: e.clearValues(RegistersBase, int(calleeRegisters)) e.copyValues(RegistersBase, ArgumentsBase, int(calleeParameters)) - proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) - {.raises: [BasicError].} = + proc leaveRoutine(e: var Emitter, parameters: int32, exitSub: bool, + slow: Label) {.raises: [BasicError].} = ## Pops a frame and jumps to wherever it said to carry on. A GOSUB - ## frame first hands the shared parameters back to the caller. + ## frame first hands the shared parameters back to the caller. Leaving + ## a sub outright only goes this way when its own frame is on top. + ## Nothing is written until both refusals have been passed. let depth = temp(0) let frame = temp(1) let base = temp(2) @@ -741,8 +791,11 @@ when NativeArm64: e.code.loadWord(depth, Context, ContextDepth) e.jumpIfZero(depth, slow) e.code.subtractImmediate(Word32, depth, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) e.code.addRegister(Word64, frame, FramesBase, depth, 4) + if exitSub: + e.code.loadByte(temp(4), frame, FrameTag) + e.jumpIfNotZero(temp(4), slow) + e.code.storeWord(depth, Context, ContextDepth) e.code.loadWord(base, frame, FrameBase) if parameters > 0: let plain = e.label() @@ -1009,6 +1062,53 @@ elif NativeAmd64: e.code.compareRegister(Word32, temp(tag), temp(other)) e.jumpWhen(NotEqualCondition, slow) + proc jumpIfSame(e: var Emitter, tag, other: int, target: Label) + {.raises: [].} = + ## Jumps when two kinds agree. + e.code.compareRegister(Word32, temp(tag), temp(other)) + e.jumpWhen(EqualCondition, target) + + proc whenWhole(e: var Emitter, tag: int, target: Label) {.raises: [].} = + ## Jumps when a kind says whole number. + e.code.testRegister(Word32, temp(tag), temp(tag)) + e.jumpWhen(EqualCondition, target) + + proc unlessFixed(e: var Emitter, tag: int, slow: Label) {.raises: [].} = + ## Takes the slow path unless a kind says fixed point. + e.code.compareImmediate(Word32, temp(tag), FixedTag) + e.jumpWhen(NotEqualCondition, slow) + + proc toFixed(e: var Emitter, value: int, slow: Label) + {.raises: [BasicError].} = + ## Turns a whole number into Q16.16 bits. One outside the fixed-point + ## range cannot be, which the interpreter refuses, so that goes slow. + let register = temp(value) + e.code.compareImmediate(Word32, register, 32767) + e.jumpWhen(GreaterCondition, slow) + e.code.compareImmediate(Word32, register, -32768) + e.jumpWhen(LessCondition, slow) + e.code.shiftLeftImmediate(Word32, register, FixedShift) + + proc scaleWide(e: var Emitter, value, tag: int) {.raises: [BasicError].} = + ## Widens a number of either kind to sixty-four bits on the fixed-point + ## scale, where every whole number and every fixed-point one compare + ## exactly, as the interpreter compares them. + let register = temp(value) + let done = e.label() + e.code.signExtendDouble(register, register) + e.code.testRegister(Word32, temp(tag), temp(tag)) + e.jumpWhen(NotEqualCondition, done) + e.code.shiftLeftImmediate(Word64, register, FixedShift) + e.place(done) + + proc loadWide(e: var Emitter, value: int, bits: int64) {.raises: [].} = + ## Loads a sixty-four bit constant into a working register. + e.code.loadImmediate(Word64, temp(value), bits) + + proc compareWide(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two widened working registers. + e.code.compareRegister(Word64, temp(left), temp(right)) + proc whenFixed(e: var Emitter, tag: int, target: Label) {.raises: [].} = ## Jumps when a kind says fixed point. @@ -1307,10 +1407,12 @@ elif NativeAmd64: e.contextField(Cell, ContextArguments) e.copyValues(RegistersBase, Cell, int(calleeParameters)) - proc leaveRoutine(e: var Emitter, parameters: int32, slow: Label) - {.raises: [BasicError].} = + proc leaveRoutine(e: var Emitter, parameters: int32, exitSub: bool, + slow: Label) {.raises: [BasicError].} = ## Pops a frame and jumps to wherever it said to carry on. A GOSUB - ## frame first hands the shared parameters back to the caller. + ## frame first hands the shared parameters back to the caller. Leaving + ## a sub outright only goes this way when its own frame is on top. + ## Nothing is written until both refusals have been passed. let depth = rax let frame = rcx let base = rsi @@ -1318,11 +1420,15 @@ elif NativeAmd64: e.code.testRegister(Word32, depth, depth) e.jumpWhen(EqualCondition, slow) e.code.subtractImmediate(Word32, depth, 1) - e.code.storeWord(depth, Context, ContextDepth) e.contextField(frame, ContextFrames) e.code.moveRegister(Word32, r8, depth) e.code.shiftLeftImmediate(Word64, r8, 4) e.code.addRegister(Word64, frame, r8) + if exitSub: + e.code.loadByteZeroed(Spare, frame, FrameTag) + e.code.testRegister(Word32, Spare, Spare) + e.jumpWhen(NotEqualCondition, slow) + e.code.storeWord(depth, Context, ContextDepth) e.code.loadWord(base, frame, FrameBase) if parameters > 0: let plain = e.label() @@ -1549,10 +1655,24 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let slow = slowFor(ToNext) e.readValue(0, 2, slot(item.b)) e.readValue(1, 3, slot(item.c)) - e.unlessSame(2, 3, slow) when ModelsFixed: + # A whole number beside a fixed-point one becomes fixed point + # first, exactly as the interpreter promotes it, and the answer + # is fixed point. + let ready = e.label() + let promoteRight = e.label() e.unlessNumeric(2, slow) + e.unlessNumeric(3, slow) + e.jumpIfSame(2, 3, ready) + e.whenFixed(2, promoteRight) + e.toFixed(0, slow) + e.loadConstant(2, FixedTag) + e.jump(ready) + e.place(promoteRight) + e.toFixed(1, slow) + e.place(ready) else: + e.unlessSame(2, 3, slow) e.unlessWhole(2, slow) case item.op of AddOp: @@ -1613,9 +1733,20 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let slow = slowFor(ToNext) e.readValue(0, 2, slot(item.b)) e.readValue(1, 3, slot(item.c)) - e.unlessSame(2, 3, slow) e.unlessNumeric(2, slow) + e.unlessNumeric(3, slow) + let sameKind = e.label() + let decided = e.label() + e.jumpIfSame(2, 3, sameKind) + # Kinds that differ compare on the widened fixed-point scale, + # where every value of either kind has an exact place. + e.scaleWide(0, 2) + e.scaleWide(1, 3) + e.compareWide(0, 1) + e.jump(decided) + e.place(sameKind) e.compare(0, 1) + e.place(decided) e.answer(0, comparisonCheck(item.op)) e.writeWhole(slot(item.a), 0) of AndOp, OrOp, XorOp, EqvOp, ImpOp: @@ -1661,9 +1792,19 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: let slow = slowFor(ToOffset) + let whole = e.label() + let decided = e.label() e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) + e.whenWhole(2, whole) + # A fixed-point global meets the constant on the widened scale. + e.unlessFixed(2, slow) + e.scaleWide(0, 2) + e.loadWide(1, int64(item.b) * 65536) + e.compareWide(0, 1) + e.jump(decided) + e.place(whole) e.compareConstant(0, item.b) + e.place(decided) e.jumpOn(takenOn(item.op), blocks[int(item.c)]) of JumpUnlessGlobalModuloEqualZeroOp: if item.b == 0: @@ -1704,12 +1845,18 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], fallsThrough = false of ReturnOp: let owner = routines[int(ownerOf[index])] - e.leaveRoutine(owner.parameters, slowFor(ToOffset)) + e.leaveRoutine(owner.parameters, false, slowFor(ToOffset)) + fallsThrough = false + of ExitSubOp: + # With the sub's own frame on top there are no GOSUB frames to + # unwind first, so leaving is a plain return. Anything else is + # left to the interpreter's code, which unwinds them. + e.leaveRoutine(0, true, slowFor(ToOffset)) fallsThrough = false of HaltOp: e.halt(offset) fallsThrough = false - of ReturnLabelOp, ExitSubOp: + of ReturnLabelOp: runSlow() e.jump(dispatchLabel) fallsThrough = false From c87888571c01f6976daf06582f5311daeee709e7 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:25:24 -0700 Subject: [PATCH 30/51] keep a loop's globals in registers while it runs --- src/bassy/jit.nim | 623 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 525 insertions(+), 98 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 620abca..7c3354f 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -882,6 +882,99 @@ when NativeArm64: true) e.code.returnToCaller() + ## Globals held in registers + ## + ## Inside a specialised loop its globals live in the registers below, + ## proved whole numbers on the way in. Nothing in such a loop calls out, + ## so registers the convention lets a callee clobber are safe to use. + + const Hoisting* = [x0, x1, x2, x3, x4, x5, x6, x7] + + proc hoisted(slot: int): Register {.inline, raises: [].} = + ## Returns the register holding one hoisted global. + Hoisting[slot] + + proc globalAt(e: var Emitter, index: int32): (Register, int) + {.raises: [BasicError].} = + ## Returns a base register and byte offset for one global. + e.reach(global(index)) + + proc guardHoisted(e: var Emitter, index: int32, failed: Label) + {.raises: [BasicError].} = + ## Leaves for the general code unless a global holds a whole number. + let (base, offset) = e.globalAt(index) + e.code.loadByte(temp(0), base, offset) + e.jumpIfNotZero(temp(0), failed) + + proc loadHoisted(e: var Emitter, slot: int, index: int32) + {.raises: [BasicError].} = + ## Reads one global's payload into its register. + let (base, offset) = e.globalAt(index) + e.code.loadWord(hoisted(slot), base, offset + ValuePayload) + + proc storeHoisted(e: var Emitter, slot: int, index: int32) + {.raises: [BasicError].} = + ## Publishes one register back as a whole number. + let (base, offset) = e.globalAt(index) + e.code.storeByte(zeroRegister, base, offset) + e.code.storeWord(hoisted(slot), base, offset + ValuePayload) + + proc beginHoisting(e: var Emitter) {.raises: [].} = + ## The globals stay addressable throughout, so nothing to prepare. + discard + + proc endHoisting(e: var Emitter) {.raises: [].} = + ## Nothing borrowed, so nothing to give back. + discard + + proc setHoisted(e: var Emitter, slot: int, bits: int32) {.raises: [].} = + ## Loads a constant into a hoisted global. + e.code.loadImmediate(Word32, hoisted(slot), int64(bits)) + + proc copyHoisted(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Copies one hoisted global into another. + e.code.moveRegister(Word32, hoisted(destination), hoisted(source)) + + proc addHoisted(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Adds one hoisted global into another, wrapping. + e.code.addRegister(Word32, hoisted(destination), hoisted(destination), + hoisted(source)) + + proc addHoistedConstant(e: var Emitter, slot: int, bits: int32) + {.raises: [BasicError].} = + ## Adds a constant to a hoisted global, wrapping. + let target = hoisted(slot) + if bits >= 0 and bits <= 4095: + e.code.addImmediate(Word32, target, target, int(bits)) + elif bits < 0 and bits >= -4095: + e.code.subtractImmediate(Word32, target, target, int(-bits)) + else: + e.code.loadImmediate(Word32, temp(6), int64(bits)) + e.code.addRegister(Word32, target, target, temp(6)) + + proc hoistedToTemp(e: var Emitter, value, slot: int) {.raises: [].} = + ## Copies a hoisted global into a working register. + e.code.moveRegister(Word32, temp(value), hoisted(slot)) + + proc tempToHoisted(e: var Emitter, slot, value: int) {.raises: [].} = + ## Copies a working register into a hoisted global. + e.code.moveRegister(Word32, hoisted(slot), temp(value)) + + proc addTempToHoisted(e: var Emitter, slot, value: int) {.raises: [].} = + ## Adds a working register into a hoisted global, wrapping. + e.code.addRegister(Word32, hoisted(slot), hoisted(slot), temp(value)) + + proc compareHoisted(e: var Emitter, slot: int, bits: int32) + {.raises: [BasicError].} = + ## Sets flags from a hoisted global against a constant. + if bits >= 0 and bits <= 4095: + e.code.compareImmediate(Word32, hoisted(slot), int(bits)) + else: + e.code.loadImmediate(Word32, temp(6), int64(bits)) + e.code.compareRegister(Word32, hoisted(slot), temp(6)) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -905,7 +998,7 @@ elif NativeAmd64: ## x86-64 code generation ## ## r15 context r12 instruction budget r13 work budget - ## rbx globals rbp current frame r14 array cells + ## rbx globals rbp current frame ## rax rcx rsi rdi r8 r9 r10 working registers; r11 one cell; ## rdx the divide's high half and a spare ## @@ -920,7 +1013,6 @@ elif NativeAmd64: Work = r13 GlobalsBase = rbx RegistersBase = rbp - MemoryBase = r14 Temps = [rax, rcx, rsi, rdi, r8, r9, r10] Cell = r11 Spare = rdx @@ -1288,12 +1380,15 @@ elif NativeAmd64: slow: Label) {.raises: [BasicError].} = ## Bounds checks an index and leaves the cell's address in Cell. One ## unsigned comparison covers both ends, as the interpreter's does. + ## The cells' base is read from the context rather than kept in a + ## register, which leaves one more register for a loop's globals. The + ## index register is left scaled. e.code.compareImmediate(Word32, temp(index), extent.length) e.jumpWhen(AboveEqualCondition, slow) - e.code.moveRegister(Word32, Cell, temp(index)) - e.code.addImmediate(Word32, Cell, extent.base) - e.code.shiftLeftImmediate(Word64, Cell, 4) - e.code.addRegister(Word64, Cell, MemoryBase) + e.code.addImmediate(Word32, temp(index), extent.base) + e.code.shiftLeftImmediate(Word64, temp(index), 4) + e.contextField(Cell, ContextMemory) + e.code.addRegister(Word64, Cell, temp(index)) proc meter(e: var Emitter, instructions, work: int32, slow: Label) {.raises: [].} = @@ -1503,7 +1598,6 @@ elif NativeAmd64: e.code.loadDouble(GlobalsBase, Context, 0) e.code.loadDouble(Instructions, Context, ContextInstructions) e.code.loadDouble(Work, Context, ContextWork) - e.contextField(MemoryBase, ContextMemory) e.code.loadWord(rcx, Context, ContextBase) e.slotAddress(RegistersBase, rcx) @@ -1516,6 +1610,84 @@ elif NativeAmd64: e.code.pop(Saved[index]) e.code.returnToCaller() + ## Globals held in registers + ## + ## Inside a specialised loop its globals live in the registers below, + ## proved whole numbers on the way in. The globals' own base register is + ## one of them, so the globals are reached through Cell while a loop + ## runs, and the base is read back from the context on the way out. + + const Hoisting* = [r8, r14, rbx] + + proc hoisted(slot: int): Register {.inline, raises: [].} = + ## Returns the register holding one hoisted global. + Hoisting[slot] + + proc beginHoisting(e: var Emitter) {.raises: [BasicError].} = + ## Reaches the globals through Cell, which no hoisted value occupies. + e.contextField(Cell, 0) + + proc endHoisting(e: var Emitter) {.raises: [BasicError].} = + ## Puts the globals' base back where the general code expects it. + e.contextField(GlobalsBase, 0) + + proc guardHoisted(e: var Emitter, index: int32, failed: Label) + {.raises: [BasicError].} = + ## Leaves for the general code unless a global holds a whole number. + let offset = int(index) * ValueStride + e.code.loadByteZeroed(temp(0), Cell, offset) + e.code.testRegister(Word32, temp(0), temp(0)) + e.jumpWhen(NotEqualCondition, failed) + + proc loadHoisted(e: var Emitter, slot: int, index: int32) + {.raises: [BasicError].} = + ## Reads one global's payload into its register. + e.code.loadWord(hoisted(slot), Cell, + int(index) * ValueStride + ValuePayload) + + proc storeHoisted(e: var Emitter, slot: int, index: int32) + {.raises: [BasicError].} = + ## Publishes one register back as a whole number. + let offset = int(index) * ValueStride + e.code.storeByteImmediate(Cell, offset, 0) + e.code.storeWord(hoisted(slot), Cell, offset + ValuePayload) + + proc setHoisted(e: var Emitter, slot: int, bits: int32) {.raises: [].} = + ## Loads a constant into a hoisted global. + e.code.loadImmediate(Word32, hoisted(slot), int64(bits)) + + proc copyHoisted(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Copies one hoisted global into another. + e.code.moveRegister(Word32, hoisted(destination), hoisted(source)) + + proc addHoisted(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Adds one hoisted global into another, wrapping. + e.code.addRegister(Word32, hoisted(destination), hoisted(source)) + + proc addHoistedConstant(e: var Emitter, slot: int, bits: int32) + {.raises: [].} = + ## Adds a constant to a hoisted global, wrapping. + e.code.addImmediate(Word32, hoisted(slot), bits) + + proc hoistedToTemp(e: var Emitter, value, slot: int) {.raises: [].} = + ## Copies a hoisted global into a working register. + e.code.moveRegister(Word32, temp(value), hoisted(slot)) + + proc tempToHoisted(e: var Emitter, slot, value: int) {.raises: [].} = + ## Copies a working register into a hoisted global. + e.code.moveRegister(Word32, hoisted(slot), temp(value)) + + proc addTempToHoisted(e: var Emitter, slot, value: int) {.raises: [].} = + ## Adds a working register into a hoisted global, wrapping. + e.code.addRegister(Word32, hoisted(slot), temp(value)) + + proc compareHoisted(e: var Emitter, slot: int, bits: int32) + {.raises: [].} = + ## Sets flags from a hoisted global against a constant. + e.code.compareImmediate(Word32, hoisted(slot), bits) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -1537,43 +1709,192 @@ proc invoke*(machine: Machine, context: var NativeContext): NativeStatus ## Runs the compiled program from the offset the context names. NativeStatus(machine.call(context.addr)) +type + Loop = object + ## A loop whose globals can live in registers while it runs. + start: int + stop: int + globals: seq[int32] + +proc loopGlobals(item: Instruction, globals: var seq[int32]) + {.raises: [].} = + ## Records every global one operation reads or writes. + template note(index: int32) = + if index notin globals: + globals.add(index) + case item.op + of StoreGlobalImmediateOp, AddGlobalImmediateOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp, + AddGlobalHostDataOp, AddGlobalRegisterOp, StoreGlobalOp: + note(item.a) + of MoveGlobalOp, AddGlobalOp, ModuloGlobalImmediateOp: + note(item.a) + note(item.b) + of LoadGlobalOp: + note(item.b) + of AddGlobalArrayGlobalIndexOp: + note(item.a) + note(item.c) + of ArrayAddGlobalsOp: + note(item.b) + note(item.c) + else: + discard + +proc fitsLoop(item: Instruction): bool {.raises: [].} = + ## Reports whether an operation can run with its globals in registers. + ## Nothing that calls out may, since the interpreter's code would find + ## the globals' memory stale, and nothing that leaves the loop's code by + ## any way but a branch may either. + case item.op + of MeterOp, LoadImmediateOp, LoadFixedOp, MoveOp, LoadGlobalOp, + LoadHostDataOp, StoreGlobalOp, StoreGlobalImmediateOp, MoveGlobalOp, + AddGlobalImmediateOp, AddGlobalOp, AddGlobalHostDataOp, + AddGlobalRegisterOp, AddGlobalArrayGlobalIndexOp, ArrayAddGlobalsOp, + AddOp, SubtractOp, MultiplyOp, IntegerDivideOp, ModuloOp, NegateOp, + EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, GreaterEqualOp, + AndOp, OrOp, XorOp, EqvOp, ImpOp, NotOp, JumpOp, JumpIfZeroOp, + JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + ArrayGetOp, ArraySetOp: + true + of DivideOp: + ModelsFixed + of ModuloGlobalImmediateOp: + item.c != 0 + of JumpUnlessGlobalModuloEqualZeroOp: + item.b != 0 + else: + false + +proc findLoops(code: seq[Instruction], capacity: int): seq[Loop] + {.raises: [].} = + ## Picks the loops worth specialising: each closed by a jump back to its + ## head, made only of operations that fit, and touching no more globals + ## than there are registers. Outer loops are tried first, and a loop + ## inside one already taken runs inside that one's registers. + var candidates: seq[(int, int)] + for index, item in code: + if item.op == JumpOp and int(item.a) <= index and item.a >= 0: + candidates.add((int(item.a), index + 1)) + var covered = newSeq[bool](code.len) + while candidates.len > 0: + var widest = 0 + for position in 1 ..< candidates.len: + let (start, stop) = candidates[position] + if stop - start > candidates[widest][1] - candidates[widest][0]: + widest = position + let (start, stop) = candidates[widest] + candidates.delete(widest) + var fits = true + var globals: seq[int32] + for index in start ..< stop: + if covered[index] or not code[index].fitsLoop: + fits = false + break + code[index].loopGlobals(globals) + if not fits or globals.len == 0 or globals.len > capacity: + continue + for index in start ..< stop: + covered[index] = true + result.add(Loop(start: start, stop: stop, globals: globals)) + proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], limits: CallLimits, far: bool): (seq[byte], seq[int]) {.raises: [BasicError].} = ## Emits the whole program and returns its bytes along with where each ## offset's block starts. + ## + ## Every offset has general code, which keeps every value in memory and + ## so can hand any instruction to the interpreter's code. A loop that + ## fits also gets a second, specialised copy that keeps its globals in + ## registers. Entering the loop's head checks they hold whole numbers + ## and moves into the copy; anything the copy does not expect writes the + ## registers back and carries on in the general code of that very + ## instruction, which does it the ordinary way. when not (NativeArm64 or NativeAmd64): raise newException(BasicError, "BASIC has no whole-program backend here") else: var e = Emitter(far: far) var blocks = newSeq[Label](code.len + 1) + var general = newSeq[Label](code.len + 1) for index in 0 .. code.len: blocks[index] = e.label() + general[index] = blocks[index] let dispatchLabel = e.label() let slowLabel = e.label() let failedLabel = e.label() + let loops = findLoops(code, Hoisting.len) + var loopAt = newSeq[int](code.len) + for index in 0 ..< code.len: + loopAt[index] = -1 + for number, loop in loops: + loopAt[loop.start] = number + general[loop.start] = e.label() + e.prologue() e.jump(dispatchLabel) var stubs: seq[Stub] - for index in 0 ..< code.len: - let item = code[index] - let offset = int32(index) - e.place(blocks[index]) + + template emitInstruction(at: int, specialised: static bool, + loop: Loop, inside: seq[Label], exits: var seq[(Label, int, bool)]) = + ## Emits one instruction, in general code or inside a loop's copy. + let item = code[at] + let offset = int32(at) template slowFor(after: Branching): Label = - ## Names a slow path for this instruction, emitted after the block. - let stub = Stub(label: e.label(), offset: offset, carry: after) - stubs.add(stub) - stub.label + ## Names where this instruction goes when it cannot run inline. + when specialised: + leaveFor(at, true) + else: + let stub = Stub(label: e.label(), offset: offset, carry: after) + stubs.add(stub) + stub.label + + template leaveFor(target: int, again: bool): Label = + ## Names a stub that writes the loop's registers back and goes on + ## in general code: the same instruction again, or a branch target. + var found = -1 + for position, exit in exits: + if exit[1] == target and exit[2] == again: + found = position + if found < 0: + exits.add((e.label(), target, again)) + found = exits.len - 1 + exits[found][0] + + template toBlock(target: int32): Label = + ## Names where a branch to an offset lands. + when specialised: + if int(target) >= loop.start and int(target) < loop.stop: + inside[int(target) - loop.start] + else: + leaveFor(int(target), false) + else: + blocks[int(target)] + + template held(which: int32): int {.used.} = + ## Returns which register holds a global inside this loop. + loop.globals.find(which) template runSlow() = ## Runs this instruction through the interpreter's code in line. e.callSlow(offset, slowLabel) - var fallsThrough = true + var fallsThrough {.used.} = true case item.op of MeterOp: e.meter(item.b, item.a, slowFor(ToNext)) @@ -1584,15 +1905,31 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], of MoveOp: e.copyValue(slot(item.a), slot(item.b)) of LoadGlobalOp: - e.copyValue(slot(item.a), global(item.b)) + when specialised: + e.hoistedToTemp(0, held(item.b)) + e.writeWhole(slot(item.a), 0) + else: + e.copyValue(slot(item.a), global(item.b)) of LoadHostDataOp: e.copyValue(slot(item.a), host(item.b)) of StoreGlobalOp: - e.copyValue(global(item.a), slot(item.b)) + when specialised: + let slow = slowFor(ToNext) + e.readValue(0, 2, slot(item.b)) + e.unlessWhole(2, slow) + e.tempToHoisted(held(item.a), 0) + else: + e.copyValue(global(item.a), slot(item.b)) of StoreGlobalImmediateOp: - e.writeConstant(global(item.a), 0, item.b) + when specialised: + e.setHoisted(held(item.a), item.b) + else: + e.writeConstant(global(item.a), 0, item.b) of MoveGlobalOp: - e.copyValue(global(item.a), global(item.b)) + when specialised: + e.copyHoisted(held(item.a), held(item.b)) + else: + e.copyValue(global(item.a), global(item.b)) of SetArgumentOp: e.copyValue(argument(item.a), slot(item.b)) of SetArgumentImmediateOp: @@ -1600,55 +1937,90 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], of SetArgumentGlobalOp: e.copyValue(argument(item.a), global(item.b)) of AddGlobalImmediateOp: - let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.loadConstant(1, item.b) - e.add(0, 1) - e.writeWhole(global(item.a), 0) + when specialised: + e.addHoistedConstant(held(item.a), item.b) + else: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.b) + e.add(0, 1) + e.writeWhole(global(item.a), 0) of AddGlobalOp, AddGlobalHostDataOp, AddGlobalRegisterOp: - let slow = slowFor(ToNext) - let source = - case item.op - of AddGlobalOp: global(item.b) - of AddGlobalHostDataOp: host(item.b) - else: slot(item.b) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.readValue(1, 3, source) - e.unlessWhole(3, slow) - e.add(0, 1) - e.writeWhole(global(item.a), 0) - of ModuloGlobalImmediateOp: - if item.c == 0: - runSlow() + when specialised: + if item.op == AddGlobalOp: + e.addHoisted(held(item.a), held(item.b)) + else: + let slow = slowFor(ToNext) + let source = + if item.op == AddGlobalHostDataOp: host(item.b) + else: slot(item.b) + e.readValue(1, 3, source) + e.unlessWhole(3, slow) + e.addTempToHoisted(held(item.a), 1) else: let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.b)) + let source = + case item.op + of AddGlobalOp: global(item.b) + of AddGlobalHostDataOp: host(item.b) + else: slot(item.b) + e.readValue(0, 2, global(item.a)) e.unlessWhole(2, slow) - e.loadConstant(1, item.c) - e.remainder(0, 1) + e.readValue(1, 3, source) + e.unlessWhole(3, slow) + e.add(0, 1) e.writeWhole(global(item.a), 0) + of ModuloGlobalImmediateOp: + if item.c == 0: + runSlow() + else: + when specialised: + e.hoistedToTemp(0, held(item.b)) + e.loadConstant(1, item.c) + e.remainder(0, 1) + e.tempToHoisted(held(item.a), 0) + else: + let slow = slowFor(ToNext) + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.loadConstant(1, item.c) + e.remainder(0, 1) + e.writeWhole(global(item.a), 0) of AddGlobalArrayGlobalIndexOp: let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.c)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.b)], slow) - e.readValue(1, 3, cell()) - e.unlessWhole(3, slow) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) - e.add(0, 1) - e.writeWhole(global(item.a), 0) + when specialised: + e.hoistedToTemp(0, held(item.c)) + e.cellAddress(0, extents[int(item.b)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.addTempToHoisted(held(item.a), 1) + else: + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.b)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) + e.add(0, 1) + e.writeWhole(global(item.a), 0) of ArrayAddGlobalsOp: let slow = slowFor(ToNext) - e.readValue(0, 2, global(item.b)) - e.unlessWhole(2, slow) - e.cellAddress(0, extents[int(item.a)], slow) - e.readValue(1, 3, cell()) - e.unlessWhole(3, slow) - e.readValue(0, 2, global(item.c)) - e.unlessWhole(2, slow) + when specialised: + e.hoistedToTemp(0, held(item.b)) + e.cellAddress(0, extents[int(item.a)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.hoistedToTemp(0, held(item.c)) + else: + e.readValue(0, 2, global(item.b)) + e.unlessWhole(2, slow) + e.cellAddress(0, extents[int(item.a)], slow) + e.readValue(1, 3, cell()) + e.unlessWhole(3, slow) + e.readValue(0, 2, global(item.c)) + e.unlessWhole(2, slow) e.add(1, 0) e.writeWhole(cell(), 1) of AddOp, SubtractOp, MultiplyOp: @@ -1776,7 +2148,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.bitNot(0) e.writeWhole(slot(item.a), 0) of JumpOp: - e.jump(blocks[int(item.a)]) + e.jump(toBlock(item.a)) fallsThrough = false of JumpIfZeroOp: # A fixed-point zero is all zero bits too, so either kind tests @@ -1784,40 +2156,46 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let slow = slowFor(ToOffset) e.readValue(0, 2, slot(item.a)) e.unlessNumeric(2, slow) - e.jumpIfZeroValue(0, blocks[int(item.b)]) + e.jumpIfZeroValue(0, toBlock(item.b)) of JumpUnlessGlobalEqualImmediateOp, JumpUnlessGlobalNotEqualImmediateOp, JumpUnlessGlobalLessImmediateOp, JumpUnlessGlobalLessEqualImmediateOp, JumpUnlessGlobalGreaterImmediateOp, JumpUnlessGlobalGreaterEqualImmediateOp: - let slow = slowFor(ToOffset) - let whole = e.label() - let decided = e.label() - e.readValue(0, 2, global(item.a)) - e.whenWhole(2, whole) - # A fixed-point global meets the constant on the widened scale. - e.unlessFixed(2, slow) - e.scaleWide(0, 2) - e.loadWide(1, int64(item.b) * 65536) - e.compareWide(0, 1) - e.jump(decided) - e.place(whole) - e.compareConstant(0, item.b) - e.place(decided) - e.jumpOn(takenOn(item.op), blocks[int(item.c)]) + when specialised: + e.compareHoisted(held(item.a), item.b) + else: + let slow = slowFor(ToOffset) + let whole = e.label() + let decided = e.label() + e.readValue(0, 2, global(item.a)) + e.whenWhole(2, whole) + # A fixed-point global meets the constant on the widened scale. + e.unlessFixed(2, slow) + e.scaleWide(0, 2) + e.loadWide(1, int64(item.b) * 65536) + e.compareWide(0, 1) + e.jump(decided) + e.place(whole) + e.compareConstant(0, item.b) + e.place(decided) + e.jumpOn(takenOn(item.op), toBlock(item.c)) of JumpUnlessGlobalModuloEqualZeroOp: if item.b == 0: runSlow() e.jump(dispatchLabel) fallsThrough = false else: - let slow = slowFor(ToOffset) - e.readValue(0, 2, global(item.a)) - e.unlessWhole(2, slow) + when specialised: + e.hoistedToTemp(0, held(item.a)) + else: + let slow = slowFor(ToOffset) + e.readValue(0, 2, global(item.a)) + e.unlessWhole(2, slow) e.loadConstant(1, item.b) e.remainder(0, 1) - e.jumpIfNotZeroValue(0, blocks[int(item.c)]) + e.jumpIfNotZeroValue(0, toBlock(item.c)) of ArrayGetOp: let slow = slowFor(ToNext) e.readValue(0, 2, slot(item.c)) @@ -1831,7 +2209,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.cellAddress(0, extents[int(item.a)], slow) e.copyValue(cell(), slot(item.c)) of CallOp, GosubOp: - let owner = routines[int(ownerOf[index])] + let owner = routines[int(ownerOf[at])] let slow = slowFor(ToOffset) if item.op == CallOp: let callee = routines[int(item.a)] @@ -1839,12 +2217,12 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], owner.registers, offset + 1, limits, slow) e.jump(blocks[int(callee.entry)]) else: - e.enterRoutine(true, ownerOf[index], owner.registers, 0, + e.enterRoutine(true, ownerOf[at], owner.registers, 0, owner.registers, offset + 1, limits, slow) e.jump(blocks[int(item.a)]) fallsThrough = false of ReturnOp: - let owner = routines[int(ownerOf[index])] + let owner = routines[int(ownerOf[at])] e.leaveRoutine(owner.parameters, false, slowFor(ToOffset)) fallsThrough = false of ExitSubOp: @@ -1864,20 +2242,69 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], PrintNewlineOp: runSlow() - # Slow paths go after the block, out of the way of the fast ones. - let blockEnds = index + 1 == code.len or code[index + 1].op == MeterOp - if blockEnds and stubs.len > 0: - if fallsThrough: - e.jump(blocks[index + 1]) - for stub in stubs: - e.place(stub.label) - e.callSlow(stub.offset, slowLabel) - case stub.carry - of ToNext: - e.jump(blocks[int(stub.offset) + 1]) - of ToOffset: - e.jump(dispatchLabel) - stubs.setLen(0) + when not specialised: + # Slow paths go after the block, out of the way of the fast ones. + let blockEnds = at + 1 == code.len or + code[at + 1].op == MeterOp + if blockEnds and stubs.len > 0: + if fallsThrough: + e.jump(blocks[at + 1]) + for stub in stubs: + e.place(stub.label) + e.callSlow(stub.offset, slowLabel) + case stub.carry + of ToNext: + e.jump(blocks[int(stub.offset) + 1]) + of ToOffset: + e.jump(dispatchLabel) + stubs.setLen(0) + + var noExits: seq[(Label, int, bool)] + var loopCopies: seq[seq[Label]] + for loop in loops: + var inside: seq[Label] + for index in loop.start ..< loop.stop: + inside.add(e.label()) + loopCopies.add(inside) + + for index in 0 ..< code.len: + e.place(blocks[index]) + let number = loopAt[index] + if number >= 0: + # Entering a loop's head: prove its globals whole numbers and move + # them into registers, or run it in general code if any is not. + # Every tag is looked at before any register is filled, because + # one of those registers may be what general code reads through. + let loop = loops[number] + e.beginHoisting() + for which in loop.globals: + e.guardHoisted(which, general[loop.start]) + for position, which in loop.globals: + e.loadHoisted(position, which) + e.jump(loopCopies[number][0]) + e.place(general[index]) + emitInstruction(index, false, Loop(), @[], noExits) + + # Each loop's own copy, after all the general code. + for number, loop in loops: + var exits: seq[(Label, int, bool)] + let inside = loopCopies[number] + for index in loop.start ..< loop.stop: + e.place(inside[index - loop.start]) + emitInstruction(index, true, loop, inside, exits) + # Leaving writes every register back, then carries on in general + # code: at a branch target, or at the instruction that could not be + # done here, which general code then does the ordinary way. + for (stub, target, again) in exits: + e.place(stub) + e.beginHoisting() + for position, which in loop.globals: + e.storeHoisted(position, which) + e.endHoisting() + if again: + e.jump(general[target]) + else: + e.jump(blocks[target]) # One past the end holds nothing to run. The interpreter's code is # left to refuse it the way it would. From c7e4511b16d5d3c2934f02a25349e2ec6766b26e Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:27:57 -0700 Subject: [PATCH 31/51] look at the budget once a pass inside a specialised loop, test low bits for powers of two --- src/bassy/amd64.nim | 8 +++ src/bassy/jit.nim | 142 +++++++++++++++++++++++++++++++++++-------- tests/test_amd64.nim | 4 ++ 3 files changed, 128 insertions(+), 26 deletions(-) diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index 8c7d734..fa0f830 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -458,3 +458,11 @@ proc jumpRegister*(assembler: var Assembler, target: Register) assembler.prefix(Word32, Register(4), target) assembler.emit(0xFF) assembler.directOperand(Register(4), target) + +proc testImmediate*(assembler: var Assembler, width: Width, + target: Register, value: int32) {.raises: [].} = + ## Sets flags from a register masked by a constant, keeping neither. + assembler.prefix(width, Register(0), target) + assembler.emit(0xF7) + assembler.directOperand(Register(0), target) + assembler.emitDouble(value) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 7c3354f..045354f 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -255,6 +255,18 @@ proc takenOn(op: Op): Check {.raises: [].} = of JumpUnlessGlobalGreaterImmediateOp: LessEqualCheck else: LessCheck +proc lowBitCount(divisor: int32): int {.raises: [].} = + ## Returns how many low bits decide divisibility when the divisor is a + ## power of two, or of its negation, and zero otherwise. Truncating + ## division leaves nothing over exactly when those bits are clear, for + ## negative dividends as well, so a bit test can stand in for a divide. + var magnitude = abs(int64(divisor)) + if magnitude < 2 or (magnitude and (magnitude - 1)) != 0: + return 0 + while magnitude > 1: + magnitude = magnitude shr 1 + inc result + when NativeArm64: ## AArch64 code generation ## @@ -642,6 +654,12 @@ when NativeArm64: ## Jumps when a working register holds anything but zero. e.jumpIfNotZero(temp(value), target) + proc jumpIfLowBits(e: var Emitter, value, bits: int, target: Label) + {.raises: [BasicError].} = + ## Jumps when any of a working register's lowest bits is set. + e.code.testLowBits(Word32, temp(value), bits) + e.jumpWhen(NotEqualCondition, target) + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, slow: Label) {.raises: [].} = ## Bounds checks an index and leaves the cell's address in Cell. One @@ -654,31 +672,37 @@ when NativeArm64: e.code.addRegister(Word32, temp(6), temp(6), position) e.code.addRegister(Word64, Cell, MemoryBase, temp(6), 4) - proc meter(e: var Emitter, instructions, work: int32, slow: Label) + proc charge(e: var Emitter, instructions, work: int32) {.raises: [BasicError].} = - ## Checks both budgets before charging either, as the interpreter does. - if instructions <= 4095: - e.code.compareImmediate(Word64, Instructions, int(instructions)) - else: - e.code.loadImmediate(Word64, temp(5), int64(instructions)) - e.code.compareRegister(Word64, Instructions, temp(5)) - e.jumpWhen(LessCondition, slow) - if work <= 4095: - e.code.compareImmediate(Word64, Work, int(work)) - else: - e.code.loadImmediate(Word64, temp(6), int64(work)) - e.code.compareRegister(Word64, Work, temp(6)) - e.jumpWhen(LessCondition, slow) + ## Charges both budgets, already known to cover it, without looking. if instructions <= 4095: e.code.subtractImmediate(Word64, Instructions, Instructions, int(instructions)) else: + e.code.loadImmediate(Word64, temp(5), int64(instructions)) e.code.subtractRegister(Word64, Instructions, Instructions, temp(5)) if work <= 4095: e.code.subtractImmediate(Word64, Work, Work, int(work)) else: + e.code.loadImmediate(Word64, temp(6), int64(work)) e.code.subtractRegister(Word64, Work, Work, temp(6)) + proc meter(e: var Emitter, instructions, work: int32, slow: Label, + needInstructions = int64(instructions), needWork = int64(work)) + {.raises: [BasicError].} = + ## Checks both budgets hold what is needed before charging either, as + ## the interpreter does. What is needed can be more than this block + ## costs, when one look is to cover every block until the next. + for (budget, need) in [(Instructions, needInstructions), + (Work, needWork)]: + if need <= 4095: + e.code.compareImmediate(Word64, budget, int(need)) + else: + e.code.loadImmediate(Word64, temp(5), need) + e.code.compareRegister(Word64, budget, temp(5)) + e.jumpWhen(LessCondition, slow) + e.charge(instructions, work) + proc frameOf(e: var Emitter, base: Register, index: Register) {.raises: [].} = ## Points a register at one register-file slot by its absolute index. @@ -1376,6 +1400,12 @@ elif NativeAmd64: e.code.testRegister(Word32, temp(value), temp(value)) e.jumpWhen(NotEqualCondition, target) + proc jumpIfLowBits(e: var Emitter, value, bits: int, target: Label) + {.raises: [].} = + ## Jumps when any of a working register's lowest bits is set. + e.code.testImmediate(Word32, temp(value), int32((1'i64 shl bits) - 1)) + e.jumpWhen(NotEqualCondition, target) + proc cellAddress(e: var Emitter, index: int, extent: ArrayExtent, slow: Label) {.raises: [BasicError].} = ## Bounds checks an index and leaves the cell's address in Cell. One @@ -1390,16 +1420,27 @@ elif NativeAmd64: e.contextField(Cell, ContextMemory) e.code.addRegister(Word64, Cell, temp(index)) - proc meter(e: var Emitter, instructions, work: int32, slow: Label) - {.raises: [].} = - ## Checks both budgets before charging either, as the interpreter does. - e.code.compareImmediate(Word64, Instructions, instructions) - e.jumpWhen(LessCondition, slow) - e.code.compareImmediate(Word64, Work, work) - e.jumpWhen(LessCondition, slow) + proc charge(e: var Emitter, instructions, work: int32) {.raises: [].} = + ## Charges both budgets, already known to cover it, without looking. e.code.subtractImmediate(Word64, Instructions, instructions) e.code.subtractImmediate(Word64, Work, work) + proc meter(e: var Emitter, instructions, work: int32, slow: Label, + needInstructions = int64(instructions), needWork = int64(work)) + {.raises: [].} = + ## Checks both budgets hold what is needed before charging either, as + ## the interpreter does. What is needed can be more than this block + ## costs, when one look is to cover every block until the next. + for (budget, need) in [(Instructions, needInstructions), + (Work, needWork)]: + if need <= int64(high(int32)): + e.code.compareImmediate(Word64, budget, int32(need)) + else: + e.code.loadImmediate(Word64, Spare, need) + e.code.compareRegister(Word64, budget, Spare) + e.jumpWhen(LessCondition, slow) + e.charge(instructions, work) + proc slotAddress(e: var Emitter, destination, index: Register) {.raises: [BasicError].} = ## Points a register at one register-file slot by its absolute index. @@ -1712,9 +1753,19 @@ proc invoke*(machine: Machine, context: var NativeContext): NativeStatus type Loop = object ## A loop whose globals can live in registers while it runs. + ## + ## Its budget is looked at only at checkpoints: its head and every + ## other backward-branch target in it. Between two checkpoints a path + ## has no backward branch, so it meets each meter at most once, and so + ## charges at most what every meter in the loop adds up to. Each look + ## asks for that much, and a loop short of it goes on in general code, + ## which looks at every block and so refuses at the very same one. start: int stop: int globals: seq[int32] + checkpoints: seq[bool] + passInstructions: int64 + passWork: int64 proc loopGlobals(item: Instruction, globals: var seq[int32]) {.raises: [].} = @@ -1805,9 +1856,36 @@ proc findLoops(code: seq[Instruction], capacity: int): seq[Loop] code[index].loopGlobals(globals) if not fits or globals.len == 0 or globals.len > capacity: continue + var loop = Loop(start: start, stop: stop, globals: globals, + checkpoints: newSeq[bool](stop - start)) + loop.checkpoints[0] = true + for index in start ..< stop: + let item = code[index] + var target = -1 + case item.op + of JumpOp: target = int(item.a) + of JumpIfZeroOp: target = int(item.b) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp, + JumpUnlessGlobalModuloEqualZeroOp: target = int(item.c) + else: discard + if target >= start and target <= index: + loop.checkpoints[target - start] = true + if item.op == MeterOp: + loop.passInstructions += int64(item.b) + loop.passWork += int64(item.a) + for offset, wanted in loop.checkpoints: + if wanted and code[start + offset].op != MeterOp: + fits = false + if not fits: + continue for index in start ..< stop: covered[index] = true - result.add(Loop(start: start, stop: stop, globals: globals)) + result.add(loop) proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], @@ -1897,7 +1975,14 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], var fallsThrough {.used.} = true case item.op of MeterOp: - e.meter(item.b, item.a, slowFor(ToNext)) + when specialised: + if loop.checkpoints[at - loop.start]: + e.meter(item.b, item.a, slowFor(ToNext), + loop.passInstructions, loop.passWork) + else: + e.charge(item.b, item.a) + else: + e.meter(item.b, item.a, slowFor(ToNext)) of LoadImmediateOp: e.writeConstant(slot(item.a), 0, item.b) of LoadFixedOp: @@ -2193,9 +2278,14 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let slow = slowFor(ToOffset) e.readValue(0, 2, global(item.a)) e.unlessWhole(2, slow) - e.loadConstant(1, item.b) - e.remainder(0, 1) - e.jumpIfNotZeroValue(0, toBlock(item.c)) + let bits = lowBitCount(item.b) + if bits > 0: + e.jumpIfLowBits(0, bits, toBlock(item.c)) + elif item.b != 1 and item.b != -1: + # Dividing by one or minus one leaves nothing over, ever. + e.loadConstant(1, item.b) + e.remainder(0, 1) + e.jumpIfNotZeroValue(0, toBlock(item.c)) of ArrayGetOp: let slow = slowFor(ToNext) e.readValue(0, 2, slot(item.c)) diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index 879840c..f545c0f 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -182,6 +182,10 @@ block: assembler.returnToCaller() assembler.resolve() cases.add(("call 1f\n1:\tret", assembler.code)) +encodes "test eax, 7": + assembler.testImmediate(Word32, rax, 7) +encodes "test r14d, 1023": + assembler.testImmediate(Word32, r14, 1023) encodes "ret": assembler.returnToCaller() From 42c303a1371928198d6ea869e61e5eabf118efbe Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:54:09 -0700 Subject: [PATCH 32/51] read host callbacks in place instead of copying the closures --- src/bassy.nim | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/bassy.nim b/src/bassy.nim index c6206c2..339034c 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3796,7 +3796,9 @@ template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = count = int( runtime.program.hostFunctions[functionId].parameters ) - callback = runtime.hostCallbacks[functionId] + # Read in place: copying the closures would count references and ask + # the cycle collector about them on every single call. + template callback(): untyped = runtime.hostCallbacks[functionId] var value: Value if callback.numeric != nil: if count == 0: From 5131250215394ae82e41ef273b264525bc9e58f9 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 07:55:59 -0700 Subject: [PATCH 33/51] give compiled host calls their own direct path --- src/bassy.nim | 115 ++++++++++++++++++++++++++++------------------ src/bassy/jit.nim | 24 +++++++--- 2 files changed, 87 insertions(+), 52 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 339034c..bbe9998 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3596,6 +3596,40 @@ template chargeMeter(runtime: Runtime, item: Instruction) = runtime.remainingInstructions -= instructionCount runtime.remainingWork -= cost +template callHost(runtime: Runtime, item: Instruction) = + ## Runs one host function and stores its answer. The interpreter, and + ## compiled code's own path for host calls, both expand this. + template register(index: int32): untyped = + runtime.registers[int(runtime.base + index)] + let + functionId = int(item.b) + count = int( + runtime.program.hostFunctions[functionId].parameters + ) + # Read in place: copying the closures would count references and ask + # the cycle collector about them on every single call. + template callback(): untyped = runtime.hostCallbacks[functionId] + var value: Value + if callback.numeric != nil: + if count == 0: + value = callback.numeric([]) + else: + value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) + else: + for i in 0 ..< count: + runtime.integerArguments[i] = runtime.arguments[i].asInt + if count == 0: + value = callback.integer(EmptyArguments) + else: + value = callback.integer( + runtime.integerArguments.toOpenArray(0, count - 1) + ) + runtime.requireValue(value) + requireType(runtime.program.hostFunctions[functionId].name, value) + if item.a >= 0: + register(item.a) = value + inc runtime.pc + template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = ## Executes one instruction after its block was charged. The interpreter ## loop and compiled code's slow path both expand this, so the two run @@ -3791,34 +3825,7 @@ template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = runtime.arguments[int(item.a)] = runtime.globals[int(item.b)] inc runtime.pc of HostCallOp: - let - functionId = int(item.b) - count = int( - runtime.program.hostFunctions[functionId].parameters - ) - # Read in place: copying the closures would count references and ask - # the cycle collector about them on every single call. - template callback(): untyped = runtime.hostCallbacks[functionId] - var value: Value - if callback.numeric != nil: - if count == 0: - value = callback.numeric([]) - else: - value = callback.numeric(runtime.arguments.toOpenArray(0, count - 1)) - else: - for i in 0 ..< count: - runtime.integerArguments[i] = runtime.arguments[i].asInt - if count == 0: - value = callback.integer(EmptyArguments) - else: - value = callback.integer( - runtime.integerArguments.toOpenArray(0, count - 1) - ) - runtime.requireValue(value) - requireType(runtime.program.hostFunctions[functionId].name, value) - if item.a >= 0: - register(item.a) = value - inc runtime.pc + runtime.callHost(item) of CallOp, GosubOp: if runtime.depth + 1 >= int32(runtime.frames.len): fail("BASIC call depth limit exceeded") @@ -3923,13 +3930,15 @@ template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = print(PrintEvent(kind: NewlinePrint)) inc runtime.pc -proc nativeStep(context: ptr NativeContext, pc: int32): int32 {.cdecl.} = - ## Runs one instruction for compiled code, through the very code the - ## interpreter runs. Compiled code keeps the frame and budgets in the - ## context, so they are carried in and back out around it. A failure - ## cannot travel back through frames compiled code built, so it is kept - ## here and raised again once compiled code has returned. - var runtime = cast[Runtime](context.runtime) +template handOver(context: ptr NativeContext, pc: int32, + body: untyped): int32 = + ## Runs interpreter code for compiled code. Compiled code keeps the frame + ## and budgets in the context, so they are carried in and back out + ## around it. A failure cannot travel back through frames compiled code + ## built, so it is kept and raised again once compiled code returns. + # A cursor, so no reference is counted and no cycle root registered on + # every call; the runtime is alive for as long as compiled code runs. + var runtime {.cursor, inject.} = cast[Runtime](context.runtime) inc runtime.handedBack runtime.pc = pc runtime.base = context.base @@ -3937,23 +3946,38 @@ proc nativeStep(context: ptr NativeContext, pc: int32): int32 {.cdecl.} = runtime.routine = context.routine runtime.remainingInstructions = context.remainingInstructions runtime.remainingWork = context.remainingWork + var status = 0'i32 try: + body + except Exception as error: + runtime.nativeError = error + status = 1 + if status == 0: + context.pc = runtime.pc + context.base = runtime.base + context.depth = runtime.depth + context.routine = runtime.routine + context.remainingInstructions = runtime.remainingInstructions + context.remainingWork = runtime.remainingWork + status + +proc nativeStep(context: ptr NativeContext, pc: int32): int32 {.cdecl.} = + ## Runs any one instruction for compiled code, through the very code the + ## interpreter runs for it. + handOver(context, pc): let item = runtime.program.code[int(pc)] if item.op == MeterOp: runtime.chargeMeter(item) inc runtime.pc else: runtime.performOp(item, runtime.printer) - except Exception as error: - runtime.nativeError = error - return 1 - context.pc = runtime.pc - context.base = runtime.base - context.depth = runtime.depth - context.routine = runtime.routine - context.remainingInstructions = runtime.remainingInstructions - context.remainingWork = runtime.remainingWork - 0 + +proc nativeHostCall(context: ptr NativeContext, pc: int32): int32 + {.cdecl.} = + ## Runs one host call for compiled code, going straight to the host + ## call's own code rather than through the general dispatch. + handOver(context, pc): + runtime.callHost(runtime.program.code[int(pc)]) proc runMachine(runtime: var Runtime, print: PrintProc) = ## Runs the compiled program from wherever the runtime stands until it @@ -3983,6 +4007,7 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = routine: runtime.routine, runtime: cast[pointer](runtime), step: cast[pointer](nativeStep), + hostStep: cast[pointer](nativeHostCall), remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 045354f..d382c18 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -56,6 +56,7 @@ type routine*: int32 runtime*: pointer step*: pointer + hostStep*: pointer NativeCall* = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -95,6 +96,7 @@ const ContextRoutine = 88 ContextRuntime = 96 ContextStep = 104 + ContextHostStep = 112 ## One frame as the interpreter lays it out: where the caller's slots ## start, which routine it was in, where to carry on, and whether it @@ -169,7 +171,8 @@ proc layoutMatches*(): bool {.raises: [].} = at(depth) == ContextDepth and at(routine) == ContextRoutine and at(runtime) == ContextRuntime and - at(step) == ContextStep + at(step) == ContextStep and + at(hostStep) == ContextHostStep type Machine* = ref object @@ -845,7 +848,7 @@ when NativeArm64: e.code.loadImmediate(Word32, x1, int64(offset)) e.code.branchLink(routine) - proc slowRoutine(e: var Emitter, failed: Label) + proc slowRoutine(e: var Emitter, failed: Label, helper: int) {.raises: [BasicError].} = ## The one place compiled code calls out. The budgets go into the ## context for the interpreter's code to charge, and come back from it @@ -854,7 +857,7 @@ when NativeArm64: e.code.storeDouble(Instructions, Context, ContextInstructions) e.code.storeDouble(Work, Context, ContextWork) e.code.moveRegister(Word64, x0, Context) - e.code.loadDouble(temp(0), Context, ContextStep) + e.code.loadDouble(temp(0), Context, helper) e.code.callRegister(temp(0)) e.code.moveRegister(Word32, temp(0), x0) e.code.loadDouble(Instructions, Context, ContextInstructions) @@ -1594,7 +1597,7 @@ elif NativeAmd64: e.code.loadImmediate(Word32, SecondArgument, int64(offset)) e.code.callLabel(routine) - proc slowRoutine(e: var Emitter, failed: Label) + proc slowRoutine(e: var Emitter, failed: Label, helper: int) {.raises: [BasicError].} = ## The one place compiled code calls out. The budgets go into the ## context for the interpreter's code to charge, and come back from it @@ -1606,7 +1609,7 @@ elif NativeAmd64: e.code.storeDouble(Work, Context, ContextWork) e.code.moveRegister(Word64, FirstArgument, Context) e.code.subtractImmediate(Word64, rsp, Padding) - e.contextField(rax, ContextStep) + e.contextField(rax, helper) e.code.callRegister(rax) e.code.addImmediate(Word64, rsp, Padding) e.code.moveRegister(Word32, r10, rax) @@ -1912,6 +1915,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], general[index] = blocks[index] let dispatchLabel = e.label() let slowLabel = e.label() + let hostLabel = e.label() let failedLabel = e.label() let loops = findLoops(code, Hoisting.len) @@ -2328,7 +2332,11 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], runSlow() e.jump(dispatchLabel) fallsThrough = false - of LoadStringOp, TextCallOp, HostCallOp, PrintTextOp, PrintValueOp, + of HostCallOp: + # Host calls have a helper of their own, which goes straight to + # the host call's code instead of through the general dispatch. + e.callSlow(offset, hostLabel) + of LoadStringOp, TextCallOp, PrintTextOp, PrintValueOp, PrintNewlineOp: runSlow() @@ -2405,7 +2413,9 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.place(dispatchLabel) e.dispatch() e.place(slowLabel) - e.slowRoutine(failedLabel) + e.slowRoutine(failedLabel, ContextStep) + e.place(hostLabel) + e.slowRoutine(failedLabel, ContextHostStep) e.place(failedLabel) e.epilogue(NativeFailed) From 42cd2146407f06caa3f4e7ce53c9a6fbfa881810 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:06:46 -0700 Subject: [PATCH 34/51] hold a block's slots and globals in registers from one instruction to the next --- src/bassy/amd64.nim | 8 +- src/bassy/jit.nim | 865 ++++++++++++++++++++++++++++++++++++++++++- tests/test_amd64.nim | 6 + 3 files changed, 859 insertions(+), 20 deletions(-) diff --git a/src/bassy/amd64.nim b/src/bassy/amd64.nim index fa0f830..851f406 100644 --- a/src/bassy/amd64.nim +++ b/src/bassy/amd64.nim @@ -356,11 +356,15 @@ proc setIfCondition*(assembler: var Assembler, target: Register, ## Writes one when the condition holds and zero otherwise. ## The low byte is set, so the register is cleared first; xor would ## disturb the flags, and movzx afterwards would need a second register. - assembler.prefix(Word32, Register(0), target) + ## A REX prefix is forced for the byte form, so rsp, rbp, rsi and rdi + ## name their low bytes rather than ah, ch, dh and bh. + let rex = byte(0x40'u32 or (target.number shr 3)) + assembler.emit(rex) assembler.emit(0x0F) assembler.emit(byte(0x90'u32 + uint32(ord(condition)))) assembler.directOperand(Register(0), target) - assembler.prefix(Word32, target, target) + assembler.emit(byte(0x40'u32 or ((target.number shr 3) shl 2) or + (target.number shr 3))) assembler.emit(0x0F) assembler.emit(0xB6) assembler.directOperand(target, target) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index d382c18..d301a98 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -1002,6 +1002,163 @@ when NativeArm64: e.code.loadImmediate(Word32, temp(6), int64(bits)) e.code.compareRegister(Word32, hoisted(slot), temp(6)) + ## Values held in registers across a block + ## + ## A block's fast version keeps the slots and globals it touches in the + ## registers below. Nothing in the pool outlives the block: every held + ## value is written back before anything else could look at memory. + + const + Pool* = [x0, x1, x2, x3, x4, x5, x6, x7, x8, x9, x10, x11, x12, x13] + FastScratch = x14 + + proc pooled(index: int): Register {.inline, raises: [].} = + ## Returns one pool register. + Pool[index] + + proc fastLoad(e: var Emitter, payload, tag: int, place: Place, + deopt: Label) {.raises: [BasicError].} = + ## Reads a value's kind and payload, taking the deopt path unless it + ## is a number of either kind. + let (base, offset) = e.reach(place) + e.code.loadByte(pooled(tag), base, offset) + e.code.loadWord(pooled(payload), base, offset + ValuePayload) + e.code.compareImmediate(Word32, pooled(tag), FixedTag) + e.jumpWhen(UnsignedGreaterCondition, deopt) + + proc fastStore(e: var Emitter, place: Place, payload, tag, kind: int) + {.raises: [BasicError].} = + ## Writes a value back: its kind from a register, or the known kind. + let (base, offset) = e.reach(place) + if tag >= 0: + e.code.storeByte(pooled(tag), base, offset) + elif kind == 0: + e.code.storeByte(zeroRegister, base, offset) + else: + e.code.loadImmediate(Word32, FastScratch, int64(kind)) + e.code.storeByte(FastScratch, base, offset) + e.code.storeWord(pooled(payload), base, offset + ValuePayload) + + proc fastMove(e: var Emitter, destination, source: int) {.raises: [].} = + ## Copies one pool register into another. + e.code.moveRegister(Word32, pooled(destination), pooled(source)) + + proc fastConstant(e: var Emitter, destination: int, bits: int32) + {.raises: [].} = + ## Loads a constant into a pool register. + e.code.loadImmediate(Word32, pooled(destination), int64(bits)) + + proc fastAdd(e: var Emitter, destination, source: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, pooled(destination), pooled(destination), + pooled(source)) + + proc fastSubtract(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, pooled(destination), + pooled(destination), pooled(source)) + + proc fastMultiply(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiply(Word32, pooled(destination), pooled(destination), + pooled(source)) + + proc fastMultiplyFixed(e: var Emitter, destination, source: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers, rounding as the library does. + let target = pooled(destination) + e.code.signedMultiplyLong(target, target, pooled(source)) + e.code.loadImmediate(Word64, FastScratch, FixedRounding) + e.code.addRegister(Word64, target, target, FastScratch) + e.code.arithmeticShiftRight(Word64, target, target, FixedShift) + e.code.moveRegister(Word32, target, target) + + proc fastNegate(e: var Emitter, destination: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negate(Word32, pooled(destination), pooled(destination)) + + proc fastToFixed(e: var Emitter, destination: int, deopt: Label) + {.raises: [BasicError].} = + ## Turns a whole number into Q16.16 bits, or takes the deopt path when + ## it is outside the fixed-point range, which the interpreter refuses. + let target = pooled(destination) + e.code.loadImmediate(Word32, FastScratch, 32767) + e.code.compareRegister(Word32, target, FastScratch) + e.jumpWhen(GreaterCondition, deopt) + e.code.loadImmediate(Word32, FastScratch, -32768) + e.code.compareRegister(Word32, target, FastScratch) + e.jumpWhen(LessCondition, deopt) + e.code.shiftLeftImmediate(Word32, target, target, FixedShift) + + proc fastCompare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two pool registers. + e.code.compareRegister(Word32, pooled(left), pooled(right)) + + proc fastCompareWide(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two widened pool registers. + e.code.compareRegister(Word64, pooled(left), pooled(right)) + + proc fastCompareConstant(e: var Emitter, value: int, bits: int32) + {.raises: [BasicError].} = + ## Sets flags from a pool register against a constant. + if bits >= 0 and bits <= 4095: + e.code.compareImmediate(Word32, pooled(value), int(bits)) + else: + e.code.loadImmediate(Word32, FastScratch, int64(bits)) + e.code.compareRegister(Word32, pooled(value), FastScratch) + + proc fastLoadWide(e: var Emitter, destination: int, bits: int64) + {.raises: [].} = + ## Loads a sixty-four bit constant into a pool register. + e.code.loadImmediate(Word64, pooled(destination), bits) + + proc fastScaleWide(e: var Emitter, value, tag: int) + {.raises: [BasicError].} = + ## Widens a number to sixty-four bits on the fixed-point scale, its + ## kind read from a register. + let target = pooled(value) + let done = e.label() + e.code.signExtendWord(target, target) + e.jumpIfNotZero(pooled(tag), done) + e.code.shiftLeftImmediate(Word64, target, target, FixedShift) + e.place(done) + + proc fastAnswer(e: var Emitter, destination: int, check: Check) + {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. + e.code.setOnCondition(Word32, pooled(destination), + nativeCondition(check)) + + proc fastJumpIfZero(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a pool register holds zero. + e.jumpIfZero(pooled(value), target) + + proc fastJumpIfNotZero(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a pool register holds anything but zero. + e.jumpIfNotZero(pooled(value), target) + + proc fastJumpIfDiffer(e: var Emitter, left, right: int, target: Label) + {.raises: [].} = + ## Jumps when two pool registers differ. + e.code.compareRegister(Word32, pooled(left), pooled(right)) + e.jumpWhen(NotEqualCondition, target) + + proc fastCellAddress(e: var Emitter, index: int, extent: ArrayExtent, + deopt: Label) {.raises: [BasicError].} = + ## Bounds checks an index held in the pool, leaving it untouched, and + ## leaves the cell's address in Cell. + let position = pooled(index) + e.code.loadImmediate(Word32, FastScratch, int64(extent.length)) + e.code.compareRegister(Word32, position, FastScratch) + e.jumpWhen(CarrySetCondition, deopt) + e.code.loadImmediate(Word32, FastScratch, int64(extent.base)) + e.code.addRegister(Word32, FastScratch, FastScratch, position) + e.code.addRegister(Word64, Cell, MemoryBase, FastScratch, 4) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -1732,6 +1889,157 @@ elif NativeAmd64: ## Sets flags from a hoisted global against a constant. e.code.compareImmediate(Word32, hoisted(slot), bits) + ## Values held in registers across a block + ## + ## A block's fast version keeps the slots and globals it touches in the + ## registers below. Nothing in the pool outlives the block: every held + ## value is written back before anything else could look at memory. rax + ## and rdx stay out of the pool for the divide and as scratch. + + const + Pool* = [rcx, rsi, rdi, r8, r9, r10, r14] + FastScratch = rax + + proc pooled(index: int): Register {.inline, raises: [].} = + ## Returns one pool register. + Pool[index] + + proc fastLoad(e: var Emitter, payload, tag: int, place: Place, + deopt: Label) {.raises: [BasicError].} = + ## Reads a value's kind and payload, taking the deopt path unless it + ## is a number of either kind. + let (base, offset) = e.reach(place) + e.code.loadByteZeroed(pooled(tag), base, offset) + e.code.loadWord(pooled(payload), base, offset + ValuePayload) + e.code.compareImmediate(Word32, pooled(tag), FixedTag) + e.jumpWhen(AboveCondition, deopt) + + proc fastStore(e: var Emitter, place: Place, payload, tag, kind: int) + {.raises: [BasicError].} = + ## Writes a value back: its kind from a register, or the known kind. + let (base, offset) = e.reach(place) + if tag >= 0: + e.code.storeByteLow(base, offset, pooled(tag)) + else: + e.code.storeByteImmediate(base, offset, byte(kind)) + e.code.storeWord(pooled(payload), base, offset + ValuePayload) + + proc fastMove(e: var Emitter, destination, source: int) {.raises: [].} = + ## Copies one pool register into another. + e.code.moveRegister(Word32, pooled(destination), pooled(source)) + + proc fastConstant(e: var Emitter, destination: int, bits: int32) + {.raises: [].} = + ## Loads a constant into a pool register. + e.code.loadImmediate(Word32, pooled(destination), int64(bits)) + + proc fastAdd(e: var Emitter, destination, source: int) {.raises: [].} = + ## Adds, wrapping. + e.code.addRegister(Word32, pooled(destination), pooled(source)) + + proc fastSubtract(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Subtracts, wrapping. + e.code.subtractRegister(Word32, pooled(destination), pooled(source)) + + proc fastMultiply(e: var Emitter, destination, source: int) + {.raises: [].} = + ## Multiplies, wrapping. + e.code.multiplyRegister(Word32, pooled(destination), pooled(source)) + + proc fastMultiplyFixed(e: var Emitter, destination, source: int) + {.raises: [BasicError].} = + ## Multiplies two Q16.16 numbers, rounding as the library does. + let target = pooled(destination) + e.code.signExtendDouble(target, target) + e.code.signExtendDouble(FastScratch, pooled(source)) + e.code.multiplyRegister(Word64, target, FastScratch) + e.code.addImmediate(Word64, target, int32(FixedRounding)) + e.code.shiftRightImmediate(Word64, target, FixedShift) + e.code.moveRegister(Word32, target, target) + + proc fastNegate(e: var Emitter, destination: int) {.raises: [].} = + ## Negates, wrapping. + e.code.negateRegister(Word32, pooled(destination)) + + proc fastToFixed(e: var Emitter, destination: int, deopt: Label) + {.raises: [BasicError].} = + ## Turns a whole number into Q16.16 bits, or takes the deopt path when + ## it is outside the fixed-point range, which the interpreter refuses. + let target = pooled(destination) + e.code.compareImmediate(Word32, target, 32767) + e.jumpWhen(GreaterCondition, deopt) + e.code.compareImmediate(Word32, target, -32768) + e.jumpWhen(LessCondition, deopt) + e.code.shiftLeftImmediate(Word32, target, FixedShift) + + proc fastCompare(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two pool registers. + e.code.compareRegister(Word32, pooled(left), pooled(right)) + + proc fastCompareWide(e: var Emitter, left, right: int) {.raises: [].} = + ## Sets flags from two widened pool registers. + e.code.compareRegister(Word64, pooled(left), pooled(right)) + + proc fastCompareConstant(e: var Emitter, value: int, bits: int32) + {.raises: [].} = + ## Sets flags from a pool register against a constant. + e.code.compareImmediate(Word32, pooled(value), bits) + + proc fastLoadWide(e: var Emitter, destination: int, bits: int64) + {.raises: [].} = + ## Loads a sixty-four bit constant into a pool register. + e.code.loadImmediate(Word64, pooled(destination), bits) + + proc fastScaleWide(e: var Emitter, value, tag: int) + {.raises: [BasicError].} = + ## Widens a number to sixty-four bits on the fixed-point scale, its + ## kind read from a register. + let target = pooled(value) + let done = e.label() + e.code.signExtendDouble(target, target) + e.code.testRegister(Word32, pooled(tag), pooled(tag)) + e.jumpWhen(NotEqualCondition, done) + e.code.shiftLeftImmediate(Word64, target, FixedShift) + e.place(done) + + proc fastAnswer(e: var Emitter, destination: int, check: Check) + {.raises: [].} = + ## Writes BASIC's -1 for true and zero for false. + e.code.setIfCondition(pooled(destination), nativeCondition(check)) + e.code.negateRegister(Word32, pooled(destination)) + + proc fastJumpIfZero(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a pool register holds zero. + e.code.testRegister(Word32, pooled(value), pooled(value)) + e.jumpWhen(EqualCondition, target) + + proc fastJumpIfNotZero(e: var Emitter, value: int, target: Label) + {.raises: [].} = + ## Jumps when a pool register holds anything but zero. + e.code.testRegister(Word32, pooled(value), pooled(value)) + e.jumpWhen(NotEqualCondition, target) + + proc fastJumpIfDiffer(e: var Emitter, left, right: int, target: Label) + {.raises: [].} = + ## Jumps when two pool registers differ. + e.code.compareRegister(Word32, pooled(left), pooled(right)) + e.jumpWhen(NotEqualCondition, target) + + proc fastCellAddress(e: var Emitter, index: int, extent: ArrayExtent, + deopt: Label) {.raises: [BasicError].} = + ## Bounds checks an index held in the pool, leaving it untouched, and + ## leaves the cell's address in Cell. + let position = pooled(index) + e.code.compareImmediate(Word32, position, extent.length) + e.jumpWhen(AboveEqualCondition, deopt) + e.code.moveRegister(Word32, FastScratch, position) + e.code.addImmediate(Word32, FastScratch, extent.base) + e.code.shiftLeftImmediate(Word64, FastScratch, 4) + e.contextField(Cell, ContextMemory) + e.code.addRegister(Word64, Cell, FastScratch) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -1754,6 +2062,22 @@ proc invoke*(machine: Machine, context: var NativeContext): NativeStatus NativeStatus(machine.call(context.addr)) type + Kind = enum + ## What the compiler knows of a held value's kind. + UnknownKind, + WholeKind, + FixedKind + + Held = object + ## A slot or global a block's fast version keeps in registers. Its + ## kind is either known here or held in a register of its own, and it + ## is always a number: anything else never gets into a register. + place: Place + payload: int + tag: int + kind: Kind + dirty: bool + Loop = object ## A loop whose globals can live in registers while it runs. ## @@ -1925,6 +2249,15 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], for number, loop in loops: loopAt[loop.start] = number general[loop.start] = e.label() + # Every ordinary block starts at a meter, and its general version gets + # a label of its own, since its entry is the fast version. + var inLoop = newSeq[bool](code.len) + for loop in loops: + for index in loop.start ..< loop.stop: + inLoop[index] = true + for index in 0 ..< code.len: + if code[index].op == MeterOp and not inLoop[index]: + general[index] = e.label() e.prologue() e.jump(dispatchLabel) @@ -2365,23 +2698,515 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], inside.add(e.label()) loopCopies.add(inside) - for index in 0 ..< code.len: - e.place(blocks[index]) - let number = loopAt[index] - if number >= 0: - # Entering a loop's head: prove its globals whole numbers and move - # them into registers, or run it in general code if any is not. - # Every tag is looked at before any register is filled, because - # one of those registers may be what general code reads through. - let loop = loops[number] - e.beginHoisting() - for which in loop.globals: - e.guardHoisted(which, general[loop.start]) - for position, which in loop.globals: - e.loadHoisted(position, which) - e.jump(loopCopies[number][0]) + ## Fast versions of ordinary blocks + ## + ## An ordinary block, one outside any specialised loop, also gets a + ## fast version that keeps the slots and globals it touches in pool + ## registers from one instruction to the next instead of in memory. A + ## value is read from memory once, written back once before anything + ## else could look, and between the two its kind is checked at most + ## once. Anything the fast version does not expect writes back what + ## was pending and goes on in the block's general version at that very + ## instruction, which does it the ordinary way. + ## + ## Registers are counted rather than guessed at. Before an instruction + ## emits anything that could leave, enough registers are freed for the + ## most it could need; within it none is handed out twice, so a way out + ## taken part way through still finds every pending value where it was. + + var + held: seq[Held] + uses = newSeq[int](Pool.len) + pinned = newSeq[bool](Pool.len) + fastExits: seq[(Label, seq[Held], int)] + + proc samePlace(a, b: Place): bool {.raises: [].} = + ## Reports whether two places name the same value. + a.home == b.home and a.index == b.index + + template kindTag(kind: Kind): int = + ## Returns the tag byte for a known kind. + (if kind == FixedKind: FixedTag else: 0) + + template writeBack(value: Held) = + ## Stores a held value where the interpreter keeps it. + e.fastStore(value.place, value.payload, value.tag, kindTag(value.kind)) + + template forget(position: int) = + ## Drops one held value, freeing its registers once nothing uses them. + dec uses[held[position].payload] + if held[position].tag >= 0: + dec uses[held[position].tag] + held.delete(position) + + template flushAll() = + ## Writes every pending value back, keeping them held. + for position in 0 ..< held.len: + if held[position].dirty: + writeBack(held[position]) + held[position].dirty = false + + template clearAll() = + ## Writes every pending value back and holds nothing further. + flushAll() + held.setLen(0) + for register in 0 ..< Pool.len: + uses[register] = 0 + + template acquire(): int = + ## Hands out a free register for the rest of this instruction. + var found = -1 + for register in 0 ..< Pool.len: + if uses[register] == 0 and not pinned[register]: + found = register + break + if found < 0: + raise newException(BasicError, + "BASIC native compiler ran out of registers") + pinned[found] = true + found + + template pin(register: int) = + ## Keeps a register from being handed out for this instruction. + if register >= 0: + pinned[register] = true + + template findHeld(target: Place): int = + ## Returns where a value is held, or -1. + var found = -1 + for position in 0 ..< held.len: + if samePlace(held[position].place, target): + found = position + found + + template makeRoom(operands: openArray[Place], extra: int) = + ## Frees enough registers for an instruction before it emits anything + ## that could leave: two for each operand not yet held, plus what it + ## needs of its own. Values it reads stay; the oldest others go, + ## clean ones before those that must be written back. + var needed = extra + var counted: seq[Place] + for operand in operands: + var seen = false + for earlier in counted: + if samePlace(earlier, operand): + seen = true + if not seen: + counted.add(operand) + if findHeld(operand) < 0: + needed += 2 + for pass in 0 .. 1: + var position = 0 + while position < held.len: + var free = 0 + for register in 0 ..< Pool.len: + if uses[register] == 0: + inc free + if free >= needed: + break + var keep = false + for operand in operands: + if samePlace(held[position].place, operand): + keep = true + if keep or (pass == 0 and held[position].dirty): + inc position + continue + if held[position].dirty: + writeBack(held[position]) + forget(position) + + template leaveHere(offset: int): Label = + ## Names a way out of the fast version: write back what is pending + ## now, then do this instruction in general code. + var pending: seq[Held] + for value in held: + if value.dirty: + pending.add(value) + let exit = e.label() + fastExits.add((exit, pending, offset)) + exit + + template fetch(target: Place, offset: int): Held = + ## Returns a value in registers, reading it from memory if it is not + ## held yet. Only a number is held; anything else leaves. + var position = findHeld(target) + if position < 0: + let payload = acquire() + let tag = acquire() + e.fastLoad(payload, tag, target, leaveHere(offset)) + held.add(Held(place: target, payload: payload, tag: tag, + kind: UnknownKind)) + inc uses[payload] + inc uses[tag] + position = held.len - 1 + pin(held[position].payload) + pin(held[position].tag) + held[position] + + template bindValue(target: Place, valueRegister, kindRegister: int, + known: Kind) = + ## Holds a new value for a place, pending until written back. + let position = findHeld(target) + if position >= 0: + forget(position) + held.add(Held(place: target, payload: valueRegister, + tag: kindRegister, kind: known, dirty: true)) + inc uses[valueRegister] + if kindRegister >= 0: + inc uses[kindRegister] + + template tagOf(value: Held): int = + ## Returns a register holding a value's kind, loading a known kind. + var register = value.tag + if register < 0: + register = acquire() + e.fastConstant(register, int32(kindTag(value.kind))) + register + + template requireWhole(value: Held, target: Place, exit: Label) = + ## Leaves unless a value is a whole number, and from here on knows + ## that it is. + case value.kind + of WholeKind: + discard + of FixedKind: + e.jump(exit) + of UnknownKind: + e.fastJumpIfNotZero(value.tag, exit) + let position = findHeld(target) + if position >= 0 and held[position].tag >= 0: + dec uses[held[position].tag] + held[position].tag = -1 + held[position].kind = WholeKind + + template fallsOnward(op: Op): bool = + ## Reports whether an operation can carry on to the next offset. + op notin {JumpOp, ReturnOp, ReturnLabelOp, ExitSubOp, HaltOp, CallOp, + GosubOp} + + template emitFast(at: int): bool = + ## Emits one instruction into a fast version, or reports that it + ## has no fast form, so that it is done the general way instead. + let item = code[at] + let offset = at + var done = true + case item.op + of LoadImmediateOp, LoadFixedOp, StoreGlobalImmediateOp: + makeRoom(newSeq[Place](), 1) + let register = acquire() + let kind = + if item.op == LoadFixedOp: FixedKind + else: WholeKind + let bits = + if item.op == LoadFixedOp: constants[int(item.b)] + else: item.b + e.fastConstant(register, bits) + let target = + if item.op == StoreGlobalImmediateOp: global(item.a) + else: slot(item.a) + bindValue(target, register, -1, kind) + of MoveOp, LoadGlobalOp, StoreGlobalOp, MoveGlobalOp, LoadHostDataOp: + let (target, source) = + case item.op + of MoveOp: (slot(item.a), slot(item.b)) + of LoadGlobalOp: (slot(item.a), global(item.b)) + of StoreGlobalOp: (global(item.a), slot(item.b)) + of MoveGlobalOp: (global(item.a), global(item.b)) + else: (slot(item.a), host(item.b)) + makeRoom([source], 0) + let value = fetch(source, offset) + bindValue(target, value.payload, value.tag, value.kind) + of SetArgumentOp, SetArgumentGlobalOp: + let source = + if item.op == SetArgumentOp: slot(item.b) + else: global(item.b) + makeRoom([source], 0) + let value = fetch(source, offset) + e.fastStore(argument(item.a), value.payload, value.tag, + kindTag(value.kind)) + of SetArgumentImmediateOp: + e.writeConstant(argument(item.a), 0, item.b) + of AddGlobalImmediateOp, AddGlobalOp, AddGlobalRegisterOp, + AddGlobalHostDataOp: + let target = global(item.a) + let source = + case item.op + of AddGlobalOp: global(item.b) + of AddGlobalRegisterOp: slot(item.b) + of AddGlobalHostDataOp: host(item.b) + else: target + makeRoom([target, source], 2) + let total = fetch(target, offset) + let exit = leaveHere(offset) + requireWhole(total, target, exit) + let register = acquire() + e.fastMove(register, total.payload) + if item.op == AddGlobalImmediateOp: + let amount = acquire() + e.fastConstant(amount, item.b) + e.fastAdd(register, amount) + else: + let value = fetch(source, offset) + requireWhole(value, source, exit) + e.fastAdd(register, value.payload) + bindValue(target, register, -1, WholeKind) + of AddOp, SubtractOp, MultiplyOp: + when ModelsFixed: + let left = slot(item.b) + let right = slot(item.c) + var known = 0 + for operand in [left, right]: + let position = findHeld(operand) + if position >= 0 and held[position].kind != UnknownKind: + inc known + makeRoom([left, right], 3 + known) + let b = fetch(left, offset) + let c = fetch(right, offset) + template operate(target, source: int, fixed: bool) = + ## Applies this instruction's operation to two registers. + case item.op + of AddOp: + e.fastAdd(target, source) + of SubtractOp: + e.fastSubtract(target, source) + else: + if fixed: + e.fastMultiplyFixed(target, source) + else: + e.fastMultiply(target, source) + if b.kind != UnknownKind and c.kind != UnknownKind: + let result = acquire() + if b.kind == c.kind: + e.fastMove(result, b.payload) + operate(result, c.payload, b.kind == FixedKind) + else: + # Kinds known to differ: the whole side becomes fixed point. + let exit = leaveHere(offset) + let widened = acquire() + if b.kind == WholeKind: + e.fastMove(widened, b.payload) + e.fastToFixed(widened, exit) + e.fastMove(result, widened) + operate(result, c.payload, true) + else: + e.fastMove(widened, c.payload) + e.fastToFixed(widened, exit) + e.fastMove(result, b.payload) + operate(result, widened, true) + let kind = + if b.kind == WholeKind and c.kind == WholeKind: WholeKind + else: FixedKind + bindValue(slot(item.a), result, -1, kind) + else: + let exit = leaveHere(offset) + let leftTag = tagOf(b) + let rightTag = tagOf(c) + let result = acquire() + let resultTag = acquire() + let widened = acquire() + let mixed = e.label() + let promoteRight = e.label() + let joined = e.label() + let finished = e.label() + e.fastJumpIfDiffer(leftTag, rightTag, mixed) + e.fastMove(result, b.payload) + e.fastMove(resultTag, leftTag) + if item.op == MultiplyOp: + let fixedWay = e.label() + e.fastJumpIfNotZero(leftTag, fixedWay) + operate(result, c.payload, false) + e.jump(finished) + e.place(fixedWay) + operate(result, c.payload, true) + else: + operate(result, c.payload, false) + e.jump(finished) + e.place(mixed) + e.fastJumpIfNotZero(leftTag, promoteRight) + e.fastMove(widened, b.payload) + e.fastToFixed(widened, exit) + e.fastMove(result, widened) + operate(result, c.payload, true) + e.jump(joined) + e.place(promoteRight) + e.fastMove(widened, c.payload) + e.fastToFixed(widened, exit) + e.fastMove(result, b.payload) + operate(result, widened, true) + e.place(joined) + e.fastConstant(resultTag, FixedTag) + e.place(finished) + bindValue(slot(item.a), result, resultTag, UnknownKind) + else: + done = false + of NegateOp: + when ModelsFixed: + makeRoom([slot(item.b)], 1) + let value = fetch(slot(item.b), offset) + let result = acquire() + e.fastMove(result, value.payload) + e.fastNegate(result) + bindValue(slot(item.a), result, value.tag, value.kind) + else: + done = false + of EqualOp, NotEqualOp, LessOp, LessEqualOp, GreaterOp, + GreaterEqualOp: + let left = slot(item.b) + let right = slot(item.c) + var known = 0 + for operand in [left, right]: + let position = findHeld(operand) + if position >= 0 and held[position].kind != UnknownKind: + inc known + makeRoom([left, right], 3 + known) + let b = fetch(left, offset) + let c = fetch(right, offset) + if b.kind != UnknownKind and b.kind == c.kind: + e.fastCompare(b.payload, c.payload) + else: + # Kinds that differ compare on the widened fixed-point scale. + let leftTag = tagOf(b) + let rightTag = tagOf(c) + let wideLeft = acquire() + let wideRight = acquire() + let mixed = e.label() + let decided = e.label() + e.fastJumpIfDiffer(leftTag, rightTag, mixed) + e.fastCompare(b.payload, c.payload) + e.jump(decided) + e.place(mixed) + e.fastMove(wideLeft, b.payload) + e.fastScaleWide(wideLeft, leftTag) + e.fastMove(wideRight, c.payload) + e.fastScaleWide(wideRight, rightTag) + e.fastCompareWide(wideLeft, wideRight) + e.place(decided) + let result = acquire() + e.fastAnswer(result, comparisonCheck(item.op)) + bindValue(slot(item.a), result, -1, WholeKind) + of JumpOp: + flushAll() + e.jump(blocks[int(item.a)]) + of JumpIfZeroOp: + makeRoom([slot(item.a)], 0) + let value = fetch(slot(item.a), offset) + flushAll() + e.fastJumpIfZero(value.payload, blocks[int(item.b)]) + of JumpUnlessGlobalEqualImmediateOp, + JumpUnlessGlobalNotEqualImmediateOp, + JumpUnlessGlobalLessImmediateOp, + JumpUnlessGlobalLessEqualImmediateOp, + JumpUnlessGlobalGreaterImmediateOp, + JumpUnlessGlobalGreaterEqualImmediateOp: + makeRoom([global(item.a)], 3) + let value = fetch(global(item.a), offset) + flushAll() + if value.kind == WholeKind: + e.fastCompareConstant(value.payload, item.b) + else: + # A fixed-point global meets the constant on the widened scale. + let tag = tagOf(value) + let wide = acquire() + let bound = acquire() + let whole = e.label() + let decided = e.label() + e.fastJumpIfZero(tag, whole) + e.fastMove(wide, value.payload) + e.fastScaleWide(wide, tag) + e.fastLoadWide(bound, int64(item.b) * 65536) + e.fastCompareWide(wide, bound) + e.jump(decided) + e.place(whole) + e.fastCompareConstant(value.payload, item.b) + e.place(decided) + e.jumpOn(takenOn(item.op), blocks[int(item.c)]) + of ArrayGetOp: + makeRoom([slot(item.c)], 2) + let index = fetch(slot(item.c), offset) + let exit = leaveHere(offset) + requireWhole(index, slot(item.c), exit) + e.fastCellAddress(index.payload, extents[int(item.b)], exit) + let payload = acquire() + let tag = acquire() + e.fastLoad(payload, tag, cell(), exit) + bindValue(slot(item.a), payload, tag, UnknownKind) + of ArraySetOp: + makeRoom([slot(item.b), slot(item.c)], 0) + let index = fetch(slot(item.b), offset) + let value = fetch(slot(item.c), offset) + let exit = leaveHere(offset) + requireWhole(index, slot(item.b), exit) + e.fastCellAddress(index.payload, extents[int(item.a)], exit) + e.fastStore(cell(), value.payload, value.tag, kindTag(value.kind)) + else: + done = false + for register in 0 ..< Pool.len: + pinned[register] = false + done + + template emitFastBlock(start, stop: int) = + ## Emits one block's fast version, then its ways out. + held.setLen(0) + for register in 0 ..< Pool.len: + uses[register] = 0 + pinned[register] = false + for at in start ..< stop: + if not emitFast(at): + clearAll() + emitInstruction(at, false, Loop(), @[], noExits) + if fallsOnward(code[stop - 1].op): + flushAll() + e.jump(blocks[stop]) + for (exit, pending, offset) in fastExits: + e.place(exit) + for value in pending: + writeBack(value) + e.jump(general[offset]) + fastExits.setLen(0) + if stubs.len > 0: + for stub in stubs: + e.place(stub.label) + e.callSlow(stub.offset, slowLabel) + case stub.carry + of ToNext: + e.jump(blocks[int(stub.offset) + 1]) + of ToOffset: + e.jump(dispatchLabel) + stubs.setLen(0) + + var index = 0 + while index < code.len: + var stop = index + 1 + while stop < code.len and code[stop].op != MeterOp: + inc stop + if code[index].op == MeterOp and not inLoop[index]: + e.place(blocks[index]) + emitFastBlock(index, stop) e.place(general[index]) - emitInstruction(index, false, Loop(), @[], noExits) + for at in index ..< stop: + if at > index: + e.place(blocks[at]) + emitInstruction(at, false, Loop(), @[], noExits) + else: + for at in index ..< stop: + e.place(blocks[at]) + let number = loopAt[at] + if number >= 0: + # Entering a loop's head: prove its globals whole numbers and + # move them into registers, or run it in general code if any + # is not. Every tag is looked at before any register is + # filled, because one of those registers may be what general + # code reads through. + let loop = loops[number] + e.beginHoisting() + for which in loop.globals: + e.guardHoisted(which, general[loop.start]) + for position, which in loop.globals: + e.loadHoisted(position, which) + e.jump(loopCopies[number][0]) + e.place(general[at]) + emitInstruction(at, false, Loop(), @[], noExits) + index = stop # Each loop's own copy, after all the general code. for number, loop in loops: @@ -2589,8 +3414,12 @@ proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], limits, false) except BasicError: # Some branch could not reach; every branch then goes the long way. - emitted = emitProgram(code, routines, ownerOf, extents, constants, - limits, true) + # Should that fail too, the program is left to the interpreter. + try: + emitted = emitProgram(code, routines, ownerOf, extents, constants, + limits, true) + except BasicError: + return nil let (bytes, starts) = emitted if bytes.len > MaxProgramBytes: return nil diff --git a/tests/test_amd64.nim b/tests/test_amd64.nim index f545c0f..1681f04 100644 --- a/tests/test_amd64.nim +++ b/tests/test_amd64.nim @@ -129,6 +129,12 @@ encodes "setl al\n\tmovzx eax, al": assembler.setIfCondition(rax, LessCondition) encodes "sete dl\n\tmovzx edx, dl": assembler.setIfCondition(rdx, EqualCondition) +encodes "setne sil\n\tmovzx esi, sil": + assembler.setIfCondition(rsi, NotEqualCondition) +encodes "setg dil\n\tmovzx edi, dil": + assembler.setIfCondition(rdi, GreaterCondition) +encodes "setle r9b\n\tmovzx r9d, r9b": + assembler.setIfCondition(r9, LessEqualCondition) encodes "shl rax, 4": assembler.shiftLeftImmediate(Word64, rax, 4) From 244e61098a29f4124dd6f21725a87608871850bf Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:11:52 -0700 Subject: [PATCH 35/51] track constants and known kinds in fast blocks --- src/bassy/jit.nim | 124 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 11 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index d301a98..116ef94 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -248,6 +248,15 @@ proc comparisonCheck(op: Op): Check {.raises: [].} = of GreaterOp: GreaterCheck else: GreaterEqualCheck +proc swapped(check: Check): Check {.raises: [].} = + ## Returns the outcome that asks the same with the operands turned round. + case check + of LessCheck: GreaterCheck + of LessEqualCheck: GreaterEqualCheck + of GreaterCheck: LessCheck + of GreaterEqualCheck: LessEqualCheck + else: check + proc takenOn(op: Op): Check {.raises: [].} = ## Returns the outcome on which a fused test takes its branch. case op @@ -2077,6 +2086,8 @@ type tag: int kind: Kind dirty: bool + constant: bool + value: int32 Loop = object ## A loop whose globals can live in registers while it runs. @@ -2844,13 +2855,15 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], held[position] template bindValue(target: Place, valueRegister, kindRegister: int, - known: Kind) = - ## Holds a new value for a place, pending until written back. + known: Kind, isConstant = false, bits = 0'i32) = + ## Holds a new value for a place, pending until written back, along + ## with what it is when that is known here. let position = findHeld(target) if position >= 0: forget(position) held.add(Held(place: target, payload: valueRegister, - tag: kindRegister, kind: known, dirty: true)) + tag: kindRegister, kind: known, dirty: true, constant: isConstant, + value: bits)) inc uses[valueRegister] if kindRegister >= 0: inc uses[kindRegister] @@ -2904,7 +2917,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let target = if item.op == StoreGlobalImmediateOp: global(item.a) else: slot(item.a) - bindValue(target, register, -1, kind) + bindValue(target, register, -1, kind, true, bits) of MoveOp, LoadGlobalOp, StoreGlobalOp, MoveGlobalOp, LoadHostDataOp: let (target, source) = case item.op @@ -2915,7 +2928,8 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], else: (slot(item.a), host(item.b)) makeRoom([source], 0) let value = fetch(source, offset) - bindValue(target, value.payload, value.tag, value.kind) + bindValue(target, value.payload, value.tag, value.kind, + value.constant, value.value) of SetArgumentOp, SetArgumentGlobalOp: let source = if item.op == SetArgumentOp: slot(item.b) @@ -2974,6 +2988,18 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.fastMultiplyFixed(target, source) else: e.fastMultiply(target, source) + template promote(widened: int, side: Held, exit: Label) = + ## Turns a whole number into fixed point in a fresh register. + ## A constant is turned here, and one out of range can only + ## ever leave, as the interpreter would refuse it. + if side.constant: + if side.value >= -32768 and side.value <= 32767: + e.fastConstant(widened, side.value shl FixedShift) + else: + e.jump(exit) + else: + e.fastMove(widened, side.payload) + e.fastToFixed(widened, exit) if b.kind != UnknownKind and c.kind != UnknownKind: let result = acquire() if b.kind == c.kind: @@ -2984,19 +3010,62 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let exit = leaveHere(offset) let widened = acquire() if b.kind == WholeKind: - e.fastMove(widened, b.payload) - e.fastToFixed(widened, exit) + promote(widened, b, exit) e.fastMove(result, widened) operate(result, c.payload, true) else: - e.fastMove(widened, c.payload) - e.fastToFixed(widened, exit) + promote(widened, c, exit) e.fastMove(result, b.payload) operate(result, widened, true) let kind = if b.kind == WholeKind and c.kind == WholeKind: WholeKind else: FixedKind bindValue(slot(item.a), result, -1, kind) + elif b.kind != UnknownKind or c.kind != UnknownKind: + # One kind is known, so one test of the other decides: the + # same kind goes straight through, the other promotes. + let knownLeft = b.kind != UnknownKind + let known = if knownLeft: b else: c + let unknown = if knownLeft: c else: b + let exit = leaveHere(offset) + let result = acquire() + let widened = acquire() + let otherWay = e.label() + let finished = e.label() + if known.kind == WholeKind: + let resultTag = acquire() + e.fastJumpIfNotZero(unknown.tag, otherWay) + e.fastMove(result, b.payload) + operate(result, c.payload, false) + e.fastConstant(resultTag, 0) + e.jump(finished) + e.place(otherWay) + promote(widened, known, exit) + if knownLeft: + e.fastMove(result, widened) + operate(result, c.payload, true) + else: + e.fastMove(result, b.payload) + operate(result, widened, true) + e.fastConstant(resultTag, FixedTag) + e.place(finished) + bindValue(slot(item.a), result, resultTag, UnknownKind) + else: + e.fastJumpIfZero(unknown.tag, otherWay) + e.fastMove(result, b.payload) + operate(result, c.payload, true) + e.jump(finished) + e.place(otherWay) + e.fastMove(widened, unknown.payload) + e.fastToFixed(widened, exit) + if knownLeft: + e.fastMove(result, b.payload) + operate(result, widened, true) + else: + e.fastMove(result, widened) + operate(result, c.payload, true) + e.place(finished) + bindValue(slot(item.a), result, -1, FixedKind) else: let exit = leaveHere(offset) let leftTag = tagOf(b) @@ -3061,7 +3130,40 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], makeRoom([left, right], 3 + known) let b = fetch(left, offset) let c = fetch(right, offset) - if b.kind != UnknownKind and b.kind == c.kind: + var check = comparisonCheck(item.op) + if (c.constant or b.constant) and not (b.constant and c.constant): + # One side is a constant: compare the other against it directly, + # turning the question round when the constant came first. + let constantRight = c.constant + let fixed = if constantRight: c else: b + let other = if constantRight: b else: c + if not constantRight: + check = swapped(check) + let bits = fixed.value + let wideBits = + if fixed.kind == FixedKind: int64(bits) + else: int64(bits) * 65536 + let sameKind = e.label() + let decided = e.label() + if other.kind == fixed.kind: + e.fastCompareConstant(other.payload, bits) + else: + let otherTag = tagOf(other) + let wide = acquire() + let bound = acquire() + if fixed.kind == WholeKind: + e.fastJumpIfZero(otherTag, sameKind) + else: + e.fastJumpIfNotZero(otherTag, sameKind) + e.fastMove(wide, other.payload) + e.fastScaleWide(wide, otherTag) + e.fastLoadWide(bound, wideBits) + e.fastCompareWide(wide, bound) + e.jump(decided) + e.place(sameKind) + e.fastCompareConstant(other.payload, bits) + e.place(decided) + elif b.kind != UnknownKind and b.kind == c.kind: e.fastCompare(b.payload, c.payload) else: # Kinds that differ compare on the widened fixed-point scale. @@ -3082,7 +3184,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.fastCompareWide(wideLeft, wideRight) e.place(decided) let result = acquire() - e.fastAnswer(result, comparisonCheck(item.op)) + e.fastAnswer(result, check) bindValue(slot(item.a), result, -1, WholeKind) of JumpOp: flushAll() From 999441338e2ed41f58b22928e63da1db0094d0b9 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:16:54 -0700 Subject: [PATCH 36/51] read strings in place for len, asc, and string equality --- src/bassy.nim | 6 +- src/bassy/jit.nim | 271 ++++++++++++++++++++++++++++++++++++++++-- src/bassy/texts.nim | 34 ++++++ tests/test_native.nim | 9 +- 4 files changed, 309 insertions(+), 11 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index bbe9998..46ed6c4 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3201,7 +3201,8 @@ proc compileNative*(runtime: var Runtime): int = runtime.globals.len, runtime.hostData.len, runtime.arguments.len, - limits + limits, + textLayoutMatches() ) if runtime.machine != nil: return runtime.program.code.len @@ -4008,6 +4009,9 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = runtime: cast[pointer](runtime), step: cast[pointer](nativeStep), hostStep: cast[pointer](nativeHostCall), + stringOwner: runtime.strings.ownerAddress, + stringSpans: runtime.strings.spansAddress, + stringArena: runtime.strings.arenaAddress, remainingInstructions: runtime.remainingInstructions, remainingWork: runtime.remainingWork, pc: runtime.pc diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 116ef94..f1f53d5 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -18,7 +18,7 @@ ## small set of emitters, so AArch64 and x86-64 stay in step. import - bytecode, machine, numbers + bytecode, machine, numbers, texts export machine.jitSupported @@ -57,6 +57,9 @@ type runtime*: pointer step*: pointer hostStep*: pointer + stringOwner*: pointer + stringSpans*: pointer + stringArena*: pointer NativeCall* = proc(context: ptr NativeContext): int32 {.cdecl, gcsafe, raises: [].} @@ -97,6 +100,9 @@ const ContextRuntime = 96 ContextStep = 104 ContextHostStep = 112 + ContextStringOwner = 120 + ContextStringSpans = 128 + ContextStringArena = 136 ## One frame as the interpreter lays it out: where the caller's slots ## start, which routine it was in, where to carry on, and whether it @@ -109,6 +115,7 @@ const FrameTag* = 12 FixedTag = 1 + StringTag = 2 FixedShift = 16 FixedRounding = 1'i64 shl (FixedShift - 1) @@ -144,7 +151,8 @@ proc layoutMatches*(): bool {.raises: [].} = # because the generated code tests the tag byte for exactly those. if image[0] != byte(ord(IntegerValue)) or ord(IntegerValue) != 0: return false - if image[ValueStride] != byte(FixedTag) or ord(FixedValue) != FixedTag: + if image[ValueStride] != byte(FixedTag) or ord(FixedValue) != FixedTag or + ord(StringValue) != StringTag: return false var payload = 0'i32 copyMem(payload.addr, image[ValuePayload].addr, sizeof(int32)) @@ -172,7 +180,10 @@ proc layoutMatches*(): bool {.raises: [].} = at(routine) == ContextRoutine and at(runtime) == ContextRuntime and at(step) == ContextStep and - at(hostStep) == ContextHostStep + at(hostStep) == ContextHostStep and + at(stringOwner) == ContextStringOwner and + at(stringSpans) == ContextStringSpans and + at(stringArena) == ContextStringArena type Machine* = ref object @@ -1168,6 +1179,109 @@ when NativeArm64: e.code.addRegister(Word32, FastScratch, FastScratch, position) e.code.addRegister(Word64, Cell, MemoryBase, FastScratch, 4) + ## Strings read in place + ## + ## A string value is its storage's owner in the high half and a handle + ## into the span table in the low half. Everything is checked just as + ## the interpreter checks it, and anything amiss takes the slow path, + ## which raises the interpreter's own error. + + proc stringSpan(e: var Emitter, place: Place, reference, start, + length: Register, slow: Label) {.raises: [BasicError].} = + ## Reads a string's start and length, or takes the slow path unless + ## the value is a string of this storage's current generation. + let (base, offset) = e.reach(place) + e.code.loadByte(start, base, offset) + e.code.compareImmediate(Word32, start, StringTag) + e.jumpWhen(NotEqualCondition, slow) + e.code.loadDouble(reference, base, offset + ValuePayload) + e.code.loadDouble(length, Context, ContextStringOwner) + e.code.loadWord(length, length, 0) + e.jumpIfZero(length, slow) + e.code.shiftRightImmediate(Word64, start, reference, 32) + e.code.compareRegister(Word32, start, length) + e.jumpWhen(NotEqualCondition, slow) + e.code.loadDouble(length, Context, ContextStringSpans) + e.code.loadDouble(start, length, 0) + e.code.moveRegister(Word32, reference, reference) + e.code.compareRegister(Word64, reference, start) + e.jumpWhen(CarrySetCondition, slow) + e.code.loadDouble(length, length, 8) + e.code.addRegister(Word64, length, length, reference, 3) + e.code.loadWord(start, length, 8) + e.code.loadWord(length, length, 12) + + proc chargeWork(e: var Emitter, cost: Register, slow: Label) + {.raises: [].} = + ## Charges work worked out at run time, or takes the slow path when it + ## cannot be afforded, where the interpreter raises. + e.code.compareRegister(Word64, Work, cost) + e.jumpWhen(LessCondition, slow) + e.code.subtractRegister(Word64, Work, Work, cost) + + proc arenaBase(e: var Emitter, destination: Register) + {.raises: [BasicError].} = + ## Points at the first byte of the string arena. + e.code.loadDouble(destination, Context, ContextStringArena) + e.code.loadDouble(destination, destination, 8) + e.code.addImmediate(Word64, destination, destination, 8) + + proc stringFunction(e: var Emitter, function: TextFunction, slow: Label) + {.raises: [BasicError].} = + ## Answers LEN or ASC of the first argument into the first working + ## register, charging what the interpreter charges. + e.stringSpan(argument(0), x0, x1, x2, slow) + if function == CodeFunction: + e.jumpIfZero(x2, slow) + e.code.addImmediate(Word64, x3, x2, 1) + e.chargeWork(x3, slow) + if function == LengthFunction: + e.code.moveRegister(Word32, temp(0), x2) + else: + e.arenaBase(x4) + e.code.addRegister(Word64, x4, x4, x1) + e.code.loadByte(temp(0), x4, 0) + + proc stringEquality(e: var Emitter, left, right: Place, equal: bool, + slow: Label) {.raises: [BasicError].} = + ## Answers whether two strings hold the same bytes, into the first + ## working register, charging both lengths as the interpreter does. + e.stringSpan(left, x0, x1, x2, slow) + e.stringSpan(right, x3, x4, x5, slow) + e.code.addRegister(Word64, x6, x2, x5) + e.chargeWork(x6, slow) + let differ = e.label() + let same = e.label() + let done = e.label() + e.code.compareRegister(Word32, x2, x5) + e.code.branchIf(NotEqualCondition, differ) + e.arenaBase(x6) + e.code.addRegister(Word64, x1, x6, x1) + e.code.addRegister(Word64, x4, x6, x4) + e.code.branchIfZero(Word32, x2, same) + let again = e.label() + e.place(again) + e.code.loadByte(x7, x1, 0) + e.code.loadByte(x8, x4, 0) + e.code.compareRegister(Word32, x7, x8) + e.code.branchIf(NotEqualCondition, differ) + e.code.addImmediate(Word64, x1, x1, 1) + e.code.addImmediate(Word64, x4, x4, 1) + e.code.subtractImmediate(Word32, x2, x2, 1) + e.code.branchIfNotZero(Word32, x2, again) + e.place(same) + e.code.loadImmediate(Word32, temp(0), if equal: -1 else: 0) + e.jump(done) + e.place(differ) + e.code.loadImmediate(Word32, temp(0), if equal: 0 else: -1) + e.place(done) + + proc jumpUnlessTag(e: var Emitter, tag: int, value: int, target: Label) + {.raises: [BasicError].} = + ## Jumps unless a working register holds one particular tag. + e.code.compareImmediate(Word32, temp(tag), value) + e.jumpWhen(NotEqualCondition, target) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -2049,6 +2163,117 @@ elif NativeAmd64: e.contextField(Cell, ContextMemory) e.code.addRegister(Word64, Cell, FastScratch) + ## Strings read in place + ## + ## A string value is its storage's owner in the high half and a handle + ## into the span table in the low half. Everything is checked just as + ## the interpreter checks it, and anything amiss takes the slow path, + ## which raises the interpreter's own error. + + proc stringSpan(e: var Emitter, place: Place, reference, start, + length: Register, slow: Label) {.raises: [BasicError].} = + ## Reads a string's start and length, or takes the slow path unless + ## the value is a string of this storage's current generation. + let (base, offset) = e.reach(place) + e.code.loadByteZeroed(start, base, offset) + e.code.compareImmediate(Word32, start, StringTag) + e.jumpWhen(NotEqualCondition, slow) + e.code.loadDouble(reference, base, offset + ValuePayload) + e.contextField(length, ContextStringOwner) + e.code.loadWord(length, length, 0) + e.code.testRegister(Word32, length, length) + e.jumpWhen(EqualCondition, slow) + e.code.moveRegister(Word64, start, reference) + e.code.shiftRightImmediate(Word64, start, 32) + e.code.compareRegister(Word32, start, length) + e.jumpWhen(NotEqualCondition, slow) + e.contextField(length, ContextStringSpans) + e.code.loadDouble(start, length, 0) + e.code.moveRegister(Word32, reference, reference) + e.code.compareRegister(Word64, reference, start) + e.jumpWhen(AboveEqualCondition, slow) + e.code.loadDouble(length, length, 8) + e.code.moveRegister(Word64, start, reference) + e.code.shiftLeftImmediate(Word64, start, 3) + e.code.addRegister(Word64, length, start) + e.code.loadWord(start, length, 8) + e.code.loadWord(length, length, 12) + + proc chargeWork(e: var Emitter, cost: Register, slow: Label) + {.raises: [].} = + ## Charges work worked out at run time, or takes the slow path when it + ## cannot be afforded, where the interpreter raises. + e.code.compareRegister(Word64, Work, cost) + e.jumpWhen(LessCondition, slow) + e.code.subtractRegister(Word64, Work, cost) + + proc arenaBase(e: var Emitter, destination: Register) + {.raises: [BasicError].} = + ## Points at the first byte of the string arena. + e.contextField(destination, ContextStringArena) + e.code.loadDouble(destination, destination, 8) + e.code.addImmediate(Word64, destination, 8) + + proc stringFunction(e: var Emitter, function: TextFunction, slow: Label) + {.raises: [BasicError].} = + ## Answers LEN or ASC of the first argument into the first working + ## register, charging what the interpreter charges. + e.stringSpan(argument(0), r8, rsi, rdi, slow) + if function == CodeFunction: + e.code.testRegister(Word32, rdi, rdi) + e.jumpWhen(EqualCondition, slow) + e.code.moveRegister(Word32, Spare, rdi) + e.code.addImmediate(Word64, Spare, 1) + e.chargeWork(Spare, slow) + if function == LengthFunction: + e.code.moveRegister(Word32, temp(0), rdi) + else: + e.arenaBase(Cell) + e.code.addRegister(Word64, Cell, rsi) + e.code.loadByteZeroed(temp(0), Cell, 0) + + proc stringEquality(e: var Emitter, left, right: Place, equal: bool, + slow: Label) {.raises: [BasicError].} = + ## Answers whether two strings hold the same bytes, into the first + ## working register, charging both lengths as the interpreter does. + e.stringSpan(left, r8, rsi, rdi, slow) + e.stringSpan(right, r9, r10, r14, slow) + e.code.moveRegister(Word32, Spare, rdi) + e.code.addRegister(Word64, Spare, r14) + e.chargeWork(Spare, slow) + let differ = e.label() + let same = e.label() + let done = e.label() + e.code.compareRegister(Word32, rdi, r14) + e.jumpWhen(NotEqualCondition, differ) + e.arenaBase(Cell) + e.code.addRegister(Word64, rsi, Cell) + e.code.addRegister(Word64, r10, Cell) + e.code.testRegister(Word32, rdi, rdi) + e.jumpWhen(EqualCondition, same) + let again = e.label() + e.place(again) + e.code.loadByteZeroed(rax, rsi, 0) + e.code.loadByteZeroed(Spare, r10, 0) + e.code.compareRegister(Word32, rax, Spare) + e.jumpWhen(NotEqualCondition, differ) + e.code.addImmediate(Word64, rsi, 1) + e.code.addImmediate(Word64, r10, 1) + e.code.subtractImmediate(Word32, rdi, 1) + e.jumpWhen(NotEqualCondition, again) + e.place(same) + e.code.loadImmediate(Word32, temp(0), if equal: -1 else: 0) + e.jump(done) + e.place(differ) + e.code.loadImmediate(Word32, temp(0), if equal: 0 else: -1) + e.place(done) + + proc jumpUnlessTag(e: var Emitter, tag: int, value: int, target: Label) + {.raises: [].} = + ## Jumps unless a working register holds one particular tag. + e.code.compareImmediate(Word32, temp(tag), int32(value)) + e.jumpWhen(NotEqualCondition, target) + proc halt(e: var Emitter, offset: int32) {.raises: [BasicError].} = ## Publishes the budgets and where the program stopped, then returns. e.code.storeDouble(Instructions, Context, ContextInstructions) @@ -2227,7 +2452,7 @@ proc findLoops(code: seq[Instruction], capacity: int): seq[Loop] proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], ownerOf: seq[int32], extents: seq[ArrayExtent], constants: seq[int32], - limits: CallLimits, far: bool): (seq[byte], seq[int]) + limits: CallLimits, far, strings: bool): (seq[byte], seq[int]) {.raises: [BasicError].} = ## Emits the whole program and returns its bytes along with where each ## offset's block starts. @@ -2536,8 +2761,21 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], # The same kind on both sides orders the same on the stored bits, # and the answer is always a whole number. let slow = slowFor(ToNext) + let answered = e.label() e.readValue(0, 2, slot(item.b)) e.readValue(1, 3, slot(item.c)) + when not specialised: + if strings and item.op in {EqualOp, NotEqualOp}: + # Two strings compare byte by byte where they are kept. Inside + # a specialised loop this would need registers its globals + # hold, so there it leaves the loop instead. + let numbers = e.label() + e.jumpUnlessTag(2, StringTag, numbers) + e.jumpUnlessTag(3, StringTag, numbers) + e.stringEquality(slot(item.b), slot(item.c), + item.op == EqualOp, slow) + e.jump(answered) + e.place(numbers) e.unlessNumeric(2, slow) e.unlessNumeric(3, slow) let sameKind = e.label() @@ -2553,6 +2791,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.compare(0, 1) e.place(decided) e.answer(0, comparisonCheck(item.op)) + e.place(answered) e.writeWhole(slot(item.a), 0) of AndOp, OrOp, XorOp, EqvOp, ImpOp: let slow = slowFor(ToNext) @@ -2680,8 +2919,17 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], # Host calls have a helper of their own, which goes straight to # the host call's code instead of through the general dispatch. e.callSlow(offset, hostLabel) - of LoadStringOp, TextCallOp, PrintTextOp, PrintValueOp, - PrintNewlineOp: + of TextCallOp: + let function = TextFunction(item.b) + if strings and item.c == 1 and + function in {LengthFunction, CodeFunction}: + # LEN and ASC read the string where it is kept. + let slow = slowFor(ToNext) + e.stringFunction(function, slow) + e.writeWhole(slot(item.a), 0) + else: + runSlow() + of LoadStringOp, PrintTextOp, PrintValueOp, PrintNewlineOp: runSlow() when not specialised: @@ -3354,7 +3602,8 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], extents: seq[ArrayExtent], constants: seq[int32], globals, hostData, - arguments: int, limits: CallLimits): Machine {.raises: [BasicError].} = + arguments: int, limits: CallLimits, strings = false): Machine + {.raises: [BasicError].} = ## Compiles every offset of a program to machine code, or returns nil ## when this target has no backend or the program is outside what the ## generator is sure of. Generated code indexes storage without @@ -3429,6 +3678,10 @@ proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], of MeterOp: if item.a < 0 or item.b < 0: return nil + of TextCallOp: + requireSlot(item.a) + if item.c < 1 or int(item.c) > arguments: + return nil of LoadImmediateOp: requireSlot(item.a) of LoadFixedOp: @@ -3513,13 +3766,13 @@ proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], var emitted: (seq[byte], seq[int]) try: emitted = emitProgram(code, routines, ownerOf, extents, constants, - limits, false) + limits, false, strings) except BasicError: # Some branch could not reach; every branch then goes the long way. # Should that fail too, the program is left to the interpreter. try: emitted = emitProgram(code, routines, ownerOf, extents, constants, - limits, true) + limits, true, strings) except BasicError: return nil let (bytes, starts) = emitted diff --git a/src/bassy/texts.nim b/src/bassy/texts.nim index c5c24fb..b8e7887 100644 --- a/src/bassy/texts.nim +++ b/src/bassy/texts.nim @@ -319,3 +319,37 @@ proc reset*(storage: var TextStorage, roots: var seq[Value]) = swap(storage.arena, storage.scratch) swap(storage.spans, storage.scratchSpans) storage.owner = owner + +## Direct access for compiled code + +proc ownerAddress*(storage: var TextStorage): pointer {.raises: [].} = + ## Returns where the current generation's owner is kept. + storage.owner.addr + +proc spansAddress*(storage: var TextStorage): pointer {.raises: [].} = + ## Returns the span table itself, so its length and buffer are read + ## fresh each time, surviving the swap a reset makes. + storage.spans.addr + +proc arenaAddress*(storage: var TextStorage): pointer {.raises: [].} = + ## Returns the arena itself, read fresh each time for the same reason. + storage.arena.addr + +proc textLayoutMatches*(): bool {.raises: [].} = + ## Confirms the layout compiled code reads strings through: a span is a + ## start then a length, both thirty-two bits, and a sequence or string + ## is a length then a pointer to a capacity followed by the items. + var spans = newSeqOfCap[TextSpan](2) + spans.add TextSpan(start: 3, length: 5) + var arena = newStringOfCap(4) + arena.add "ab" + let spanOrigin = cast[int](spans[0].addr) + if sizeof(TextSpan) != 8 or + cast[int](spans[0].length.addr) - spanOrigin != 4: + return false + let spanFields = cast[ptr array[2, int]](spans.addr) + if spanFields[0] != spans.len or spanFields[1] + 8 != spanOrigin: + return false + let arenaFields = cast[ptr array[2, int]](arena.addr) + arenaFields[0] == arena.len and + arenaFields[1] + 8 == cast[int](arena[0].addr) diff --git a/tests/test_native.nim b/tests/test_native.nim index c795465..797b7ed 100644 --- a/tests/test_native.nim +++ b/tests/test_native.nim @@ -443,6 +443,8 @@ proc whole(g: var Generator): string = of 10: "twice(" & g.whole() & ")" else: "pick(" & g.whole() & ", " & g.whole() & ")" +proc text(g: var Generator): string + proc numeric(g: var Generator): string proc cellIndex(g: var Generator): string = @@ -484,7 +486,12 @@ proc numeric(g: var Generator): string = of 11: "(not " & g.numeric() & ")" of 12: "-" & g.numeric() of 13: "halve(" & g.numeric() & ")" - of 14: "len(" & g.pick(Texts) & ")" + of 14: + case g.random.rand(0 .. 3) + of 0: "len(" & g.text() & ")" + of 1: "asc(" & g.text() & ")" + of 2: "(" & g.text() & " = " & g.text() & ")" + else: "(" & g.text() & " <> " & g.text() & ")" of 15: g.whole() else: "twice(" & g.whole() & ")" From 78a8ea89181eab38a05bf2fbbea3c229e5dacba3 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:27:50 -0700 Subject: [PATCH 37/51] carry held values from one block into the next --- src/bassy/jit.nim | 68 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index f1f53d5..d0155f2 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -125,6 +125,8 @@ const ModelsFixed* = not defined(fixedChecks) MaxProgramBytes = 64 * 1024 * 1024 + ## How many blocks one fast version carries on into, holding values. + MaxChain = 4 ## Leaving a routine without one of these would run on into the next ## routine's code, which no call set up. Terminators = {JumpOp, ReturnOp, ReturnLabelOp, ExitSubOp, HaltOp} @@ -3152,6 +3154,10 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], let offset = at var done = true case item.op + of MeterOp: + # The budget is looked at exactly as the general code would, and a + # block short of it writes back and goes there to be refused. + e.meter(item.b, item.a, leaveHere(offset)) of LoadImmediateOp, LoadFixedOp, StoreGlobalImmediateOp: makeRoom(newSeq[Place](), 1) let register = acquire() @@ -3494,19 +3500,63 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], pinned[register] = false done - template emitFastBlock(start, stop: int) = + template blockEnd(start: int): int = + ## Returns the offset just past the block starting here. + var stop = start + 1 + while stop < code.len and code[stop].op != MeterOp: + inc stop + stop + + template chainable(target: int): bool = + ## Reports whether a block can be carried on into with values still + ## held: an ordinary block, starting at its meter. + target >= 0 and target < code.len and code[target].op == MeterOp and + not inLoop[target] + + template emitFastBlock(start: int) = ## Emits one block's fast version, then its ways out. + ## + ## Where the block carries on into another, by falling through or by + ## an unconditional jump, a copy of that one follows with every value + ## still held, so nothing is written back or read again at the seam. + ## Only this way in reaches the copy; the block's own fast version + ## serves every other. A chain stops after a few blocks, at a block + ## it has already taken in, and wherever general code took over. held.setLen(0) for register in 0 ..< Pool.len: uses[register] = 0 pinned[register] = false - for at in start ..< stop: - if not emitFast(at): - clearAll() - emitInstruction(at, false, Loop(), @[], noExits) - if fallsOnward(code[stop - 1].op): - flushAll() - e.jump(blocks[stop]) + var current = start + var chained = @[start] + while true: + let stop = blockEnd(current) + var handled = true + var next = -1 + for at in current ..< stop: + let item = code[at] + if at == stop - 1 and item.op == JumpOp and + chainable(int(item.a)) and int(item.a) notin chained and + chained.len < MaxChain: + next = int(item.a) + break + if emitFast(at): + handled = true + else: + handled = false + clearAll() + emitInstruction(at, false, Loop(), @[], noExits) + let last = code[stop - 1] + if next < 0 and handled and fallsOnward(last.op) and + chainable(stop) and stop notin chained and + chained.len < MaxChain: + next = stop + if next < 0: + if fallsOnward(last.op): + flushAll() + e.jump(blocks[stop]) + break + chained.add(next) + current = next for (exit, pending, offset) in fastExits: e.place(exit) for value in pending: @@ -3531,7 +3581,7 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], inc stop if code[index].op == MeterOp and not inLoop[index]: e.place(blocks[index]) - emitFastBlock(index, stop) + emitFastBlock(index) e.place(general[index]) for at in index ..< stop: if at > index: From 80e821ee1e8eb17ae25d4105d938540cd4a64501 Mon Sep 17 00:00:00 2001 From: treeform Date: Wed, 23 Sep 2026 08:29:44 -0700 Subject: [PATCH 38/51] add a string benchmark alongside the raytracer --- .github/workflows/build.yml | 2 + tests/bench_strings.nim | 86 +++++++++++++++++++++++++++++++++++++ tests/strings.bas | 79 ++++++++++++++++++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 tests/bench_strings.nim create mode 100644 tests/strings.bas diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4a12b6a..7748b12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,6 +74,8 @@ jobs: run: nim r -d:release -d:bassyNoJit tests/test_jit.nim - name: Render the BASIC raytracer and check cross-platform determinism run: nim r -d:release tests/bench_raytracer.nim + - name: Run the BASIC text workload and check cross-platform determinism + run: nim r -d:release tests/bench_strings.nim - name: Report native compilation speedups run: nim r -d:release tests/bench_jit.nim - name: Run examples diff --git a/tests/bench_strings.nim b/tests/bench_strings.nim new file mode 100644 index 0000000..9417ec6 --- /dev/null +++ b/tests/bench_strings.nim @@ -0,0 +1,86 @@ +## Runs the BASIC text workload on both execution paths. +## +## The string counterpart of the raytracer: splitting, slicing, reversing, +## encoding, comparing and scoring words, where nearly every instruction +## touches a string. It shows what compiling buys when most of the work is +## string storage rather than arithmetic, and checks that a script this +## string-heavy still agrees on both paths. + +import + std/[monotimes, strformat, times], + bassy + +const + Source = staticRead("strings.bas") + Rounds = 200 + Runs = 5 + +proc textLimits(): Limits = + ## Returns limits large enough for every string the workload builds. + result = defaultLimits() + result.maxInstructions = 2_000_000_000 + result.maxWorkUnits = 2_000_000_000 + result.maxStrings = 200_000 + result.maxStringBytes = 4 * 1024 * 1024 + result.maxMemoryBytes = 256 * 1024 * 1024 + +proc processOnce(runtime: var Runtime): (float, int32) = + ## Runs the workload once from a fresh state, timing it. + runtime.reset() + runtime.setGlobal("rounds", toValue(Rounds.int32)) + let started = getMonoTime() + discard runtime.run() + let elapsed = (getMonoTime() - started).inNanoseconds.float / 1_000_000.0 + (elapsed, runtime.getGlobal("checksum")) + +proc process(runtime: var Runtime): (float, int32) = + ## Returns the fastest of several runs and the checksum. + result[0] = Inf + for attempt in 1 .. Runs: + let (elapsed, checksum) = runtime.processOnce() + if elapsed < result[0]: + result[0] = elapsed + result[1] = checksum + +let limits = textLimits() +let program = compile(Source, limits) + +echo &"native compilation available: {jitSupported()}" +echo &"host: {hostCPU} {hostOS}" +echo &"rounds: {Rounds}, bytecode {program.instructions} instructions" + +var plain = initRuntime(program, limits) +var fast = initRuntime(program, limits) +let compiled = fast.compileNative() + +let (plainTime, plainSum) = plain.process() +let (fastTime, fastSum) = fast.process() + +echo &" interpreted {plainTime:9.2f} ms checksum {plainSum}" +echo &" native {fastTime:9.2f} ms checksum {fastSum} " & + &"compiled offsets {compiled}" +if compiled > 0 and fastTime > 0.0: + echo &" ratio {plainTime / fastTime:9.2f}x" +echo &" instructions charged: {plain.instructionsUsed} vs " & + &"{fast.instructionsUsed}" +echo &" work charged: {plain.workUsed} vs {fast.workUsed}" +echo &" strings: {plain.stringCount} vs {fast.stringCount}, " & + &"bytes {plain.stringBytes} vs {fast.stringBytes}" + +# The checksum is pinned, so every architecture must build the very same +# strings, not merely agree with the interpreter running beside it. +const ExpectedChecksum = 350997'i32 +if plainSum != ExpectedChecksum: + quit(&"expected checksum {ExpectedChecksum} but computed {plainSum}") + +if plainSum != fastSum: + quit("the two paths disagreed on the checksum") +if plain.instructionsUsed != fast.instructionsUsed: + quit("the two paths disagreed on the instruction budget") +if plain.workUsed != fast.workUsed: + quit("the two paths disagreed on the work budget") +if plain.stringCount != fast.stringCount or + plain.stringBytes != fast.stringBytes: + quit("the two paths disagreed on the strings they built") + +echo "the text workload agrees on both paths" diff --git a/tests/strings.bas b/tests/strings.bas new file mode 100644 index 0000000..f0e7728 --- /dev/null +++ b/tests/strings.bas @@ -0,0 +1,79 @@ +rem A text workload: split sentences into words, then reverse, encode, +rem compare and score every word. It leans on every string function the +rem language has, the way a chatty game script would. + +dim words$(15) +checksum = 0 +palindromes = 0 +repeats = 0 +shouts = 0 +round = 0 +while round < rounds + sentence = 0 + while sentence < 4 + select case (sentence + round) mod 4 + case 0 + text$ = "the quick brown fox jumps over the lazy dog" + case 1 + text$ = "a man a plan a canal panama" + case 2 + text$ = "never odd or even said the racecar driver" + case else + text$ = " pack my box with five dozen liquor jugs " + end select + text$ = trim$(text$) + + rem Split on spaces. + count = 0 + start = 1 + finish = instr(start, text$, " ") + while finish > 0 + words$(count) = mid$(text$, start, finish - start) + count = count + 1 + start = finish + 1 + finish = instr(start, text$, " ") + wend + words$(count) = mid$(text$, start) + count = count + 1 + + index = 0 + while index < count + word$ = words$(index) + size = len(word$) + + rem Reverse it one letter at a time. + reversed$ = "" + letter = size + while letter > 0 + reversed$ = reversed$ + mid$(word$, letter, 1) + letter = letter - 1 + wend + if reversed$ = word$ and size > 1 then palindromes = palindromes + 1 + + rem Shift every letter three places along the alphabet. + coded$ = "" + letter = 1 + while letter <= size + code = asc(mid$(word$, letter, 1)) - 97 + coded$ = coded$ + chr$((code + 3) mod 26 + 97) + letter = letter + 1 + wend + + rem Count words seen earlier in the same sentence. + earlier = 0 + while earlier < index + if words$(earlier) = word$ then repeats = repeats + 1 + earlier = earlier + 1 + wend + + if ucase$(word$) = "THE" then shouts = shouts + 1 + checksum = (checksum * 31 + asc(coded$) * size + len(reversed$)) mod 1000003 + index = index + 1 + wend + checksum = (checksum + len(left$(text$, 5)) + len(right$(text$, 3))) mod 1000003 + sentence = sentence + 1 + wend + round = round + 1 +wend +summary$ = str$(checksum) + str$(palindromes) + str$(repeats) + str$(shouts) +checksum = (checksum + len(summary$)) mod 1000003 From 353547eb608064c36a3abd883b5fb6f1a99c9544 Mon Sep 17 00:00:00 2001 From: treeform Date: Thu, 24 Sep 2026 16:48:09 -0700 Subject: [PATCH 39/51] fuzz DATA arrays and runtime-aware host callbacks against the interpreter --- tests/test_native.nim | 66 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/tests/test_native.nim b/tests/test_native.nim index 797b7ed..ccd5b41 100644 --- a/tests/test_native.nim +++ b/tests/test_native.nim @@ -19,6 +19,7 @@ const Texts = ["s$", "t$"] Cells = 6 TextCells = 3 + TableCells = 6 var failures = 0 @@ -60,6 +61,25 @@ proc makeHost(): Host = proc(arguments: openArray[int32]): int32 = if arguments[0] > arguments[1]: arguments[0] else: arguments[1] , 3) + # Runtime-aware callbacks: one reads an array through a checked view, + # charging per cell, and writes the total into the first cell of + # another; the other burns work. Either can refuse part way. + let gather: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + let source = runtime.arrayView(arguments[0]) + let target = runtime.arrayView(arguments[1], writable = true) + runtime.chargeOperations(int64(source.len)) + var total = toValue(0'i32) + for index in 0 ..< source.len: + total = total + source[index] + target[0] = total + toValue(int32(source.len)) + discard result.addFunction("gather", 2, gather) + let burn: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + runtime.chargeWork(int64(arguments[0].asInt)) + arguments[0] + discard result.addFunction("burn", 1, burn) proc observe(source: string, native: bool, limits: Limits, runs: int): string = @@ -104,6 +124,9 @@ proc observe(source: string, native: bool, limits: Limits, for index in 0 ..< TextCells: transcript.add(&"text {index} " & runtime.render(runtime.getArrayValue("words$", int32(index)))) + for index in 0 ..< TableCells: + transcript.add(&"table {index} " & + runtime.render(runtime.getArrayValue("table", int32(index)))) if native: handed += runtime.handedBack transcript.join("\n") @@ -132,6 +155,8 @@ proc agree(name, source: string, limits = defaultLimits(), runs = 3) = const Preamble = """ dim cells(5) dim words$(2) +data table = 3, -1, 0.5, 2147483647, -7.25, 13 +data weights as fixed32 = 0.5, 1.25, -3 """ agree("arithmetic of every kind", Preamble & """ @@ -327,6 +352,34 @@ y = -32768 y = y - 0.5 """) +agree("DATA read in a loop kept in registers", Preamble & """ +a = 0 +b = 0 +while a < 6 + b = b + table(a) + a = a + 1 +wend +c = weights(0) * table(2) + weights(2) +""") + +agree("DATA handed to host code", Preamble & """ +a = gather(table, cells()) +b = gather(weights, cells) +c = burn(40) +d = gather(cells, table()) +e = 1 +""") + +block: + var limits = defaultLimits() + limits.maxInstructions = 400 + agree("host code charging operations until the budget runs out", + Preamble & """ +while 1 + a = a + gather(table, cells()) +wend +""", limits) + agree("select, for, do, and on-goto", Preamble & """ for i = 1 to 10 step 3 select case i @@ -420,10 +473,11 @@ proc whole(g: var Generator): string = inc g.depth defer: dec g.depth if g.depth > 3 or g.chance(35): - case g.random.rand(0 .. 6) + case g.random.rand(0 .. 7) of 0, 1: return $g.random.rand(-20 .. 20) of 2: return $g.pick([0, 1, -1, 2147483647, -2147483647, 13, 65536]) of 3, 4, 5: return g.pick(Scalars) + of 6: return "table(" & $g.random.rand(0 ..< TableCells) & ")" else: return "cells(" & $g.random.rand(0 ..< Cells) & ")" case g.random.rand(0 .. 11) of 0 .. 3: @@ -461,10 +515,14 @@ proc numeric(g: var Generator): string = inc g.depth defer: dec g.depth if g.depth > 3 or g.chance(30): - case g.random.rand(0 .. 5) + case g.random.rand(0 .. 6) of 0, 1: return g.literal() of 2, 3: return g.pick(Scalars) of 4: return g.pick(Decimals) + of 5: + if g.chance(50): + return "table(" & g.cellIndex() & ")" + return "weights(" & $g.random.rand(0 .. 3) & ")" else: return "cells(" & g.cellIndex() & ")" case g.random.rand(0 .. 16) of 0 .. 4: @@ -536,7 +594,9 @@ proc statement(g: var Generator, indent: string, room: int): string = indent & "print " & g.numeric() & "; " & g.text() & "\n" of 9: indent & g.pick(["bump(" & g.whole() & ")", "gosub tally", - "a = a + 1", "b = b - 1"]) & "\n" + "a = a + 1", "b = b - 1", "c = gather(table, cells())", + "d = gather(weights, cells)", "a = burn(" & g.whole() & ")", + "b = gather(cells, table())"]) & "\n" of 10 .. 12: let condition = g.numeric() var text = indent & "if " & condition & " then\n" & From 9ff319f029067beddfc4e62134c5a4e181480fb5 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:18:11 -0700 Subject: [PATCH 40/51] bound every computed jump to the program's own offsets A dispatch or a return now compares the offset against the program's length before reading the table, and sends anything outside it to the block past the end instead of jumping through memory beyond the table. Co-Authored-By: Claude Opus 5.5 --- src/bassy/jit.nim | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 9a621d5..76a946d 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -345,6 +345,8 @@ when NativeArm64: ## The assembler plus whether branches must reach anywhere at all. code: Assembler far: bool + limit: int + outside: Label proc label(e: var Emitter): Label {.inline, raises: [].} = ## Reserves a label. @@ -391,6 +393,15 @@ when NativeArm64: else: e.code.branchIfNotZero(Word32, register, target) + proc withinProgram(e: var Emitter, offset: Register) + {.raises: [BasicError].} = + ## Sends an offset that is not one of the program's own to the block + ## past the end, which refuses it, instead of reading a table entry + ## that is not there. One unsigned comparison covers both ends. + e.code.loadImmediate(Word32, Temps[6], int64(e.limit)) + e.code.compareRegister(Word32, offset, Temps[6]) + e.jumpWhen(CarrySetCondition, e.outside) + proc reach(e: var Emitter, place: Place): (Register, int) {.raises: [BasicError].} = ## Returns a base register and byte offset for a value, working the @@ -860,6 +871,7 @@ when NativeArm64: e.code.loadWord(resume, frame, FrameReturn) e.code.storeWord(resume, Context, ContextOffset) e.frameOf(RegistersBase, base) + e.withinProgram(resume) e.code.addRegister(Word64, temp(4), TableBase, resume, 3) e.code.loadDouble(temp(4), temp(4), 0) e.code.jumpRegister(temp(4)) @@ -893,6 +905,7 @@ when NativeArm64: proc dispatch(e: var Emitter) {.raises: [BasicError].} = ## Jumps to the block for whatever offset the context names. e.code.loadWord(temp(0), Context, ContextOffset) + e.withinProgram(temp(0)) e.code.addRegister(Word64, temp(1), TableBase, temp(0), 3) e.code.loadDouble(temp(1), temp(1), 0) e.code.jumpRegister(temp(1)) @@ -1360,6 +1373,8 @@ elif NativeAmd64: ## there is nothing to widen. code: Assembler far: bool + limit: int + outside: Label proc label(e: var Emitter): Label {.inline, raises: [].} = ## Reserves a label. @@ -1378,6 +1393,13 @@ elif NativeAmd64: ## Jumps when a condition holds. e.code.branchIf(condition, target) + proc withinProgram(e: var Emitter, offset: Register) {.raises: [].} = + ## Sends an offset that is not one of the program's own to the block + ## past the end, which refuses it, instead of reading a table entry + ## that is not there. One unsigned comparison covers both ends. + e.code.compareImmediate(Word32, offset, int32(e.limit)) + e.jumpWhen(AboveEqualCondition, e.outside) + proc contextField(e: var Emitter, destination: Register, offset: int) {.raises: [BasicError].} = ## Loads one pointer from the context. @@ -1867,6 +1889,7 @@ elif NativeAmd64: e.code.storeWord(Spare, Context, ContextOffset) e.code.moveRegister(Word32, r8, base) e.slotAddress(RegistersBase, r8) + e.withinProgram(Spare) e.contextField(rax, ContextTable) e.code.shiftLeftImmediate(Word64, Spare, 3) e.code.addRegister(Word64, rax, Spare) @@ -1909,6 +1932,7 @@ elif NativeAmd64: proc dispatch(e: var Emitter) {.raises: [BasicError].} = ## Jumps to the block for whatever offset the context names. e.code.loadWord(rax, Context, ContextOffset) + e.withinProgram(rax) e.code.shiftLeftImmediate(Word64, rax, 3) e.contextField(rcx, ContextTable) e.code.addRegister(Word64, rcx, rax) @@ -2475,6 +2499,8 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], for index in 0 .. code.len: blocks[index] = e.label() general[index] = blocks[index] + e.limit = code.len + e.outside = blocks[code.len] let dispatchLabel = e.label() let slowLabel = e.label() let hostLabel = e.label() From 286a9898a5ed359e7eca2aba06f3c676f27117a5 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:20:01 -0700 Subject: [PATCH 41/51] follow buffers host code replaces during a call Every call out of compiled code now republishes where the runtime's storage is, and the slow routine reloads every base register from it, so a host callback that replaces a buffer cannot leave compiled code writing through the freed one. A safety test replaces all of them mid-run; without the fix it computed different totals. Co-Authored-By: Claude Opus 5.5 --- src/bassy.nim | 34 +++++++++++++------------- src/bassy/jit.nim | 10 ++++++++ tests/test_jit_safety.nim | 50 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 74 insertions(+), 20 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 624a610..3ee972d 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -4320,6 +4320,20 @@ template performOp(runtime: Runtime, item: Instruction, print: PrintProc) = print(PrintEvent(kind: NewlinePrint)) inc runtime.pc +proc publishStorage(runtime: Runtime, context: ptr NativeContext) + {.raises: [].} = + ## Tells compiled code where the runtime's storage is now. Called before + ## compiled code starts and after every call out of it, since host code + ## is free to replace a buffer in between. + template first(values: untyped): pointer = + if values.len == 0: nil else: values[0].addr + context.globals = first(runtime.globals) + context.memory = first(runtime.memory) + context.hostData = first(runtime.hostData) + context.frames = first(runtime.frames) + context.arguments = first(runtime.arguments) + context.registerFile = first(runtime.registers) + template handOver(context: ptr NativeContext, pc: int32, body: untyped): int32 = ## Runs interpreter code for compiled code. Compiled code keeps the frame @@ -4344,6 +4358,7 @@ template handOver(context: ptr NativeContext, pc: int32, status = 1 if status == 0: context.pc = runtime.pc + runtime.publishStorage(context) context.base = runtime.base context.depth = runtime.depth context.routine = runtime.routine @@ -4373,24 +4388,6 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = ## Runs the compiled program from wherever the runtime stands until it ## halts, or raises whatever the interpreter's code raised on its way. var context = NativeContext( - globals: - if runtime.globals.len == 0: nil - else: runtime.globals[0].addr, - memory: - if runtime.memory.len == 0: nil - else: runtime.memory[0].addr, - hostData: - if runtime.hostData.len == 0: nil - else: runtime.hostData[0].addr, - frames: - if runtime.frames.len == 0: nil - else: runtime.frames[0].addr, - arguments: - if runtime.arguments.len == 0: nil - else: runtime.arguments[0].addr, - registerFile: - if runtime.registers.len == 0: nil - else: runtime.registers[0].addr, table: runtime.machine.tableAddress, base: runtime.base, depth: runtime.depth, @@ -4405,6 +4402,7 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = remainingWork: runtime.remainingWork, pc: runtime.pc ) + runtime.publishStorage(context.addr) runtime.printer = print let status = runtime.machine.invoke(context) runtime.printer = nil diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 76a946d..9be169e 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -887,6 +887,8 @@ when NativeArm64: ## The one place compiled code calls out. The budgets go into the ## context for the interpreter's code to charge, and come back from it ## along with the frame, since a call or a return may have moved it. + ## Every storage base comes back too: host code may have replaced a + ## buffer, and nothing held from before the call may be trusted after. e.code.storePair(framePointer, linkRegister, stackPointer, -16, true) e.code.storeDouble(Instructions, Context, ContextInstructions) e.code.storeDouble(Work, Context, ContextWork) @@ -896,6 +898,11 @@ when NativeArm64: e.code.moveRegister(Word32, temp(0), x0) e.code.loadDouble(Instructions, Context, ContextInstructions) e.code.loadDouble(Work, Context, ContextWork) + e.code.loadDouble(GlobalsBase, Context, 0) + e.code.loadDouble(MemoryBase, Context, ContextMemory) + e.code.loadDouble(ArgumentsBase, Context, ContextArguments) + e.code.loadDouble(FramesBase, Context, ContextFrames) + e.code.loadDouble(FileBase, Context, ContextRegisterFile) e.code.loadWord(temp(1), Context, ContextBase) e.frameOf(RegistersBase, temp(1)) e.code.loadPair(framePointer, linkRegister, stackPointer, 16, true) @@ -1907,6 +1914,8 @@ elif NativeAmd64: ## The one place compiled code calls out. The budgets go into the ## context for the interpreter's code to charge, and come back from it ## along with the frame, since a call or a return may have moved it. + ## The globals' base comes back too, host code having possibly replaced + ## that buffer; every other base is read from the context where used. ## A failure leaves through the shared exit, dropping the return ## address this routine was called with on the way. let refused = e.label() @@ -1920,6 +1929,7 @@ elif NativeAmd64: e.code.moveRegister(Word32, r10, rax) e.code.loadDouble(Instructions, Context, ContextInstructions) e.code.loadDouble(Work, Context, ContextWork) + e.contextField(GlobalsBase, 0) e.code.loadWord(rcx, Context, ContextBase) e.slotAddress(RegistersBase, rcx) e.code.testRegister(Word32, r10, r10) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index fa6f6bc..b291d8e 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -10,8 +10,8 @@ ## the difference could be written to exploit it. import - std/[random, strformat], - bassy, + std/[importutils, random, strformat], + bassy {.all.}, bassy/jit var failures = 0 @@ -704,6 +704,52 @@ report( &"{generatedFailures} disagreed" ) +## Host code that replaces the runtime's buffers + +block: + # A runtime-aware callback moves every buffer compiled code reaches + # into fresh memory, keeping the contents, then returns into compiled + # code. Everything after must land in the new buffers, not the freed. + proc relocated(native: bool): string = + var host = initHost() + let move: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + privateAccess(Runtime) + runtime.globals = runtime.globals & @[] + runtime.memory = runtime.memory & @[] + runtime.registers = runtime.registers & @[] + runtime.frames = runtime.frames & @[] + runtime.arguments = runtime.arguments & @[] + runtime.hostData = runtime.hostData & @[] + arguments[0] + discard host.addFunction("move", 1, move) + let program = compile(""" +dim cells(7) +sub stash(n) + cells(n mod 8) = cells(n mod 8) + n + total = total + move(n) +end sub +i = 0 +while i < 200 + a = a + i + stash(i) + b = b + cells(i mod 8) * 2 + i = i + 1 +wend +""", host) + var runtime = initRuntime(program, host) + if native: + discard runtime.compileNative() + discard runtime.run() + result = $runtime.getGlobal("a") & " " & $runtime.getGlobal("b") & + " " & $runtime.getGlobal("total") + for index in 0 ..< 8: + result.add(" " & $runtime.getArray("cells", int32(index))) + let plain = relocated(false) + let fast = relocated(true) + report("buffers replaced by host code are followed, not written through", + plain == fast, &"{plain} then {fast}") + if failures > 0: quit($failures & " safety checks failed") echo "native compilation is indistinguishable from interpretation" From 4784d7265b70ebb974b0dc4bd2a33f4499ab0554 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:20:39 -0700 Subject: [PATCH 42/51] refuse an offset outside the program with a BasicError nativeStep and the host call helper looked the offset up before they could refuse it, so a missed proof became an IndexDefect a host catching BasicError would not expect. Both now refuse it first. Safety tests hand compiled code offsets past the end, at the end, and negative. Co-Authored-By: Claude Opus 5.5 --- src/bassy.nim | 5 +++++ tests/test_jit_safety.nim | 23 ++++++++++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/bassy.nim b/src/bassy.nim index 3ee972d..5a56f1c 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -4352,6 +4352,11 @@ template handOver(context: ptr NativeContext, pc: int32, runtime.remainingWork = context.remainingWork var status = 0'i32 try: + # An offset that is not the program's own cannot be looked up. The + # compiler proves none is ever reached, and this makes a missed proof + # an ordinary refusal rather than a defect the host did not expect. + if pc < 0 or int(pc) >= runtime.program.code.len: + fail("BASIC compiled code reached an offset outside the program") body except Exception as error: runtime.nativeError = error diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index b291d8e..98e498f 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -10,7 +10,7 @@ ## the difference could be written to exploit it. import - std/[importutils, random, strformat], + std/[importutils, random, strformat, strutils], bassy {.all.}, bassy/jit @@ -750,6 +750,27 @@ wend report("buffers replaced by host code are followed, not written through", plain == fast, &"{plain} then {fast}") +## Offsets outside the program + +block: + # No legal program reaches an offset outside itself. Should one ever be + # handed to compiled code anyway, it must end in an ordinary refusal, + # not a jump through memory past the table or a defect. + privateAccess(Runtime) + let program = compile("a = 1\nb = a + 1\n") + for offset in [int32(program.instructions), int32(program.instructions + 7), + -1'i32, high(int32)]: + var runtime = initRuntime(program) + discard runtime.compileNative() + runtime.pc = offset + var refused = "" + try: + discard runtime.run() + except BasicError as error: + refused = error.msg + report(&"compiled code refuses offset {offset}", + (not jitSupported()) or "outside the program" in refused, refused) + if failures > 0: quit($failures & " safety checks failed") echo "native compilation is indistinguishable from interpretation" From 95fc816b127613d7c7b4abc1d960d4380834f890 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:21:31 -0700 Subject: [PATCH 43/51] fall back to the interpreter under bassyNative instead of aborting bassyNative now compiles every runtime and leaves a program the compiler refuses on the interpreter, as a normal build does. The abort that test suites rely on, proving every program really ran as machine code, moves to bassyNativeStrict, which CI now uses. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 6 +++--- src/bassy.nim | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7748b12..cc0d59c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -63,9 +63,9 @@ jobs: nim r -d:fixedChecks tests/test_native.nim - name: Run the whole suite as machine code run: | - nim r -d:release -d:bassyNative tests/tests.nim - nim r -d:danger -d:bassyNative tests/tests.nim - nim r -d:fixedChecks -d:bassyNative tests/tests.nim + nim r -d:release -d:bassyNativeStrict tests/tests.nim + nim r -d:danger -d:bassyNativeStrict tests/tests.nim + nim r -d:fixedChecks -d:bassyNativeStrict tests/tests.nim - name: Test that compiled programs cannot outrun the sandbox run: | nim r -d:release tests/test_jit_safety.nim diff --git a/src/bassy.nim b/src/bassy.nim index 5a56f1c..51383f6 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3241,12 +3241,19 @@ proc initRuntimeState( for i, function in program.hostFunctions: let id = host.functionIds.getOrDefault(function.name, -1'i32) result.hostCallbacks[i] = host.functions[int(id)].callback - when defined(bassyNative): - # Every runtime runs as machine code, so a whole test suite checks the - # two paths agree. A program the compiler refuses fails loudly here. + when defined(bassyNative) or defined(bassyNativeStrict): + # Every runtime runs as machine code wherever the compiler accepts it, + # and anything it refuses stays on the interpreter, as usual. if jitSupported(): - doAssert result.compileNative() == program.code.len, - "native compilation refused this program" + let compiled = result.compileNative() + when defined(bassyNativeStrict): + # For test suites only: a refusal fails the whole run, and not as a + # BasicError a test expecting one could mistake for its own, so a + # suite proves every program it holds really ran as machine code. + if compiled != program.code.len: + raiseAssert("native compilation refused this program") + else: + discard compiled proc initRuntime*(program: Program, limits = defaultLimits()): Runtime = ## Allocates a runtime for a program without host bindings. From 48eb23be0d5ab404918b15e3da92c6904bd53d3d Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:24:17 -0700 Subject: [PATCH 44/51] refuse to write code pages while compiled code runs on the thread Apple Silicon gates writes to every JIT page per thread. A host callback that compiled more code while compiled frames were live would open that gate beneath them. Compiling from such a thread is now refused and that program runs on the interpreter; the write window nests and is always closed again, even if a write fails. Co-Authored-By: Claude Opus 5.5 --- src/bassy.nim | 7 ++++++- src/bassy/jit.nim | 8 +++++++- src/bassy/machine.nim | 42 +++++++++++++++++++++++++++++++++------ tests/test_jit_safety.nim | 32 +++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 51383f6..2ad5c98 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -4416,7 +4416,12 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = ) runtime.publishStorage(context.addr) runtime.printer = print - let status = runtime.machine.invoke(context) + enterCompiledCode() + let status = + try: + runtime.machine.invoke(context) + finally: + leaveCompiledCode() runtime.printer = nil if status == NativeFailed: # The interpreter's code left the runtime exactly as it failed. diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 9be169e..e3fe4da 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -20,7 +20,8 @@ import bytecode, machine, numbers, texts -export machine.jitSupported +export machine.jitSupported, machine.enterCompiledCode, + machine.leaveCompiledCode, machine.runningCompiledCode const NativeArm64* = NativeCode and defined(arm64) @@ -3702,6 +3703,11 @@ proc compileProgram*(code: seq[Instruction], routines: seq[RoutineExtent], else: if not layoutMatches() or code.len == 0 or routines.len == 0: return nil + # Writing code pages from a thread that is running compiled code would + # open the write gate beneath live frames, so such a compile is + # refused and the program runs on the interpreter instead. + if runningCompiledCode(): + return nil if limits.frames <= 0 or limits.slots < 0: return nil # Every value is reached through a displacement worked out here, so diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index ccd47c5..d8e9650 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -175,15 +175,41 @@ proc capacity*(buffer: CodeBuffer): int {.inline, raises: [].} = ## Returns the reserved byte count, rounded up to whole pages. buffer.capacity +var + ## How deep this thread is in writes to code pages. On Apple Silicon the + ## gate is per thread and covers every such page at once, so only the + ## outermost write may open it and only the outermost may close it. + writeDepth {.threadvar.}: int + ## How deep this thread is in running compiled code. Pages must not be + ## opened for writing while compiled frames are live beneath. + runDepth {.threadvar.}: int + proc beginWrite(buffer: var CodeBuffer) {.raises: [].} = ## Makes the pages writable on platforms that enforce write-xor-execute. when NativeCode and AppleSilicon: - jitWriteProtect(0) + if writeDepth == 0: + jitWriteProtect(0) + inc writeDepth proc endWrite(buffer: var CodeBuffer) {.raises: [].} = ## Restores execute permission after a batch of writes. + dec writeDepth when NativeCode and AppleSilicon: - jitWriteProtect(1) + if writeDepth == 0: + jitWriteProtect(1) + +proc enterCompiledCode*() {.raises: [].} = + ## Records that this thread is about to run compiled code. + inc runDepth + +proc leaveCompiledCode*() {.raises: [].} = + ## Records that this thread has come back out of compiled code. + dec runDepth + +proc runningCompiledCode*(): bool {.raises: [].} = + ## Reports whether compiled frames are live on this thread, in which case + ## no code page may be written from it. + runDepth > 0 proc write*(buffer: var CodeBuffer, source: pointer, size: int) {.raises: [BasicError].} = @@ -194,11 +220,15 @@ proc write*(buffer: var CodeBuffer, source: pointer, size: int) fail("code buffer capacity exceeded") if size == 0: return + if runDepth > 0: + fail("code pages cannot be written while compiled code runs") buffer.beginWrite() - copyMem( - cast[pointer](cast[int](buffer.memory) + buffer.length), source, size - ) - buffer.endWrite() + try: + copyMem( + cast[pointer](cast[int](buffer.memory) + buffer.length), source, size + ) + finally: + buffer.endWrite() buffer.length += size proc write*(buffer: var CodeBuffer, words: openArray[uint32]) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 98e498f..f3a079d 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -771,6 +771,38 @@ block: report(&"compiled code refuses offset {offset}", (not jitSupported()) or "outside the program" in refused, refused) +## Compiling from inside compiled code + +block: + # A host callback that compiles and runs another program while this one + # is running as machine code must not open code pages for writing + # beneath live frames. That compile is refused, the inner program runs + # on the interpreter, and compiling works again once the outer returns. + let inner = compile("x = 0\nwhile x < 50\n x = x + 2\nwend\n") + var innerCompiled = -1 + var innerResult = 0'i32 + var host = initHost() + let nest: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + var other = initRuntime(inner) + innerCompiled = other.compileNative() + discard other.run() + innerResult = other.getGlobal("x") + toValue(innerResult) + discard host.addFunction("nest", 0, nest) + let program = compile("a = nest()\nb = a + 1\n", host) + var runtime = initRuntime(program, host) + discard runtime.compileNative() + discard runtime.run() + report("compiling beneath compiled frames is refused", + (not jitSupported()) or innerCompiled == 0, $innerCompiled) + report("the refused program still runs, interpreted", + innerResult == 50 and runtime.getGlobal("b") == 51, + &"{innerResult} {runtime.getGlobal(\"b\")}") + var after = initRuntime(inner) + report("compiling works again once compiled code returns", + (not jitSupported()) or after.compileNative() == inner.instructions) + if failures > 0: quit($failures & " safety checks failed") echo "native compilation is indistinguishable from interpretation" From 1576907d2e71a03cc3efb279810dbd6204a6c022 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 15:24:26 -0700 Subject: [PATCH 45/51] skip the out-of-program offset test where nothing is compiled Co-Authored-By: Claude Opus 5.5 --- tests/test_jit_safety.nim | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index f3a079d..6800911 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -760,6 +760,10 @@ block: let program = compile("a = 1\nb = a + 1\n") for offset in [int32(program.instructions), int32(program.instructions + 7), -1'i32, high(int32)]: + if not jitSupported(): + # With no compiled code there is nothing here to test, and the + # interpreter is not what this refusal is asked of. + break var runtime = initRuntime(program) discard runtime.compileNative() runtime.pc = offset From 6bddafe39752f0f2d460300cc46e82f22400e23a Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:05:43 -0700 Subject: [PATCH 46/51] retire the compiled program when host code resizes its storage Republishing a buffer's address after a call out was only half the fix: compiled code indexes globals, frames, the register file, arguments and host data by offsets proved against the lengths at compile time. Every call out now compares those lengths with the ones compiled against, and on any change retires the compiled program, so the rest of the run is interpreted with every access checked. The machine is held for the length of the call, so retiring it cannot free code still returning. Safety tests shrink, empty and replace each store mid-run; before this they wrote past the new buffer or crashed. Co-Authored-By: Claude Opus 5.5 --- src/bassy.nim | 29 +++++++++++++++++++++- src/bassy/jit.nim | 41 ++++++++++++++++++++++-------- tests/test_jit_safety.nim | 52 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 11 deletions(-) diff --git a/src/bassy.nim b/src/bassy.nim index 2ad5c98..684019a 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -226,6 +226,7 @@ type allocatedBytes: int64 finished: bool machine: Machine + machineShape: array[6, int] printer: PrintProc nativeError: ref Exception handedBack: int64 @@ -3400,6 +3401,12 @@ proc handedBack*(runtime: Runtime): int64 {.inline.} = ## host calls, or about to fail. Everything else ran as machine code. runtime.handedBack +proc storageShape(runtime: Runtime): array[6, int] {.raises: [].} = + ## Returns how long each store compiled code indexes directly is. The + ## compiler proved every index it emits against exactly these. + [runtime.globals.len, runtime.memory.len, runtime.hostData.len, + runtime.frames.len, runtime.arguments.len, runtime.registers.len] + proc compileNative*(runtime: var Runtime): int = ## Compiles this program to machine code and returns how many bytecode ## offsets now run natively: all of them, or none where the target has @@ -3432,6 +3439,7 @@ proc compileNative*(runtime: var Runtime): int = textLayoutMatches() ) if runtime.machine != nil: + runtime.machineShape = runtime.storageShape return runtime.program.code.len proc arrayExtent*(program: Program, id: int32): (int32, int32) {.inline.} = @@ -4368,6 +4376,13 @@ template handOver(context: ptr NativeContext, pc: int32, except Exception as error: runtime.nativeError = error status = 1 + if status == 0 and runtime.storageShape != runtime.machineShape: + # Host code resized storage the compiled program indexes by offsets + # proved at compile time. None of them can be trusted now, so the + # program is retired and the rest of the run is interpreted, where + # every access is checked. + runtime.machine = nil + status = int32(ord(NativeRetired)) if status == 0: context.pc = runtime.pc runtime.publishStorage(context) @@ -4399,6 +4414,14 @@ proc nativeHostCall(context: ptr NativeContext, pc: int32): int32 proc runMachine(runtime: var Runtime, print: PrintProc) = ## Runs the compiled program from wherever the runtime stands until it ## halts, or raises whatever the interpreter's code raised on its way. + if runtime.storageShape != runtime.machineShape: + # Storage changed shape since compiling, so the program is retired + # before it runs rather than part way through. + runtime.machine = nil + return + # Held here as well, so retiring the program part way cannot free the + # code it is still returning through. + let machine = runtime.machine var context = NativeContext( table: runtime.machine.tableAddress, base: runtime.base, @@ -4419,7 +4442,7 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = enterCompiledCode() let status = try: - runtime.machine.invoke(context) + machine.invoke(context) finally: leaveCompiledCode() runtime.printer = nil @@ -4428,6 +4451,10 @@ proc runMachine(runtime: var Runtime, print: PrintProc) = let error = runtime.nativeError runtime.nativeError = nil raise error + if status == NativeRetired: + # The interpreter's code left the runtime where it stopped, and the + # run carries on interpreted from there. + return runtime.remainingInstructions = context.remainingInstructions runtime.remainingWork = context.remainingWork runtime.pc = context.pc diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index e3fe4da..6df5c8b 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -36,7 +36,8 @@ type NativeStatus* = enum ## Why compiled code returned to its caller. NativeCompleted, - NativeFailed + NativeFailed, + NativeRetired NativeContext* = object ## The interpreter state compiled code reads and writes. The frame and @@ -939,10 +940,8 @@ when NativeArm64: e.code.loadWord(temp(0), Context, ContextBase) e.frameOf(RegistersBase, temp(0)) - proc epilogue(e: var Emitter, status: NativeStatus) - {.raises: [BasicError].} = - ## Restores what the platform says to keep and returns a status. - e.code.loadImmediate(Word32, x0, int64(ord(status))) + proc restoreAndReturn(e: var Emitter) {.raises: [BasicError].} = + ## Restores what the platform says to keep and returns x0 as it is. e.code.loadPair(x19, x20, stackPointer, 16) e.code.loadPair(x21, x22, stackPointer, 32) e.code.loadPair(x23, x24, stackPointer, 48) @@ -952,6 +951,17 @@ when NativeArm64: true) e.code.returnToCaller() + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [BasicError].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, x0, int64(ord(status))) + e.restoreAndReturn() + + proc leaveWithAnswer(e: var Emitter) {.raises: [BasicError].} = + ## Returns whatever status the interpreter's code answered with. + e.code.moveRegister(Word32, x0, temp(0)) + e.restoreAndReturn() + ## Globals held in registers ## ## Inside a specialised loop its globals live in the registers below, @@ -1962,15 +1972,24 @@ elif NativeAmd64: e.code.loadWord(rcx, Context, ContextBase) e.slotAddress(RegistersBase, rcx) - proc epilogue(e: var Emitter, status: NativeStatus) - {.raises: [].} = - ## Restores what the platform says to keep and returns a status. - e.code.loadImmediate(Word32, rax, int64(ord(status))) + proc restoreAndReturn(e: var Emitter) {.raises: [].} = + ## Restores what the platform says to keep and returns rax as it is. e.code.addImmediate(Word64, rsp, Padding) for index in countdown(Saved.len - 1, 0): e.code.pop(Saved[index]) e.code.returnToCaller() + proc epilogue(e: var Emitter, status: NativeStatus) + {.raises: [].} = + ## Restores what the platform says to keep and returns a status. + e.code.loadImmediate(Word32, rax, int64(ord(status))) + e.restoreAndReturn() + + proc leaveWithAnswer(e: var Emitter) {.raises: [].} = + ## Returns whatever status the interpreter's code answered with. + e.code.moveRegister(Word32, rax, r10) + e.restoreAndReturn() + ## Globals held in registers ## ## Inside a specialised loop its globals live in the registers below, @@ -3681,8 +3700,10 @@ proc emitProgram(code: seq[Instruction], routines: seq[RoutineExtent], e.slowRoutine(failedLabel, ContextStep) e.place(hostLabel) e.slowRoutine(failedLabel, ContextHostStep) + # The interpreter's code answers with the status to leave with: a + # failure it raised, or the compiled program retired part way. e.place(failedLabel) - e.epilogue(NativeFailed) + e.leaveWithAnswer() let bytes = e.finish() var starts = newSeq[int](code.len + 1) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 6800911..3e21137 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -750,6 +750,58 @@ wend report("buffers replaced by host code are followed, not written through", plain == fast, &"{plain} then {fast}") +block: + # Host code that shrinks a store the compiled program indexes directly. + # The indexes were proved against the old lengths, so the compiled + # program is retired and the rest runs interpreted, where every access + # is checked. The answer must be the interpreter's own, failure and all. + # Without bounds checks the interpreter itself has no defined answer. + when compileOption("boundChecks"): + var source = "dim cells(7)\n" + for index in 0 ..< 90: + source.add(&"g{index} = {index}\n") + source.add(""" +sub bump(n) + cells(n mod 8) = cells(n mod 8) + n +end sub +i = 0 +while i < 20 + g80 = g80 + shrink(i) + cells(3) = cells(3) + g80 + bump(i) + i = i + 1 +wend +""") + for mode in ["globals", "no globals", "no cells", "registers"]: + proc shrunk(native: bool): string = + var host = initHost() + var calls = 0 + let shrink: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + privateAccess(Runtime) + inc calls + if calls == 5: + case mode + of "globals": runtime.globals = newSeq[Value](1) + of "no globals": runtime.globals.setLen(0) + of "no cells": runtime.memory.setLen(0) + else: runtime.registers.setLen(1) + toValue(1'i32) + discard host.addFunction("shrink", 1, shrink) + let program = compile(source, host) + var runtime = initRuntime(program, host) + if native: + discard runtime.compileNative() + try: + discard runtime.run() + result = "finished" + except Exception as error: + result = $error.name & ": " & error.msg + let plain = shrunk(false) + let fast = shrunk(true) + report(&"host code shrinking {mode} ends as the interpreter does", + plain == fast, &"{plain} then {fast}") + ## Offsets outside the program block: From 509de648891f04c151c2ab00943e4ae27a10b29a Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:08:00 -0700 Subject: [PATCH 47/51] check a return's offset before popping its frame A return committed the frame pop before it looked at the offset it would carry on at, so a refusal left the call stack one frame short. It now checks first. On AArch64 a GOSUB frame handing back more than eight shared parameters copied them through the registers holding the frame's base and return offset; both are now read again after the copy. New tests cover a corrupted return offset and a GOSUB inside an eleven-parameter sub, which computed wrong totals before. Co-Authored-By: Claude Opus 5.5 --- src/bassy/jit.nim | 19 +++++++++++++------ tests/test_jit_safety.nim | 29 +++++++++++++++++++++++++++++ tests/test_native.nim | 17 +++++++++++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/bassy/jit.nim b/src/bassy/jit.nim index 6df5c8b..786807d 100644 --- a/src/bassy/jit.nim +++ b/src/bassy/jit.nim @@ -845,7 +845,8 @@ when NativeArm64: ## Pops a frame and jumps to wherever it said to carry on. A GOSUB ## frame first hands the shared parameters back to the caller. Leaving ## a sub outright only goes this way when its own frame is on top. - ## Nothing is written until both refusals have been passed. + ## Nothing is written until every refusal has been passed, the offset + ## it would carry on at included, so a refusal leaves the frame on. let depth = temp(0) let frame = temp(1) let base = temp(2) @@ -857,23 +858,27 @@ when NativeArm64: if exitSub: e.code.loadByte(temp(4), frame, FrameTag) e.jumpIfNotZero(temp(4), slow) + e.code.loadWord(resume, frame, FrameReturn) + e.withinProgram(resume) e.code.storeWord(depth, Context, ContextDepth) - e.code.loadWord(base, frame, FrameBase) if parameters > 0: let plain = e.label() e.code.loadByte(temp(4), frame, FrameTag) e.code.compareImmediate(Word32, temp(4), 1) e.code.branchIf(NotEqualCondition, plain) + e.code.loadWord(base, frame, FrameBase) e.frameOf(Far, base) e.copyValues(Far, RegistersBase, int(parameters)) e.place(plain) + # A long copy works through the same registers, so what it needs from + # the frame is read again after it rather than kept across it. + e.code.loadWord(base, frame, FrameBase) + e.code.loadWord(resume, frame, FrameReturn) e.code.storeWord(base, Context, ContextBase) e.code.loadWord(temp(4), frame, FrameRoutine) e.code.storeWord(temp(4), Context, ContextRoutine) - e.code.loadWord(resume, frame, FrameReturn) e.code.storeWord(resume, Context, ContextOffset) e.frameOf(RegistersBase, base) - e.withinProgram(resume) e.code.addRegister(Word64, temp(4), TableBase, resume, 3) e.code.loadDouble(temp(4), temp(4), 0) e.code.jumpRegister(temp(4)) @@ -1873,7 +1878,8 @@ elif NativeAmd64: ## Pops a frame and jumps to wherever it said to carry on. A GOSUB ## frame first hands the shared parameters back to the caller. Leaving ## a sub outright only goes this way when its own frame is on top. - ## Nothing is written until both refusals have been passed. + ## Nothing is written until every refusal has been passed, the offset + ## it would carry on at included, so a refusal leaves the frame on. let depth = rax let frame = rcx let base = rsi @@ -1889,6 +1895,8 @@ elif NativeAmd64: e.code.loadByteZeroed(Spare, frame, FrameTag) e.code.testRegister(Word32, Spare, Spare) e.jumpWhen(NotEqualCondition, slow) + e.code.loadWord(Cell, frame, FrameReturn) + e.withinProgram(Cell) e.code.storeWord(depth, Context, ContextDepth) e.code.loadWord(base, frame, FrameBase) if parameters > 0: @@ -1907,7 +1915,6 @@ elif NativeAmd64: e.code.storeWord(Spare, Context, ContextOffset) e.code.moveRegister(Word32, r8, base) e.slotAddress(RegistersBase, r8) - e.withinProgram(Spare) e.contextField(rax, ContextTable) e.code.shiftLeftImmediate(Word64, Spare, 3) e.code.addRegister(Word64, rax, Spare) diff --git a/tests/test_jit_safety.nim b/tests/test_jit_safety.nim index 3e21137..8896282 100644 --- a/tests/test_jit_safety.nim +++ b/tests/test_jit_safety.nim @@ -802,6 +802,35 @@ wend report(&"host code shrinking {mode} ends as the interpreter does", plain == fast, &"{plain} then {fast}") +block: + # Host code that points a live frame's return at no offset at all. The + # return must refuse before it pops anything, so the frame is still on + # and a second run refuses the same way rather than carrying on a call + # further down. + privateAccess(Runtime) + privateAccess(Frame) + var host = initHost() + let corrupt: ContextHostProc = proc(runtime: Runtime, + arguments: openArray[Value]): Value = + runtime.frames[int(runtime.depth) - 1].returnPc = 9999 + toValue(0'i32) + discard host.addFunction("corrupt", 0, corrupt) + let program = compile("sub inner()\n a = corrupt()\nend sub\ninner()\nb = 1\n", + host) + var runtime = initRuntime(program, host) + if runtime.compileNative() > 0: + var messages: seq[string] + for attempt in 0 .. 1: + try: + discard runtime.run() + messages.add("finished") + except BasicError as error: + messages.add(error.msg) + report("a corrupted return offset is refused before the frame is popped", + messages.len == 2 and "outside the program" in messages[0] and + messages[1] == messages[0] and runtime.depth == 1, + &"{messages} depth {runtime.depth}") + ## Offsets outside the program block: diff --git a/tests/test_native.nim b/tests/test_native.nim index 9e7beda..bef6ede 100644 --- a/tests/test_native.nim +++ b/tests/test_native.nim @@ -384,6 +384,23 @@ while 1 wend """, limits) +agree("GOSUB inside a sub with many parameters", Preamble & """ +sub many(p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10) + gosub more + gosub more + a = a + p0 + p1 + p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9 + p10 + exit sub +more: + p0 = p0 + 1 + p10 = p10 + 100 + b = b + 1 + return +end sub +many(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11) +many(10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110) +c = a * 2 + b +""") + agree("select, for, do, and on-goto", Preamble & """ for i = 1 to 10 step 3 select case i From eca1e5278652ffde59b7e33b165ddb8753b4763a Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:08:43 -0700 Subject: [PATCH 48/51] let strict native builds allow the one refusal made by design A runtime made from inside compiled code is refused compilation on purpose, and bassyNativeStrict turned that into an assertion. Strict builds now accept that refusal, and CI runs the safety suite under bassyNativeStrict so the path is exercised. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 1 + src/bassy.nim | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cc0d59c..66d98dc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -70,6 +70,7 @@ jobs: run: | nim r -d:release tests/test_jit_safety.nim nim r -d:danger tests/test_jit_safety.nim + nim r -d:release -d:bassyNativeStrict tests/test_jit_safety.nim - name: Test the same scripts with the interpreter forced run: nim r -d:release -d:bassyNoJit tests/test_jit.nim - name: Render the BASIC raytracer and check cross-platform determinism diff --git a/src/bassy.nim b/src/bassy.nim index 684019a..3ffca63 100644 --- a/src/bassy.nim +++ b/src/bassy.nim @@ -3251,7 +3251,9 @@ proc initRuntimeState( # For test suites only: a refusal fails the whole run, and not as a # BasicError a test expecting one could mistake for its own, so a # suite proves every program it holds really ran as machine code. - if compiled != program.code.len: + # The one refusal by design is a runtime made from inside compiled + # code, which must stay interpreted. + if compiled != program.code.len and not runningCompiledCode(): raiseAssert("native compilation refused this program") else: discard compiled From 7e8107474a8ed5583820063397ea43fd09a9f2fd Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:09:21 -0700 Subject: [PATCH 49/51] fuzz GOSUB through subs with many parameters The generator only ever made one-parameter subs, so it could not reach the register clash a long parameter copy caused on return. With the old return, seeds 33 and 47 now disagree. Co-Authored-By: Claude Opus 5.5 --- tests/test_native.nim | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/test_native.nim b/tests/test_native.nim index bef6ede..2e61549 100644 --- a/tests/test_native.nim +++ b/tests/test_native.nim @@ -617,7 +617,9 @@ proc statement(g: var Generator, indent: string, room: int): string = indent & g.pick(["bump(" & g.whole() & ")", "gosub tally", "a = a + 1", "b = b - 1", "c = gather(table, cells())", "d = gather(weights, cells)", "a = burn(" & g.whole() & ")", - "b = gather(cells, table())"]) & "\n" + "b = gather(cells, table())", + "wide(" & g.whole() & ", 1, 2, 3, 4, " & g.whole() & + ", 6, 7, 8, " & g.whole() & ", 10)"]) & "\n" of 10 .. 12: let condition = g.numeric() var text = indent & "if " & condition & " then\n" & @@ -651,6 +653,13 @@ proc generated(seed: int64): string = result.add("tally:\n c = c + " & g.literal() & "\n return\n") result.add("sub bump(n)\n d = d + n\n" & g.body(" ", 1) & " if n < 3 then bump(n + 1)\nend sub\n") + # More parameters than a return copies in straight-line code, handed + # back and forth through GOSUB, which shares them with the caller. + result.add("sub wide(p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10)\n" & + " gosub widen\n gosub widen\n" & + " c = c + p0 + p3 + p7 + p10\n exit sub\n" & + "widen:\n p0 = p0 + 1\n p10 = p10 + p9\n p5 = p5 - p1\n" & + " return\nend sub\n") var tried = 0 From 042cdeac202a94f6a75d82a4300c290d75e7ed3c Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:09:22 -0700 Subject: [PATCH 50/51] note what the per-thread write gate does and does not cover Co-Authored-By: Claude Opus 5.5 --- src/bassy/machine.nim | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/bassy/machine.nim b/src/bassy/machine.nim index d8e9650..82e8da1 100644 --- a/src/bassy/machine.nim +++ b/src/bassy/machine.nim @@ -183,6 +183,12 @@ var ## How deep this thread is in running compiled code. Pages must not be ## opened for writing while compiled frames are live beneath. runDepth {.threadvar.}: int + ## + ## Both are per thread, as the gate is. Another thread compiling opens + ## only its own gate, and may write any such page while it is open; the + ## thread running compiled code keeps its gate closed throughout, so a + ## script cannot reach that window. Compiling from several threads at + ## once is host code the host is trusted with. proc beginWrite(buffer: var CodeBuffer) {.raises: [].} = ## Makes the pages writable on platforms that enforce write-xor-execute. From b82e9a86adafe30b1cb6c10d1a73bc1898b59d30 Mon Sep 17 00:00:00 2001 From: treeform Date: Mon, 28 Sep 2026 16:10:59 -0700 Subject: [PATCH 51/51] run CI on macOS arm64, Linux x86-64, and Windows x86-64 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 66d98dc..859eb7f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,11 +16,9 @@ jobs: fail-fast: false matrix: os: - - ubuntu-latest # x86-64 Linux, native backend - - ubuntu-24.04-arm # arm64 Linux, native backend - macos-latest # arm64 macOS, native backend + - ubuntu-latest # x86-64 Linux, native backend - windows-latest # x86-64 Windows, native backend - # macOS is arm only here. Intel runners are not covered. runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5