Skip to content

compiler: several optimizations and cleanups - #1034

Open
apoelstra wants to merge 10 commits into
rust-bitcoin:masterfrom
apoelstra:2026-08/compiler-opts-1
Open

apoelstra wants to merge 10 commits into
rust-bitcoin:masterfrom
apoelstra:2026-08/compiler-opts-1

Conversation

@apoelstra

@apoelstra apoelstra commented Aug 23, 2026 •

Copy link
Copy Markdown
Member

This is a followup to #988 and starts a project to optimize the compiler. It pulls apart the compiler module into multiple submodules, inlines the CompilerExtData struct (which since #988 is just a pair of floats which never change after construction) (previously it also had a branch_prob value that would be mutated during compilation).

This also cleans up the cast-computation logic, type checking within the compiler, and eliminates a few other places where we were passing miscellaneous closures around. It leaves the codebase in a much cleaner state.

Some of the commits have benchmark data showing a significant speedup, but I don't have a spare computer right now to run clean before and after benchmarks on the whole branch. Will update the PR with that data once I have it.

It also greatly speeds up the existing regression test between the compiler on master and the one released on 12.x. I ran 150 MM iterations just now, plus a whole bunch when I was developing the branch some months ago.

@apoelstra

Copy link
Copy Markdown
Member Author

On d4a97c6 successfully ran local tests

Comment thread src/policy/compiler/ext_data.rs Outdated
Comment on lines +136 to +139
dissat_cost: match (left.dissat_cost, right.dissat_cost) {
(Some(l), Some(r)) => Some(l + r),
_ => None,
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This can leverage the zip and map methods on Option<T>

Suggested change
dissat_cost: match (left.dissat_cost, right.dissat_cost) {
(Some(l), Some(r)) => Some(l + r),
_ => None,
},
dissat_cost: left.dissat_cost.zip(right.dissat_cost).map(|(l, r)| l + r),

Comment thread src/policy/compiler/ext_data.rs Outdated
Comment on lines +259 to +271
dissat_cost: if let (Some(ldis), Some(rdis)) = (left.dissat_cost, right.dissat_cost) {
if (2.0 + ldis) > (1.0 + rdis) {
Some(1.0 + rdis)
} else {
Some(2.0 + ldis)
}
} else if let Some(ldis) = left.dissat_cost {
Some(2.0 + ldis)
} else if let Some(rdis) = right.dissat_cost {
Some(1.0 + rdis)
} else {
None
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

// Complex if/let is better as is.

Unsure what the argument is, but this whole if/let can be simplified using match

Suggested change
dissat_cost: if let (Some(ldis), Some(rdis)) = (left.dissat_cost, right.dissat_cost) {
if (2.0 + ldis) > (1.0 + rdis) {
Some(1.0 + rdis)
} else {
Some(2.0 + ldis)
}
} else if let Some(ldis) = left.dissat_cost {
Some(2.0 + ldis)
} else if let Some(rdis) = right.dissat_cost {
Some(1.0 + rdis)
} else {
None
},
dissat_cost: match (
left.dissat_cost.map(|l| 2.0 + l),
right.dissat_cost.map(|r| 1.0 + r),
) {
(Some(l), Some(r)) => Some(l.min(r)),
(left, right) => left.or(right),
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Nice! Clippy wanted me to turn it into a flat series of } else if {s.

@apoelstra
apoelstra force-pushed the 2026-08/compiler-opts-1 branch from d4a97c6 to 8fd590e Compare August 31, 2026 21:12
@apoelstra

Copy link
Copy Markdown
Member Author

Addressed @Abeeujah's commenst.

@apoelstra

Copy link
Copy Markdown
Member Author

On 8fd590e successfully ran local tests

@apoelstra

Copy link
Copy Markdown
Member Author

8fd590e needs rebase

It feels a little weird to export this at the root but there isn't
anywhere else in the public API where it really fits, so just dump it
there.
If we want to regression test the Concrete parser, that's fine but
belongs in a separate fuzztest (which I may add in a separate PR).

But for testing the compiler, we definitely want to be synthesizing
policies directly rather than parsing and rejecting.
Gets rid of a couple 'as f64' casts.
These policies exercise paths that earlier iterations of this PR
introduced bugs to. (The bugs were found by fuzzing.)
Better encapsulation, and I want to clean up these types.

Code move only.
The Type::type_check function takes a terminal, matches on it, calls the
appropriate method on Type, and wraps it in a nice error. It turns out
that in the compiler, this is obscenely slow. I'm not sure if it's because
the big match undermines inlining, or if the error-wrapping (which the
compiler doesn't even use; it only uses typeck errors as a binary "ok or
not?" signal), or what.

But empirically, replacing the call to Type::type_check with direct calls
to the methods on Type, results in a massive compiler speedup. At the cost
of a bit more code repetition. IMO definitely worth it.

You shouldn't trust these benchmarks too much because my system was busy,
but the difference is quite extreme:

Before:
test benchmarks::compiler_benches::compile_basic              ... bench:   6,285,527.70 ns/iter (+/- 4,895,149.56)
test benchmarks::compiler_benches::compile_large              ... bench:  14,602,446.20 ns/iter (+/- 9,250,348.77)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 982,121,582.90 ns/iter (+/- 1,209,655,507.47)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 582,951,636.20 ns/iter (+/- 58,752,609.07)

After:
test benchmarks::compiler_benches::compile_basic              ... bench:   1,520,658.20 ns/iter (+/- 13,249.16)
test benchmarks::compiler_benches::compile_large              ... bench:   4,128,737.40 ns/iter (+/- 55,408.00)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 716,569,601.10 ns/iter (+/- 130,612,914.15)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 148,633,378.80 ns/iter (+/- 16,348,954.09)


On `compile_large_tap` it's "only" a 35% speedup but on the others, and
on the `segwit_limits` unit tests, it's a 3-4x speedup. Wild.
This is a weird structure. I'm not sure what I was thinking here. It's 4 function
pointers in a struct, with a `cast()` method on them that calls all the functions
in the right order to transform an AstElemExt. Why not just make it an AstElemExt
method directly?

I did this to simplify the code, and it also comes with another fairly big speedup
(though this one is small enough that it's plausibly just noise from my busy machine).

The weird order is copied from the old logic. I don't think there's any rhyme or
reason to it, but if we change the order then some compilations can change (e.g.
swapping the l and n wrappers, which functionally commute).

Before (copied from the "after" from my previous commit)
test benchmarks::compiler_benches::compile_basic              ... bench:   1,520,658.20 ns/iter (+/- 13,249.16)
test benchmarks::compiler_benches::compile_large              ... bench:   4,128,737.40 ns/iter (+/- 55,408.00)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 716,569,601.10 ns/iter (+/- 130,612,914.15)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 148,633,378.80 ns/iter (+/- 16,348,954.09)

After:
test benchmarks::compiler_benches::compile_basic              ... bench:   1,199,013.60 ns/iter (+/- 28,096.21)
test benchmarks::compiler_benches::compile_large              ... bench:   3,350,305.90 ns/iter (+/- 463,149.87)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 703,996,003.60 ns/iter (+/- 835,362,175.58)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 140,988,430.90 ns/iter (+/- 24,692,231.52)

seems like 30% or so on the small ones, 5% or so on the big ones. Nice.
This simplifies and cleans up the code, but with a pretty severe performance
hit for the "small" benchmarks, in exchange for a improvement on the larger
ones. Will keep an eye on this.

Before:
test benchmarks::compiler_benches::compile_basic              ... bench:   1,199,013.60 ns/iter (+/- 28,096.21)
test benchmarks::compiler_benches::compile_large              ... bench:   3,350,305.90 ns/iter (+/- 463,149.87)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 703,996,003.60 ns/iter (+/- 835,362,175.58)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 140,988,430.90 ns/iter (+/- 24,692,231.52)

After:
test benchmarks::compiler_benches::compile_basic              ... bench:   1,570,666.35 ns/iter (+/- 1,066,467.89)
test benchmarks::compiler_benches::compile_large              ... bench:   6,010,996.75 ns/iter (+/- 3,744,165.23)
test benchmarks::compiler_benches::compile_large_tap          ... bench: 694,140,897.60 ns/iter (+/- 656,723,194.35)
test benchmarks::compiler_benches::compile_xlarge             ... bench: 135,546,606.00 ns/iter (+/- 11,686,439.12)
There is no longer any reason to keep this as a separate data structure.
@apoelstra
apoelstra force-pushed the 2026-08/compiler-opts-1 branch from 8fd590e to 482b0a1 Compare September 3, 2026 13:06
@apoelstra

Copy link
Copy Markdown
Member Author

On 482b0a1 successfully ran local tests

@apoelstra

Copy link
Copy Markdown
Member Author

482b0a1 needs rebase

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