Skip to content

chore(compiler): speed up typecheck for deep else-if ladders (remove quadratic complexity) - #1865

Open
klondikedragon wants to merge 7 commits into
vectordotdev:mainfrom
itlightning:perf/else-if-typecheck-flatten
Open

chore(compiler): speed up typecheck for deep else-if ladders (remove quadratic complexity)#1865
klondikedragon wants to merge 7 commits into
vectordotdev:mainfrom
itlightning:perf/else-if-typecheck-flatten

Conversation

@klondikedragon

@klondikedragon klondikedragon commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Hi! I 💖 vector and have been using it for many years. thank you!

This PR speeds up VRL compile / typecheck for programs with long nested else if chains by removing O(n^2) behavior for this case.

How we discovered it: We're using VRL to classify events in complex ways, which means some of our VRL has long else-if chains (40-60+ arms in several places). Our test suite (which runs validate over each VRL file + exercises data flow) went from a few seconds total to minutes as we've developed more VRL files. We found vector taking a long time (8+ seconds) to validate/compile some of our VRL files. Profiling pointed at Expression::apply_type_info / IfStatement / Kind / TypeState / LocalEnv clone and merge.

After these changes, these same VRL files validate/compile an order of magnitude faster (~8s to ~0.8s), taking our overall test suite run back from minutes to seconds.

While VRL compile-time is a one-time cost at startup, it's single-threaded and with our VRL, machines were taking 30+ seconds from vector startup before processing events. This removes that long delay.

I've contributed a smaller PR in the past, but this is my first time digging in to the compiler in VRL... I'm happy to adjust and discuss!

Change Type

  • Bug fix
  • New feature
  • Non-functional (chore, refactoring, docs)
  • Performance

Is this a breaking change?

  • Yes
  • No (no longer breaking after made further updates to PR)

Semantics note

After some updates to the PR, there are no changes to semantics. Additional tests were added to increase coverage of semantics for edge cases around if-else-if chains and related.

Problem

  • The parser converts else if to nested else { if ... }.
  • IfStatement::type_info clones TypeState, typechecks both sides, then merges.
  • Each nest re-walks the remaining ladder and deep-clones / merges large Kind trees and local bindings, so cost grows O(n^2) rather than a single linear pass over arms (O(n)).
  • Clones of certain large structures in the type check phase are expensive, causing further slowdown.

Approach

Ordered commits:

  1. Tests for if / else-if predicate local escape (documents intended semantics; else-if test alone is red until flatten).
  2. Flatten parser else { if ... } into a multi-arm IfStatement at compile so each arm is typechecked once. Main benefit that solves quadratic behavior.
  3. In-place exact object/array Kind inserts and assignment type updates (avoid deep-clone on every nested write).
  4. Arc + COW for Collection known maps.
  5. Compile timing example (examples/else_if_ladder_compile.rs) with ladder + --program and --env-width / --env-depth knobs.
  6. Arc + COW for LocalEnv bindings (this commit further speeds up ~2.7× on a large --program workload in that example driver, on top of the earlier commits).
  7. (pr fragment)
  8. Fix to make semantics the same as before: strip later else-if predicate-assigned locals after chain. After flattening, re-apply apply_child_scope from the post-arm-0 local env so bindings first introduced in later predicates are not definite after the chain (restoring nested else { if } Block semantics).

Benchmarks

Timings below are from my local release builds of examples/else_if_ladder_compile

Synthetic benchmark

cargo run --release --example else_if_ladder_compile -- \
  --arms 5,10,20,40,60 --fields 40 --warmup 1 --repeat 3

Performance after primary fix of removing quadratic complexity:

arms med_ms before any fix med_ms after flatten speedup
5 27 13 ~2×
10 73 25 ~3×
20 251 46 ~5×
40 890 94 ~9×
60 2003 147 ~13×

After doing in-place mutations of Kind plus cheap clones of Collection and LocalEnv (via Arc), the 60 arm test drops to med_ms=49.4:

$ cargo run --release --example else_if_ladder_compile -- --arms 5,10,20,40,60 --fields 40 --warmup 1 --repeat 3
else-if ladder compile (fields/arm=40, env=default, warmup=1, repeat=3)
  arms   src_bytes      min_ms      med_ms      max_ms      ms/arm^2
     5        5861         3.5         3.6         6.1        0.1449
    10       10676         6.5         8.1         8.3        0.0811
    20       20726        12.9        15.0        15.1        0.0376
    40       40826        33.4        34.4        36.0        0.0215
    60       60926        47.4        49.4        50.0        0.0137

