`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. Assisted by: Claude Opus 4.8
161 lines
5.8 KiB
C++
161 lines
5.8 KiB
C++
//===- ImmutableSetIteratorBM.cpp - Benchmark ImmutableSet iterators ------===//
|
|
//
|
|
// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
|
|
// See https://llvm.org/LICENSE.txt for license information.
|
|
// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
|
|
//
|
|
//===----------------------------------------------------------------------===//
|
|
//
|
|
// Benchmarks in-order traversal of the ImutAVLTree backing ImmutableSet using
|
|
// only its public iterator API. It does not compare iterator implementations
|
|
// directly; instead, run the binary before and after a change to the iterator
|
|
// and compare the two reports (e.g. with llvm/utils/compare.py) to see the
|
|
// effect of that change.
|
|
//
|
|
// Two access patterns are measured:
|
|
// * Iterate - a plain forward walk over ImmutableSet::iterator, the common
|
|
// client usage.
|
|
// * Skip - a walk that calls skipSubTree on the tree iterator at every
|
|
// 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"
|
|
#include "llvm/ADT/ImmutableSet.h"
|
|
#include <algorithm>
|
|
#include <cstdint>
|
|
#include <numeric>
|
|
#include <random>
|
|
#include <vector>
|
|
|
|
using namespace llvm;
|
|
|
|
namespace {
|
|
|
|
using Tree = ImmutableSet<int>::TreeTy;
|
|
|
|
// Holds a factory plus a built set so the (non-trivial) tree construction is
|
|
// kept out of the timed region. The factory must outlive the tree.
|
|
struct Fixture {
|
|
ImmutableSet<int>::Factory F{/*canonicalize=*/false};
|
|
ImmutableSet<int> Set = F.getEmptySet();
|
|
|
|
explicit Fixture(size_t N) {
|
|
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)
|
|
Set = F.add(Set, V);
|
|
}
|
|
};
|
|
|
|
// Plain forward iteration over the public ImmutableSet::iterator.
|
|
static void BM_Iterate(benchmark::State &State) {
|
|
const size_t N = State.range(0);
|
|
Fixture Fix(N);
|
|
const ImmutableSet<int> &S = Fix.Set;
|
|
benchmark::DoNotOptimize(S.getRootWithoutRetain());
|
|
|
|
for (auto _ : State) {
|
|
int64_t Sum = 0;
|
|
for (int V : S)
|
|
Sum += V;
|
|
benchmark::DoNotOptimize(Sum);
|
|
}
|
|
State.SetItemsProcessed(State.iterations() * N);
|
|
}
|
|
|
|
// Iterate while skipping every other subtree, exercising skipSubTree (the
|
|
// pattern behind ImutAVLTree::isEqual / canonicalization) rather than plain ++.
|
|
static void BM_IterateWithSkips(benchmark::State &State) {
|
|
const size_t N = State.range(0);
|
|
Fixture Fix(N);
|
|
const Tree *Root = Fix.Set.getRootWithoutRetain();
|
|
benchmark::DoNotOptimize(Root);
|
|
|
|
for (auto _ : State) {
|
|
int64_t Sum = 0;
|
|
unsigned K = 0;
|
|
for (Tree::iterator I(Root), E; I != E; ++K) {
|
|
Sum += I->getValue();
|
|
if (K & 1)
|
|
I.skipSubTree();
|
|
else
|
|
++I;
|
|
}
|
|
benchmark::DoNotOptimize(Sum);
|
|
}
|
|
}
|
|
|
|
// Compare two iterators positioned at the same (non-end) node on every step.
|
|
// This isolates operator==: it is the case that differs between comparing the
|
|
// current node only (O(1)) and comparing the whole ancestor path (O(depth)).
|
|
// It is a microbenchmark of the comparison itself, not a typical client loop
|
|
// (those compare against end(), which is O(1) either way).
|
|
static void BM_CompareSamePosition(benchmark::State &State) {
|
|
const size_t N = State.range(0);
|
|
Fixture Fix(N);
|
|
const Tree *Root = Fix.Set.getRootWithoutRetain();
|
|
benchmark::DoNotOptimize(Root);
|
|
|
|
for (auto _ : State) {
|
|
bool R = false;
|
|
Tree::iterator E;
|
|
for (Tree::iterator A(Root), B(Root); A != E; ++A, ++B)
|
|
R ^= (A == B);
|
|
benchmark::DoNotOptimize(R);
|
|
}
|
|
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)
|
|
|
|
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();
|