Skip to content
Merged
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
7 changes: 4 additions & 3 deletions .github/workflows/benchmark.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,17 +70,18 @@ jobs:
run: |
mkdir -p .bench
git worktree add .bench/base "${{ github.event.pull_request.base.sha }}"
# Override the base's bench file with HEAD's so both runs execute
# Override the base's bench files with HEAD's so both runs execute
# the same benchmark suite — only the implementation under test differs.
cp serialize_bench_test.go .bench/base/serialize_bench_test.go
cp run_expression_bench_test.go .bench/base/run_expression_bench_test.go
- name: Benchmark base
run: |
cd .bench/base
go test -run=^$ -bench=BenchmarkSerialize -count=6 -timeout 20m \
go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \
github.com/flanksource/gomplate/v3 | tee "$GITHUB_WORKSPACE/bench-base.txt"
- name: Benchmark head
run: |
go test -run=^$ -bench=BenchmarkSerialize -count=6 -timeout 20m \
go test -run=^$ -bench='BenchmarkSerialize|BenchmarkRunExpressionContext' -count=6 -timeout 20m \
github.com/flanksource/gomplate/v3 | tee bench-head.txt
- name: Compare
run: |
Expand Down
130 changes: 130 additions & 0 deletions run_expression_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package gomplate

// This benchmark exercises the CEL expression evaluation path (RunExpressionContext)
// the way callers use it at runtime: with a CacheKey so the compiled cel.Program is
// served from celExpressionCache on every iteration after the first.
//
// Motivation: a production heap profile showed RunExpressionContext -> GetCelEnv
// (notably kubernetes.Library()) + Serialize accounting for the single largest slice
// of lifetime allocation, because GetCelEnv was rebuilt on EVERY evaluation even
// though the compiled program is cached. After iteration 1 (the cache miss), all
// remaining iterations are cache hits; any allocation that remains is the per-call
// overhead that runs regardless of the program cache.
//
// Only the cache-hit steady state is benchmarked: that is the prod hot path and the
// regression guard for the "GetCelEnv must not run on cache hits" invariant.
//
// Run:
// go test -run=^$ -bench=BenchmarkRunExpressionContext -benchmem
//
// Capture a heap profile and inspect it the same way we inspect prod ones:
// go test -run=^$ -bench=BenchmarkRunExpressionContext/cacheHit -benchmem \
// -memprofile /tmp/cel.mem.pprof -memprofilerate=1
// go tool pprof -alloc_space -top -nodecount=25 /tmp/cel.mem.pprof
// go tool pprof -alloc_space -peek 'GetCelEnv$' /tmp/cel.mem.pprof

import (
"fmt"
"testing"
)

// benchExprEnv returns an env map shaped like a Kubernetes Pod config item as the
// scraper passes it to template evaluation. GetCelEnv registers one cel.Variable per
// top-level key and Serialize walks the entire structure, so env size directly drives
// the per-call allocation under test.
func benchExprEnv(withNestedConfig bool) map[string]any {
env := map[string]any{
"id": "0192f0a4-1234-7000-8000-aaaaaaaaaaaa",
"name": "nginx-7c5ddbdf54-abcde",
"namespace": "default",
"config_type": "Kubernetes::Pod",
"config_class": "Pod",
"tags": map[string]any{
"cluster": "production",
"namespace": "default",
},
}

if withNestedConfig {
containers := make([]any, 0, 3)
for i := 0; i < 3; i++ {
containers = append(containers, map[string]any{
"name": fmt.Sprintf("container-%d", i),
"image": fmt.Sprintf("registry.example.com/app:%d.2.3", i),
"ports": []any{map[string]any{"containerPort": 8080 + i, "protocol": "TCP"}},
"env": []any{
map[string]any{"name": "LOG_LEVEL", "value": "info"},
map[string]any{"name": "REGION", "value": "us-east-1"},
},
"resources": map[string]any{
"limits": map[string]any{"cpu": "500m", "memory": "512Mi"},
"requests": map[string]any{"cpu": "100m", "memory": "128Mi"},
},
})
}

env["config"] = map[string]any{
"apiVersion": "v1",
"kind": "Pod",
"metadata": map[string]any{
"name": "nginx-7c5ddbdf54-abcde",
"namespace": "default",
"labels": map[string]any{
"app": "nginx", "team": "platform", "env": "production", "version": "v1.2.3",
},
"annotations": map[string]any{
"prometheus.io/scrape": "true",
"prometheus.io/port": "8080",
},
"ownerReferences": []any{
map[string]any{"apiVersion": "apps/v1", "kind": "ReplicaSet", "name": "nginx-7c5ddbdf54"},
},
},
"spec": map[string]any{"containers": containers, "nodeName": "ip-10-0-1-23"},
"status": map[string]any{"phase": "Running", "podIP": "10.0.5.12", "hostIP": "10.0.1.23"},
}
}

return env
}

// exprBenchSink prevents the compiler from optimizing away results.
var exprBenchSink any

// BenchmarkRunExpressionContext measures the CEL evaluation path on the cache-hit
// steady state: a CacheKey is set so the compiled cel.Program is reused from
// celExpressionCache. After the warm-up run, every iteration is a cache hit, and the
// reported B/op / allocs/op is the per-call overhead that runs regardless of the program
// cache (Serialize + Eval, plus GetCelEnv if a regression reintroduces it before the
// cache lookup).
func BenchmarkRunExpressionContext(b *testing.B) {
const expression = `config_type == "Kubernetes::Pod"`

for _, withConfig := range []bool{false, true} {
name := "cacheHit/smallEnv"
if withConfig {
name = "cacheHit/largeEnv"
}
b.Run(name, func(b *testing.B) {
env := benchExprEnv(withConfig)
tmpl := Template{
Expression: expression,
CacheKey: "bench.RunExpressionContext:config_type==Kubernetes::Pod",
}
// Warm the cache once so we measure steady state, not the one-time compile.
if _, err := RunExpression(env, tmpl); err != nil {
b.Fatal(err)
}

b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
out, err := RunExpression(env, tmpl)
if err != nil {
b.Fatal(err)
}
exprBenchSink = out
}
})
}
}
49 changes: 27 additions & 22 deletions template.go
Original file line number Diff line number Diff line change
Expand Up @@ -187,28 +187,11 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an
return "", err
}

envOptions := GetCelEnv(data)
for name, fn := range template.Functions {
_name := name
_fn := fn
envOptions = append(envOptions, cel.Function(_name, cel.Overload(
_name,
nil,
cel.AnyType,
cel.FunctionBinding(func(values ...ref.Val) ref.Val {
ogFunc, ok := _fn.(func() any)
if !ok {
return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", _name))
}

out := ogFunc()
return types.DefaultTypeAdapter.NativeToValue(out)
}),
)))
}

envOptions = append(envOptions, template.CelEnvs...)

// Look up the compiled-program cache BEFORE constructing the CEL env options.
// GetCelEnv (notably kubernetes.Library()) is the dominant allocation on the CEL
// path. On the overwhelmingly common cache hit it would be built and then
// immediately discarded, since cel.NewEnv is only needed to compile a new
// program. Build env options only when we actually need to compile.
var prg cel.Program
if template.IsCacheable() {
cached, ok := celExpressionCache.Get(template.cacheKey(_environment))
Expand All @@ -220,6 +203,28 @@ func RunExpressionContext(ctx commonsContext.Context, _environment map[string]an
}

if prg == nil {
envOptions := GetCelEnv(data)
for name, fn := range template.Functions {
_name := name
_fn := fn
envOptions = append(envOptions, cel.Function(_name, cel.Overload(
_name,
nil,
cel.AnyType,
cel.FunctionBinding(func(values ...ref.Val) ref.Val {
ogFunc, ok := _fn.(func() any)
if !ok {
return types.WrapErr(fmt.Errorf("%s is expected to be of type func() any", _name))
}

out := ogFunc()
return types.DefaultTypeAdapter.NativeToValue(out)
}),
)))
}

envOptions = append(envOptions, template.CelEnvs...)

env, err := cel.NewEnv(envOptions...)
if err != nil {
return "", err
Expand Down
Loading