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.
newProgram creates a fresh interpreter.Dispatcher per program and copies every
function-overload binding of the environment into it (disp.Add(e.functionBindings...)).
For a stdlib + extensions environment that is hundreds of map inserts per program, and
the dispatcher contents are byte-identical across every program of the environment.
Additionally, funcBindOnce memoizes binding materialization per environment, so
consumers that extend a base environment per compilation unit (e.g. Kubernetes CRD
validation, one extended env per rule-bearing schema node) re-materialize the full
binding set once per extended env even though the function declarations are shared.
Programs are frequently retained (Kubernetes retains them for the lifetime of each CRD),
so this is resident heap, not just construction-time garbage. Heap profiling of
Kubernetes API servers with large numbers of CRDs attributes ~18KB resident per retained
program to dispatcher/binding duplication.
Measurements
Benchmarks: Program() on an already-compiled AST — marginal cost on a warm env
(ProgramK8sShape) and first-program cost per extended env
(ProgramFirstPerEnvK8sShape). Harness is cel/env_sharing_bench_test.go on the branch
below. go1.24.1, linux/amd64, Intel Ultra 7 165U, -count=10, benchstat. Base for this
comparison is the extend-sharing branch (see companion issue); the improvement relative
to master is the same, since extend-sharing does not change these benchmarks.
│ extend-sharing │ + dispatcher-sharing │
│ sec/op │ sec/op vs base │
ProgramK8sShape-14 15147.0n ± 4% 887.1n ± 15% -94.14% (p=0.000 n=10)
ProgramFirstPerEnvK8sShape-14 38.496µ ± 10% 1.867µ ± 30% -95.15% (p=0.000 n=10)
│ extend-sharing │ + dispatcher-sharing │
│ B/op │ B/op vs base │
ProgramK8sShape-14 14.266Ki ± 0% 1.070Ki ± 0% -92.50% (p=0.000 n=10)
ProgramFirstPerEnvK8sShape-14 30.793Ki ± 1% 1.070Ki ± 0% -96.52% (p=0.000 n=10)
│ extend-sharing │ + dispatcher-sharing │
│ allocs/op │ allocs/op vs base │
ProgramK8sShape-14 32.00 ± 0% 19.00 ± 0% -40.62% (p=0.000 n=10)
ProgramFirstPerEnvK8sShape-14 383.50 ± 0% 19.00 ± 0% -95.05% (p=0.000 n=10)
Live-heap retained (TestExtendRetainedHeapReport, stdlib-only env):
| configuration |
per extended env (1 program) |
per additional program |
| master |
33.0KB |
7.5KB |
| + extend sharing (companion) |
23.3KB |
7.5KB |
| + dispatcher sharing (this) |
3.7KB |
0.9KB |
Proposal
Memoize the function bindings, a read-only base dispatcher, and the has-async-bindings
property once on the environment which owns the function declaration map; extended
environments whose declarations are shared delegate to their parent. Each program then
uses a two-layer dispatcher: a (usually nil) map of program-local bindings added via the
Functions() program option, over the shared read-only base.
Behavior notes:
- Duplicate-overload error semantics are preserved: adding a program-local binding whose
identifier exists in either layer is an error, exactly as with the flat dispatcher.
(A plain interpreter.ExtendDispatcher would not work here — its Add intentionally
allows shadowing the parent, which would turn today's error into a silent override.)
- The per-program scan of bindings for async detection is folded into the same memoized
step.
- Small behavior fix included: a binding materialization error is now returned from every
Program() call; previously only the first call observed it and subsequent calls
proceeded with a partially-populated dispatcher.
Prototype
master...jpbetz:cel-go:envshare-pr4-dispatcher
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.
newProgramcreates a freshinterpreter.Dispatcherper program and copies everyfunction-overload binding of the environment into it (
disp.Add(e.functionBindings...)).For a stdlib + extensions environment that is hundreds of map inserts per program, and
the dispatcher contents are byte-identical across every program of the environment.
Additionally,
funcBindOncememoizes binding materialization per environment, soconsumers that extend a base environment per compilation unit (e.g. Kubernetes CRD
validation, one extended env per rule-bearing schema node) re-materialize the full
binding set once per extended env even though the function declarations are shared.
Programs are frequently retained (Kubernetes retains them for the lifetime of each CRD),
so this is resident heap, not just construction-time garbage. Heap profiling of
Kubernetes API servers with large numbers of CRDs attributes ~18KB resident per retained
program to dispatcher/binding duplication.
Measurements
Benchmarks:
Program()on an already-compiled AST — marginal cost on a warm env(
ProgramK8sShape) and first-program cost per extended env(
ProgramFirstPerEnvK8sShape). Harness iscel/env_sharing_bench_test.goon the branchbelow. go1.24.1, linux/amd64, Intel Ultra 7 165U,
-count=10, benchstat. Base for thiscomparison is the extend-sharing branch (see companion issue); the improvement relative
to master is the same, since extend-sharing does not change these benchmarks.
Live-heap retained (
TestExtendRetainedHeapReport, stdlib-only env):Proposal
Memoize the function bindings, a read-only base dispatcher, and the has-async-bindings
property once on the environment which owns the function declaration map; extended
environments whose declarations are shared delegate to their parent. Each program then
uses a two-layer dispatcher: a (usually nil) map of program-local bindings added via the
Functions()program option, over the shared read-only base.Behavior notes:
identifier exists in either layer is an error, exactly as with the flat dispatcher.
(A plain
interpreter.ExtendDispatcherwould not work here — itsAddintentionallyallows shadowing the parent, which would turn today's error into a silent override.)
step.
Program()call; previously only the first call observed it and subsequent callsproceeded with a partially-populated dispatcher.
Prototype
master...jpbetz:cel-go:envshare-pr4-dispatcher