So approximately a 40x speedup for the 60-arm case before to final after. The performance is also the same after the 8th commit.

Synthentic benchmarks are interesting, but how about a real-world case...

End-to-end Vector validate (bench-vrl-runtime.py)

Our CI harness in one of its steps uses vector validate over all of VRL scripts. We exposed this piece of it to benchmark this piece (with our large VRL it was causing big slowdowns). We built vector 0.57 with our branch cherry-picked to the version of VRL paired with 0.57, and compared it to stock vector on my Linux system (was version 0.56, sorry it's not quite the same, I could re-run if you want; other systems where I'm running 0.57 show almost identical behavior though, and the profiling data showed the hotspot was the type checking and VRL synthetic benchmarks confirm that):

Our bench-vrl-runtime.py isn't part of the PR, it's something we wrote to interface with our VRL CI.

Same harness flags; PATH selects the Vector binary.

Stock (/usr/bin/vector 0.56.0 release on this machine):

PATH=/usr/bin:$PATH python3 tools/bench-vrl-runtime.py \
  --scenario mix --events 1 --arms full --work /tmp/sl-vrl-runtime-bench
corpus /tmp/sl-vrl-runtime-bench/mix-1.ndjson lines=1 bytes=548 from security-mix.ndjson
validate cfg-no_vrl.yaml: 0.07s
validate cfg-thin_sec.yaml: 8.13s
validate cfg-full.yaml: 8.07s

Patched (workspace Vector 0.57.0 release build pinned to this VRL):

PATH=~/itl/workspace/vector/target/release:$PATH python3 tools/bench-vrl-runtime.py \
  --scenario mix --events 1 --arms full --work /tmp/sl-bench-patched-rel
corpus /tmp/sl-bench-patched-rel/mix-1.ndjson lines=1 bytes=548 from security-mix.ndjson
validate cfg-no_vrl.yaml: 0.17s
validate cfg-thin_sec.yaml: 0.97s
validate cfg-full.yaml: 0.79s

So cfg-full validate ~8.07s → ~0.79s on that A/B.

How did you test this PR?

  • make all green
  • New unit tests: eight expressions/if_statement files: first-if predicate escape, an assert-heavy success file for cross-arm / type-merge / short-circuit cases, and E701 goldens for else-if pred escape, braced else { if }, else-if body escape, body-->later-pred isolation, multi-stmt else, and third-arm pred + final else
  • Synthetic benchmark driver else_if_ladder_compile
  • Benchmarks of VRL integrated into vector with our real-world VRL workload
  • Full CI suite with modified vector over our corpus of known-in--known-out --> no change

Does this PR include user facing changes?

  • Yes (big perf speedup). Please add a changelog fragment based on
    our guidelines.
  • No. A maintainer will apply the "no-changelog" label to this PR.

Checklist

AI disclosure

I used Cursor Grok 4.5 to help develop this PR (understanding the compiler code base, developing a strategy, writing code and tests). I've personally reviewed the code and believe it's correct. This is my first time in the VRL compiler codebase though, so I greatly appreciate your expertise in reviewing!

References

If you like this PR, consider reviewing my unrelated vector PRs I'm writing up for vectordotdev/vector#25810 and vectordotdev/vector#25815

@klondikedragon
klondikedragon requested a review from a team as a code owner July 23, 2026 19:43

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 76838b148a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/compiler/compiler.rs
Comment thread src/compiler/expression/if_statement.rs
@klondikedragon

Copy link
Copy Markdown
Contributor Author

Update: fixed so that the semantics are now exactly the same as before

Codex flagged that flattening else { if } into a multi-arm IfStatement made locals first assigned in a later predicate look definite after the chain (and treated braced else { if } like else if).

While that was an intentional choice in the original PR to make the behavior of else if predicates more like if predicates, because of how we were peeling, it would lead to an incorrect behavior for a block that did else { if (x = 1; true) { null } } x -- x should never escape there.

Fix: after merging arm states, re-apply apply_child_scope from the local env as it existed after arm 0’s predicate. Bindings first introduced by later predicates are dropped; updates to pre-existing locals still merge. Same rule the old nested else { if } Blocks enforced. Runtime still short-circuits predicates (no change).

Tests: expanded expressions/if_statement coverage (cross-arm visibility, non-escape goldens for else if / braced / body / multi-stmt else / third-arm+final else). Goal of these tests was to show no change in semantics/behavior across various edge cases involve if/else-if/else.

Running tests on main branch vrl: copied these new .vrl files onto main and confirmed all tests pass with unpatched vrl:

cargo run -q -p vrl-tests -- -p if_statement25/25 OK

So these goldens match pre-flatten nested semantics; this commit is not introducing new local-escape behavior and not changing semantics.

@klondikedragon

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: abe4e9fc7c

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/compiler/compiler.rs Outdated
@klondikedragon

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Delightful!

Reviewed commit: 799f72623a

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

@pront

pront commented Jul 24, 2026

Copy link
Copy Markdown
Member

Hi @klondikedragon, thank you for your contribution! I will take a look.

@pront pront self-assigned this Jul 24, 2026
@klondikedragon
klondikedragon force-pushed the perf/else-if-typecheck-flatten branch from 8825c44 to 7aaf471 Compare August 7, 2026 02:24
@klondikedragon

Copy link
Copy Markdown
Contributor Author

Thanks @pront for taking a look!

We did a bunch for testing/profiling on our workloads. Vector was still taking 15-20 seconds to fully startup even after our latest changes, so we dug in again. The O(N^2) problem with depth of nested if-else arms was not recurring, it was now due to cost of merging more complex type states:

Profiling showed compile time still dominated by per-operator TypeState clone/merge: Op::type_info cloned the full type state 3x per binary operator, and merging a state with itself did a deep copy plus a full Kind union.

  • perf(compiler): identity short-circuits in typecheck merges so we dropped the redundant clones; Arc::ptr_eq fast paths in LocalEnv::merge / apply_child_scope / Collection::merge.
  • perf(compiler): skip no-op RHS state merges in Op: x ?? "literal" (side-effect-free fallback, very common in our shaping code) skips the RHS state merge entirely.
  • Drive-bys: --coalesce/--locals knobs for the timing example; vrl-tests runs on Windows.

Real-world effect (release build, single thread, median):

  • a 3.7k-line, 82-arm classification ladder with 689 ?? operators compiles in 1354ms -> 267ms (5.1x speedup)
  • a second production program 363ms -> 63 ms (5.8x)

Overall, combined with this PR's flattening fix: ~16s -> 0.27s VRL compilation time for one of our larger VRL programs. On 1-2 core hosts compiling ~24 programs at startup this makes a huge difference.

Reproduce with the included example driver:
cargo run --release --example else_if_ladder_compile -- --arms 82 --fields 40 --locals 3 --coalesce 8 --coalesce-chain 3
-> 414 ms before these follow-on commits, 194 ms after (2.1x). Real programs gain more because
their arm bodies are richer (nested conditionals, multi-clause predicates), multiplying the
per-operator clones these commits remove.

Correctness:

  • 16 new unit tests, each short-circuit differentially checked against the full merge path
  • our internal 940-case generated corpus compiles to byte-identical type-info digests before/after
  • all fast paths are conservative (Arc::ptr_eq / structural identity only)

I know this PR is pretty large now so it could be split up. On the other hand, each individual commit is fairly narrow and the concepts are all related and one depends on the other, so conceptually it still feels like this is one logical unit that could merge together. I'm happy to take any feedback. thanks!!

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7aaf471cdd

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/compiler/expression/if_statement.rs
@klondikedragon

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e73c7912b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread examples/else_if_ladder_compile.rs Outdated
@klondikedragon

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3653707f86

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

out.push_str(" ._itl.class = \"NOTABLE\"\n");
out.push_str(&format!(" ._itl.arm = {i}\n"));
}
out.push_str("} else {\n");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Reject zero-arm synthetic ladders

