Skip to content

Commit d674129

Browse files
committed
perf(runtime): add typed-slice fast paths to in operator
`in` dispatches through `runtime.In`, which uses reflect to iterate the right-hand side. The reflect path is correct for any slice type but pays one heap allocation per element on every typed slice, because `reflect.Value.Index(i).Interface()` must box the element when the slice's element type is not already `interface{}`. For `[]any` this boxing is a no-op (the cell is already an interface), so the existing path is already zero-alloc-per-element. For `[]string`, `[]float64`, `[]int64`, `[]int`, and `[]bool` it adds N heap allocations per `in` evaluation, which is significant when `in` runs in a hot loop (e.g. rule engines or expression-based filters over candidate lists). This patch adds a type-switch at the top of `In` for those five common shapes. Each case uses a pure-Go `for ... range` loop, so no reflect, no per-element boxing, no Equal() round-trip. On a needle/element type mismatch the case falls through to the existing reflect path so Equal()'s cross-type promotion semantics are preserved (e.g. an int needle against a []float64 still matches). Benchmarks (Apple M4 Pro, darwin/arm64, -benchtime=1s): bench (N elements) before after speedup StringSlice/N=8 112.8 ns/op, 6 allocs 18.96 ns/op, 1 alloc 6.0x StringSlice/N=64 659.8 ns/op, 34 allocs 31.69 ns/op, 1 alloc 20.8x StringSlice/N=256 2240 ns/op, 130 allocs 60.28 ns/op, 1 alloc 37.2x Float64Slice/N=8 85.1 ns/op, 6 allocs 14.99 ns/op, 1 alloc 5.7x Float64Slice/N=64 442.1 ns/op, 34 allocs 23.66 ns/op, 1 alloc 18.7x Float64Slice/N=256 1794 ns/op, 130 allocs 169.1 ns/op, 1 alloc 10.6x Int64Slice/N=8 82.0 ns/op, 6 allocs 14.77 ns/op, 1 alloc 5.6x Int64Slice/N=64 973.8 ns/op, 34 allocs 23.15 ns/op, 1 alloc 42.1x Int64Slice/N=256 1610 ns/op, 130 allocs 166.0 ns/op, 1 alloc 9.7x AnySliceOfString/N=* unchanged (already uses zero-alloc reflect path) The remaining 1 alloc/op is the call-site boxing the needle into `any` when calling runtime.In; it lives outside the changed code. Tests in `vm/runtime/runtime_test.go` cover hit/miss for each fast path, empty typed slice, cross-type needle (must fall through to reflect), and unchanged `[]any` semantics. The existing test suite is untouched and still passes. Signed-off-by: MinJae Kwon <mingrammer@gmail.com>
1 parent 3a46b19 commit d674129

3 files changed

Lines changed: 212 additions & 0 deletions

File tree

