Kubernetes uses CEL heavily for CRD validation. This results in a large number of CEL
environments where the only difference is the schema of the value and oldValue variables.
All function bindings and all other settings are identical across the environments.
Env.Extend copies the full environment into each extended environment: the function
declaration map (~hundreds of entries for stdlib + extensions), the types.Registry
(including pb.Db), and — lazily, via initChecker — a complete copy of the parent's
validated checker declaration set (checker.Scopes.Copy).
For the common extension pattern of adding only variables and a wrapping type provider,
none of that copied state differs from the parent's. Kubernetes CRD validation is an
extreme but representative consumer: it extends a shared base environment once per
rule-bearing schema node (adding only self/oldSelf and a type provider), and retains
the compiled programs — which embed *Env — for the lifetime of each CRD. Heap profiling
of Kubernetes API servers with large numbers of CRDs attributes ~85KB of resident heap per
extended environment to these copies (dominated by checker.Scopes.Copy /
checker.NewEnv under initChecker), which at fleet scale is the largest single term of
resident CEL memory.
Measurements
Benchmark: extend a stdlib base env with 2 variables + a wrapping CustomTypeProvider,
then compile one expression (forces checker construction). Harness is
cel/env_sharing_bench_test.go on the branches below. go1.24.1, linux/amd64,
Intel Ultra 7 165U, -count=10, benchstat.
│ master │ extend-sharing branch │
│ sec/op │ sec/op vs base │
EnvExtendK8sShape-14 49.19µ ± 7% 21.66µ ± 6% -55.97% (p=0.000 n=10)
│ master │ extend-sharing branch │
│ B/op │ B/op vs base │
EnvExtendK8sShape-14 27.60Ki ± 0% 13.31Ki ± 0% -51.77% (p=0.000 n=10)
│ master │ extend-sharing branch │
│ allocs/op │ allocs/op vs base │
EnvExtendK8sShape-14 356.0 ± 0% 248.0 ± 0% -30.34% (p=0.000 n=10)
Live-heap retained per extended environment holding one compiled program
(TestExtendRetainedHeapReport, stdlib-only env; the k8s figure is larger because its
base env carries extension libraries): 33.0KB → 23.3KB with this change alone; the
remainder is per-program dispatcher/binding duplication, addressed separately (see the
companion issue). With both changes: 3.7KB.
Downstream validation: with the equivalent patch applied to the cel-go vendored into
kubernetes, BenchmarkCompile in apiextensions-apiserver schema/cel (compiles a CRD
schema's validation rules) goes from 1.09ms / 197.5KB / 1903 allocs per op to
149µs / 44.5KB / 655 allocs, with all Kubernetes CEL test suites passing under
-race.
Proposal
Share rather than copy, with copy-on-write for the mutable pieces:
- Functions map and type registry:
Extend aliases the parent's map/registry and
records sharing state. The only mutation sites (Function/FunctionDecls, library
loading, Types, TypeDescs, JSONFieldNames) obtain the state through accessors
(mutableFunctions, mutableProvider) which copy before first mutation, so the
parent can never observe child mutations.
- Checker declarations: when the child adds no functions, layer the parent's
validated declarations beneath a new scope instead of copying them. This uses two
small checker additions: Scopes.PushGlobal (a scope layer that participates in
global identifier resolution, unlike the local scopes created by Push) and a
checker.InheritedDeclarations option. Only the delta variables are added to the new
layer, pre-merged against inherited declarations via a new decls.VariableDecl.Merge
so that single-scope semantics are preserved exactly (overlap errors, conflicting
constants, and constant-value merging on equivalent redeclaration).
Semantics are pinned by a parity test (TestExtendCheckerParity) which compiles and
evaluates identical configurations through the layered path and the from-scratch path and
requires identical results, output types, and error messages — including
container-qualified resolution and comprehension-variable shadowing cases.
One documented contract change: the Extend doc comment currently promises the extended
Env "should not share memory with the original". The proposal preserves observable
isolation for all API-mediated mutation, but callers that obtain CELTypeProvider() and
mutate the registry directly (rather than via options) could now affect the parent. The
doc comment is updated accordingly; flagging explicitly for review.
Prototype
decls.VariableDecl.Merge extraction (pure refactor):
master...jpbetz:cel-go:envshare-pr1-decls-merge
- Checker scope layering (
Scopes.PushGlobal, InheritedDeclarations):
master...jpbetz:cel-go:envshare-pr2-checker-layering
- Copy-on-write
Extend + layered checker fast path + benchmarks/parity tests:
master...jpbetz:cel-go:envshare-pr3-cow-extend
Kubernetes uses CEL heavily for CRD validation. This results in a large number of CEL
environments where the only difference is the schema of the
valueandoldValuevariables.All function bindings and all other settings are identical across the environments.
Env.Extendcopies the full environment into each extended environment: the functiondeclaration map (~hundreds of entries for stdlib + extensions), the
types.Registry(including
pb.Db), and — lazily, viainitChecker— a complete copy of the parent'svalidated checker declaration set (
checker.Scopes.Copy).For the common extension pattern of adding only variables and a wrapping type provider,
none of that copied state differs from the parent's. Kubernetes CRD validation is an
extreme but representative consumer: it extends a shared base environment once per
rule-bearing schema node (adding only
self/oldSelfand a type provider), and retainsthe compiled programs — which embed
*Env— for the lifetime of each CRD. Heap profilingof Kubernetes API servers with large numbers of CRDs attributes ~85KB of resident heap per
extended environment to these copies (dominated by
checker.Scopes.Copy/checker.NewEnvunderinitChecker), which at fleet scale is the largest single term ofresident CEL memory.
Measurements
Benchmark: extend a stdlib base env with 2 variables + a wrapping
CustomTypeProvider,then compile one expression (forces checker construction). Harness is
cel/env_sharing_bench_test.goon the branches below. go1.24.1, linux/amd64,Intel Ultra 7 165U,
-count=10, benchstat.Live-heap retained per extended environment holding one compiled program
(
TestExtendRetainedHeapReport, stdlib-only env; the k8s figure is larger because itsbase env carries extension libraries): 33.0KB → 23.3KB with this change alone; the
remainder is per-program dispatcher/binding duplication, addressed separately (see the
companion issue). With both changes: 3.7KB.
Downstream validation: with the equivalent patch applied to the cel-go vendored into
kubernetes,
BenchmarkCompilein apiextensions-apiserverschema/cel(compiles a CRDschema's validation rules) goes from 1.09ms / 197.5KB / 1903 allocs per op to
149µs / 44.5KB / 655 allocs, with all Kubernetes CEL test suites passing under
-race.Proposal
Share rather than copy, with copy-on-write for the mutable pieces:
Extendaliases the parent's map/registry andrecords sharing state. The only mutation sites (
Function/FunctionDecls, libraryloading,
Types,TypeDescs,JSONFieldNames) obtain the state through accessors(
mutableFunctions,mutableProvider) which copy before first mutation, so theparent can never observe child mutations.
validated declarations beneath a new scope instead of copying them. This uses two
small checker additions:
Scopes.PushGlobal(a scope layer that participates inglobal identifier resolution, unlike the local scopes created by
Push) and achecker.InheritedDeclarationsoption. Only the delta variables are added to the newlayer, pre-merged against inherited declarations via a new
decls.VariableDecl.Mergeso that single-scope semantics are preserved exactly (overlap errors, conflicting
constants, and constant-value merging on equivalent redeclaration).
Semantics are pinned by a parity test (
TestExtendCheckerParity) which compiles andevaluates identical configurations through the layered path and the from-scratch path and
requires identical results, output types, and error messages — including
container-qualified resolution and comprehension-variable shadowing cases.
One documented contract change: the
Extenddoc comment currently promises the extendedEnv "should not share memory with the original". The proposal preserves observable
isolation for all API-mediated mutation, but callers that obtain
CELTypeProvider()andmutate the registry directly (rather than via options) could now affect the parent. The
doc comment is updated accordingly; flagging explicitly for review.
Prototype
decls.VariableDecl.Mergeextraction (pure refactor):master...jpbetz:cel-go:envshare-pr1-decls-merge
Scopes.PushGlobal,InheritedDeclarations):master...jpbetz:cel-go:envshare-pr2-checker-layering
Extend+ layered checker fast path + benchmarks/parity tests:master...jpbetz:cel-go:envshare-pr3-cow-extend