Skip to content

Commit 5805f3a

Browse files
authored
Merge branch 'master' into master
2 parents 24ddbe2 + 55e913b commit 5805f3a

2 files changed

Lines changed: 189 additions & 27 deletions

File tree

vm/vm.go

Lines changed: 107 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"github.com/expr-lang/expr/vm/runtime"
1818
)
1919

20+
const maxFnArgsBuf = 256
21+
2022
func Run(program *Program, env any) (any, error) {
2123
if program == nil {
2224
return nil, fmt.Errorf("program is nil")
@@ -83,6 +85,8 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
8385
vm.memory = 0
8486
vm.ip = 0
8587

88+
var fnArgsBuf []any
89+
8690
for vm.ip < len(program.Bytecode) {
8791
if debug && vm.debug {
8892
<-vm.step
@@ -399,62 +403,53 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) {
399403
vm.push(out)
400404

401405
case OpCall1:
402-
a := vm.pop()
403-
out, err := program.functions[arg](a)
406+
var args []any
407+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, 1)
408+
out, err := program.functions[arg](args...)
404409
if err != nil {
405410
panic(err)
406411
}
407412
vm.push(out)
408413

409414
case OpCall2:
410-
b := vm.pop()
411-
a := vm.pop()
412-
out, err := program.functions[arg](a, b)
415+
var args []any
416+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, 2)
417+
out, err := program.functions[arg](args...)
413418
if err != nil {
414419
panic(err)
415420
}
416421
vm.push(out)
417422

418423
case OpCall3:
419-
c := vm.pop()
420-
b := vm.pop()
421-
a := vm.pop()
422-
out, err := program.functions[arg](a, b, c)
424+
var args []any
425+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, 3)
426+
out, err := program.functions[arg](args...)
423427
if err != nil {
424428
panic(err)
425429
}
426430
vm.push(out)
427431

428432
case OpCallN:
429433
fn := vm.pop().(Function)
430-
size := arg
431-
in := make([]any, size)
432-
for i := int(size) - 1; i >= 0; i-- {
433-
in[i] = vm.pop()
434-
}
435-
out, err := fn(in...)
434+
var args []any
435+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, arg)
436+
out, err := fn(args...)
436437
if err != nil {
437438
panic(err)
438439
}
439440
vm.push(out)
440441

441442
case OpCallFast:
442443
fn := vm.pop().(func(...any) any)
443-
size := arg
444-
in := make([]any, size)
445-
for i := int(size) - 1; i >= 0; i-- {
446-
in[i] = vm.pop()
447-
}
448-
vm.push(fn(in...))
444+
var args []any
445+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, arg)
446+
vm.push(fn(args...))
449447

450448
case OpCallSafe:
451449
fn := vm.pop().(SafeFunction)
452-
size := arg
453-
in := make([]any, size)
454-
for i := int(size) - 1; i >= 0; i-- {
455-
in[i] = vm.pop()
456-
}
457-
out, mem, err := fn(in...)
450+
var args []any
451+
args, fnArgsBuf = vm.getArgsForFunc(fnArgsBuf, program, arg)
452+
out, mem, err := fn(args...)
458453
if err != nil {
459454
panic(err)
460455
}
@@ -671,6 +666,64 @@ func (vm *VM) scope() *Scope {
671666
return vm.Scopes[len(vm.Scopes)-1]
672667
}
673668

