perf: add std.foldl object-merge fast path (avoid O(N^2) super-chain) - #1114
Merged
Merged
Conversation
Composing an object inside `std.foldl` — e.g.
`std.foldl(function(acc, x) acc { [key(x)]: x }, arr, {})` — builds a `super`
chain of depth N. Key-union, field lookup, and materialization each walk that
chain, so the fold is O(N^2) in time and transient memory. At large N this is
a real blowup (one universe target needed ~12 GB and OOMed the 7 GB RBE cap).
When the per-step object literal cannot observe the accumulator, the chain is
semantically inert. `StaticOptimizer` now recognizes this shape (through
`Builtin.specialize`) and rewrites the call to a fast path that gathers each
step's own members into a single map layered over `init` as its only super,
which is O(N) overall. `init` is kept intact, so its own super chain and
assertions still resolve and fire exactly as under naive evaluation.
The accumulator's key union is threaded through the fold in one shared,
incrementally-grown map rather than rebuilt per step, so a callback that
*reads* the accumulator (e.g. a `std.objectHas(acc, k)` dedup guard) resolves
each lookup in O(1) instead of re-gathering the whole chain.
Adds `FoldlObjectMergeTests` covering correctness, firing, and non-firing cases.
std.foldl(function(acc, x) acc { [key(x)]: x }, arr, {})Isn't that just {[key(x)]: x for x in arr}With extra steps?.. |
Collaborator
it's slightly different. std.foldl allows you to override the previous value. obj comprehension will throw an error. |
stephenamar-db
self-requested a review
August 7, 2026 20:39
stephenamar-db
approved these changes
Aug 7, 2026
Yep, for I long time I think that object comprehensions need syntax such as
(exclamation point after field name - it should override the value instead of failing) |
He-Pin
approved these changes
Aug 8, 2026
Contributor
|
Nice, there may be more patterns that can be specialized. |
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
Building an object by composition inside
std.foldlis a common pattern:Each
acc { ... }step creates a new object whosesuperis the previous accumulator, so the fold builds asuperchain of depth N. Every key-union, field lookup, and materialization then walks that chain, making the fold O(N^2) in both time and transient memory. Astd.objectHas(acc, k)/acc[k]dedup guard inside the callback rebuilds the whole key union each step, compounding the blowup.In the Databricks monorepo (113,113
.jsonnet/.libsonnetfiles) there are 304std.foldlcall sites across 240 files that can be optimized. Of those, 62 folds read the accumulator mid-fold — 53 files viastd.objectHas(acc, key)(insert-if-absent / dedup) and 12 via directacc[key]indexing (dedup-with-value-check / merge-if-smaller). No fold usesobjectFields/objectValues/length/inon the accumulator, so the shared key-union map is the only cross-step state these patterns need.An internal config target (a
std.foldldedup over ~1,800 tuples, evaluated 4x) needed ~12 GB and OOM'd the 7 GB build-worker heap cap at ~2 min. With this change it evaluates in ~2 s using ~0.8 GB.What this does
When the per-step object literal cannot observe the accumulator, the
superchain is semantically inert — it exists only to enablesuper/ late-boundself/+:reuse, none of which such a literal uses.StaticOptimizerrecognizes this shape throughBuiltin.specializeand rewrites the call to a fast path (FoldlObjectMerge) that:initas its only super — O(N) overall;initintact, so its own super chain and assertions still resolve and fire exactly as under naive evaluation;std.objectHas(acc, k),acc[k]) resolves each lookup in O(1) per step instead of re-gathering the whole chain.Patterns it fires on
Detection is fully static. The callback must be a 2-parameter function literal whose body — after stripping
local/assertwrappers and seeing throughif/else— is a tree of leaves, each one of:acc— a no-op step;acc { <object literal> }— object extension;acc + <object literal>—+.…where every "delta" object literal has no
+:fields, no method fields, no assertions, nosuperreference, and no reference toacc(transitively — anylocalwhose right-hand side mentionsaccdisqualifies the fold).accmay still appear freely inassert/ifconditions and messages, since those are forced immediately rather than captured into the result.Examples that can be optimized:
Testing
FoldlObjectMergeTests— correctness (output identical to naive evaluation, includinginitwith its own super chain / asserts, hidden-field visibility, and self-referential deltas), plus firing / non-firing shape assertions.jsonnet_to_jsontargets produced byte-identical output versus the baseline interpreter.