[llvm] [ADT] Use isEqual for ImmutableSet/Map tree canonicalization (PR #207596)
Balázs Benics via llvm-commits
llvm-commits at lists.llvm.org
Sun Jul 5 09:17:13 PDT 2026
https://github.com/steakhal created https://github.com/llvm/llvm-project/pull/207596
`ImutAVLFactory::getCanonicalTree` deduplicates a newly built tree against the trees already in its cache. On a digest collision it confirmed structural equality with `compareTreeWithSection`, a plain element-by-element in-order walk that is always linear in the tree size.
`ImutAVLTree` already provides `isEqual`, which performs the same structural comparison but skips subtrees that are shared by pointer. These persistent trees are heavily structurally shared -- a tree produced by `add`/`remove` shares everything but the mutated spine with its predecessor -- so switching `getCanonicalTree` to `isEqual` reduces the confirmation from `O(tree size)` to `O(number of differing nodes)` in the common case, and is never asymptotically worse. The comparison is exact, so canonicalization behavior is unchanged.
This builds on the recent iterator rewrite in #205552 ("Rewrite ImmutableSet/Map in-order iterator without per-node state"), which made in-order traversal and `skipSubTree` ~2x faster. That change is a constant-factor speedup of the iterator primitive and it deliberately preserved `skipSubTree`/`isEqual` semantics; this patch is complementary and algorithmic: it routes canonicalization through the pointer-skipping `isEqual`, cutting the number of nodes visited rather than the per-node cost. The two compose -- the remaining traversal here runs on the faster iterator from #205552.
This confirmation is the dominant cost of `ExplodedGraph` canonicalization in the Clang Static Analyzer whenever many equivalent `ProgramState`s are kept live and re-derived on different paths (for example with node reclamation disabled): each re-derivation of an already-cached `Environment`/`Store` map paid a full in-order walk to confirm the match. On several large reproducers the analyzer's `ImutAVLFactory` node comparisons dropped by ~26-36x with identical diagnostics and identical `ExplodedGraph` node/step counts. (These measurements already include #205552, so this speedup is on top of that iterator win.)
`compareTreeWithSection` had no other callers and is removed.
## Analyzer wall-clock
Measured on the three reproducers from
https://github.com/llvm/llvm-project/issues/105512#issuecomment-2389083480, best-of-3 wall-clock, `RelWithDebInfo` on an Apple M-series host. "ON" is the default; "OFF" disables node reclamation (`-analyzer-config graph-trim-interval=0`), the configuration that exposes the cost:
| Reproducer | Config | Before | After | Speedup |
| ------------ | ------ | -------- | ------- | ------- |
| ruby-unicode | ON | 2.18 s | 2.06 s | ~1.0x |
| ruby-unicode | OFF | 11.97 s | 2.28 s | ~5.2x |
| linux-topro | ON | 5.26 s | 5.08 s | ~1.0x |
| linux-topro | OFF | 11.16 s | 5.21 s | ~2.1x |
| MSP430 | ON | 2.71 s | 2.65 s | ~1.0x |
| MSP430 | OFF | 7.58 s | 2.71 s | ~2.8x |
The default (ON) path is unchanged within noise; the OFF path drops from 2.1-5.5x slower than ON to parity.
## Microbenchmark
Add a `CanonicalizeSharedEqual` case to `ImmutableSetIteratorBM` covering `getCanonicalTree`/`isEqual`. It re-canonicalizes a freshly built tree that is equal to, and shares subtrees with, a cached tree (`RelWithDebInfo`, same host):
| Set size `N` | Before | After | Speedup |
| ------------ | --------- | ------- | ------- |
| 16 | 212 ns | 186 ns | ~1.1x |
| 256 | 1354 ns | 347 ns | ~3.9x |
| 4096 | 21603 ns | 547 ns | ~40x |
| 65536 | 677240 ns | 1015 ns | ~667x |
The existing `Iterate`/`Skip`/`CompareSamePos` cases are unchanged; this does not touch the iterator.
>From 3f538b519250e64ff09a55dcdbaf567b539ad9a1 Mon Sep 17 00:00:00 2001
From: Balazs Benics <benicsbalazs at gmail.com>
Date: Sun, 5 Jul 2026 16:32:48 +0100
Subject: [PATCH] [ADT] Use isEqual for ImmutableSet/Map tree canonicalization
`ImutAVLFactory::getCanonicalTree` deduplicates a newly built tree against the
trees already in its cache. On a digest collision it confirmed structural
equality with `compareTreeWithSection`, a plain element-by-element in-order
walk that is always linear in the tree size.
`ImutAVLTree` already provides `isEqual`, which performs the same structural
comparison but skips subtrees that are shared by pointer. These persistent
trees are heavily structurally shared -- a tree produced by `add`/`remove`
shares everything but the mutated spine with its predecessor -- so switching
`getCanonicalTree` to `isEqual` reduces the confirmation from `O(tree size)` to
`O(number of differing nodes)` in the common case, and is never asymptotically
worse. The comparison is exact, so canonicalization behavior is unchanged.
This builds on the recent iterator rewrite in #205552 ("Rewrite
ImmutableSet/Map in-order iterator without per-node state"), which made
in-order traversal and `skipSubTree` ~2x faster. That change is a
constant-factor speedup of the iterator primitive and it deliberately preserved
`skipSubTree`/`isEqual` semantics; this patch is complementary and algorithmic:
it routes canonicalization through the pointer-skipping `isEqual`, cutting the
number of nodes visited rather than the per-node cost. The two compose -- the
remaining traversal here runs on the faster iterator from #205552.
This confirmation is the dominant cost of `ExplodedGraph` canonicalization in
the Clang Static Analyzer whenever many equivalent `ProgramState`s are kept
live and re-derived on different paths (for example with node reclamation
disabled): each re-derivation of an already-cached `Environment`/`Store` map
paid a full in-order walk to confirm the match. On several large reproducers
the analyzer's `ImutAVLFactory` node comparisons dropped by ~26-36x with
identical diagnostics and identical `ExplodedGraph` node/step counts. (These
measurements already include #205552, so this speedup is on top of that
iterator win.)
`compareTreeWithSection` had no other callers and is removed.
## Analyzer wall-clock
Measured on the three reproducers from
https://github.com/llvm/llvm-project/issues/105512#issuecomment-2389083480,
best-of-3 wall-clock, `RelWithDebInfo` on an Apple M-series host. "ON" is the
default; "OFF" disables node reclamation (`-analyzer-config
graph-trim-interval=0`), the configuration that exposes the cost:
| Reproducer | Config | Before | After | Speedup |
| ------------ | ------ | -------- | ------- | ------- |
| ruby-unicode | ON | 2.18 s | 2.06 s | ~1.0x |
| ruby-unicode | OFF | 11.97 s | 2.28 s | ~5.2x |
| linux-topro | ON | 5.26 s | 5.08 s | ~1.0x |
| linux-topro | OFF | 11.16 s | 5.21 s | ~2.1x |
| MSP430 | ON | 2.71 s | 2.65 s | ~1.0x |
| MSP430 | OFF | 7.58 s | 2.71 s | ~2.8x |
The default (ON) path is unchanged within noise; the OFF path drops from
2.1-5.5x slower than ON to parity.
## Microbenchmark
Add a `CanonicalizeSharedEqual` case to `ImmutableSetIteratorBM` covering
`getCanonicalTree`/`isEqual`. It re-canonicalizes a freshly built tree that is
equal to, and shares subtrees with, a cached tree (`RelWithDebInfo`, same
host):
| Set size `N` | Before | After | Speedup |
| ------------ | --------- | ------- | ------- |
| 16 | 212 ns | 186 ns | ~1.1x |
| 256 | 1354 ns | 347 ns | ~3.9x |
| 4096 | 21603 ns | 547 ns | ~40x |
| 65536 | 677240 ns | 1015 ns | ~667x |
The existing `Iterate`/`Skip`/`CompareSamePos` cases are unchanged; this does
not touch the iterator.
---
llvm/benchmarks/ImmutableSetIteratorBM.cpp | 36 ++++++++++++++++++++++
llvm/include/llvm/ADT/ImmutableSet.h | 21 +++----------
2 files changed, 41 insertions(+), 16 deletions(-)
diff --git a/llvm/benchmarks/ImmutableSetIteratorBM.cpp b/llvm/benchmarks/ImmutableSetIteratorBM.cpp
index 3ee704ede4374..a1918467c7ca8 100644
--- a/llvm/benchmarks/ImmutableSetIteratorBM.cpp
+++ b/llvm/benchmarks/ImmutableSetIteratorBM.cpp
@@ -19,6 +19,14 @@
// other node, the pattern used by ImutAVLTree::isEqual and the
// tree canonicalization that the clang static analyzer relies on.
//
+// It also measures tree canonicalization directly:
+// * CanonicalizeSharedEqual - re-canonicalize a freshly built tree that is
+// structurally equal to, and shares subtrees with, a tree already
+// in the factory's cache. This is the clang static analyzer's hot
+// path when equivalent ProgramStates are re-derived on many
+// paths, and it exercises ImutAVLFactory::getCanonicalTree ->
+// ImutAVLTree::isEqual.
+//
//===----------------------------------------------------------------------===//
#include "benchmark/benchmark.h"
@@ -110,6 +118,33 @@ static void BM_CompareSamePosition(benchmark::State &State) {
State.SetItemsProcessed(State.iterations() * N);
}
+// Re-canonicalize a freshly built tree that is structurally equal to a tree
+// already in the factory's cache and shares most of its subtrees. This mirrors
+// the clang static analyzer, where equivalent ProgramStates re-derived on many
+// paths are canonicalized against ones already cached. Adding then removing an
+// absent (largest) key rebuilds only the right spine while sharing the rest of
+// Base's subtrees, so getCanonicalTree confirms equality against a structurally
+// shared equal tree -- exactly where isEqual's subtree pointer skipping matters.
+static void BM_CanonicalizeSharedEqual(benchmark::State &State) {
+ const size_t N = State.range(0);
+ ImmutableSet<int>::Factory F(/*canonicalize=*/true);
+ ImmutableSet<int> Base = F.getEmptySet();
+ std::vector<int> Vals(N);
+ std::iota(Vals.begin(), Vals.end(), 0);
+ std::mt19937 Rng(0xC0FFEE);
+ std::shuffle(Vals.begin(), Vals.end(), Rng);
+ for (int V : Vals)
+ Base = F.add(Base, V); // Base is canonical and lives in the factory cache.
+ const int Absent = static_cast<int>(N) + 1;
+
+ for (auto _ : State) {
+ ImmutableSet<int> Tmp = F.add(Base, Absent);
+ ImmutableSet<int> Eq = F.remove(Tmp, Absent); // structurally equal to Base
+ benchmark::DoNotOptimize(Eq.getRootWithoutRetain());
+ }
+ State.SetItemsProcessed(State.iterations());
+}
+
} // namespace
#define ITER_SIZES Arg(16)->Arg(256)->Arg(4096)->Arg(65536)
@@ -117,5 +152,6 @@ static void BM_CompareSamePosition(benchmark::State &State) {
BENCHMARK(BM_Iterate)->Name("Iterate")->ITER_SIZES;
BENCHMARK(BM_IterateWithSkips)->Name("Skip")->ITER_SIZES;
BENCHMARK(BM_CompareSamePosition)->Name("CompareSamePos")->ITER_SIZES;
+BENCHMARK(BM_CanonicalizeSharedEqual)->Name("CanonicalizeSharedEqual")->ITER_SIZES;
BENCHMARK_MAIN();
diff --git a/llvm/include/llvm/ADT/ImmutableSet.h b/llvm/include/llvm/ADT/ImmutableSet.h
index b6ddcf0a23d2e..9793942369283 100644
--- a/llvm/include/llvm/ADT/ImmutableSet.h
+++ b/llvm/include/llvm/ADT/ImmutableSet.h
@@ -430,17 +430,6 @@ class ImutAVLFactory {
return (hl > hr ? hl : hr) + 1;
}
- static bool compareTreeWithSection(TreeTy* T,
- typename TreeTy::iterator& TI,
- typename TreeTy::iterator& TE) {
- typename TreeTy::iterator I = T->begin(), E = T->end();
- for ( ; I!=E ; ++I, ++TI) {
- if (TI == TE || !I->isElementEqual(&*TI))
- return false;
- }
- return true;
- }
-
//===--------------------------------------------------===//
// "createNode" is used to generate new tree roots that link
// to other trees. The function may also simply move links
@@ -628,12 +617,12 @@ class ImutAVLFactory {
TreeTy *&entry = Cache[maskCacheIndex(digest)];
if (entry) {
for (TreeTy *T = entry ; T != nullptr; T = T->next) {
- // Compare the Contents('T') with Contents('TNew')
- typename TreeTy::iterator TI = T->begin(), TE = T->end();
- if (!compareTreeWithSection(TNew, TI, TE))
+ // Compare the contents of 'T' with 'TNew'. isEqual skips subtrees that
+ // are shared by pointer, so for structurally-shared persistent trees
+ // (the common case, e.g. one derived from the other) this is linear in
+ // the number of differing nodes rather than in the tree size.
+ if (!TNew->isEqual(*T))
continue;
- if (TI != TE)
- continue; // T has more contents than TNew.
// Trees did match! Return 'T'.
if (TNew->refCount == 0)
TNew->destroy();
More information about the llvm-commits
mailing list