Skip to main content
YAK

Performance Optimization: IRify Round-2 Full-Path Performance Refactor (SSA/SyntaxFlow/ANTLR)

· 22 min read
Yak ProjectYak Project

Over the past few months, Yaklang has completed its second relatively systematic round of performance optimization along four lines: SSA, CodeScan, SyntaxFlow / SFVM, and ANTLR / front-end.

If you look only at the commit log, it reads like a string of scattered fixes, refactors, and experiments; but when you put all of this work together, you find it actually revolves around the same goal:

Make IRify genuinely able to compile more stably on large projects, scan more efficiently, and execute rules more controllably — while giving the subsequent third and fourth rounds of optimization clear points to land.

First, a few of the most visible results.

  • On the real target spring-cloud-netflix, the observed value during this round of CodeScan tuning dropped from 10.08s to 5.59s.
  • Along the SSA instruction-search line, core hotspots like sfvm.nativecall:getFormalParams:java-servlet-param have been pushed down from the early 6.065s range to the "tens of milliseconds" range.
  • On the most typical — and ugliest — front-end hotspot, PHP mixed HTML, BenchmarkFrontendPfsenseSystemInformationFixture dropped from 15.36s/op to 6.95s/op, a roughly 54.74% reduction in time; when re-run locally on the current main branch, the result also stays stable around 7.82s/op.
  • In the AST-only experiment on a large Java decompiled target, tuning the ANTLR cache-reset parameter moved results from the 78.46s / 19.34GB band down to the 53.01s / 5.63GB band; if you chase a more aggressive speed sweet spot, it can already hit around 51s.
  • On the large Java decompiled project target, front-end AST errors and panics have been cleaned down to 0, and the system bottleneck has shifted from "the front end cannot even pass" to "how to keep squeezing the database-loading and persistence stage."

Put together, these sets of figures already say a lot:

First, this round of optimization is no longer at the level of "feels a bit faster" — it has genuinely knocked down a batch of problems measured in seconds, tens of seconds, or even tens of gigabytes of memory.

Second, the focus of this round is not only speedup, but also pinpointing the hotspots accurately, streamlining the paths, and picking stable default parameters.

Third, what has landed now is the second-round result; the third and fourth rounds will follow.

What has landed in this round can be summarized as four things:

1. SSA compilation and search paths are more unified, and the related commands and documentation have been consolidated at ssa.to/docs.

  1. SSA instruction search has started advancing from a string-matching path toward a structural optimization of "constant pool + ID path."

3. SyntaxFlow / SFVM's conditional-statement execution logic was refactored for a pass; the conditional paths that used to drag on for a long time under large-value scenarios have been genuinely reined in.

4. ANTLR / front-end not only continues to fix grammar boundaries but has also begun systematically squeezing time and memory through parameter sweeps, unified-target experiments, and TokenSource optimization.

At the same time, this is not the end point.

The worktrees in the current repo already make clear that subsequent directions are still being pushed forward; they correspond to the next set of hard nuts to crack in the upcoming phase:

  • Continue squeezing compile-time memory and data storage
  • Continue strengthening timing and diagnostic capabilities
  • Continue advancing the lazy-build path
  • Continue refining data-flow analysis capability

So, to sum up what this article is trying to say in one sentence:

This round of IRify performance optimization has pushed past "can it run" into "how do we make it run faster, more stably, and more cheaply" — and the third and fourth rounds after it are already on the schedule.

Below, we will walk through several of the most critical locations one by one, and explain exactly what was optimized in this round, why it was done this way, and what results it has delivered.

The core merged PRs corresponding to this section are mainly: #4019, #4040, #4066, #4092.

Before

Before this round, IRify's core capability in SSA instruction search was already usable; the problems were mainly concentrated on "the larger the project, the more easily the search path gets dragged down."

The positions that were truly slow were not really in any single rule itself, but in the data shape behind the search chain:

  • The names of instructions, variables, members, and object keys were flattened into string fields in the database during persistence
  • In index tables like irindex, the name field held a large amount of duplication
  • Once a large project had "many values sharing the same name" or "an especially large number of colliding names," the search would do redundant work over and over across those duplicated names

In other words, the problem with the old path was not "whether there was search capability," but that the search still carried too much string-level redundant labor.

The Problem

The easiest mistake to make at the start of this round was to interpret the hotspot simplistically as "regex matching is slow."

In fact, the real problem was further down:

  1. What the database retained was a "name-to-value" mapping, but with many name collisions.

  2. Once you searched by string, the database kept doing redundant matches over duplicated names.

  3. SQLite's callback-style regex further amplified the cost in this high-duplication scenario.

So the root cause cannot be explained away by a single phrase "regex is slow."

More precisely, it is:

There are too many values sharing the same name, and the string-search path is too heavy, so the database keeps doing redundant work over duplicated names.

This is also why this round eventually arrived at "constant-pool optimization."

The "constant pool" referred to here actually lands on the namepool path: first centrally manage the names, then push the search from string matching to an id-based path as much as possible.

The Approach

The real breakthrough on this line was not a big-bang rewrite, but starting with experiments.

A few of the first things done were critical:

  • Repeatedly running the scan-only baseline on the same real target spring-cloud-netflix
  • Breaking down hotspots to specific execution points
  • Trying to load the search data into memory to verify whether it could bypass SQLite's callback-style regex
  • Further analyzing the duplication rate of name in irindex

After the experiments, the direction became clear:

  1. First deduplicate the duplicated names in memory.

  2. Then match against the deduplicated name set.

  3. After matching, reverse-look-up the corresponding values.

  4. Going further, push the string-matching path to the id path, i.e., the constant pool / name pool.

The key locations finally landed in code are:

  • common/yak/ssa/ssadb/name_cache.go
  • common/yak/ssa/database_search.go
  • common/yak/ssaapi/sf_search.go
  • common/yak/ssaapi/sf_native_call.go

Specific changes include:

  • memory mode gained a pure in-memory constant pool
  • The search changed from pattern -> string to pattern -> NameCache IDs -> value IDs
  • SearchWithValue gained a lazy / DB fast path
  • NativeCall_GetFormalParams was changed to avoid materializing the full function object as much as possible

Current State and Refactor Results

The most important change in this chain now is not that "a cache was added," but that the search model has been swapped out:

  • No longer prioritizing large-range string matching
  • Now prioritizing the constant pool / namepool
  • Deduplicate first, then match, then reverse-look-up by id
  • The search models of memory mode and DB mode have started to converge

This is also why this section should be called "constant-pool optimization" rather than simply namepool.

Because what it solves behind the scenes is a larger problem:

Shifting search from "repeatedly rescanning strings" to "centrally managed names, with the ID path taking priority."

Efficiency Comparison

This section already has a few sets of figures that are quite telling.

In the scan-only baseline of spring-cloud-netflix, the early core hotspots included:

HotspotEarly time
sfvm.nativecall:getFormalParams:java-servlet-param~6.065s
sf.SearchWithValue:search-glob:*alibaba*fastjson~3.416s
sf.SearchWithValue:search-regexp:org.apache.logging.log4j~3.324s

After continued optimization, one of the brightest results on this line is:

  • sfvm.nativecall:getFormalParams:java-servlet-param

Dropped from the seconds range to the "tens of milliseconds" range.

This is essentially a two-order-of-magnitude drop.

At the same time, looking at the overall observed value during tuning on the same real target:

Observed itemBefore tuningAfter tuning
spring-cloud-netflix observed value10.08s5.59s

This overall set of figures cannot simply be attributed to "the constant pool alone made it this much faster," because layered on top were also:

  • The use of the scan-only path
  • Empty-rule noise cleanup
  • Other hotspot fixes

But it is already enough to show that the structural optimization along the SSA instruction-search line has begun to genuinely push down large-project scan times.

Refactor of the SyntaxFlow Conditional-Statement Execution Logic

The core merged PRs corresponding to this section are mainly: #4108, #4143, #4140.

Before

SyntaxFlow's filter syntax has always been powerful; these forms are familiar to everyone:

  • a?{.b}
  • a?{.*<len>==2}
  • a?(*<len>==3)

Syntactically, they are all easy to understand:

  • a?{.b}: check whether each a has .b
  • a?{.*<len>==2}: expand the members of a, then check whether the number of members of this a is 2
  • a?(*<len>==3): first expand, then make a judgment on the expanded result inside the condition

Use these conditions to filter the a preceding the ?.

In the SyntaxFlow documentation at ssa.to, ?{...} has always been explained as "each input value is evaluated in its own context."

But at the runtime-implementation level, this had not previously been tied up quite this cleanly.

The Problem

Before this round, the real problem was not "the syntax cannot express it," but that the execution logic carried several layers of historical baggage:

  • Values was tangled up with the old list semantics
  • The boundaries among conditional judgment, value grouping, and anchor re-projection were unclear
  • The condition-judgment path used a loop-based processing approach

This might not be obvious in simple rules, but once you hit:

  • A large number of input values
  • Heavy conditional nesting
  • Operations like * expansion, <len>, <slice> in between

A very unpleasant phenomenon appears:

The rule can still run; it's just that the condition judgment loops through the values one by one along the old path — the more values, the longer it takes, nearly linear in the number of values, so once it enters a large-value scenario it becomes very slow.

So the problem here is not "the judgment logic is incorrect," but rather:

The execution path of the judgment logic is not stable enough under large-value scenarios.

The Approach

What this round's refactor did was take this entire conditional path and rewrite it from scratch.

The key changes mainly landed in:

  • common/syntaxflow/sfvm/frame.go
  • common/syntaxflow/sfvm/condition_exec.go
  • common/syntaxflow/sfvm/native_call.go
  • common/syntaxflow/sfvm/values.go
  • common/syntaxflow/docs/sfvm-values-condition.md

There are four core ideas:

  1. Use Values to clearly unify the runtime value container.
  2. Bring conditional judgment back uniformly into anchor-scope.
  3. Hand the grouped behavior of NativeCall back to SFVM itself to handle.
  4. Replace the old ValueList and the old loop path.

Semantically, after this round of refactoring, the execution logic of ?{...} finally truly aligns with what the SyntaxFlow documentation describes:

  • a?{.b} no longer takes the old path of "pulling all values out and looping through them one by one"; instead, it judges each input a together for whether it has .b
  • a?{.*<len>==2} also no longer mixes all results together to compute; it first marks the predecessors, then executes the judgment together, and maps the judgment result back to each original input a

Likewise:

  • a?{.*<len>==2}

also no longer flattens all values to compute a single total len; instead:

  • First expand
  • Then re-project by the original input a
  • Finally decide which a should be kept

This is also why the core of this round's approach is not just "swapping a few functions," but re-straightening the whole set of relationships among:

  • Values
  • Condition
  • Anchor
  • NativeCall

Current State and Refactor Results

The most important changes along the SyntaxFlow conditional-statement line now are:

  • The judgment semantics are finally more consistent with the documentation
  • The conditional-judgment path is more unified
  • The old loop path that used to drag on for a long time under large-value scenarios has been genuinely reined in

This even has a very plain signal in the current repo's tests:

  • filter condition without iter loop

The test name itself has already spelled out the problem.

It is not proving "it can run"; it is proving:

This conditional-filter path no longer relies on the old iter-loop logic to grind through.

Efficiency Comparison

Its payoff is "reining in the long tail and the bad paths."

  • Before: once a large number of values entered the conditional judgment, it would easily loop through the judgments indefinitely, taking a very long time
  • Now: this kind of judgment path executes all values together just like an ordinary search; it can already complete stably and needs no loop operations at all.

That is to say, the payoff of this round is mainly reflected in:

  • Reining in the unacceptable long tail
  • Turning rule judgment from "easily collapsing when complex" into "still executing stably when complex"

This is also why this section reads more like "an execution-logic refactor" than "a single-point speedup PR."

ANTLR Cache-Reset Mechanism

The core merged PR corresponding to this section is mainly: #4139.

Before

A perennial problem on the ANTLR line is memory and time. In previous optimizations we completed two treatments in sequence:

  • Initially we found that memory was never cleaned up; then we moved away from a global cache to a per-project cache, to gain more stable cache-control cleanup.
  • Concurrent use of a single project cache introduced lock problems that made concurrent execution very slow; we split out worker coroutines for concurrency, each maintaining its own cache.

But running it now revealed a new problem: the runtime cache keeps ballooning on large projects, causing the GC to consume a large amount of CPU time.

The two most critical things are:

  • DFA
  • PredictionContextCache

If you ignore them entirely, they will take up more and more memory on large targets;

But if you clear them too aggressively, the cache cannot be reused in time, and parse time dominates, so the overall compilation efficiency still stays low.

So this is not an "on or off" question, but one that needs experimental judgment; we needed to find an indicator on the cleanup question and seek a "sweet-spot" configuration as the default.

The Problem

What this line really needs to solve is:

Exactly when should the cache be reset, so that memory is not pushed too high and performance is not wrecked?

This must look at two sets of metrics simultaneously:

  • Time
  • Peak memory

Because looking at only one, it is easy to pick a strategy that looks great but is actually terrible overall.

The Approach

The approach this round was not to change the default value on a hunch, but to expose the cache-reset parameter and then run continuous experiments around a unified target, comparing time and memory under different parameters, and finding a genuinely suitable "sweet spot" from the results.

The key locations are:

  • common/yak/ssaapi/ssa_compile_utils.go
  • common/yak/java/tests/ast_parse_metrics_local_test.go

The most central environment variable is:

  • YAK_ANTLR_CACHE_RESET_FILES

Its meaning is straightforward:

After how many files each worker parses, reset the runtime cache once.

What we did next was keep running experiments around a unified target, comparing time and memory under different reset cycles to find the genuinely suitable "sweet spot."

Current State and Refactor Results

After this round, the ANTLR cache reset is no longer a vague rule of thumb; it is a mechanism backed by experimental data.

And in the current main-branch code, the default value has already landed:

  • YAK_ANTLR_CACHE_RESET_FILES=100

This default was not written in on a hunch; it was chosen after comparing round after round of results, as the steadier default point.

Efficiency Comparison

First, the extreme cases.

StrategyTimePeak memoryConclusion
No reset78.46s19.34GBHigh memory, heavy GC pressure
Reset after every parse88.54s2.20GBVery low memory, but noticeably slower

Now the experiments that reset by "file count."

YAK_ANTLR_CACHE_RESET_FILESTimePeak memory
10053.01s5.63GB
12551.02s6.41GB
14550.17s7.09GB
15051.95s7.01GB
25050.67s9.67GB

This table is quite telling:

  • The 145/150 band is the "sweet spot" for speed
  • The 100 band leans toward "both speed and memory are stable"

So the conclusion that finally landed from this round is not:

  • One value being absolutely optimal

But rather:

  • There exists a clearly defined sweet-spot band
  • The default should pick a steadier, more acceptable point

Therefore the main-branch code ultimately chose:

  • Default 100

This value does not chase the single fastest run, but it keeps both time and memory within a relatively stable band.

ANTLR SLL Mode and TokenSource Optimization

The core merged PR corresponding to this section is mainly: #4165; the Java-related fixup maps to #4164, and the general SLL-first path is tied to #4139.

Before

This section actually splits into two lines:

  • Java: the main problems are AST boundaries in various real projects, especially decompiled code
  • PHP: besides grammar boundaries, there are also genuine front-end performance hotspots

Earlier on, the parse behavior of each front-end was not this unified either.

SLL, LL, cache reuse, and error fallback lacked a unified abstraction. Many language front-ends still handled their own parse details individually, which meant:

  • Some places went LL first
  • Some places did their own fallback
  • Some places had inconsistent cache behavior

After this round, a very important foundational change is: #4139 formally folded SLL-first into a unified helper.

That is, every front-end now defaults to trying the faster, lower-allocation SLL path first, and only falls back to LL when needed, making "run fast first, fall back on failure" the unified default behavior.

The Problem

The main problem on the Java side is:

  • A large number of bizarre AST boundaries appear in real decompiled projects
  • The system often gets stuck at the front-end AST before it ever reaches the later performance stages

The problem on the PHP side is more complex:

  • The real pfsense project has a batch of parser-boundary problems
  • What is more troublesome is that large mixed HTML/PHP files are especially slow

Once you break this PHP problem apart, you find:

  • It is not that SSA build is slow
  • It is not that the go test harness is slow
  • And it is not a simple SLL->LL fallback causing the slowness

The real hotspot is:

  • ANTLR underwent a decision explosion on mixed HTML/PHP input
  • Concentrated especially in inlineHtmlStatement

The Approach

The approach in this section has two layers.

The first layer is unifying the parse mode:

  • common/yak/antlr4util/sll_first_parse.go

This layer folds:

  • SLL-first
  • fallback to LL
  • error listening
  • cache detach

these behaviors into a unified helper.

The second layer is the PHP-specific optimization:

  • common/yak/php/php2ssa/html_token_source.go
  • common/yak/php/php2ssa/builder.go

Here a very critical capability is introduced:

  • decorateTokenSource func(antlr.TokenSource) antlr.TokenSource

It allows a front-end to insert a layer of its own TokenSource decorator between the lexer and the CommonTokenStream.

In the end, the most effective optimization was not to keep overhauling the grammar, but to add a layer of HTML token coalescing to PHP:

  • Consecutive HTML tokens are merged into larger chunks
  • PHP start boundaries and XML boundaries are preserved

On the Java side, this round was mainly about:

  • Continuing to fix up AST boundaries in real projects
  • Getting the clean compile of large decompiled targets to run through

Current State and Refactor Results

This line has now formed a fairly clear division of labor:

  • Java: the focus is on fixups and getting real large targets to run through
  • PHP: the focus is on genuinely bringing down the front-end performance hotspot

On the Java side, the most important result now is:

  • AST errors and panics in real large decompiled targets have been cleaned to 0
  • The front end is no longer the first wall

On the PHP side, the most important result now is:

  • The ugliest hotspot, mixed HTML, has been clearly located and pressed down

Efficiency Comparison

The most beautiful set of data in this section comes from PHP.