669+
// getArgsForFunc lazily initializes the buffer the first time it is called for
670+
// a given program (thus, it also needs "program" to run). It will
671+
// take "needed" elements from the buffer and populate them with vm.pop() in
672+
// reverse order. Because the estimation can fall short, this function can
673+
// occasionally make a new allocation.
674+
func (vm *VM) getArgsForFunc(argsBuf []any, program *Program, needed int) (args []any, argsBufOut []any) {
675+
if needed == 0 || program == nil {
676+
return nil, argsBuf
677+
}
678+
679+
// Step 1: fix estimations and preallocate
680+
if argsBuf == nil {
681+
estimatedFnArgsCount := estimateFnArgsCount(program)
682+
if estimatedFnArgsCount > maxFnArgsBuf {
683+
// put a practical limit to avoid excessive preallocation
684+
estimatedFnArgsCount = maxFnArgsBuf
685+
}
686+
if estimatedFnArgsCount < needed {
687+
// in the case that the first call is for example OpCallN with a large
688+
// number of arguments, then make sure we will be able to serve them at
689+
// least.
690+
estimatedFnArgsCount = needed
691+
}
692+
693+
// in the case that we are preparing the arguments for the first
694+
// function call of the program, then argsBuf will be nil, so we
695+
// initialize it. We delay this initial allocation here because a
696+
// program could have many function calls but exit earlier than the
697+
// first call, so in that case we avoid allocating unnecessarily
698+
argsBuf = make([]any, estimatedFnArgsCount)
699+
}
700+
701+
// Step 2: get the final slice that will be returned
702+
var buf []any
703+
if len(argsBuf) >= needed {
704+
// in this case, we are successfully using the single preallocation. We
705+
// use the full slice expression [low : high : max] because in that way
706+
// a function that receives this slice as variadic arguments will not be
707+
// able to make modifications to contiguous elements with append(). If
708+
// they call append on their variadic arguments they will make a new
709+
// allocation.
710+
buf = (argsBuf)[:needed:needed]
711+
argsBuf = (argsBuf)[needed:] // advance the buffer
712+
} else {
713+
// if we have been making calls to something like OpCallN with many more
714+
// arguments than what we estimated, then we will need to allocate
715+
// separately
716+
buf = make([]any, needed)
717+
}
718+
719+
// Step 3: populate the final slice bulk copying from the stack. This is the
720+
// exact order and copy() is a highly optimized operation
721+
copy(buf, vm.Stack[len(vm.Stack)-needed:])
722+
vm.Stack = vm.Stack[:len(vm.Stack)-needed]
723+
724+
return buf, argsBuf
725+
}
726+
674727
func (vm *VM) Step() {
675728
vm.step <- struct{}{}
676729
}
@@ -685,3 +738,30 @@ func clearSlice[S ~[]E, E any](s S) {
685738
s[i] = zero // clear mem, optimized by the compiler, in Go 1.21 the "clear" builtin can be used
686739
}
687740
}
741+
742+
// estimateFnArgsCount inspects a *Program and estimates how many function
743+
// arguments will be required to run it.
744+
func estimateFnArgsCount(program *Program) int {
745+
// Implementation note: a program will not necessarily go through all
746+
// operations, but this is just an estimation
747+
var count int
748+
for _, op := range program.Bytecode {
749+
if int(op) < len(opArgLenEstimation) {
750+
count += opArgLenEstimation[op]
751+
}
752+
}
753+
return count
754+
}
755+
756+
var opArgLenEstimation = [...]int{
757+
OpCall1: 1,
758+
OpCall2: 2,
759+
OpCall3: 3,
760+
// we don't know exactly but we know at least 4, so be conservative as this
761+
// is only an optimization and we also want to avoid excessive preallocation
762+
OpCallN: 4,
763+
// here we don't know either, but we can guess it could be common to receive
764+
// up to 3 arguments in a function
765+
OpCallFast: 3,
766+
OpCallSafe: 3,
767+
}

vm/vm_bench_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package vm_test
2+
3+
import (
4+
"runtime"
5+
"testing"
6+
7+
"github.com/expr-lang/expr"
8+
"github.com/expr-lang/expr/checker"
9+
"github.com/expr-lang/expr/compiler"
10+
"github.com/expr-lang/expr/conf"
11+
"github.com/expr-lang/expr/vm"
12+
)
13+
14+
func BenchmarkVM(b *testing.B) {
15+
cases := []struct {
16+
name, input string
17+
}{
18+
{"function calls", `
19+
func(
20+
func(
21+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
22+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
23+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
24+
),
25+
func(
26+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
27+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
28+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
29+
),
30+
func(
31+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
32+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
33+
func(func(a, 'a', 1, nil), func(a, 'a', 1, nil), func(a, 'a', 1, nil)),
34+
)
35+
)
36+
`},
37+
}
38+
39+
a := new(recursive)
40+
for i, b := 0, a; i < 40*4; i++ {
41+
b.Inner = new(recursive)
42+
b = b.Inner
43+
}
44+
45+
f := func(params ...any) (any, error) { return nil, nil }
46+
env := map[string]any{
47+
"a": a,
48+
"b": true,
49+
"func": f,
50+
}
51+
config := conf.New(env)
52+
expr.Function("func", f, f)(config)
53+
config.Check()
54+
55+
for _, c := range cases {
56+
tree, err := checker.ParseCheck(c.input, config)
57+
if err != nil {
58+
b.Fatal(c.input, "parse and check", err)
59+
}
60+
prog, err := compiler.Compile(tree, config)
61+
if err != nil {
62+
b.Fatal(c.input, "compile", err)
63+
}
64+
//b.Logf("disassembled:\n%s", prog.Disassemble())
65+
//b.FailNow()
66+
runtime.GC()
67+
68+
var vm vm.VM
69+
b.Run("name="+c.name, func(b *testing.B) {
70+
for i := 0; i < b.N; i++ {
71+
_, err = vm.Run(prog, env)
72+
}
73+
})
74+
if err != nil {
75+
b.Fatal(err)
76+
}
77+
}
78+
}
79+
80+
type recursive struct {
81+
Inner *recursive `expr:"a"`
82+
}

0 commit comments

Comments
 (0)