Skip to content

Env.Extend deep-copies environment state that is almost always identical to the parent's #1398

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.

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:

  1. 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.
  2. 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

  1. decls.VariableDecl.Merge extraction (pure refactor):
    master...jpbetz:cel-go:envshare-pr1-decls-merge
  2. Checker scope layering (Scopes.PushGlobal, InheritedDeclarations):
    master...jpbetz:cel-go:envshare-pr2-checker-layering
  3. Copy-on-write Extend + layered checker fast path + benchmarks/parity tests:
    master...jpbetz:cel-go:envshare-pr3-cow-extend

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