Skip to content

Commit a9ac10f

Browse files
authored
perf(builtin): add fast paths for min, max, mean, and median (#909)
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 <ville@vesilehto.fi>
1 parent 556b1d1 commit a9ac10f

File tree

3 files changed

+291
-6
lines changed

3 files changed

+291
-6
lines changed

bench_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,3 +571,71 @@ func Benchmark_reduce(b *testing.B) {
571571

572572
require.Equal(b, 5050, out.(int))
573573
}
574+
575+
func Benchmark_min(b *testing.B) {
576+
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}
577+
env := map[string]any{"arr": arr}
578+
579+
program, err := expr.Compile(`min(arr)`, expr.Env(env))
580+
require.NoError(b, err)
581+
582+
var out any
583+
b.ResetTimer()
584+
for n := 0; n < b.N; n++ {
585+
out, _ = vm.Run(program, env)
586+
}
587+
b.StopTimer()
588+
589+
require.Equal(b, 1, out)
590+
}
591+
592+
func Benchmark_max(b *testing.B) {
593+
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}
594+
env := map[string]any{"arr": arr}
595+
596+
program, err := expr.Compile(`max(arr)`, expr.Env(env))
597+
require.NoError(b, err)
598+
599+
var out any
600+
b.ResetTimer()
601+
for n := 0; n < b.N; n++ {
602+
out, _ = vm.Run(program, env)
603+
}
604+
b.StopTimer()
605+
606+
require.Equal(b, 100, out)
607+
}
608+
609+
func Benchmark_mean(b *testing.B) {
610+
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}
611+
env := map[string]any{"arr": arr}
612+
613+
program, err := expr.Compile(`mean(arr)`, expr.Env(env))
614+
require.NoError(b, err)
615+
616+
var out any
617+
b.ResetTimer()
618+
for n := 0; n < b.N; n++ {
619+
out, _ = vm.Run(program, env)
620+
}
621+
b.StopTimer()
622+
623+
require.Equal(b, 50.5, out)
624+
}
625+
626+
func Benchmark_median(b *testing.B) {
627+
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}
628+
env := map[string]any{"arr": arr}
629+
630+
program, err := expr.Compile(`median(arr)`, expr.Env(env))
631+
require.NoError(b, err)
632+
633+
var out any
634+
b.ResetTimer()
635+
for n := 0; n < b.N; n++ {
636+
out, _ = vm.Run(program, env)
637+
}
638+
b.StopTimer()
639+
640+
require.Equal(b, 50.5, out)
641+
}

builtin/builtin_test.go

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,19 @@ import (
2121
func TestBuiltin(t *testing.T) {
2222
ArrayWithNil := []any{42}
2323
env := map[string]any{
24-
"ArrayOfString": []string{"foo", "bar", "baz"},
25-
"ArrayOfInt": []int{1, 2, 3},
26-
"ArrayOfAny": []any{1, "2", true},
27-
"ArrayOfFoo": []mock.Foo{{Value: "a"}, {Value: "b"}, {Value: "c"}},
28-
"PtrArrayWithNil": &ArrayWithNil,
24+
"ArrayOfString": []string{"foo", "bar", "baz"},
25+
"ArrayOfInt": []int{1, 2, 3},
26+
"ArrayOfFloat": []float64{1.5, 2.5, 3.5},
27+
"ArrayOfInt32": []int32{1, 2, 3},
28+
"ArrayOfAny": []any{1, "2", true},
29+
"ArrayOfFoo": []mock.Foo{{Value: "a"}, {Value: "b"}, {Value: "c"}},
30+
"PtrArrayWithNil": &ArrayWithNil,
31+
"EmptyIntArray": []int{},
32+
"EmptyFloatArray": []float64{},
33+
"NestedIntArrays": []any{[]int{1, 2}, []int{3, 4}},
34+
"NestedAnyArrays": []any{[]any{1, 2}, []any{3, 4}},
35+
"MixedNestedArray": []any{1, []int{2, 3}, []float64{4.0, 5.0}},
36+
"NestedInt32Array": []any{[]int32{1, 2}, []int32{3, 4}},
2937
}
3038

3139
var tests = []struct {
@@ -86,6 +94,22 @@ func TestBuiltin(t *testing.T) {
8694
{`min([1, 2, 3])`, 1},
8795
{`min([1.5, 2.5, 3.5])`, 1.5},
8896
{`min(-1, [1.5, 2.5, 3.5])`, -1},
97+
{`max(ArrayOfInt)`, 3},
98+
{`min(ArrayOfInt)`, 1},
99+
{`max(ArrayOfFloat)`, 3.5},
100+
{`min(ArrayOfFloat)`, 1.5},
101+
{`max(EmptyIntArray, 5)`, 5},
102+
{`min(EmptyFloatArray, 5)`, 5},
103+
{`max(NestedIntArrays)`, 4},
104+
{`min(NestedIntArrays)`, 1},
105+
{`max(NestedAnyArrays)`, 4},
106+
{`min(NestedAnyArrays)`, 1},
107+
{`max(MixedNestedArray)`, 5.0},
108+
{`min(MixedNestedArray)`, 1},
109+
{`max(ArrayOfInt32)`, int32(3)},
110+
{`min(ArrayOfInt32)`, int32(1)},
111+
{`max(NestedInt32Array)`, int32(4)},
112+
{`min(NestedInt32Array)`, int32(1)},
89113
{`sum(1..9)`, 45},
90114
{`sum([.5, 1.5, 2.5])`, 4.5},
91115
{`sum([])`, 0},
@@ -97,6 +121,13 @@ func TestBuiltin(t *testing.T) {
97121
{`mean(10, [1, 2, 3], 1..9)`, 4.6923076923076925},
98122
{`mean(-10, [1, 2, 3, 4])`, 0.0},
99123
{`mean(10.9, 1..9)`, 5.59},
124+
{`mean(ArrayOfInt)`, 2.0},
125+
{`mean(ArrayOfFloat)`, 2.5},
126+
{`mean(NestedIntArrays)`, 2.5},
127+
{`mean(NestedAnyArrays)`, 2.5},
128+
{`mean(MixedNestedArray)`, 3.0},
129+
{`mean(ArrayOfInt32)`, 2.0},
130+
{`mean(NestedInt32Array)`, 2.5},
100131
{`median(1..9)`, 5.0},
101132
{`median([.5, 1.5, 2.5])`, 1.5},
102133
{`median([])`, 0.0},
@@ -105,6 +136,13 @@ func TestBuiltin(t *testing.T) {
105136
{`median(10, [1, 2, 3], 1..9)`, 4.0},
106137
{`median(-10, [1, 2, 3, 4])`, 2.0},
107138
{`median(1..5, 4.9)`, 3.5},
139+
{`median(ArrayOfInt)`, 2.0},
140+
{`median(ArrayOfFloat)`, 2.5},
141+
{`median(NestedIntArrays)`, 2.5},
142+
{`median(NestedAnyArrays)`, 2.5},
143+
{`median(MixedNestedArray)`, 3.0},
144+
{`median(ArrayOfInt32)`, 2.0},
145+
{`median(NestedInt32Array)`, 2.5},
108146
{`toJSON({foo: 1, bar: 2})`, "{\n \"bar\": 2,\n \"foo\": 1\n}"},
109147
{`fromJSON("[1, 2, 3]")`, []any{1.0, 2.0, 3.0}},
110148
{`toBase64("hello")`, "aGVsbG8="},

builtin/lib.go

Lines changed: 180 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,75 @@ func minMax(name string, fn func(any, any) bool, depth int, args ...any) (any, e
259259
}
260260
var val any
261261
for _, arg := range args {
262+
// Fast paths for common typed slices - avoid reflection and allocations
263+
switch arr := arg.(type) {
264+
case []int:
265+
if len(arr) == 0 {
266+
continue
267+
}
268+
m := arr[0]
269+
for i := 1; i < len(arr); i++ {
270+
if fn(m, arr[i]) {
271+
m = arr[i]
272+
}
273+
}
274+
if val == nil || fn(val, m) {
275+
val = m
276+
}
277+
continue
278+
case []float64:
279+
if len(arr) == 0 {
280+
continue
281+
}
282+
m := arr[0]
283+
for i := 1; i < len(arr); i++ {
284+
if fn(m, arr[i]) {
285+
m = arr[i]
286+
}
287+
}
288+
if val == nil || fn(val, m) {
289+
val = m
290+
}
291+
continue
292+
case []any:
293+
// Fast path for []any with simple numeric types
294+
for _, elem := range arr {
295+
switch e := elem.(type) {
296+
case int, int8, int16, int32, int64,
297+
uint, uint8, uint16, uint32, uint64,
298+
float32, float64:
299+
if val == nil || fn(val, e) {
300+
val = e
301+
}
302+
case []int, []float64, []any:
303+
// Nested array - recurse
304+
nested, err := minMax(name, fn, depth+1, e)
305+
if err != nil {
306+
return nil, err
307+
}
308+
if nested != nil && (val == nil || fn(val, nested)) {
309+
val = nested
310+
}
311+
default:
312+
// Could be another slice type, use reflection
313+
rv := reflect.ValueOf(e)
314+
if rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array {
315+
nested, err := minMax(name, fn, depth+1, e)
316+
if err != nil {
317+
return nil, err
318+
}
319+
if nested != nil && (val == nil || fn(val, nested)) {
320+
val = nested
321+
}
322+
} else {
323+
return nil, fmt.Errorf("invalid argument for %s (type %T)", name, e)
324+
}
325+
}
326+
}
327+
continue
328+
}
329+
330+
// Slow path: use reflection for other types
262331
rv := reflect.ValueOf(arg)
263332
switch rv.Kind() {
264333
case reflect.Array, reflect.Slice:
@@ -278,7 +347,6 @@ func minMax(name string, fn func(any, any) bool, depth int, args ...any) (any, e
278347
default:
279348
return nil, fmt.Errorf("invalid argument for %s (type %T)", name, elemVal)
280349
}
281-
282350
}
283351
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
284352
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64,
@@ -305,6 +373,67 @@ func mean(depth int, args ...any) (int, float64, error) {
305373
var count int
306374

307375
for _, arg := range args {
376+
// Fast paths for common typed slices - avoid reflection and allocations
377+
switch arr := arg.(type) {
378+
case []int:
379+
for _, v := range arr {
380+
total += float64(v)
381+
}
382+
count += len(arr)
383+
continue
384+
case []float64:
385+
for _, v := range arr {
386+
total += v
387+
}
388+
count += len(arr)
389+
continue
390+
case []any:
391+
// Fast path for []any - single pass without recursive calls for flat arrays
392+
for _, elem := range arr {
393+
switch e := elem.(type) {
394+
case int:
395+
total += float64(e)
396+
count++
397+
case float64:
398+
total += e
399+
count++
400+
case []int, []float64, []any:
401+
// Nested array - recurse
402+
nestedCount, nestedSum, err := mean(depth+1, e)
403+
if err != nil {
404+
return 0, 0, err
405+
}
406+
total += nestedSum
407+
count += nestedCount
408+
default:
409+
// Other numeric types or slices - use reflection
410+
rv := reflect.ValueOf(e)
411+
switch rv.Kind() {
412+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
413+
total += float64(rv.Int())
414+
count++
415+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
416+
total += float64(rv.Uint())
417+
count++
418+
case reflect.Float32, reflect.Float64:
419+
total += rv.Float()
420+
count++
421+
case reflect.Slice, reflect.Array:
422+
nestedCount, nestedSum, err := mean(depth+1, e)
423+
if err != nil {
424+
return 0, 0, err
425+
}
426+
total += nestedSum
427+
count += nestedCount
428+
default:
429+
return 0, 0, fmt.Errorf("invalid argument for mean (type %T)", e)
430+
}
431+
}
432+
}
433+
continue
434+
}
435+
436+
// Slow path: use reflection for other types
308437
rv := reflect.ValueOf(arg)
309438
switch rv.Kind() {
310439
case reflect.Array, reflect.Slice:
@@ -340,6 +469,56 @@ func median(depth int, args ...any) ([]float64, error) {
340469
var values []float64
341470

342471
for _, arg := range args {
472+
// Fast paths for common typed slices - avoid reflection and allocations
473+
switch arr := arg.(type) {
474+
case []int:
475+
for _, v := range arr {
476+
values = append(values, float64(v))
477+
}
478+
continue
479+
case []float64:
480+
values = append(values, arr...)
481+
continue
482+
case []any:
483+
// Fast path for []any - single pass without recursive calls for flat arrays
484+
for _, elem := range arr {
485+
switch e := elem.(type) {
486+
case int:
487+
values = append(values, float64(e))
488+
case float64:
489+
values = append(values, e)
490+
case []int, []float64, []any:
491+
// Nested array - recurse
492+
elems, err := median(depth+1, e)
493+
if err != nil {
494+
return nil, err
495+
}
496+
values = append(values, elems...)
497+
default:
498+
// Other numeric types or slices - use reflection
499+
rv := reflect.ValueOf(e)
500+
switch rv.Kind() {
501+
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
502+
values = append(values, float64(rv.Int()))
503+
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
504+
values = append(values, float64(rv.Uint()))
505+
case reflect.Float32, reflect.Float64:
506+
values = append(values, rv.Float())
507+
case reflect.Slice, reflect.Array:
508+
elems, err := median(depth+1, e)
509+
if err != nil {
510+
return nil, err
511+
}
512+
values = append(values, elems...)
513+
default:
514+
return nil, fmt.Errorf("invalid argument for median (type %T)", e)
515+
}
516+
}
517+
}
518+
continue
519+
}
520+
521+
// Slow path: use reflection for other types
343522
rv := reflect.ValueOf(arg)
344523
switch rv.Kind() {
345524
case reflect.Array, reflect.Slice:

0 commit comments

Comments
 (0)