Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions vm/runtime/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,65 @@ func In(needle any, array any) bool {
if array == nil {
return false
}

// Fast paths for common typed-slice shapes. The generic reflect path below
// works for these too, but it pays one heap allocation per element
// (reflect.Value.Index(i).Interface() boxes the element when the slice's
// element type is not interface{}). These switch cases let `in` over
// []string / []float64 / []int64 / []int / []bool run with zero
// per-element allocations, matching the cost of []any.
//
// On a needle/element type mismatch the case falls through to the reflect
// path below, so Equal()'s cross-type promotion semantics are preserved
// (e.g. comparing int needle against []float64 still works).
switch arr := array.(type) {
case []string:
if s, ok := needle.(string); ok {
for _, e := range arr {
if e == s {
return true
}
}
return false
}
case []float64:
if f, ok := needle.(float64); ok {
for _, e := range arr {
if e == f {
return true
}
}
return false
}
case []int64:
if n, ok := needle.(int64); ok {
for _, e := range arr {
if e == n {
return true
}
}
return false
}
case []int:
if n, ok := needle.(int); ok {
for _, e := range arr {
if e == n {
return true
}
}
return false
}
case []bool:
if bn, ok := needle.(bool); ok {
for _, e := range arr {
if e == bn {
return true
}
}
return false
}
}

v := reflect.ValueOf(array)

switch v.Kind() {
Expand Down
88 changes: 88 additions & 0 deletions vm/runtime/runtime_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package runtime_test

import (
"strconv"
"testing"

"github.com/expr-lang/expr/vm/runtime"
)

// BenchmarkIn benchmarks the `in` operator over the common slice shapes at
// representative list sizes. The interesting comparison is between the typed
// slice variants (which previously paid one heap alloc per element through
// reflect.Value.Index(i).Interface()) and the []any variant (which has always
// been zero-alloc per element because the slice's element type is interface).
//
// Run with:
//
// go test -bench=BenchmarkIn -benchmem ./vm/runtime/
func BenchmarkIn(b *testing.B) {
sizes := []int{8, 64, 256}

for _, n := range sizes {
// Plant a hit roughly halfway through so the loop's short-circuit
// fires at the same position in every variant.
strs := make([]string, n)
anys := make([]any, n)
for i := 0; i < n; i++ {
s := strconv.Itoa(i)
strs[i] = s
anys[i] = s
}
strs[n/2] = "needle"
anys[n/2] = "needle"

b.Run("StringSlice/N="+strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if !runtime.In("needle", strs) {
b.Fatal("expected hit")
}
}
})
b.Run("AnySliceOfString/N="+strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if !runtime.In("needle", anys) {
b.Fatal("expected hit")
}
}
})

floats := make([]float64, n)
floatAnys := make([]any, n)
for i := 0; i < n; i++ {
floats[i] = float64(i)
floatAnys[i] = float64(i)
}
floats[n/2] = 99999.0
floatAnys[n/2] = 99999.0

b.Run("Float64Slice/N="+strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if !runtime.In(99999.0, floats) {
b.Fatal("expected hit")
}
}
})

ints := make([]int64, n)
intAnys := make([]any, n)
for i := 0; i < n; i++ {
ints[i] = int64(i)
intAnys[i] = int64(i)
}
ints[n/2] = 99999
intAnys[n/2] = int64(99999)

b.Run("Int64Slice/N="+strconv.Itoa(n), func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
if !runtime.In(int64(99999), ints) {
b.Fatal("expected hit")
}
}
})
}
}
65 changes: 65 additions & 0 deletions vm/runtime/runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package runtime_test

import (
"testing"

"github.com/expr-lang/expr/internal/testify/assert"

"github.com/expr-lang/expr/vm/runtime"
)

// TestIn_TypedSlices exercises the typed-slice fast paths in runtime.In to
// guarantee they preserve the semantics of the reflect-based fallback.
func TestIn_TypedSlices(t *testing.T) {
cases := []struct {
name string
needle any
array any
want bool
}{
// []string fast path
{"string in []string (hit)", "b", []string{"a", "b", "c"}, true},
{"string in []string (miss)", "z", []string{"a", "b", "c"}, false},
{"string in empty []string", "x", []string{}, false},

// []float64 fast path
{"float64 in []float64 (hit)", 2.5, []float64{1.0, 2.5, 3.0}, true},
{"float64 in []float64 (miss)", 9.9, []float64{1.0, 2.5, 3.0}, false},

// []int64 fast path
{"int64 in []int64 (hit)", int64(2), []int64{1, 2, 3}, true},
{"int64 in []int64 (miss)", int64(9), []int64{1, 2, 3}, false},

// []int fast path
{"int in []int (hit)", 2, []int{1, 2, 3}, true},
{"int in []int (miss)", 9, []int{1, 2, 3}, false},

// []bool fast path
{"true in []bool (hit)", true, []bool{false, true, false}, true},
{"false in []bool (hit)", false, []bool{true, true, false}, true},
{"true in []bool (miss all-false)", true, []bool{false, false}, false},

// Type-mismatched needles must fall through to the reflect path so
// Equal()'s cross-type semantics are preserved. e.g. an int needle
// against a []float64 should still match via numeric promotion.
{"int needle in []float64 (promoted hit)", 2, []float64{1.0, 2.0, 3.0}, true},
{"int needle in []float64 (promoted miss)", 9, []float64{1.0, 2.0, 3.0}, false},
{"int needle in []int64 (promoted hit)", 2, []int64{1, 2, 3}, true},

// []any keeps using the reflect path (unchanged).
{"string in []any (hit)", "b", []any{"a", "b", "c"}, true},
{"int in []any (hit)", 2, []any{1, 2, 3}, true},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, runtime.In(tc.needle, tc.array))
})
}
}

// TestIn_NilArray ensures the early-return for a nil right-hand side is
// preserved (it lives above the typed-slice fast paths).
func TestIn_NilArray(t *testing.T) {
assert.False(t, runtime.In("x", nil))
}