Skip to content

Commit 9312355

Browse files
committed
perf(sql): optimize expression evaluation (#4075)
## Summary - Reuse the project evaluator and its valuer chain across input rows. - Normalize numeric operands with one type switch instead of repeated type checks and cast dispatch. - Compare common `changed_cols` value types directly before falling back to `reflect.DeepEqual`. ## Why - SQL expression evaluation runs for every input row, so small setup and dispatch costs accumulate in projection, arithmetic, filtering, sorting, and change detection. - The project operator rebuilt the same evaluator scaffolding for every row even though only the current tuple, window, and aggregate data changed. - Numeric normalization inspected each operand type multiple times, while `changed_cols` used reflection even for primitive values. ## Changes In This PR - Expose reusable internal multi-valuer implementations and reset their per-row data from `ProjectOp` without changing expression indices or SliceTuple output semantics. - Replace `convertNum` helper dispatch with a single type switch that preserves the existing integer and floating-point conversions. - Add primitive fast paths for string, int64, float64, bool, and int comparisons in `changed_cols`; complex values retain `reflect.DeepEqual` behavior. - Add focused regression tests and benchmarks for all three paths. - Organize the changes as three independent commits for review. ## Notes - Benchmark medians on linux/amd64, Intel i9-14900HX, seven runs: - int64 `SimpleDataEval`: 10.82 ns/op to 8.08 ns/op (about 25% faster), 0 allocations before and after. - SliceTuple projection: 158.6 ns/op to 54.9 ns/op (about 65% faster), from 112 B / 4 allocations to 0 B / 0 allocations. - stable `changed_cols`: 337.0 ns/op to 304.8 ns/op (about 10% faster), with allocations unchanged. - Passed `go test -race ./internal/xsql ./internal/topo/operator -count=1` and focused race tests for the changed column functions. - `git diff --check` passes. - A full local binder/function run still hits the pre-existing `TestIncAggFunctionErr` failure, reproduced unchanged on `upstream/master`. `go vet` likewise reports the existing lock-copy warning in untouched `internal/xsql/row.go:187`. --------- Signed-off-by: Jiayin Ng <ngjaying@gmail.com>
1 parent 2ff8a9c commit 9312355

6 files changed

Lines changed: 264 additions & 53 deletions

File tree

internal/binder/function/funcs_cols.go

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022-2024 EMQ Technologies Co., Ltd.
1+
// Copyright 2022-2026 EMQ Technologies Co., Ltd.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -89,7 +89,7 @@ func changedFunc(ctx api.FunctionContext, args []interface{}, keys []string) (Re
8989
if err != nil {
9090
return nil, err
9191
}
92-
if !reflect.DeepEqual(v, lv) {
92+
if !changedValueEqual(v, lv) {
9393
if r == nil {
9494
r = make(ResultCols)
9595
}
@@ -102,3 +102,32 @@ func changedFunc(ctx api.FunctionContext, args []interface{}, keys []string) (Re
102102
}
103103
return r, nil
104104
}
105+
106+
func changedValueEqual(v1, v2 interface{}) bool {
107+
if v1 == nil || v2 == nil {
108+
return v1 == v2
109+
}
110+
switch t1 := v1.(type) {
111+
case string:
112+
if t2, ok := v2.(string); ok {
113+
return t1 == t2
114+
}
115+
case int64:
116+
if t2, ok := v2.(int64); ok {
117+
return t1 == t2
118+
}
119+
case float64:
120+
if t2, ok := v2.(float64); ok {
121+
return t1 == t2
122+
}
123+
case bool:
124+
if t2, ok := v2.(bool); ok {
125+
return t1 == t2
126+
}
127+
case int:
128+
if t2, ok := v2.(int); ok {
129+
return t1 == t2
130+
}
131+
}
132+
return reflect.DeepEqual(v1, v2)
133+
}

internal/binder/function/funcs_cols_test.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright 2022 EMQ Technologies Co., Ltd.
1+
// Copyright 2022-2026 EMQ Technologies Co., Ltd.
22
//
33
// Licensed under the Apache License, Version 2.0 (the "License");
44
// you may not use this file except in compliance with the License.
@@ -305,3 +305,53 @@ func TestExecIgnoreNull(t *testing.T) {
305305
}
306306
}
307307
}
308+
309+
func TestChangedValueEqual(t *testing.T) {
310+
tests := []struct {
311+
name string
312+
v1 any
313+
v2 any
314+
want bool
315+
}{
316+
{name: "nil", want: true},
317+
{name: "one nil", v1: int64(1), want: false},
318+
{name: "string", v1: "value", v2: "value", want: true},
319+
{name: "different string", v1: "value", v2: "other", want: false},
320+
{name: "int64", v1: int64(1), v2: int64(1), want: true},
321+
{name: "different numeric types", v1: int64(1), v2: int(1), want: false},
322+
{name: "float64", v1: float64(1.5), v2: float64(1.5), want: true},
323+
{name: "bool", v1: true, v2: true, want: true},
324+
{name: "int", v1: int(1), v2: int(1), want: true},
325+
{name: "slice fallback", v1: []int{1, 2}, v2: []int{1, 2}, want: true},
326+
{name: "map fallback", v1: map[string]any{"a": 1}, v2: map[string]any{"a": 2}, want: false},
327+
}
328+
for _, tt := range tests {
329+
t.Run(tt.name, func(t *testing.T) {
330+
if got := changedValueEqual(tt.v1, tt.v2); got != tt.want {
331+
t.Errorf("changedValueEqual(%v, %v) = %v, want %v", tt.v1, tt.v2, got, tt.want)
332+
}
333+
})
334+
}
335+
}
336+
337+
func BenchmarkChangedColsStable(b *testing.B) {
338+
contextLogger := conf.Log.WithField("rule", "BenchmarkChangedColsStable")
339+
ctx := kctx.WithValue(kctx.Background(), kctx.LoggerKey, contextLogger)
340+
tempStore, err := state.CreateStore("BenchmarkChangedColsStable", def.AtMostOnce)
341+
if err != nil {
342+
b.Fatal(err)
343+
}
344+
fctx := kctx.NewDefaultFuncContext(ctx.WithMeta("BenchmarkChangedColsStable", "changed_cols", tempStore), 1)
345+
args := []any{"p_", true, "same", int64(42), true}
346+
keys := []string{"prefix", "ignore", "string", "integer", "boolean"}
347+
if _, err := changedFunc(fctx, args, keys); err != nil {
348+
b.Fatal(err)
349+
}
350+
b.ReportAllocs()
351+
b.ResetTimer()
352+
for i := 0; i < b.N; i++ {
353+
if _, err := changedFunc(fctx, args, keys); err != nil {
354+
b.Fatal(err)
355+
}
356+
}
357+
}

internal/topo/operator/project_operator.go

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ type ProjectOp struct {
5050

5151
kvs []interface{}
5252
alias []interface{}
53+
54+
ve *xsql.ValuerEval
55+
wv *xsql.WildcardValuer
56+
wrv *xsql.WindowRangeValuer
57+
mvs xsql.MultiValuerList
58+
mav *xsql.AggregateMultiValuer
5359
}
5460

5561
// Apply
@@ -121,14 +127,41 @@ func (pp *ProjectOp) Apply(ctx api.StreamContext, data interface{}, fv *xsql.Fun
121127

122128
func (pp *ProjectOp) getVE(tuple xsql.RawRow, agg xsql.AggregateData, wr *xsql.WindowRange, fv *xsql.FunctionValuer, afv *xsql.AggregateFunctionValuer) *xsql.ValuerEval {
123129
afv.SetData(agg)
130+
if pp.ve == nil {
131+
pp.ve = &xsql.ValuerEval{}
132+
pp.wv = &xsql.WildcardValuer{}
133+
pp.wrv = &xsql.WindowRangeValuer{}
134+
pp.mvs = make(xsql.MultiValuerList, 4)
135+
if pp.IsAggregate {
136+
pp.mav = &xsql.AggregateMultiValuer{MultiValuerList: pp.mvs}
137+
pp.ve.Valuer = pp.mav
138+
} else {
139+
pp.ve.Valuer = &pp.mvs
140+
}
141+
}
142+
pp.wv.Data = tuple
124143
if pp.IsAggregate {
125-
return &xsql.ValuerEval{Valuer: xsql.MultiAggregateValuer(agg, fv, tuple, fv, afv, &xsql.WildcardValuer{Data: tuple})}
144+
pp.mvs[0] = tuple
145+
pp.mvs[1] = fv
146+
pp.mvs[2] = afv
147+
pp.mvs[3] = pp.wv
148+
pp.mav.Reset(agg, fv)
126149
} else {
127150
if wr != nil {
128-
return &xsql.ValuerEval{Valuer: xsql.MultiValuer(tuple, &xsql.WindowRangeValuer{WindowRange: wr}, fv, &xsql.WildcardValuer{Data: tuple})}
151+
pp.mvs = pp.mvs[:4]
152+
pp.wrv.WindowRange = wr
153+
pp.mvs[0] = tuple
154+
pp.mvs[1] = pp.wrv
155+
pp.mvs[2] = fv
156+
pp.mvs[3] = pp.wv
157+
} else {
158+
pp.mvs = pp.mvs[:3]
159+
pp.mvs[0] = tuple
160+
pp.mvs[1] = fv
161+
pp.mvs[2] = pp.wv
129162
}
130-
return &xsql.ValuerEval{Valuer: xsql.MultiValuer(tuple, fv, &xsql.WildcardValuer{Data: tuple})}
131163
}
164+
return pp.ve
132165
}
133166

134167
func (pp *ProjectOp) getRowVE(tuple xsql.Row, wr *xsql.WindowRange, fv *xsql.FunctionValuer, afv *xsql.AggregateFunctionValuer) *xsql.ValuerEval {

internal/topo/operator/project_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3630,3 +3630,48 @@ func TestProjectClearsCachedAliasesAfterError(t *testing.T) {
36303630
require.Same(t, succeeded, result)
36313631
require.Equal(t, map[string]any{"calculated": int64(4)}, succeeded.ToMap())
36323632
}
3633+
3634+
func TestProjectReusesValuer(t *testing.T) {
3635+
pp := &ProjectOp{}
3636+
fv, afv := xsql.NewFunctionValuersForOp(nil)
3637+
first := &xsql.Tuple{Emitter: "test", Message: xsql.Message{"a": int64(1)}}
3638+
second := &xsql.Tuple{Emitter: "test", Message: xsql.Message{"a": int64(2)}}
3639+
field := &ast.FieldRef{Name: "a"}
3640+
3641+
ve := pp.getRowVE(first, nil, fv, afv)
3642+
require.Equal(t, int64(1), ve.Eval(field))
3643+
reused := pp.getRowVE(second, nil, fv, afv)
3644+
require.Same(t, ve, reused)
3645+
require.Equal(t, int64(2), reused.Eval(field))
3646+
3647+
windowed := pp.getVE(second, nil, xsql.NewWindowRange(10, 20, 30), fv, afv)
3648+
require.Same(t, ve, windowed)
3649+
require.Equal(t, int64(10), windowed.Eval(&ast.Call{Name: "window_start"}))
3650+
3651+
withoutWindow := pp.getRowVE(first, nil, fv, afv)
3652+
require.Same(t, ve, withoutWindow)
3653+
require.Equal(t, int64(1), withoutWindow.Eval(field))
3654+
}
3655+
3656+
func BenchmarkSliceProjectEvaluation(b *testing.B) {
3657+
stmt, err := xsql.NewParser(strings.NewReader("SELECT a, b, c FROM test")).Parse()
3658+
if err != nil {
3659+
b.Fatal(err)
3660+
}
3661+
pp := &ProjectOp{}
3662+
parseStmtWithSlice(pp, stmt.Fields, true)
3663+
fv, afv := xsql.NewFunctionValuersForOp(nil)
3664+
source := model.SliceVal{int64(10), int64(20), int64(30)}
3665+
sink := make(model.SliceVal, 0, pp.FieldLen)
3666+
tuple := &xsql.SliceTuple{}
3667+
b.ReportAllocs()
3668+
b.ResetTimer()
3669+
for i := 0; i < b.N; i++ {
3670+
tuple.SourceContent = source
3671+
tuple.SinkContent = sink[:0]
3672+
ve := pp.getRowVE(tuple, nil, fv, afv)
3673+
if err := pp.project(tuple, ve); err != nil {
3674+
b.Fatal(err)
3675+
}
3676+
}
3677+
}

0 commit comments

Comments
 (0)