When --arms 0 is accepted, the loop above emits no opening if, but this unconditional } else { still gets appended, so the generated VRL starts with an unmatched brace and compile_once exits before any timing data is collected. Either reject zero in parse_args or emit a valid else-only/no-op baseline for this case.

Useful? React with 👍 / 👎.

Comment thread examples/else_if_ladder_compile.rs Outdated
Comment on lines +389 to +390
for _ in 0..coalesce_chain.max(1) {
expr.push_str(&format!("to_string(.src{c}) ?? "));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Reject or report zero coalesce chains

When a benchmark is run with --coalesce-chain 0, this silently promotes the generated expression to one to_string(.srcN) ?? ... step, while the summary still prints chain=0. That makes zero-chain coalesce experiments measure fallible-call/coalesce work under a mislabeled configuration, so either reject 0 in argument parsing or store/report the effective value used here.

Useful? React with 👍 / 👎.

Comment thread examples/else_if_ladder_compile.rs Outdated
}

println!(
"else-if ladder compile (fields/arm={}, locals/arm={}, coalesce/arm={} chain={} shared={}, {env}, warmup={}, repeat={})",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3 Badge Include field-disjoint mode in output

When comparing synthetic runs with and without --fields-disjoint, the generated program changes from repeatedly updating a fixed event shape to growing distinct fields per arm, but the printed configuration only reports shared for locals and never records fields_disjoint. That makes saved benchmark output for these two modes indistinguishable except for timings/source size, so include this flag in the run label.

Useful? React with 👍 / 👎.

@pront

pront commented Aug 7, 2026

Copy link
Copy Markdown
Member

I know this PR is pretty large now so it could be split up. On the other hand, each individual commit is fairly narrow and the concepts are all related and one depends on the other, so conceptually it still feels like this is one logical unit that could merge together. I'm happy to take any feedback. thanks!!

@klondikedragon I highly recommend splitting this up as much possible otherwise it will be a while before it is merged.

@klondikedragon

Copy link
Copy Markdown
Contributor Author

@pront I could split this so that all of the July 23rd commits are on this PR, and all of the Aug 6+ commits are in a follow-on. The first batch stands on its own and is a great speedup by itself. the second follow-on is another big 5x speedup but also naturally stands on its own. how does that sound?

@pront

pront commented Aug 7, 2026

Copy link
Copy Markdown
Member

@pront I could split this so that all of the July 23rd commits are on this PR, and all of the Aug 6+ commits are in a follow-on. The first batch stands on its own and is a great speedup by itself. the second follow-on is another big 5x speedup but also naturally stands on its own. how does that sound?

@klondikedragon that's a good start. However, I would try to split pre-July 23rd commits as well if possible.

@klondikedragon
klondikedragon force-pushed the perf/else-if-typecheck-flatten branch from 3653707 to 7bb8ee8 Compare August 7, 2026 19:47
@klondikedragon

Copy link
Copy Markdown
Contributor Author

@pront understood. I'm splitting the first batch in half again as they are independently composable (different files, different optimizations). will update shortly and open the second PR shortly. thanks!

Locals assigned in if and else-if predicates remain visible after
the chain; locks flatten semantics vs nested else-scope strip.

Co-authored-by: Cursor Grok 4.5
Signed-off-by: Klondike Dragon <klondikedragon@gmail.com>
Peel parser else { if ... } into multi-arm IfStatement so
typecheck visits each arm once instead of re-walking nested
suffixes. Keeps predicate side-effect order and join semantics.

Co-authored-by: Cursor Grok 4.5
Signed-off-by: Klondike Dragon <klondikedragon@gmail.com>
Wall-clock harness for synthetic ladders and --program files,
plus --env-width/--env-depth/--env-seed-fields to seed a
spine+stubs ExternalEnv Kind for more realistic compile timing.

Co-authored-by: Cursor Grok 4.5
Signed-off-by: Klondike Dragon <klondikedragon@gmail.com>
Signed-off-by: Klondike Dragon <klondikedragon@gmail.com>
Flattened typecheck treated later-predicate bindings as definite
after the chain. Re-apply apply_child_scope from post-arm0 locals
and expand if/else-if suite coverage.

Co-authored-by: Cursor Grok 4.5
Signed-off-by: Klondike Dragon <klondikedragon@gmail.com>
@klondikedragon

Copy link
Copy Markdown
Contributor Author

@pront - this split into 3 pieces:

Happy to take more feedback. Thanks!

@klondikedragon

Copy link
Copy Markdown
Contributor Author

P.S. this isn't just a speedup. If you've got a VRL program with a large number of else-if arms, it will currently stack-overflow, because the unflattened representation recurses per arm. (e.g., the test driver with 160 arms stack overflows attempting to use 1 GB of stack). It's not a good idea to have 160 straight else-if in prod programs anyways LOL, which is why this was never hit, but it's removing something could cause vrl/vector to crash or use an unexpected amount of memory, etc.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants