From 9e6a2e69dbd5c934cf82c6589767e8e44bb11233 Mon Sep 17 00:00:00 2001 From: Ville Vesilehto Date: Mon, 19 Jan 2026 22:45:21 +0200 Subject: [PATCH] perf(vm): optimize loop iteration with scope pool Iteration over slices previously used reflection to access elements, which was slow and allocated unnecessarily. This change adds type-specialized fast paths for common slice types ([]int, []float64, []string, []any) that bypass reflection entirely. Scope objects are now pooled and reused across loop iterations. The current scope pointer is cached to avoid repeated slice lookups. Signed-off-by: Ville Vesilehto --- vm/utils.go | 22 ++++++++++++ vm/vm.go | 100 ++++++++++++++++++++++++++++++++++++---------------- 2 files changed, 91 insertions(+), 31 deletions(-) diff --git a/vm/utils.go b/vm/utils.go index 11005137c..7f1ca1e89 100644 --- a/vm/utils.go +++ b/vm/utils.go @@ -20,6 +20,28 @@ type Scope struct { Len int Count int Acc any + // Fast paths + Ints []int + Floats []float64 + Strings []string + Anys []any +} + +// Item returns the current element from the scope using fast paths when available. +func (s *Scope) Item() any { + if s.Ints != nil { + return s.Ints[s.Index] + } + if s.Floats != nil { + return s.Floats[s.Index] + } + if s.Strings != nil { + return s.Strings[s.Index] + } + if s.Anys != nil { + return s.Anys[s.Index] + } + return s.Array.Index(s.Index).Interface() } type groupBy = map[any][]any diff --git a/vm/vm.go b/vm/vm.go index 887825413..29ceb51f7 100644 --- a/vm/vm.go +++ b/vm/vm.go @@ -46,6 +46,9 @@ type VM struct { debug bool step chan struct{} curr chan int + scopePool []Scope // Pre-allocated pool of Scope values; grows as needed but never shrinks + scopePoolIdx int // Current index into scopePool for allocation + currScope *Scope // Cached pointer to the current scope (optimization) } func (vm *VM) Run(program *Program, env any) (_ any, err error) { @@ -76,6 +79,8 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { clearSlice(vm.Scopes) vm.Scopes = vm.Scopes[0:0] } + vm.scopePoolIdx = 0 // Reset pool index for reuse + vm.currScope = nil if len(vm.Variables) < program.variables { vm.Variables = make([]any, program.variables) } @@ -221,8 +226,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { if arg < 0 { panic("negative jump offset is invalid") } - scope := vm.scope() - if scope.Index >= scope.Len { + if vm.currScope.Index >= vm.currScope.Len { vm.ip += arg } @@ -511,40 +515,34 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { vm.push(deref.Interface(a)) case OpIncrementIndex: - vm.scope().Index++ + vm.currScope.Index++ case OpDecrementIndex: - scope := vm.scope() - scope.Index-- + vm.currScope.Index-- case OpIncrementCount: - scope := vm.scope() - scope.Count++ + vm.currScope.Count++ case OpGetIndex: - vm.push(vm.scope().Index) + vm.push(vm.currScope.Index) case OpGetCount: - scope := vm.scope() - vm.push(scope.Count) + vm.push(vm.currScope.Count) case OpGetLen: - scope := vm.scope() - vm.push(scope.Len) + vm.push(vm.currScope.Len) case OpGetAcc: - vm.push(vm.scope().Acc) + vm.push(vm.currScope.Acc) case OpSetAcc: - vm.scope().Acc = vm.pop() + vm.currScope.Acc = vm.pop() case OpSetIndex: - scope := vm.scope() - scope.Index = vm.pop().(int) + vm.currScope.Index = vm.pop().(int) case OpPointer: - scope := vm.scope() - vm.push(scope.Array.Index(scope.Index).Interface()) + vm.push(vm.currScope.Item()) case OpThrow: panic(vm.pop().(error)) @@ -554,7 +552,7 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case 1: vm.push(make(groupBy)) case 2: - scope := vm.scope() + scope := vm.currScope var desc bool order, ok := vm.pop().(string) if !ok { @@ -578,21 +576,19 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { } case OpGroupBy: - scope := vm.scope() + scope := vm.currScope key := vm.pop() - item := scope.Array.Index(scope.Index).Interface() - scope.Acc.(groupBy)[key] = append(scope.Acc.(groupBy)[key], item) + scope.Acc.(groupBy)[key] = append(scope.Acc.(groupBy)[key], scope.Item()) case OpSortBy: - scope := vm.scope() + scope := vm.currScope value := vm.pop() - item := scope.Array.Index(scope.Index).Interface() sortable := scope.Acc.(*runtime.SortBy) - sortable.Array = append(sortable.Array, item) + sortable.Array = append(sortable.Array, scope.Item()) sortable.Values = append(sortable.Values, value) case OpSort: - scope := vm.scope() + scope := vm.currScope sortable := scope.Acc.(*runtime.SortBy) sort.Sort(sortable) vm.memGrow(uint(scope.Len)) @@ -608,11 +604,26 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case OpBegin: a := vm.pop() - array := reflect.ValueOf(a) - vm.Scopes = append(vm.Scopes, &Scope{ - Array: array, - Len: array.Len(), - }) + s := vm.allocScope() + switch v := a.(type) { + case []int: + s.Ints = v + s.Len = len(v) + case []float64: + s.Floats = v + s.Len = len(v) + case []string: + s.Strings = v + s.Len = len(v) + case []any: + s.Anys = v + s.Len = len(v) + default: + s.Array = reflect.ValueOf(a) + s.Len = s.Array.Len() + } + vm.Scopes = append(vm.Scopes, s) + vm.currScope = s case OpAnd: a := vm.pop() @@ -626,6 +637,11 @@ func (vm *VM) Run(program *Program, env any) (_ any, err error) { case OpEnd: vm.Scopes = vm.Scopes[:len(vm.Scopes)-1] + if len(vm.Scopes) > 0 { + vm.currScope = vm.Scopes[len(vm.Scopes)-1] + } else { + vm.currScope = nil + } default: panic(fmt.Sprintf("unknown bytecode %#x", op)) @@ -679,6 +695,28 @@ func (vm *VM) scope() *Scope { return vm.Scopes[len(vm.Scopes)-1] } +// allocScope returns a pointer to a Scope from the pool, growing the pool if needed. +// Callers must set Len and exactly one of: Ints, Floats, Strings, Anys, or Array. +func (vm *VM) allocScope() *Scope { + if vm.scopePoolIdx >= len(vm.scopePool) { + vm.scopePool = append(vm.scopePool, Scope{}) + } + s := &vm.scopePool[vm.scopePoolIdx] + vm.scopePoolIdx++ + // Reset iteration state + s.Index = 0 + s.Count = 0 + s.Acc = nil + // Clear typed slice pointers to avoid stale fast-path matches + s.Ints = nil + s.Floats = nil + s.Strings = nil + s.Anys = nil + // Clear Array to release reference for GC (only matters for fallback path) + s.Array = reflect.Value{} + return s +} + // getArgsForFunc lazily initializes the buffer the first time it is called for // a given program (thus, it also needs "program" to run). It will // take "needed" elements from the buffer and populate them with vm.pop() in