argotorg / argotorg/solidity

Slow compilation under via-ir & SSA-CFG on deeply nested try/catch

Open
#16,697 0 comments 0 reactions 1 assignee Claimed by @rodiazet View on GitHub
Dominant language
C++
Stars
25.7k
Forks
6.2k
Avg merge
1d 11h
Merged PRs (30d)
21

Description

## Description

A small (≈12 KB) Solidity source consisting of a modifier with deeply nested `try this.f() { ... } catch { ... }` chains causes the via-IR pipeline to take **~115× longer** than the legacy pipeline (no optimizer) and **~22× longer** than the legacy optimizer pipeline. The experimental SSA-CFG path is essentially the same as via-IR + optimizer. The legacy pipeline handles the same source in ~100 ms (no optimizer) / ~560 ms (optimizer).

| Configuration | Time | Peak RSS |
| ---------------------------------------------- | -------- | -------- |
| `--evm-version osaka` (legacy, no opt) | 106 ms | 43 MB |
| `--evm-version osaka --optimize` (legacy, opt) | 563 ms | 51 MB |
| `--via-ir` (no opt) | 1990 ms | 112 MB |
| `--via-ir --optimize` | 12201 ms | 130 MB |
| `--via-ir --optimize --experimental --via-ssa-cfg` | 12592 ms | 214 MB |

Found via differential fuzzing.

## Environment

- Compiler version: `0.8.35-develop.2026.5.7+commit.b83005c9.Linux.g++`
- Compilation pipeline (legacy, IR, EOF): all three IR-based configurations (`--via-ir`, `--via-ir --optimize`, `--via-ir --optimize --via-ssa-cfg`) are affected. Legacy is fast.
- Target EVM version (as per compiler settings): `osaka`
- Framework/IDE: `solc` command line
- EVM execution environment / backend / blockchain client: N/A — pure compilation
- Operating system: `Linux 7.0.3-arch1-2`

## Steps to Reproduce

```bash
solc --bin --evm-version osaka --via-ir --optimize C.sol
solc --bin --evm-version osaka --via-ir --optimize --experimental --via-ssa-cfg C.sol
```

The source is a contract with:
- a state variable `uint x;`
- a modifier `m()` whose body opens with a tiny inline-assembly Yul function then enters chains of `try this.f() returns (uint a) { try this.f() returns (uint a) { ... } catch {} } catch {}`, nested up to 25 levels deep and laid out in several adjacent chains, before `_;`.
- two `public` functions `f()` and `g()` that both apply the modifier **twice** (`m m`) and each contain another tiny inline-assembly block. Applying the modifier twice doubles the entire deeply-nested body in the generated IR/Yul for both functions, i.e. the deep try/catch chain ends up duplicated four times in the final Yul object.

Full source (666 lines): [`source.sol`](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-source-sol).

Inline source (click to expand)

```solidity
contract C {
uint x;
modifier m() {
uint t;
assembly {
function f() -> x { x := 8 }
t := f() }
try this.f() returns (uint a) {
try this.f() returns (uint a) {
try this.f() returns (uint a) {
// ... 25-level try/catch chain ...
try this.f() returns (uint a) {
a = 1;
} catch {

}
// ... matching catch blocks ...
} catch {
// next chain (24 levels)
try this.f() returns (uint a) {
...
} catch {
// next chain (25 levels) — and so on for several chains
...
}
_;
}
function f() m m public returns (uint r) {
assembly { function f() -> x { x := 1 } r := f() }
}
function g() m m public returns (uint r) {
assembly { function f() -> x { x := 2 } r := f() }
}
}
```

The structural shape repeats: opening fans of 25 nested `try this.f() returns (uint a) { ... } catch { }` are interleaved with restarts in some `catch` clauses, producing several adjacent deeply-nested chains within the same modifier body. The full text (with all chains expanded) is in the gist; the elided file is 666 lines / ~12.3 KB.

## Nature of the slowdown

The slowdown is not a runtime / output bug — all five configurations compile successfully. The issue is **compile-time cost** localised in the IR/Yul pipeline:

1. **Even with no optimizer, `--via-ir` is ~19× slower than legacy** (1990 ms vs. 106 ms). The dominant cost is printing the generated Yul object, not generating it.
2. **`--via-ir --optimize` adds another ~6× on top of that** (12 s total). The dominant cost shifts to `BlockDeduplicator::deduplicate()` on the assembled EVM and `DataFlowAnalyzer` over Yul switches.
3. **`--via-ir --optimize --via-ssa-cfg` shows the same hot spots as via-IR + optimizer**, with slightly worse peak memory (214 MB vs. 130 MB).

The Solidity feature driving this is `try/catch`: each `try this.f() returns (...) { ... } catch { ... }` lowers (in Yul) to a `switch` over the call success/return-data path, surrounding the result handling and the catch clause. With ~25-deep nesting repeated several times and the modifier applied twice on two functions, the resulting Yul contains a very large number of nested `switch` nodes and a correspondingly large number of similar EVM assembly blocks (each `try` produces structurally similar return-data length / decode / revert sequences).

## Relevant perf data

Full perf top-50 reports and flamegraphs are attached as gists below. The salient parts:

### `--via-ir --optimize` — 12.2 s ([flamegraph](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-opt_viair-true-flamegraph-svg), [perf top50](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-opt_viair-true-perf_top50-txt))

```
45.58% CompilerStack::generateEVMFromIR
44.98% YulStack::assembleEVMWithDeployed
43.82% evmasm::Assembly::optimiseInternal
39.07% evmasm::BlockDeduplicator::deduplicate
34.30% 15.33% BlockDeduplicator::deduplicate()::{lambda(...)}::operator() ← 15% self
10.01% 5.37% BlockDeduplicator::BlockIterator::operator++ ← 5% self
5.69% 5.59% boost::algorithm::find_format_all_impl2 ← ~6% self
5.51% 5.51% evmasm::AssemblyItem::instruction ← 5.5% self
4.68% 3.59% evmasm::SemanticInformation::altersControlFlow

38.76% yul::YulStack::optimize
34.51% yul::OptimiserSuite::run
27.27% DataFlowAnalyzer::operator()(Block&)
26.81% DataFlowAnalyzer::operator()(Switch&) ← 26% spent recursing into Yul switches
14.94% DataFlowAnalyzer::operator()(If&)
11.85% 9.47% DataFlowAnalyzer::clearValues ← ~9% self
```

`BlockDeduplicator::deduplicate` alone is ~39% of the total run, almost matching the entire Yul optimizer pipeline (~38%).

### `--via-ir --optimize --via-ssa-cfg` — 12.6 s ([flamegraph](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-opt_ssacfg-flamegraph-svg), [perf top50](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-opt_ssacfg-perf_top50-txt))

Profile is essentially identical to the non-SSA-CFG via-IR + optimizer case:

```
45.55% evmasm::Assembly::optimiseInternal
39.94% BlockDeduplicator::deduplicate
35.14% 15.81% BlockDeduplicator::deduplicate lambda ← 15% self
33.56% yul::OptimiserSuite::run
26.57% DataFlowAnalyzer::operator()(Block&)
26.13% DataFlowAnalyzer::operator()(Switch&)
11.37% 9.04% DataFlowAnalyzer::clearValues ← ~9% self
```

So the slowdown is *not* introduced by SSA-CFG — it sits in the shared optimizer/assembly stages.

### `--via-ir` (no optimizer) — 1.99 s ([flamegraph](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-noopt_viair-true-flamegraph-svg), [perf top50](https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44#file-noopt_viair-true-perf_top50-txt))

A different hot path dominates: even without any optimizer, just *printing* the generated Yul takes most of the time.

```
76.85% CompilerStack::generateIR
56.94% YulStack::optimize ← still runs even without --optimize
36.93% yul::AsmPrinter::operator()(Block const&)
35.27% AsmPrinter::operator()(FunctionDefinition const&)
34.06% AsmPrinter::operator()(Switch const&)
32.16% 31.55% boost::algorithm::find_format_all_impl2 ← ~32% self, string replace in printer
9.30% std::__detail::__regex_algo_impl ← regex on every emitted block
8.48% 7.82% std::__detail::_Executor ← regex executor, ~8% self
9.96% StackLayoutGenerator::processEntryPoint
9.93% yul::VarNameCleaner::renameVariables
```

So roughly 32% of the no-optimizer via-IR run is spent inside a single boost string-replace function called from the Yul `AsmPrinter`, plus ~8% in `std::regex`.

For comparison, the legacy paths' top frames are dominated by the analyser/codegen and stay well under 1 s total.

## Potential reasons

- `BlockDeduplicator::deduplicate` (~39% under opt) likely scales poorly with the many structurally similar return-data-check / decode / revert stubs that each `try` lowers to.
- `DataFlowAnalyzer::operator()(Switch&)` (~26%) — `try/catch` lowers to nested Yul `switch`, and `clearValues` on exit (~9% self) appears to scale with the surrounding live-variable set.
- Under no-opt via-IR, `AsmPrinter` reaches `boost::replace_all` / `std::regex` (~32% + ~8% self), consistent with repeated find/replace over a growing buffer.
- The modifier is applied twice on two functions (`m m`), so the deep nest is duplicated 4× in the generated Yul, multiplying input size for every downstream pass.
- SSA-CFG profile matches non-SSA-CFG opt — regression sits in shared stages, not SSA-CFG.

## Attachments

All artefacts are in a single gist: https://gist.github.com/msooseth/711d02aab365a0da3a214b569fc4cf44

- `source.sol` — the full reproducer (666 lines)
- `noOpt_viaIR=false.perf_top50.txt`, `opt_viaIR=false.perf_top50.txt` — legacy reference profiles (fast)
- `noOpt_viaIR=true.perf_top50.txt`, `opt_viaIR=true.perf_top50.txt`, `opt_ssaCFG.perf_top50.txt` — slow IR-based profiles
- `noOpt_viaIR=true.flamegraph.svg`, `opt_viaIR=true.flamegraph.svg`, `opt_ssaCFG.flamegraph.svg` — flamegraphs for the three slow configurations

Related PR: https://github.com/argotorg/solidity/pull/16684

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.