Automatic tail-call optimization (auto-TCO) in StaticOptimizer#694
Merged
stephenamar-db merged 2 commits intodatabricks:masterfrom Apr 11, 2026
Merged
Automatic tail-call optimization (auto-TCO) in StaticOptimizer#694stephenamar-db merged 2 commits intodatabricks:masterfrom
stephenamar-db merged 2 commits intodatabricks:masterfrom
Conversation
7adb221 to
da38f7c
Compare
3532dbf to
2755bea
Compare
4547413 to
5e74bf5
Compare
Detect self-recursive calls in tail position during static optimization and mark them for the TailCall trampoline, eliminating JVM stack overflow on deep recursion without requiring users to annotate call sites with 'tailstrict'. Key design: introduce TailstrictModeAutoTCO — a third TailstrictMode that enables the trampoline (like TailstrictModeEnabled) but does NOT force eager argument evaluation (unlike explicit tailstrict). This preserves Jsonnet's standard lazy evaluation semantics for auto-TCO'd calls. Implementation: - StaticOptimizer.transformBind: detects self-recursive function bindings - hasNonRecursiveExit: safety check ensuring at least one non-recursive code path exists (prevents infinite trampoline on trivially infinite functions like f(x) = f(x)) - markTailCalls: walks the AST marking self-recursive tail calls with tailstrict=true, autoTCO=true - Expr: adds isAutoTCO/autoTCO field to Apply0-3 and Apply case classes - Val: adds TailstrictModeAutoTCO, TailCall.autoTCO flag, restores @tailrec on TailCall.resolve - Evaluator: visitExprWithTailCallSupport uses visitAsLazy for auto-TCO args; visitApply* defensively handles auto-TCO for future-proofing Test: auto_tco.jsonnet with 6 patterns including lazy semantics regression test (error in auto-TCO'd args is NOT eagerly evaluated). Upstream: databricks#623
…rror tail propagation Motivation: Code review identified several improvements to the auto-TCO implementation: - The `autoTCO` boolean field had confusing polarity (true = lazy args) - And/Or/Error expressions were not propagated as tail positions - Test comments had inaccuracies about named args and Apply0 Modification: - Rename `autoTCO: Boolean` → `strict: Boolean` with inverted polarity (strict=true → eager args, strict=false → lazy/auto-TCO args) - Propagate tail positions through And (rhs), Or (rhs), and Expr.Error in both StaticOptimizer.markTailCalls and Evaluator.visitExprWithTailCallSupport - Add hasNonRecursiveExit cases for And/Or/Error in StaticOptimizer - Extract visitAndRhsTailPos/visitOrRhsTailPos helpers to preserve @tailrec - Add error.auto_tco_bool_check test for direct non-bool rhs detection - Fix test comments: Apply0 → Apply1, named args auto-TCO behavior Result: - Clearer field semantics (strict=true is the natural default) - Tail calls through && and || chains are now optimized (aligns with Scala 2/3 @tailrec and google/jsonnet behavior) - All 270 tests pass on Scala 3.3.7 and 2.13.18
He-Pin
added a commit
to He-Pin/sjsonnet
that referenced
this pull request
Apr 11, 2026
The auto-TCO PR (databricks#694) added a 'strict' boolean parameter to Apply, Apply0, Apply1, Apply2, and Apply3 case classes, but hasSelfRefExpr patterns were not updated, causing compilation failure on all Scala versions.
stephenamar-db
pushed a commit
that referenced
this pull request
Apr 11, 2026
## Motivation The auto-TCO commit (ecdd0b6, PR #694) added a `strict: Boolean` field to `Apply`, `Apply0`, `Apply1`, `Apply2`, and `Apply3` case classes. The `hasSelfRefExpr` pattern matches in `Materializer` were not updated, causing: 1. **Compilation failure on Scala 2.13.18**: `wrong number of arguments for pattern sjsonnet.Expr.Apply` 2. **Runtime `MatchError` on Scala 3.3.7**: The pattern match silently fell through to the wildcard case, causing incorrect materialization paths for lazy reverse arrays (`lazy_reverse_correctness.jsonnet` failure) ## Modification Added wildcard for the new `strict` field in all five Apply pattern matches in `Materializer.hasSelfRefExpr`: - `Apply(_, v, args, _, _)` → `Apply(_, v, args, _, _, _)` - `Apply0(_, v, _)` → `Apply0(_, v, _, _)` - `Apply1(_, v, a1, _)` → `Apply1(_, v, a1, _, _)` - `Apply2(_, v, a1, a2, _)` → `Apply2(_, v, a1, a2, _, _)` - `Apply3(_, v, a1, a2, a3, _)` → `Apply3(_, v, a1, a2, a3, _, _)` ## Result - Scala 2.13.18 compiles and all tests pass (including `lazy_reverse_correctness.jsonnet`) - Scala 3.3.7 all tests pass (no regressions)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Jsonnet programs often use recursive functions that are naturally tail-recursive (the recursive call is the last expression in a branch). Without tail-call optimization (TCO), these hit StackOverflowError on deep recursion. Currently, sjsonnet supports TCO via explicit
tailstrictannotation, but users must manually add it.refs: #623
Key Design Decision
Implement automatic TCO detection in the
StaticOptimizer. During the optimization pass, analyze function bodies to identify self-recursive tail-position calls and mark them withtailstrict=true, strict=false. The Evaluator's existingTailCalltrampoline then handles them — with lazy argument evaluation (preserving Jsonnet semantics). This happens transparently — no source code changes needed.strict vs tailstrict
tailstrict=true, strict=true— explicittailstrictannotation (eager args, existing behavior)tailstrict=true, strict=false— auto-TCO detected (lazy args, new behavior)This distinction matters because explicit
tailstrictforces eager evaluation of all arguments (including unused ones), while auto-TCO must preserve Jsonnet's lazy semantics.Modification
sjsonnet/src/sjsonnet/StaticOptimizer.scala:markTailCalls(expr, funcName): walks function bodies to find self-recursive calls in tail positionhasNonRecursiveExit(expr, funcName): safety check — only marks functions that have at least one non-recursive exit path (prevents infinite trampolining)IfElse(both branches),LocalExpr(returned),AssertExpr(returned),And(rhs),Or(rhs),Expr.Error(value)tailstrict(user's explicit annotation) to avoid double-markingsjsonnet/src/sjsonnet/Expr.scala:autoTCO: Boolean→strict: Boolean(inverted polarity) on all Apply variantsstrict=true(default) = eager args,strict=false= lazy args (auto-TCO)isStrictaccessorsjsonnet/src/sjsonnet/ExprTransform.scala:autoTCO→strictin all Apply pattern matchessjsonnet/src/sjsonnet/Evaluator.scala:visitExprWithTailCallSupport: addedAnd(rhs),Or(rhs), andExpr.Erroras tail positionsvisitAndRhsTailPos/visitOrRhsTailPoshelpers to preserve@tailrecannotationisAutoTCO/autoTCO→isStrict/strictthroughoutstrict = isStrict— lazy args when!isStrictsjsonnet/src/sjsonnet/Val.scala:TailCall: renamedautoTCO→strictwith inverted polarityTailCall.resolve:if (tc.strict) TailstrictModeEnabled else TailstrictModeAutoTCOTests:
auto_tco.jsonnet— basic deep recursion (>10,000 depth)auto_tco_directional.jsonnet— comprehensive 10-section test suite:tailstrictinteraction (no double-marking, eager vs lazy, mixed branches)errorargs not evaluated)auto_tco_patterns.jsonnet— pattern-focused testserror.auto_tco_bool_check.jsonnet— verifies&&type check catches direct non-bool rhsAnd/Or Semantic Note
When tail-calling through
&&/||, the rhs type check allowsTailCallsentinels to pass through without a boolean check. This is a deliberate trade-off:true && "hello") are still caught — the helper methods check forVal.Bool | TailCalland error on anything elsetrue && f(n-1)wherefreturns a string) pass through — this aligns with:&&is simplyif a then b else falsewith no rhs type check@tailrecworks through&&because it desugars toif (lhs) rhs else false&&/||Benchmark Results
Environment: Apple M-series, macOS, JDK 21 (Zulu 21.0.10), GraalVM native-image.
JVM (hyperfine, 15 runs, 5 warmup — steady-state)
All JMH outliers re-verified via hyperfine (15 runs, 5 warmup) — confirmed as JIT noise:
GraalVM Native (hyperfine, 12 runs, 3 warmup)
Compute-heavy benchmarks (>25ms) — reliable:
Note: Native benchmarks <15ms are dominated by startup time and not reliable for performance comparison. All compute-heavy benchmarks show no regression.
Summary
markTailCallspass adds negligible compile-time overheadAnalysis
self.methodcalls are untouched.hasNonRecursiveExitprevents marking functions with no base case. Lazy args preserve Jsonnet semantics.tailstrictcalls. Mixed branches (some explicit, some auto) work correctly.TailCall.resolve— zero overhead for non-recursive calls.Result
Automatic tail-call optimization for self-recursive Jsonnet functions. Eliminates StackOverflowError on deep recursion without source changes, while preserving lazy evaluation semantics. No performance regression on existing benchmarks.