From 4e035ddd112b6a5d34a4e0c00b320a72e9b022ec Mon Sep 17 00:00:00 2001 From: Ville Vesilehto Date: Wed, 21 Jan 2026 12:24:34 +0200 Subject: [PATCH] perf(builtin): add fast paths for min, max, mean, and median Avoid reflection and per-element allocations for common typed slices ([]int, []float64, []any) by adding type-switch fast paths that iterate directly without calling reflect.Value.Interface(). For []any containing numeric types, the fast path handles int and float64 directly, falling back to reflection for other numeric types (int32, etc.) to keep the code compact while still avoiding per-element recursion. Falls back to reflection for other slice types to maintain compatibility. Signed-off-by: Ville Vesilehto --- bench_test.go | 68 +++++++++++++++ builtin/builtin_test.go | 48 +++++++++-- builtin/lib.go | 181 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 291 insertions(+), 6 deletions(-) diff --git a/bench_test.go b/bench_test.go index d70a0935..99fd4aab 100644 --- a/bench_test.go +++ b/bench_test.go @@ -571,3 +571,71 @@ func Benchmark_reduce(b *testing.B) { require.Equal(b, 5050, out.(int)) } + +func Benchmark_min(b *testing.B) { + arr := []any{55, 58, 42, 61, 75, 52, 64, 62, 16, 79, 40, 14, 50, 76, 23, 2, 5, 80, 89, 51, 21, 96, 91, 13, 71, 82, 65, 63, 11, 17, 94, 81, 74, 4, 97, 1, 39, 3, 28, 8, 84, 90, 47, 85, 7, 56, 49, 93, 33, 12, 19, 60, 86, 100, 44, 45, 36, 72, 95, 77, 34, 92, 24, 73, 18, 38, 43, 26, 41, 69, 67, 57, 9, 27, 66, 87, 46, 35, 59, 70, 10, 20, 53, 15, 32, 98, 68, 31, 54, 25, 83, 88, 22, 48, 29, 37, 6, 78, 99, 30} + env := map[string]any{"arr": arr} + + program, err := expr.Compile(`min(arr)`, expr.Env(env)) + require.NoError(b, err) + + var out any + b.ResetTimer() + for n := 0; n < b.N; n++ { + out, _ = vm.Run(program, env) + } + b.StopTimer() + + require.Equal(b, 1, out) +} + +func Benchmark_max(b *testing.B) { + arr := []any{55, 58, 42, 61, 75, 52, 64, 62, 16, 79, 40, 14, 50, 76, 23, 2, 5, 80, 89, 51, 21, 96, 91, 13, 71, 82, 65, 63, 11, 17, 94, 81, 74, 4, 97, 1, 39, 3, 28, 8, 84, 90, 47, 85, 7, 56, 49, 93, 33, 12, 19, 60, 86, 100, 44, 45, 36, 72, 95, 77, 34, 92, 24, 73, 18, 38, 43, 26, 41, 69, 67, 57, 9, 27, 66, 87, 46, 35, 59, 70, 10, 20, 53, 15, 32, 98, 68, 31, 54, 25, 83, 88, 22, 48, 29, 37, 6, 78, 99, 30} + env := map[string]any{"arr": arr} + + program, err := expr.Compile(`max(arr)`, expr.Env(env)) + require.NoError(b, err) + + var out any + b.ResetTimer() + for n := 0; n < b.N; n++ { + out, _ = vm.Run(program, env) + } + b.StopTimer() + + require.Equal(b, 100, out) +} + +func Benchmark_mean(b *testing.B) { + arr := []any{55, 58, 42, 61, 75, 52, 64, 62, 16, 79, 40, 14, 50, 76, 23, 2, 5, 80, 89, 51, 21, 96, 91, 13, 71, 82, 65, 63, 11, 17, 94, 81, 74, 4, 97, 1, 39, 3, 28, 8, 84, 90, 47, 85, 7, 56, 49, 93, 33, 12, 19, 60, 86, 100, 44, 45, 36, 72, 95, 77, 34, 92, 24, 73, 18, 38, 43, 26, 41, 69, 67, 57, 9, 27, 66, 87, 46, 35, 59, 70, 10, 20, 53, 15, 32, 98, 68, 31, 54, 25, 83, 88, 22, 48, 29, 37, 6, 78, 99, 30} + env := map[string]any{"arr": arr} + + program, err := expr.Compile(`mean(arr)`, expr.Env(env)) + require.NoError(b, err) + + var out any + b.ResetTimer() + for n := 0; n < b.N; n++ { + out, _ = vm.Run(program, env) + } + b.StopTimer() + + require.Equal(b, 50.5, out) +} + +func Benchmark_median(b *testing.B) { + arr := []any{55, 58, 42, 61, 75, 52, 64, 62, 16, 79, 40, 14, 50, 76, 23, 2, 5, 80, 89, 51, 21, 96, 91, 13, 71, 82, 65, 63, 11, 17, 94, 81, 74, 4, 97, 1, 39, 3, 28, 8, 84, 90, 47, 85, 7, 56, 49, 93, 33, 12, 19, 60, 86, 100, 44, 45, 36, 72, 95, 77, 34, 92, 24, 73, 18, 38, 43, 26, 41, 69, 67, 57, 9, 27, 66, 87, 46, 35, 59, 70, 10, 20, 53, 15, 32, 98, 68, 31, 54, 25, 83, 88, 22, 48, 29, 37, 6, 78, 99, 30} + env := map[string]any{"arr": arr} + + program, err := expr.Compile(`median(arr)`, expr.Env(env)) + require.NoError(b, err) + + var out any + b.ResetTimer() + for n := 0; n < b.N; n++ { + out, _ = vm.Run(program, env) + } + b.StopTimer() + + require.Equal(b, 50.5, out) +} diff --git a/builtin/builtin_test.go b/builtin/builtin_test.go index a5dabbbb..c25b98ae 100644 --- a/builtin/builtin_test.go +++ b/builtin/builtin_test.go @@ -21,11 +21,19 @@ import ( func TestBuiltin(t *testing.T) { ArrayWithNil := []any{42} env := map[string]any{ - "ArrayOfString": []string{"foo", "bar", "baz"}, - "ArrayOfInt": []int{1, 2, 3}, - "ArrayOfAny": []any{1, "2", true}, - "ArrayOfFoo": []mock.Foo{{Value: "a"}, {Value: "b"}, {Value: "c"}}, - "PtrArrayWithNil": &ArrayWithNil, + "ArrayOfString": []string{"foo", "bar", "baz"}, + "ArrayOfInt": []int{1, 2, 3}, + "ArrayOfFloat": []float64{1.5, 2.5, 3.5}, + "ArrayOfInt32": []int32{1, 2, 3}, + "ArrayOfAny": []any{1, "2", true}, + "ArrayOfFoo": []mock.Foo{{Value: "a"}, {Value: "b"}, {Value: "c"}}, + "PtrArrayWithNil": &ArrayWithNil, + "EmptyIntArray": []int{}, + "EmptyFloatArray": []float64{}, + "NestedIntArrays": []any{[]int{1, 2}, []int{3, 4}}, + "NestedAnyArrays": []any{[]any{1, 2}, []any{3, 4}}, + "MixedNestedArray": []any{1, []int{2, 3}, []float64{4.0, 5.0}}, + "NestedInt32Array": []any{[]int32{1, 2}, []int32{3, 4}}, } var tests = []struct { @@ -86,6 +94,22 @@ func TestBuiltin(t *testing.T) { {`min([1, 2, 3])`, 1}, {`min([1.5, 2.5, 3.5])`, 1.5}, {`min(-1, [1.5, 2.5, 3.5])`, -1}, + {`max(ArrayOfInt)`, 3}, + {`min(ArrayOfInt)`, 1}, + {`max(ArrayOfFloat)`, 3.5}, + {`min(ArrayOfFloat)`, 1.5}, + {`max(EmptyIntArray, 5)`, 5}, + {`min(EmptyFloatArray, 5)`, 5}, + {`max(NestedIntArrays)`, 4}, + {`min(NestedIntArrays)`, 1}, + {`max(NestedAnyArrays)`, 4}, + {`min(NestedAnyArrays)`, 1}, + {`max(MixedNestedArray)`, 5.0}, + {`min(MixedNestedArray)`, 1}, + {`max(ArrayOfInt32)`, int32(3)}, + {`min(ArrayOfInt32)`, int32(1)}, + {`max(NestedInt32Array)`, int32(4)}, + {`min(NestedInt32Array)`, int32(1)}, {`sum(1..9)`, 45}, {`sum([.5, 1.5, 2.5])`, 4.5}, {`sum([])`, 0}, @@ -97,6 +121,13 @@ func TestBuiltin(t *testing.T) { {`mean(10, [1, 2, 3], 1..9)`, 4.6923076923076925}, {`mean(-10, [1, 2, 3, 4])`, 0.0}, {`mean(10.9, 1..9)`, 5.59}, + {`mean(ArrayOfInt)`, 2.0}, + {`mean(ArrayOfFloat)`, 2.5}, + {`mean(NestedIntArrays)`, 2.5}, + {`mean(NestedAnyArrays)`, 2.5}, + {`mean(MixedNestedArray)`, 3.0}, + {`mean(ArrayOfInt32)`, 2.0}, + {`mean(NestedInt32Array)`, 2.5}, {`median(1..9)`, 5.0}, {`median([.5, 1.5, 2.5])`, 1.5}, {`median([])`, 0.0}, @@ -105,6 +136,13 @@ func TestBuiltin(t *testing.T) { {`median(10, [1, 2, 3], 1..9)`, 4.0}, {`median(-10, [1, 2, 3, 4])`, 2.0}, {`median(1..5, 4.9)`, 3.5}, + {`median(ArrayOfInt)`, 2.0}, + {`median(ArrayOfFloat)`, 2.5}, + {`median(NestedIntArrays)`, 2.5}, + {`median(NestedAnyArrays)`, 2.5}, + {`median(MixedNestedArray)`, 3.0}, + {`median(ArrayOfInt32)`, 2.0}, + {`median(NestedInt32Array)`, 2.5}, {`toJSON({foo: 1, bar: 2})`, "{\n \"bar\": 2,\n \"foo\": 1\n}"}, {`fromJSON("[1, 2, 3]")`, []any{1.0, 2.0, 3.0}}, {`toBase64("hello")`, "aGVsbG8="}, diff --git a/builtin/lib.go b/builtin/lib.go index 07a029b2..40ba19e9 100644 --- a/builtin/lib.go +++ b/builtin/lib.go @@ -259,6 +259,75 @@ func minMax(name string, fn func(any, any) bool, depth int, args ...any) (any, e } var val any for _, arg := range args { + // Fast paths for common typed slices - avoid reflection and allocations + switch arr := arg.(type) { + case []int: + if len(arr) == 0 { + continue + } + m := arr[0] + for i := 1; i < len(arr); i++ { + if fn(m, arr[i]) { + m = arr[i] + } + } + if val == nil || fn(val, m) { + val = m + } + continue + case []float64: + if len(arr) == 0 { + continue + } + m := arr[0] + for i := 1; i < len(arr); i++ { + if fn(m, arr[i]) { + m = arr[i] + } + } + if val == nil || fn(val, m) { + val = m + } + continue + case []any: + // Fast path for []any with simple numeric types + for _, elem := range arr { + switch e := elem.(type) { + case int, int8, int16, int32, int64, + uint, uint8, uint16, uint32, uint64, + float32, float64: + if val == nil || fn(val, e) { + val = e + } + case []int, []float64, []any: + // Nested array - recurse + nested, err := minMax(name, fn, depth+1, e) + if err != nil { + return nil, err + } + if nested != nil && (val == nil || fn(val, nested)) { + val = nested + } + default: + // Could be another slice type, use reflection + rv := reflect.ValueOf(e) + if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array { + nested, err := minMax(name, fn, depth+1, e) + if err != nil { + return nil, err + } + if nested != nil && (val == nil || fn(val, nested)) { + val = nested + } + } else { + return nil, fmt.Errorf("invalid argument for %s (type %T)", name, e) + } + } + } + continue + } + + // Slow path: use reflection for other types rv := reflect.ValueOf(arg) switch rv.Kind() { case reflect.Array, reflect.Slice: @@ -278,7 +347,6 @@ func minMax(name string, fn func(any, any) bool, depth int, args ...any) (any, e default: return nil, fmt.Errorf("invalid argument for %s (type %T)", name, elemVal) } - } case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, @@ -305,6 +373,67 @@ func mean(depth int, args ...any) (int, float64, error) { var count int for _, arg := range args { + // Fast paths for common typed slices - avoid reflection and allocations + switch arr := arg.(type) { + case []int: + for _, v := range arr { + total += float64(v) + } + count += len(arr) + continue + case []float64: + for _, v := range arr { + total += v + } + count += len(arr) + continue + case []any: + // Fast path for []any - single pass without recursive calls for flat arrays + for _, elem := range arr { + switch e := elem.(type) { + case int: + total += float64(e) + count++ + case float64: + total += e + count++ + case []int, []float64, []any: + // Nested array - recurse + nestedCount, nestedSum, err := mean(depth+1, e) + if err != nil { + return 0, 0, err + } + total += nestedSum + count += nestedCount + default: + // Other numeric types or slices - use reflection + rv := reflect.ValueOf(e) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + total += float64(rv.Int()) + count++ + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + total += float64(rv.Uint()) + count++ + case reflect.Float32, reflect.Float64: + total += rv.Float() + count++ + case reflect.Slice, reflect.Array: + nestedCount, nestedSum, err := mean(depth+1, e) + if err != nil { + return 0, 0, err + } + total += nestedSum + count += nestedCount + default: + return 0, 0, fmt.Errorf("invalid argument for mean (type %T)", e) + } + } + } + continue + } + + // Slow path: use reflection for other types rv := reflect.ValueOf(arg) switch rv.Kind() { case reflect.Array, reflect.Slice: @@ -340,6 +469,56 @@ func median(depth int, args ...any) ([]float64, error) { var values []float64 for _, arg := range args { + // Fast paths for common typed slices - avoid reflection and allocations + switch arr := arg.(type) { + case []int: + for _, v := range arr { + values = append(values, float64(v)) + } + continue + case []float64: + values = append(values, arr...) + continue + case []any: + // Fast path for []any - single pass without recursive calls for flat arrays + for _, elem := range arr { + switch e := elem.(type) { + case int: + values = append(values, float64(e)) + case float64: + values = append(values, e) + case []int, []float64, []any: + // Nested array - recurse + elems, err := median(depth+1, e) + if err != nil { + return nil, err + } + values = append(values, elems...) + default: + // Other numeric types or slices - use reflection + rv := reflect.ValueOf(e) + switch rv.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + values = append(values, float64(rv.Int())) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + values = append(values, float64(rv.Uint())) + case reflect.Float32, reflect.Float64: + values = append(values, rv.Float()) + case reflect.Slice, reflect.Array: + elems, err := median(depth+1, e) + if err != nil { + return nil, err + } + values = append(values, elems...) + default: + return nil, fmt.Errorf("invalid argument for median (type %T)", e) + } + } + } + continue + } + + // Slow path: use reflection for other types rv := reflect.ValueOf(arg) switch rv.Kind() { case reflect.Array, reflect.Slice: