Skip to content

perf(cel): build static CEL env once and Extend per call#162

Merged
moshloop merged 2 commits into
mainfrom
perf/cel-static-base-env
Jun 24, 2026
Merged

perf(cel): build static CEL env once and Extend per call#162
moshloop merged 2 commits into
mainfrom
perf/cel-static-base-env

Conversation

@adityathebe

@adityathebe adityathebe commented Jun 24, 2026

Copy link
Copy Markdown
Member

Problem

GetCelEnv rebuilt the entire CEL environment on every expression compile — funcs.CelEnvOption, kubernetes.Library(), the cel-go extensions, stdlib, gomplate's custom functions — and then cel.NewEnv validated all of those declarations from scratch. In production heap profiles this compile path was the single largest source of CEL allocation.

It runs more often than the cache-hit benchmark suggests:

  • on the first compile of every expression, and
  • on every evaluation of any expression that attaches a CelEnv (e.g. duty's catalog.query, gitops.source, …), which are non-cacheable by design because their bindings capture the request context (IsCacheable() is false when len(CelEnvs) != 0).

Change

Build the environment-independent options once into a cached base *cel.Env and layer only the per-call delta on top with Env.Extend:

  • staticCelEnvOptions() — the constant options (libraries, stdlib, extensions, gomplate funcs).
  • baseCelEnvsync.OnceValues + cel.EagerlyValidateDeclarations(true) so the heavy declarations are validated a single time for the process.
  • RunExpressionContext now compiles against base.Extend(perCallOptions...), where the per-call options are: registered native types (typeAdapters), one cel.Variable per env key, caller Functions, and CelEnvs.

cel.Env.Extend deep-copies the env (and copies the type registry), so the cached base is never mutated and is safe to share across goroutines. EagerlyValidateDeclarations is what lets Extend reuse the base's validated declarations and only validate the small per-call delta.

GetCelEnv is retained for external callers and now shares staticCelEnvOptions(). This also fixes a latent aliasing bug where var opts = funcs.CelEnvOption could append into the package-level slice's backing array.

Benchmark

New BenchmarkRunExpressionContextCompile exercises the compile (cache-miss) path. arm64, same machine, before vs after:

case before after delta
compile/smallEnv 425747 B/op, 5442 allocs/op, 324µs/op 199132 B/op, 2254 allocs/op, 179µs/op −53% bytes, −59% allocs, −45% time
compile/largeEnv 427602 B/op, 5526 allocs/op, 340µs/op 201045 B/op, 2337 allocs/op, 181µs/op −53% bytes, −58% allocs, −47% time

The cache-hit path is unchanged — the base env is neither built nor extended on a hit.

Verification

  • Full go test . passes; go vet/gofmt clean.
  • -race smoke test (32 goroutines hammering the compile path concurrently) — no data races on the shared base env.
  • Smoke-tested through the primary consumer (duty) via a temporary go.work: CEL templating tests pass.
  • RegisterType/typeAdapters kept off the cached base and applied per-call, so runtime type registration still takes effect.

Notes for reviewers

  • No external callers of GetCelEnv/RegisterType across the flanksource Go repos, but both are kept working for out-of-tree consumers.
  • Follow-up (duty-side, not in this PR): pure-CEL templates currently always get go-template Functions attached, which keeps them out of the program cache; fixing that would let this base-env work also collapse their first-compile cost.

Summary by CodeRabbit

  • Performance Improvements
    • Improved CEL expression handling by reusing shared environment setup, which should reduce overhead and speed up expression compilation and evaluation.
    • Cached compiled expressions more effectively when templates are eligible for reuse, helping repeated runs perform better.

@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@adityathebe, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 51 minutes and 6 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 309bdacb-de5d-4089-8578-ef0cfc305a98

📥 Commits

Reviewing files that changed from the base of the PR and between c1718f6 and 0266b1b.

📒 Files selected for processing (2)
  • Dockerfile
  • Dockerfile.integration

Walkthrough

A shared base CEL environment is now built once via sync.OnceValues using a new staticCelEnvOptions() helper in cel.go. The RunExpressionContext compile path in template.go is updated to extend this cached base with per-call options instead of constructing a full environment from scratch. A new benchmark exercises the compile path.

Changes

CEL Static Environment Caching

Layer / File(s) Summary
Static CEL env options and base env initializer
cel.go
Adds staticCelEnvOptions() to collect environment-independent options (generated functions, kubernetes library, stdlib extensions, optional types, nil-safe library, gomplate functions). Introduces baseCelEnv as a sync.OnceValues-based package-level initializer that builds and caches a single cel.Env with eager declaration validation. Refactors GetCelEnv to call staticCelEnvOptions() instead of rebuilding the base option list inline on every invocation.
RunExpressionContext integration with base.Extend
template.go
On cache miss in RunExpressionContext, replaces the call to GetCelEnv(data) followed by cel.NewEnv(envOptions...) with baseCelEnv() to obtain the shared base, then assembles only per-call envOptions (type adapters and per-key cel.Variable declarations), and creates the final environment via base.Extend(envOptions...).
Compile-path benchmark
run_expression_bench_test.go
Adds BenchmarkRunExpressionContextCompile that registers a noop CEL function (bench_noop) through Template.CelEnvs to force the non-cacheable compile path. Runs small-env and large-env sub-benchmarks with allocation reporting and captures results in exprBenchSink.

Possibly related PRs

  • flanksource/gomplate#161: Directly modifies RunExpressionContext in template.go around the same celExpressionCache hit/miss logic and CEL environment construction that this PR refactors.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: caching a static CEL environment and extending it per call for performance.
Docstring Coverage ✅ Passed Docstring coverage is 80.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch perf/cel-static-base-env
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch perf/cel-static-base-env

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jun 24, 2026

Copy link
Copy Markdown

Benchstat

Base: f34f0dcfd979feecfda66981d64e9d03bddbb40f
Head: 0266b1b50317c17c48a7b1f981bef99d0c89a2f4

2 minor regression(s) (all within 5% threshold)

Benchmark Base Head Change p-value
RunExpressionContext/cacheHit/smallEnv-4 1.697µ 1.739µ +2.48% 0.002
Serialize/Size-10-4 34.57µ 35.20µ +1.81% 0.002
6 improvement(s)
Benchmark Base Head Change p-value
RunExpressionContextCompile/compile/smallEnv-4 5.442k 2.254k -58.58% 0.002
RunExpressionContextCompile/compile/largeEnv-4 5.525k 2.337k -57.70% 0.002
RunExpressionContextCompile/compile/smallEnv-4 415.5Ki 194.4Ki -53.22% 0.002
RunExpressionContextCompile/compile/largeEnv-4 417.4Ki 196.3Ki -52.98% 0.002
RunExpressionContextCompile/compile/smallEnv-4 643.0µ 333.0µ -48.21% 0.002
RunExpressionContextCompile/compile/largeEnv-4 668.9µ 348.7µ -47.87% 0.002
Full benchstat output
goos: linux
goarch: amd64
pkg: github.com/flanksource/gomplate/v3
cpu: AMD EPYC 9V74 80-Core Processor                
                                               │ bench-base.txt │           bench-head.txt            │
                                               │     sec/op     │    sec/op     vs base               │
RunExpressionContext/cacheHit/smallEnv-4           1.697µ ±  1%   1.739µ ± 17%   +2.48% (p=0.002 n=6)
RunExpressionContext/cacheHit/largeEnv-4           10.64µ ±  8%   10.58µ ±  1%        ~ (p=0.485 n=6)
RunExpressionContextCompile/compile/smallEnv-4     643.0µ ±  1%   333.0µ ±  3%  -48.21% (p=0.002 n=6)
RunExpressionContextCompile/compile/largeEnv-4     668.9µ ±  4%   348.7µ ±  1%  -47.87% (p=0.002 n=6)
Serialize/Size-10-4                                34.57µ ±  1%   35.20µ ±  1%   +1.81% (p=0.002 n=6)
Serialize/Size-100-4                               343.1µ ±  5%   333.2µ ±  0%        ~ (p=0.394 n=6)
Serialize/Size-1000-4                              3.500m ±  5%   3.410m ±  3%        ~ (p=0.485 n=6)
Serialize/Size-10000-4                             43.60m ±  8%   43.72m ±  3%        ~ (p=0.818 n=6)
Serialize_NoNativeTypes/Size-100-4                 89.78µ ±  9%   91.18µ ±  2%        ~ (p=0.485 n=6)
Serialize_NoNativeTypes/Size-1000-4                910.0µ ±  5%   920.5µ ±  1%        ~ (p=0.394 n=6)
Serialize_NoNativeTypes/Size-10000-4               9.146m ± 15%   9.532m ±  3%        ~ (p=0.065 n=6)
geomean                                            352.7µ         314.6µ        -10.79%

                                               │ bench-base.txt │            bench-head.txt             │
                                               │      B/op      │     B/op      vs base                 │
RunExpressionContext/cacheHit/smallEnv-4             664.0 ± 0%     664.0 ± 0%        ~ (p=1.000 n=6) ¹
RunExpressionContext/cacheHit/largeEnv-4           2.445Ki ± 0%   2.445Ki ± 0%        ~ (p=1.000 n=6) ¹
RunExpressionContextCompile/compile/smallEnv-4     415.5Ki ± 0%   194.4Ki ± 0%  -53.22% (p=0.002 n=6)
RunExpressionContextCompile/compile/largeEnv-4     417.4Ki ± 0%   196.3Ki ± 0%  -52.98% (p=0.002 n=6)
Serialize/Size-10-4                                11.06Ki ± 0%   11.06Ki ± 0%        ~ (p=1.000 n=6) ¹
Serialize/Size-100-4                               95.52Ki ± 0%   95.51Ki ± 0%        ~ (p=0.234 n=6)
Serialize/Size-1000-4                              952.5Ki ± 0%   952.5Ki ± 0%        ~ (p=1.000 n=6)
Serialize/Size-10000-4                             10.04Mi ± 0%   10.04Mi ± 0%        ~ (p=0.944 n=6)
Serialize_NoNativeTypes/Size-100-4                 36.33Ki ± 0%   36.33Ki ± 0%        ~ (p=0.182 n=6)
Serialize_NoNativeTypes/Size-1000-4                365.6Ki ± 0%   365.6Ki ± 0%        ~ (p=0.567 n=6)
Serialize_NoNativeTypes/Size-10000-4               3.584Mi ± 0%   3.584Mi ± 0%        ~ (p=0.102 n=6)
geomean                                            127.0Ki        110.7Ki       -12.86%
¹ all samples are equal

                                               │ bench-base.txt │            bench-head.txt            │
                                               │   allocs/op    │  allocs/op   vs base                 │
RunExpressionContext/cacheHit/smallEnv-4             20.00 ± 0%    20.00 ± 0%        ~ (p=1.000 n=6) ¹
RunExpressionContext/cacheHit/largeEnv-4             101.0 ± 0%    101.0 ± 0%        ~ (p=1.000 n=6) ¹
RunExpressionContextCompile/compile/smallEnv-4      5.442k ± 0%   2.254k ± 0%  -58.58% (p=0.002 n=6)
RunExpressionContextCompile/compile/largeEnv-4      5.525k ± 0%   2.337k ± 0%  -57.70% (p=0.002 n=6)
Serialize/Size-10-4                                  268.0 ± 0%    268.0 ± 0%        ~ (p=1.000 n=6) ¹
Serialize/Size-100-4                                2.518k ± 0%   2.518k ± 0%        ~ (p=1.000 n=6) ¹
Serialize/Size-1000-4                               25.80k ± 0%   25.80k ± 0%        ~ (p=0.805 n=6)
Serialize/Size-10000-4                              265.0k ± 0%   265.0k ± 0%        ~ (p=1.000 n=6)
Serialize_NoNativeTypes/Size-100-4                   911.0 ± 0%    911.0 ± 0%        ~ (p=1.000 n=6) ¹
Serialize_NoNativeTypes/Size-1000-4                 9.758k ± 0%   9.758k ± 0%        ~ (p=1.000 n=6) ¹
Serialize_NoNativeTypes/Size-10000-4                100.1k ± 0%   100.1k ± 0%        ~ (p=0.071 n=6)
geomean                                             3.095k        2.641k       -14.64%
¹ all samples are equal

@adityathebe adityathebe force-pushed the perf/cel-static-base-env branch from bcc0a70 to 29397b2 Compare June 24, 2026 04:02
GetCelEnv rebuilt the entire CEL environment -- including kubernetes.Library()
and the validation of all its declarations -- on every expression compile. That
makes the compile (cache-miss) path the single largest source of CEL
allocation, and it runs on every evaluation of an expression that attaches a
CelEnv (e.g. catalog.query), which are non-cacheable by design.

Build the environment-independent options once into a cached base *cel.Env
(sync.OnceValues + EagerlyValidateDeclarations) and layer the per-call
variables, registered types and caller functions on top with Env.Extend, which
deep-copies and is safe to share across goroutines.

Compile-path benchmark (BenchmarkRunExpressionContextCompile/smallEnv):
  before: 425747 B/op, 5442 allocs/op, 324us/op
  after:  199132 B/op, 2254 allocs/op, 179us/op   (~53% bytes, ~59% allocs)

Cache-hit path is unchanged (base env is untouched on a hit). GetCelEnv is
retained for external callers and now shares the static option set.
@adityathebe adityathebe force-pushed the perf/cel-static-base-env branch from 29397b2 to c1718f6 Compare June 24, 2026 06:53

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
run_expression_bench_test.go (1)

165-168: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Hoist Template construction out of the timed loop.

Line 165 creates a new Template and []cel.EnvOption{noopFn} every iteration, which adds benchmark-only allocation noise. Build it once before b.ResetTimer() and reuse it.

♻️ Proposed fix
 		b.Run(name, func(b *testing.B) {
 			env := benchExprEnv(withConfig)
+			tmpl := Template{
+				Expression: expression,
+				CelEnvs:    []cel.EnvOption{noopFn},
+			}
 
 			b.ReportAllocs()
 			b.ResetTimer()
 			for i := 0; i < b.N; i++ {
-				out, err := RunExpression(env, Template{
-					Expression: expression,
-					CelEnvs:    []cel.EnvOption{noopFn},
-				})
+				out, err := RunExpression(env, tmpl)
 				if err != nil {
 					b.Fatal(err)
 				}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@run_expression_bench_test.go` around lines 165 - 168, Hoist the per-iteration
Template and []cel.EnvOption{noopFn} construction out of the benchmark loop in
RunExpressionBenchmark so the timed section only measures RunExpression. Create
the Template once before b.ResetTimer(), store the reusable Template and
CelEnvs, and pass them into RunExpression inside the loop to remove
benchmark-only allocation noise.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@cel.go`:
- Line 8: The Docker build image is still on an older Go version that cannot
compile code using sync.OnceValues. Update the Dockerfile base image from
golang:1.19-alpine to a Go 1.21+ image so it matches the module and CI
toolchain, and ensure the build stage uses a Go version that supports the
symbols introduced in cel.go.

---

Nitpick comments:
In `@run_expression_bench_test.go`:
- Around line 165-168: Hoist the per-iteration Template and
[]cel.EnvOption{noopFn} construction out of the benchmark loop in
RunExpressionBenchmark so the timed section only measures RunExpression. Create
the Template once before b.ResetTimer(), store the reusable Template and
CelEnvs, and pass them into RunExpression inside the loop to remove
benchmark-only allocation noise.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1d12c9d7-5066-4e77-9df7-d681529aff0d

📥 Commits

Reviewing files that changed from the base of the PR and between f34f0dc and c1718f6.

📒 Files selected for processing (3)
  • cel.go
  • run_expression_bench_test.go
  • template.go

Comment thread cel.go
@moshloop moshloop merged commit 9eb0380 into main Jun 24, 2026
8 checks passed
@moshloop moshloop deleted the perf/cel-static-base-env branch June 24, 2026 07:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants