Skip to content

Type-checking time grows quadratically with expression length: checker copies its substitution mapping per assignability check #1553

Closed

Description

@stefanvanburen

Describe the bug

Type-checking time and memory grow quadratically with the length of an expression that uses macros or parameterized overloads. A conjunction of 400 short terms takes about 1.1s and allocates 841 MiB to check on an Apple M1.

The checker keeps one type substitution mapping for the whole expression, and it only grows: each macro and each parameterized overload such as size(list(A)) adds type parameters to it. isAssignable and isAssignableList in checker/types.go copy the entire mapping before each attempt, so that a failed attempt can be discarded. Overload resolution makes many such attempts per call, so each copy costs time proportional to everything checked so far.

Counting calls to mapping.copy for the expression below (instrumentation in a local copy, not part of any change):

terms type params in mapping copies entries copied entries per copy
25 75 998 37,774 38
50 150 1,998 150,698 75
100 300 3,998 601,696 150
200 600 7,998 2,403,992 301
400 1,200 15,998 9,609,184 601

Each term adds 3 type parameters and causes 40 copies, and each copy holds on average half the final mapping, so the entries copied grow with the square of the number of terms.

To Reproduce

Check which components this affects:

  • parser
  • checker
  • interpreter

Sample expression and input that reproduces the issue:

["a0"].map(s, s + "x").size() > 0 && ["a1"].map(s, s + "x").size() > 0 && ... && ["a399"].map(s, s + "x").size() > 0

Test setup:

// BenchmarkCheckLongConjunction checks a conjunction of n terms that each use
// a macro and a parameterized overload, so that every term adds type
// parameters to the checker's substitution mapping.
func BenchmarkCheckLongConjunction(b *testing.B) {
	for _, n := range []int{25, 50, 100, 200, 400} {
		terms := make([]string, n)
		for i := range terms {
			terms[i] = fmt.Sprintf(`["a%d"].map(s, s + "x").size() > 0`, i)
		}
		src := common.NewTextSource(strings.Join(terms, " && "))
		b.Run(fmt.Sprintf("terms=%d", n), func(b *testing.B) {
			p, err := parser.NewParser(parser.Macros(parser.AllMacros...))
			if err != nil {
				b.Fatalf("parser.NewParser() failed: %v", err)
			}
			pAst, errs := p.Parse(src)
			if len(errs.GetErrors()) > 0 {
				b.Fatalf("Parse() failed: %v", errs.ToDisplayString())
			}
			reg, err := types.NewRegistry()
			if err != nil {
				b.Fatalf("types.NewRegistry() failed: %v", err)
			}
			cont, err := containers.NewContainer()
			if err != nil {
				b.Fatalf("containers.NewContainer() failed: %v", err)
			}
			env, err := NewEnv(cont, reg, CrossTypeNumericComparisons(true))
			if err != nil {
				b.Fatalf("NewEnv() failed: %v", err)
			}
			env.AddFunctions(stdlib.Functions()...)
			b.ReportAllocs()
			b.ResetTimer()
			for i := 0; i < b.N; i++ {
				if _, errs := Check(pAst, src, env); len(errs.GetErrors()) > 0 {
					b.Fatalf("Check() failed: %v", errs.ToDisplayString())
				}
			}
		})
	}
}

On master (5dcc82d), Apple M1:

CheckLongConjunction/terms=25-8      3.73ms     3.6 MiB/op     17.8k allocs/op
CheckLongConjunction/terms=50-8     12.60ms    13.8 MiB/op     39.5k allocs/op
CheckLongConjunction/terms=100-8    47.62ms    53.6 MiB/op     87.2k allocs/op
CheckLongConjunction/terms=200-8   249.10ms   211.3 MiB/op    190.9k allocs/op
CheckLongConjunction/terms=400-8  1128.33ms   841.4 MiB/op    427.0k allocs/op

Memory per check grows about 4× each time the expression doubles.

Expected behavior

Checking time and memory grow linearly with the length of the expression.

Additional context

I found this through a language server that type-checks a CEL file on every edit, where files of a few hundred lines became noticeably slow. v0.32.0 behaves the same.

A fix I've tried locally: instead of copying the mapping, isAssignable and isAssignableList change it in place while mapping.add records each entry it replaces, and a failed attempt restores those entries in reverse order. Every caller reaches these functions through checker.isAssignable and checker.isAssignableList, which adopt the result on success, so changing the mapping in place leaves behavior unchanged. With it, on master:

terms time memory
25 3.73ms → 0.45ms (−88%) 3.6 MiB → 354 KiB
100 47.6ms → 2.08ms (−96%) 53.6 MiB → 1.4 MiB
400 1128ms → 13.3ms (−99%) 841 MiB → 5.5 MiB

Memory now grows about 2× per doubling. Across the 139 cases of the existing BenchmarkCheck, alternating old and new binaries over 8 runs, time is unchanged (−1.5%), memory is down 3.7%, and allocations are down 6.5%. The checker and cel tests pass.

I'm happy to send this as a pull request with the benchmark and a test that a failed overload attempt leaves the mapping unchanged, if the approach sounds right.

Activity

  1. TristonianJones commented on Oct 16, 2026

    @TristonianJones
    Collaborator

    This is definitely a known issue, but good to track and fix. If you have PRs for both benchmark and fix, I'm happy to review them.

  2. added a commit that references this issue on Oct 21, 2026
    19b0cd9
  3. stefanvanburen commented on Oct 21, 2026

    @stefanvanburen
    ContributorAuthor

    hey @TristonianJones, sorry for the delay and the couple of PRs. #1574 has a benchmark, #1575 has an attempted fix (can be landed independently of each other), and lastly #1576 is an independent fix for benchmarks that I noticed (since they aren't being run in CI).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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