[RFC] Modernizing LLVM's `FoldingSet`: Open Addressing with Swiss Table and Algorithm R

Summary

We propose modernizing llvm::FoldingSet<T> by transitioning it to an
open-addressing hash set, yielding a 1.4% to 1.5% compile-time speedup on
clang -O3
.

This RFC describes FoldingSet, evaluates three open-addressing implementations
(Swiss Table and two variants of Knuth’s Algorithm R), and compares their
performance and memory trade-offs.

Background: What is FoldingSet?

FoldingSet<T>, heavily used in AST, SCEV, and SelectionDAG, uniquely stores
pointers (T*) by structural profile rather than pointer identity. For
example, when creating X + 1, SelectionDAG profiles the opcode, type, and
operands into a FoldingSetNodeID and queries FoldingSet<SDNode>, reusing
any existing node before allocating a new one.

Unique Characteristics of the Current Implementation

The current implementation of FoldingSet has several unique characteristics:

Intrusive Collision Chains

Every stored object derives from FoldingSetNode, which embeds a single
pointer:

class FoldingSetNode {
  void *NextInFoldingSetBucket = nullptr;
  ...
};

In the current implementation, this pointer serves as the linked-list node
connecting elements in the same bucket collision chain.

Two-Phase FindNodeOrInsertPos

FoldingSet separates lookup and insertion so callers can construct nodes
lazily on a miss without re-probing on insert:

void *InsertPos = nullptr;
if (T *Existing = Set.FindNodeOrInsertPos(ID, InsertPos))
  return Existing;

T *NewNode = new (Allocator) T(...);
Set.InsertNode(NewNode, InsertPos); // Skips re-probing

In-Place Node Modification and Circular Bucket Rings

Some users (e.g., SelectionDAG) mutate nodes in place before removal. Because
mutating a node changes its hash, RemoveNode(N) cannot locate N’s bucket by
re-profiling. The current implementation solves this with circular bucket
chains: RemoveNode(N) follows the ring to N’s predecessor to unlink N in
O(chain length) without needing its hash.

Proposed Modernization: Open-Addressing Hash Table

Repurposing FoldingSetNode

Instead of a collision pointer, FoldingSetNode caches the 32-bit hash:

class FoldingSetNode {
  uint32_t FoldingSetHash = 0;
  ...
};
  • Fast Removal: RemoveNode(N) reads the cached hash directly from N to
    locate its slot without re-profiling.
  • Zero Growth Re-Profiling: Table doubling re-inserts existing nodes using
    their cached hashes without invoking Profile().

Explored Implementations

We have implemented and evaluated three open-addressing designs:

  • PR #218156 (Swiss Table):
    Uses 8-way SWAR/SIMD group probing on a 1-byte control metadata array (Ctrl),
    matching 7-bit $H_2$ hash fingerprints in parallel to eliminate ~99.2% of
    non-matching slots in a single word operation. Deletions place tombstones
    (0xFE) reclaimed on rehash; operates at an 87.5% load factor (9.0 B/bucket).

  • PR #218188 (1-bit Algorithm R):
    Uses linear probing with occupancy tracked in a packed 1-bit bitmap (Used),
    inspecting the cached 32-bit hash on FoldingSetNode. Deletions are completely
    tombstone-free using Knuth TAOCP 6.4 Algorithm R (shifting downstream cluster
    elements backward); operates at a 75% load factor (8.125 B/bucket).

  • PR #218190 (Parallel Hashes Algorithm R):
    Uses linear probing with Algorithm R deletion, but duplicates the full 32-bit
    hash into a separate uint32_t Hashes[] table array alongside Buckets[]
    (similar to StringMap). Avoids node dereferences during hash mismatches;
    operates at a 75% load factor (12.0 B/bucket).

Performance & Memory Evaluation

Across a wide benchmark suite of large C++ translation units, PR #218156
(Swiss Table)
and PR #218188 (1-bit Algorithm R) deliver a 1.40% and
1.50% speedup on clang -O3, respectively.

FoldingSet bucket allocations account for ~3% of peak heap memory (~42 MB /
1.3 GB) when compiling SLPVectorizer.cpp. Below is Max RSS measured on
SLPVectorizer.cpp (3-run average; ASLR and THP disabled):

Implementation Table Footprint Max Load Factor Max RSS % Overhead
Current implementation (Chained) 8.000 B / bucket 200% (2.0) 1,295,303 KB β€”
PR #218188 (1-bit Algorithm R) 8.125 B / bucket 75% (0.75) 1,309,015 KB +1.06%
PR #218156 (Swiss Table) 9.000 B / bucket 87.5% (0.875) 1,316,964 KB +1.67%
PR #218190 (Hashes Algorithm R) 12.000 B / bucket 75% (0.75) 1,334,760 KB +3.05%

Analysis & Trade-Offs

  • Table Footprint: Lower load factors (<= 75% ~ 87.5%) require allocating
    more table buckets (2x to 2.67) than the chained design
    (200%), increasing central table array memory from 4.0 B/node to
    ~10.3–10.8 B/node.
  • 1-bit vs. Parallel Hashes: Duplicating hashes in the table array
    (PR #218190) increases table memory from 8.125 B to 12.0 B/bucket (+3.05% RSS
    vs. +1.06% for PR #218188). However, this does not save cache misses on lookup
    hits, where candidate nodes must be dereferenced to compute Profile() anyway.

Feedback Requested

Feedback is greatly appreciated! In an era where RAM is at a premium, is the
compile-time speedup worth the modest memory trade-off? If so, which
open-addressing option do you think is most appropriate?

@MaskRay @kuhar

For optimal performance (balancing code complexity), use linear probing + Algorithm R deletion.
Swiss Table family algorithms are bad at small keys and inefficient without SIMD, and with SIMD
the code complexity usually doesn’t pay off.

For best performance, use a parallel hash array (original #218190).

If max-rss is a priority: Adopt the SmallPtrSet layout and omit the cached parallel hash array.
I’ve updated 218190.

For FoldingSet/SmallPtrSet: Because their sentinel values don’t conflict with valid values, we should avoid the packed bit occupancy array altogether. I’ve chosen the packed bit occupancy array in DenseMap for integer keys like DenseMap<unsigned, X>.

clang++ -c SLPVectorizer.cpp β€” Release, no asserts, ASLR off, mean of 3 after a warmup

Intel Core i7-14700K, P core

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                β”‚     vanilla     β”‚ hasharray β”‚ new 218190  β”‚ bit array β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ max-rss (KiB)  β”‚         992,617 β”‚ 1,029,915 β”‚   1,004,705 β”‚ 1,004,201 β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ vs vanilla     β”‚               β€” β”‚    +3.76% β”‚      +1.22% β”‚    +1.17% β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ instructions:u β”‚ 173,876,146,613 β”‚   βˆ’0.892% β”‚     βˆ’0.955% β”‚   βˆ’0.825% β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

@MaskRay Is my following understanding correct? Your updated PR 218190 uses nullptr as EmptyKey, so we don’t need any side table (DenseMap-style bit vector, StringMap-style parallel array, or Swiss Table-style Ctrl array). So, that’s the smallest we can go as far as the memory footprint of an open-addressing hash table goes. We just have an array of pointers T *.

Yes, that’s right. The bucket array is the whole table.
New representation:

class FoldingSetBase : public DebugEpochBase {
  void **Buckets;        // safe_calloc(NumBuckets, sizeof(void *)); null == empty
  unsigned NumBuckets;   // power of two
  unsigned NumNodes;
  class Node { uint32_t FoldingSetHash; };        // 4 bytes, in every node
};

A null pointer marks an empty slot, so there is no Used bitmap, no parallel hash array and no Ctrl array. Two refinements to β€œsmallest”, though:

There is still 4 bytes per node, not per bucket: FoldingSetNode caches its 32-bit hash so that RemoveNode can find a node’s home and grow can rehash without re-running Profile(). All three proposals carry that, and it replaces an 8-byte next-in-bucket pointer, so it’s a net 4-byte saving per node against the current chained table.

The side table was never where the memory went. Dropping it saves NumBuckets/8 bytes β€” 64 KiB at 512K buckets, about 0.006% of a 1 GB compile. What actually costs memory is the load factor: 0.75 versus the chained table’s 2.0 is ~2.7x the buckets. That’s the whole story behind tramp3d-v4 at +5.00% max-rss on stage1-O3, and it applies equally to any open-addressing proposal at 0.75 β€” with the parallel hash array it was +5.64%, so removing 4 bytes/bucket moved it only 0.6pp.

There are also a few correctness deltas I’ve noted on #218190 (my initial Claude Opus-generated patch had at least some of the faults as well): rehash writing to every node, RemoveNode for a never-inserted node, and the sanitizeHash/SDVTListNode::HashValue interaction β€” which apply to whichever patch we land.

1 Like

Any comments? @nikic @dblaikie @kuhar ?

An RFC seems a bit overkill for such a low-level discussion? I mean, we silently do much more substantial changes all the time and we don’t do code reviews on Discourse… It would be great if you two could come to an agreement on the best path forward here, given that you looked into this in detail.

I think a minor max-rss increase is tolerable. When comparing performance of such data structures, it would be good to measure wall-time/cycle improvements, not instructions, on some somewhat recent CPUs (with a larger C++ file like SLPVectorizer.cpp as workload). branch-misses might be another relevant metric. Code size (and inlining decisions) might be other interesting factors; judging from #217571, inlining is important in some cases but can also cause non-trivial regressions. Maybe @fhahn did some further experiments here which provide some insights?

1 Like

Agreed the RFC is heavier than the topic (FoldingSet optimization) warrants. We’ve converged on the now-merged [FoldingSet] Switch to linear probing and Algorithm R deletion by MaskRay Β· Pull Request #218190 Β· llvm/llvm-project Β· GitHub
(The bit-array variant (#218188) and the Swiss Table version (#218156) are closed.) It matches my expectation: Swiss Table is usually not a good choice (we don’t need super high load factor. We don’t want to use SIMD. We can invalidate iterators more than Swiss Table family guarantees.)

Next: I’m working on a UniquingSet variant that drops FoldingSetNodeID for users that can supply a profile directly (getKey() instead of Profile()). Will send that separately.

Dimension FoldingSet UniquingSet DenseMap / DenseSet, typed key
lookup key serialised into a FoldingSetNodeID typed, aliases caller data typed, aliases caller data
hash out-of-line xxh3 inline DenseMapInfo fold inline fold
bucket 8 B pointer, hash cached in the node 8 B pointer, hash cached in the node key and value inline, 16 B and up
equality on a hit rebuild the profile and memcmp, or a Trait::Equals walking fields key’s == against getKey(node), or Info::isEqual against fields field-wise against the bucket-resident key
growth / erase free β€” cached hash free β€” cached hash re-derives the hash of every moved or shifted element (moveFrom, backward-shift erase)
miss β†’ insert token, one probe token, one probe find then insert β€” a second probe, and measured faster than single-probe try_emplace
key consistency one Profile() builds both sides β€” cannot disagree getKey and the lookup site are two hand-maintained sides the key is stored, so none β€” except DenseSet<T*> with a node-derived key, which has UniquingSet’s hazard
element requirements derives from FoldingSetNode; T may be incomplete derives from FoldingSetNode; T complete with the default Info no base class, and the element need not be a node
polymorphic keys natural needs isEqual and a tail awkward