Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
d6967f7
Vm initial boilerplate
ascandone Jun 26, 2026
bc5346c
impl decoder
ascandone Jun 26, 2026
d85d3ea
runtime module draft
ascandone Jun 26, 2026
259c9d9
alligned runtime with interpreter semantics
ascandone Jun 26, 2026
c68e878
update impl to bigint
ascandone Jun 26, 2026
8ef0023
handle backtracking
ascandone Jun 26, 2026
45fd5ae
impl save related code
ascandone Jun 26, 2026
fd1eceb
impl prewarm
ascandone Jun 26, 2026
4654bab
refactor: migrate impl to runtime
ascandone Jun 26, 2026
5528dc6
cleanup pull method intf
ascandone Jun 26, 2026
ce4e8bc
impl pull instructions
ascandone Jun 26, 2026
8610ec1
impl Op_SendToAccount
ascandone Jun 26, 2026
04dab55
impl Op_CheckEnoughFunds
ascandone Jun 26, 2026
f5c6e4a
impl allotment utility
ascandone Jun 26, 2026
a0c3fe8
migrate allot
ascandone Jun 26, 2026
bf8e04f
refactor allot
ascandone Jun 26, 2026
a685979
impl allotment opcode
ascandone Jun 26, 2026
a010be9
reduce allocs
ascandone Jun 26, 2026
ebd9494
refactor: edit PullUncapped api
ascandone Jun 26, 2026
acda484
fix
ascandone Jun 26, 2026
711f484
add skipped test
ascandone Jun 26, 2026
7eeb706
impl virtual instructions
ascandone Jun 26, 2026
d4b7907
compiled simple program
ascandone Jun 27, 2026
308f5e8
made fields public
ascandone Jun 27, 2026
dbc8289
fix
ascandone Jun 28, 2026
7ddce3e
impl assembler
ascandone Jun 28, 2026
17772a6
impl test and fixed errs
ascandone Jun 28, 2026
4813449
bench
ascandone Jun 28, 2026
bec4a7d
feat: assemble jmp and label
ascandone Jun 28, 2026
62fb267
compiler inorder
ascandone Jun 28, 2026
706b950
add utility
ascandone Jun 28, 2026
2cae11f
add e2e test
ascandone Jun 28, 2026
82b6c25
compiled max clause
ascandone Jun 28, 2026
5460bb1
add e2e test
ascandone Jun 28, 2026
4bdf824
more benchs
ascandone Jun 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added compiler.test
Binary file not shown.
445 changes: 445 additions & 0 deletions internal/compiler/assemble.go

Large diffs are not rendered by default.

65 changes: 65 additions & 0 deletions internal/compiler/assemble_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package compiler

import (
"testing"

"github.com/formancehq/numscript/internal/vm"
)

func TestAssemble_AddInt(t *testing.T) {
// Three distinct virtual int registers map to the first three int-bank
// indices in first-use order.
prog, err := Assemble([]vInstr{
binaryOp{op: opAddInt{}, dest: 10, left: 20, right: 30},
})
if err != nil {
t.Fatalf("Assemble: %v", err)
}

instrs := prog.Instructions
if len(instrs) != 1 {
t.Fatalf("got %d instructions, want 1", len(instrs))
}
want := vm.Instruction{Opcode: byte(vm.Op_AddInt), A: 0, B: 1, C: 2}
if instrs[0] != want {
t.Errorf("got %+v, want %+v", instrs[0], want)
}
}

func TestAssemble_AddInt_ReusesRegisterIndices(t *testing.T) {
// A virtual register reused across operands/instructions keeps the same
// bank index; new ones get fresh indices in first-use order.
prog, err := Assemble([]vInstr{
// reg 7 -> 0, reg 8 -> 1 ; dest==left==7
binaryOp{op: opAddInt{}, dest: 7, left: 7, right: 8},
// reg 9 -> 2 ; reuses 7->0 and 8->1
binaryOp{op: opAddInt{}, dest: 9, left: 7, right: 8},
})
if err != nil {
t.Fatalf("Assemble: %v", err)
}

got := prog.Instructions
want := []vm.Instruction{
{Opcode: byte(vm.Op_AddInt), A: 0, B: 0, C: 1},
{Opcode: byte(vm.Op_AddInt), A: 2, B: 0, C: 1},
}
if len(got) != len(want) {
t.Fatalf("got %d instructions, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("instr[%d] = %+v, want %+v", i, got[i], want[i])
}
}
}

func TestAssemble_Empty(t *testing.T) {
prog, err := Assemble(nil)
if err != nil {
t.Fatalf("Assemble: %v", err)
}
if len(prog.Instructions) != 0 {
t.Errorf("expected no instructions, got %d", len(prog.Instructions))
}
}
227 changes: 227 additions & 0 deletions internal/compiler/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
package compiler

import (
"context"
"math/big"
"testing"

"github.com/formancehq/numscript/internal/interpreter"
"github.com/formancehq/numscript/internal/parser"
"github.com/formancehq/numscript/internal/runtime"
"github.com/formancehq/numscript/internal/vm"
)

// Both benchmarks run the SAME program with the same starting balance; only the
// per-iteration RUN is measured (parse/compile/assemble happen once, up front).
const benchSrc = `send [USD/2 10] (
source = @src
destination = @dest
)`

// BenchmarkTreeWalker measures the tree-walking interpreter on a pre-parsed AST.
func BenchmarkTreeWalker(b *testing.B) {
parsed := parser.Parse(benchSrc)
if len(parsed.Errors) != 0 {
b.Fatalf("parse errors: %v", parsed.Errors)
}
store := interpreter.StaticStore{
Balances: interpreter.Balances{
{Account: "src", Asset: "USD/2", Amount: big.NewInt(100)},
},
}
ctx := context.Background()

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil)
if err != nil {
b.Fatalf("run: %v", err)
}
}
}

// BenchmarkRuntimeBaseline is the floor: it drives runtime.RunState directly,
// performing exactly the funds operations the program lowers to — with no AST
// walk and no bytecode dispatch. It reuses one RunState (like the VM reuses its
// runstate) and hoists the constants (the compiler would pool them). The gap
// between this and BenchmarkCompiledVM is the VM's dispatch/register overhead;
// the gap to BenchmarkTreeWalker is the interpreter's front-end overhead.
func BenchmarkRuntimeBaseline(b *testing.B) {
store := e2eStore{balances: map[runtime.PairKey]*big.Int{
{Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100),
}}
rs := runtime.New(store)

ten := big.NewInt(10) // the sent amount / pull cap
zero := big.NewInt(0) // bounded overdraft of 0
pulled := new(big.Int) // reused output register
dest := "dest"

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rs.Reset(store)
rs.SetCurrentAsset("USD/2")
rs.Pull(pulled, "src", ten, zero, "")
_ = pulled.Cmp(ten) // CheckEnoughFunds
rs.SendUncapped(&dest, nil)
_ = rs.GetPostings()
}
}

// BenchmarkCompiledVM measures the compiled bytecode on the register VM, reusing
// a single Vm instance across iterations (its register banks are not realloc'd).
func BenchmarkCompiledVM(b *testing.B) {
parsed := parser.Parse(benchSrc)
if len(parsed.Errors) != 0 {
b.Fatalf("parse errors: %v", parsed.Errors)
}
compiled, cErr := compileProgramToVirtual(parsed.Value)
if cErr != nil {
b.Fatalf("compile: %v", cErr)
}
program, aErr := Assemble(compiled.instructions)
if aErr != nil {
b.Fatalf("assemble: %v", aErr)
}
store := e2eStore{balances: map[runtime.PairKey]*big.Int{
{Account: "src", Asset: "USD/2", Color: ""}: big.NewInt(100),
}}

machine := vm.NewVm(program) // reused across iterations

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := vm.Exec(machine, nil, store)
if err != nil {
b.Fatalf("exec: %v", err)
}
}
}

// --- Capped inorder script: `{ @a ; max [USD/2 5] from @b ; @c }` -----------
// Same methodology as above, on a more representative script (inorder traversal,
// a `max` cap with a min_int, running total, and an early-exit jump). Balances:
// a=3, b=100 (capped to 5), c=100 → pulls 3 / 5 / 2.
const benchSrcCapped = `send [USD/2 10] (
source = {
@a
max [USD/2 5] from @b
@c
}
destination = @dest
)`

func BenchmarkTreeWalkerCapped(b *testing.B) {
parsed := parser.Parse(benchSrcCapped)
if len(parsed.Errors) != 0 {
b.Fatalf("parse errors: %v", parsed.Errors)
}
store := interpreter.StaticStore{
Balances: interpreter.Balances{
{Account: "a", Asset: "USD/2", Amount: big.NewInt(3)},
{Account: "b", Asset: "USD/2", Amount: big.NewInt(100)},
{Account: "c", Asset: "USD/2", Amount: big.NewInt(100)},
},
}
ctx := context.Background()

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := interpreter.RunProgram(ctx, parsed.Value, nil, store, nil)
if err != nil {
b.Fatalf("run: %v", err)
}
}
}

func cappedStore() e2eStore {
return e2eStore{balances: map[runtime.PairKey]*big.Int{
{Account: "a", Asset: "USD/2", Color: ""}: big.NewInt(3),
{Account: "b", Asset: "USD/2", Color: ""}: big.NewInt(100),
{Account: "c", Asset: "USD/2", Color: ""}: big.NewInt(100),
}}
}

// BenchmarkRuntimeBaselineCapped is the floor: it drives runtime.RunState
// directly, performing the funds ops the capped-inorder script lowers to (with
// the cap/running-total/early-exit arithmetic done inline on reused big.Ints) —
// no AST walk, no bytecode dispatch. RunState reused across iterations.
func BenchmarkRuntimeBaselineCapped(b *testing.B) {
store := cappedStore()
rs := runtime.New(store)

zero := big.NewInt(0)
ten := big.NewInt(10)
five := big.NewInt(5)
remaining := new(big.Int)
capB := new(big.Int)
pulled := new(big.Int)
total := new(big.Int)
dest := "dest"

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
rs.Reset(store)
rs.SetCurrentAsset("USD/2")
total.SetInt64(0)
remaining.Set(ten) // inorder cap = copy(amount)

// @a (cap = remaining)
rs.Pull(pulled, "a", remaining, zero, "")
total.Add(total, pulled)
remaining.Sub(remaining, pulled)

if remaining.Sign() != 0 { // jmp_if_zero(remaining)
// max [USD/2 5] from @b -> cap = min(5, remaining)
if five.Cmp(remaining) < 0 {
capB.Set(five)
} else {
capB.Set(remaining)
}
rs.Pull(pulled, "b", capB, zero, "")
total.Add(total, pulled)
remaining.Sub(remaining, pulled)

if remaining.Sign() != 0 {
rs.Pull(pulled, "c", remaining, zero, "") // @c (cap = remaining)
total.Add(total, pulled)
}
}

_ = total.Cmp(ten) // check_enough_funds
rs.SendUncapped(&dest, nil)
_ = rs.GetPostings()
}
}

func BenchmarkCompiledVMCapped(b *testing.B) {
parsed := parser.Parse(benchSrcCapped)
if len(parsed.Errors) != 0 {
b.Fatalf("parse errors: %v", parsed.Errors)
}
compiled, cErr := compileProgramToVirtual(parsed.Value)
if cErr != nil {
b.Fatalf("compile: %v", cErr)
}
program, aErr := Assemble(compiled.instructions)
if aErr != nil {
b.Fatalf("assemble: %v", aErr)
}
store := cappedStore()

machine := vm.NewVm(program) // reused across iterations

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, err := vm.Exec(machine, nil, store)
if err != nil {
b.Fatalf("exec: %v", err)
}
}
}
Loading
Loading