benchmarkBeforeAfterImprovement
BenchmarkFrontendPfsenseSystemInformationFixture15.36s/op6.95s/op54.74%
Allocated bytes15.98 GB/op7.26 GB/op54.59%
Allocations259,896,130117,823,71454.67%

When re-run locally on the current main branch, this benchmark also stays stable at:

  • 7.82s/op

This shows that this optimization has landed on the main branch fairly stably.

On the Java side, the focus is not on any single small benchmark, but on whether real projects can run through.

In the final clean-compile results:

  • wall time 44:10.96
  • parse 6m14s
  • save 27m25s

The core point it makes is:

  • The Java front end is now no longer the first wall
  • The subsequent performance focus will keep shifting toward the save / persistence direction

How AI Agents Lowered Development and Experimentation Costs

Before

In the past, with this kind of performance optimization, the most expensive cost was often not "writing code," but:

  • Running experiments
  • Reading experiment results
  • Verifying whether the idea was right
  • Running experiments repeatedly
  • Changing parameters and running again
  • Changing the target and running again
  • Changing the branch and running again

Especially for this kind of work:

  • A large number of parameter combinations
  • The same target must be tested repeatedly
  • And logs must be kept, comparisons made, and reviews done in hindsight

Done by hand, the cost would be very high.

The Problem

The few key optimizations in this round were, by nature, not "change it and immediately know whether it's right" problems.

For example:

  • The constant-pool / SSA search optimization needed repeated observation of hotspot changes on the same large target
  • The ANTLR cache reset needed multiple sets of parameter sweeps
  • The PHP mixed HTML needed repeated runs of benchmark, pprof, and comparison of different approaches

If all of these were done by hand, the experimentation cost would be exorbitant.

The Approach

One thing very worth a special mention in this round is:

AI agents provided a large volume of originally expensive, highly repetitive experimentation capability here.

What developers actually did was:

  • Judge the direction
  • See the experiment results
  • Decide the final approach

What the AI agent helped do was:

  • Continuously run the unified target
  • Run multiple sets of parameters
  • Run multiple worktrees / multiple branches
  • Collate the results back

The two most typical lines were:

  • SSA constant pool / search-chain experiments
  • ANTLR cache reset parameter sweeps

Current State and Refactor Results

The change brought by this is not "the AI wrote the optimization for the developer," but rather:

  • Many experiments that were originally too expensive, too tedious, and too repetitive can now be run continuously
  • The cost of multiple worktrees, multiple parameters, and multiple rounds of re-runs has been noticeably driven down

Developers can put more energy into:

  • Judging root causes
  • Deciding trade-offs
  • Designing experiments
  • Making decisions and reviewing the final implementation

Efficiency Comparison

This section has no directly corresponding "code run time" comparison table.

But it did change how this round of optimization was produced:

  • The constant-pool optimization allowed multiple rounds of baseline comparison
  • The ANTLR reset allowed full sweeps and re-runs
  • The PHP mixed HTML allowed benchmark, pprof, and rule verification to be strung together

Placed in this round, this is best summed up in one sentence:

This round of optimization let AI assistance genuinely merge into the performance-tuning workflow.

Conclusion

If we compress this round of IRify performance optimization 2.0 into one sentence, it is:

This round did not just do "faster"; it genuinely turned several of the most critical paths into "faster, more stable, and more continually optimizable."

The five things most worth remembering from this round are:

1. SSA compilation and related documentation are more unified, and ssa.to/docs has been filled in accordingly.

2. SSA instruction search has started moving from a string path to a constant-pool / id path, genuinely bringing down the redundant-search problem in large projects.

3. SyntaxFlow / SFVM's conditional-statement execution logic was refactored for a pass; the old judgment paths that used to drag on for a long time under large-value scenarios have been reined in.

4. ANTLR side, cache reset is no longer a rule of thumb but a "sweet-spot" choice that has been swept; and PHP mixed HTML has produced very solid before-and-after comparison data.

  1. AI agents have begun noticeably lowering the cost of this kind of development and experimentation.

If the first round did "open the road," the second round did:

  • Bring down the heaviest hotspots
  • Rein in the worst long tails
  • Pick the steadiest critical default parameters

And for the third and fourth rounds to come, the direction is in fact already clear:

  • Continue squeezing compile-time memory
  • Continue squeezing storage and database loading
  • Continue advancing the lazy-build path
  • Continue refining data-flow analysis

That is to say, this round is not the end point, but a very clear relay station.

From here on, IRify is no longer just "able to run"; it has begun genuinely entering the stage of "able to keep getting faster."


This article was first published on the Yak Project WeChat official account. Read the original.