vm/runtime/runtime.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,65 @@ func In(needle any, array any) bool {
210210
if array == nil {
211211
return false
212212
}
213+
214+
// Fast paths for common typed-slice shapes. The generic reflect path below
215+
// works for these too, but it pays one heap allocation per element
216+
// (reflect.Value.Index(i).Interface() boxes the element when the slice's
217+
// element type is not interface{}). These switch cases let `in` over
218+
// []string / []float64 / []int64 / []int / []bool run with zero
219+
// per-element allocations, matching the cost of []any.
220+
//
221+
// On a needle/element type mismatch the case falls through to the reflect
222+
// path below, so Equal()'s cross-type promotion semantics are preserved
223+
// (e.g. comparing int needle against []float64 still works).
224+
switch arr := array.(type) {
225+
case []string:
226+
if s, ok := needle.(string); ok {
227+
for _, e := range arr {
228+
if e == s {
229+
return true
230+
}
231+
}
232+
return false
233+
}
234+
case []float64:
235+
if f, ok := needle.(float64); ok {
236+
for _, e := range arr {
237+
if e == f {
238+
return true
239+
}
240+
}
241+
return false
242+
}
243+
case []int64:
244+
if n, ok := needle.(int64); ok {
245+
for _, e := range arr {
246+
if e == n {
247+
return true
248+
}
249+
}
250+
return false
251+
}
252+
case []int:
253+
if n, ok := needle.(int); ok {
254+
for _, e := range arr {
255+
if e == n {
256+
return true
257+
}
258+
}
259+
return false
260+
}
261+
case []bool:
262+
if bn, ok := needle.(bool); ok {
263+
for _, e := range arr {
264+
if e == bn {
265+
return true
266+
}
267+
}
268+
return false
269+
}
270+
}
271+
213272
v := reflect.ValueOf(array)
214273

215274
switch v.Kind() {

vm/runtime/runtime_bench_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package runtime_test
2+
3+
import (
4+
"strconv"
5+
"testing"
6+
7+
"github.com/expr-lang/expr/vm/runtime"
8+
)
9+
10+
// BenchmarkIn benchmarks the `in` operator over the common slice shapes at
11+
// representative list sizes. The interesting comparison is between the typed
12+
// slice variants (which previously paid one heap alloc per element through
13+
// reflect.Value.Index(i).Interface()) and the []any variant (which has always
14+
// been zero-alloc per element because the slice's element type is interface).
15+
//
16+
// Run with:
17+
//
18+
// go test -bench=BenchmarkIn -benchmem ./vm/runtime/
19+
func BenchmarkIn(b *testing.B) {
20+
sizes := []int{8, 64, 256}
21+
22+
for _, n := range sizes {
23+
// Plant a hit roughly halfway through so the loop's short-circuit
24+
// fires at the same position in every variant.
25+
strs := make([]string, n)
26+
anys := make([]any, n)
27+
for i := 0; i < n; i++ {
28+
s := strconv.Itoa(i)
29+
strs[i] = s
30+
anys[i] = s
31+
}
32+
strs[n/2] = "needle"
33+
anys[n/2] = "needle"
34+
35+
b.Run("StringSlice/N="+strconv.Itoa(n), func(b *testing.B) {
36+
b.ReportAllocs()
37+
for i := 0; i < b.N; i++ {
38+
if !runtime.In("needle", strs) {
39+
b.Fatal("expected hit")
40+
}
41+
}
42+
})
43+
b.Run("AnySliceOfString/N="+strconv.Itoa(n), func(b *testing.B) {
44+
b.ReportAllocs()
45+
for i := 0; i < b.N; i++ {
46+
if !runtime.In("needle", anys) {
47+
b.Fatal("expected hit")
48+
}
49+
}
50+
})
51+
52+
floats := make([]float64, n)
53+
floatAnys := make([]any, n)
54+
for i := 0; i < n; i++ {
55+
floats[i] = float64(i)
56+
floatAnys[i] = float64(i)
57+
}
58+
floats[n/2] = 99999.0
59+
floatAnys[n/2] = 99999.0
60+
61+
b.Run("Float64Slice/N="+strconv.Itoa(n), func(b *testing.B) {
62+
b.ReportAllocs()
63+
for i := 0; i < b.N; i++ {
64+
if !runtime.In(99999.0, floats) {
65+
b.Fatal("expected hit")
66+
}
67+
}
68+
})
69+
70+
ints := make([]int64, n)
71+
intAnys := make([]any, n)
72+
for i := 0; i < n; i++ {
73+
ints[i] = int64(i)
74+
intAnys[i] = int64(i)
75+
}
76+
ints[n/2] = 99999
77+
intAnys[n/2] = int64(99999)
78+
79+
b.Run("Int64Slice/N="+strconv.Itoa(n), func(b *testing.B) {
80+
b.ReportAllocs()
81+
for i := 0; i < b.N; i++ {
82+
if !runtime.In(int64(99999), ints) {
83+
b.Fatal("expected hit")
84+
}
85+
}
86+
})
87+
}
88+
}

vm/runtime/runtime_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package runtime_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/expr-lang/expr/internal/testify/assert"
7+
8+
"github.com/expr-lang/expr/vm/runtime"
9+
)
10+
11+
// TestIn_TypedSlices exercises the typed-slice fast paths in runtime.In to
12+
// guarantee they preserve the semantics of the reflect-based fallback.
13+
func TestIn_TypedSlices(t *testing.T) {
14+
cases := []struct {
15+
name string
16+
needle any
17+
array any
18+
want bool
19+
}{
20+
// []string fast path
21+
{"string in []string (hit)", "b", []string{"a", "b", "c"}, true},
22+
{"string in []string (miss)", "z", []string{"a", "b", "c"}, false},
23+
{"string in empty []string", "x", []string{}, false},
24+
25+
// []float64 fast path
26+
{"float64 in []float64 (hit)", 2.5, []float64{1.0, 2.5, 3.0}, true},
27+
{"float64 in []float64 (miss)", 9.9, []float64{1.0, 2.5, 3.0}, false},
28+
29+
// []int64 fast path
30+
{"int64 in []int64 (hit)", int64(2), []int64{1, 2, 3}, true},
31+
{"int64 in []int64 (miss)", int64(9), []int64{1, 2, 3}, false},
32+
33+
// []int fast path
34+
{"int in []int (hit)", 2, []int{1, 2, 3}, true},
35+
{"int in []int (miss)", 9, []int{1, 2, 3}, false},
36+
37+
// []bool fast path
38+
{"true in []bool (hit)", true, []bool{false, true, false}, true},
39+
{"false in []bool (hit)", false, []bool{true, true, false}, true},
40+
{"true in []bool (miss all-false)", true, []bool{false, false}, false},
41+
42+
// Type-mismatched needles must fall through to the reflect path so
43+
// Equal()'s cross-type semantics are preserved. e.g. an int needle
44+
// against a []float64 should still match via numeric promotion.
45+
{"int needle in []float64 (promoted hit)", 2, []float64{1.0, 2.0, 3.0}, true},
46+
{"int needle in []float64 (promoted miss)", 9, []float64{1.0, 2.0, 3.0}, false},
47+
{"int needle in []int64 (promoted hit)", 2, []int64{1, 2, 3}, true},
48+
49+
// []any keeps using the reflect path (unchanged).
50+
{"string in []any (hit)", "b", []any{"a", "b", "c"}, true},
51+
{"int in []any (hit)", 2, []any{1, 2, 3}, true},
52+
}
53+
54+
for _, tc := range cases {
55+
t.Run(tc.name, func(t *testing.T) {
56+
assert.Equal(t, tc.want, runtime.In(tc.needle, tc.array))
57+
})
58+
}
59+
}
60+
61+
// TestIn_NilArray ensures the early-return for a nil right-hand side is
62+
// preserved (it lives above the typed-slice fast paths).
63+
func TestIn_NilArray(t *testing.T) {
64+
assert.False(t, runtime.In("x", nil))
65+
}

0 commit comments

Comments
 (0)