Skip to content

Program construction rebuilds a dispatcher containing every function binding of the environment #1397

Description

@jpbetz

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions