Skip to content

Add a cheap hash to Expr nodes to speed up IREquality - #9432

Merged
mcourteaux merged 5 commits into
mainfrom
abadams/expr_hash
Sep 8, 2026
Merged

Add a cheap hash to Expr nodes to speed up IREquality#9432
mcourteaux merged 5 commits into
mainfrom
abadams/expr_hash

Conversation

@abadams

@abadams abadams commented Sep 7, 2026

Copy link
Copy Markdown
Member

Summary

Adds a 32-bit hash to every IR node, packed into the previously-unused
upper 24 bits of IRNode::node_type's storage word (the node type
itself keeps the low 8 bits), so this doesn't increase the memory
footprint of an IR node.

  • Every Expr node's make() method fills in the hash from an
    ultra-simple multiply-add combination of the hashes/values of its
    arguments (combine_hash in Expr.h). Stmt nodes leave the extra
    bits zero, since equal/less_than on Stmts are much less
    frequently called than on Exprs.
  • Type::hash() (a 4-byte memcpy of the type's code/bits/lanes) lets
    type-bearing nodes like Cast fold their target type into the hash,
    so a Cast doesn't collide with its own child when they share a
    node type.
  • (U)IntImm's hash multiplies the 64-bit value by a large odd
    constant and keeps the high 32 bits (Knuth multiplicative hashing),
    so small immediates (by far the most common case) still get a well
    distributed hash instead of colliding with each other.
  • IREquality.h's equal/graph_equal/less_than/graph_less_than
    now compare/order by this hash before falling back to a full
    recursive comparison. A hash mismatch already implies the node
    types differ, so the separate node-type check was dropped. For
    less_than/graph_less_than the resulting order is arbitrary
    anyway (only used for map keys), so it's fine to order directly by
    hash when hashes differ.

Performance

Measured with HL_TIME_LOWERING_PASSES=1, target=host, comparing
this branch against main (a3690b3b6), 4 runs per app, means shown:

app before (ms) after (ms) delta
bilateral_grid 34.79 32.60 -6.3%
camera_pipe 55.10 54.17 -1.7%
conv_layer 13.51 12.68 -6.1%
hist 14.73 13.77 -6.5%
iir_blur 12.77 11.73 -8.1%
interpolate 131.60 123.84 -5.9%
max_filter 17.11 15.31 -10.5%
nl_means 56.58 53.21 -6.0%
stencil_chain 315.65 300.65 -4.8%
unsharp 17.56 16.40 -6.6%
harris 18.12 17.12 -5.5%
bgu 578.76 472.17 -18.4%
blur 8.38 7.05 -15.9%
depthwise_separable_conv 75.48 69.01 -8.6%
wavelet 11.52 10.51 -8.8%
local_laplacian 150.82 141.02 -6.5%
lens_blur 223.75 206.83 -7.6%

Geomean speed-up across these 17 apps: 1.086x (8.6% faster lowering).

(fft and resize were excluded from this sweep: fft's generator
needs specific size params via its own driver rather than bare -g fft
defaults, and resize needs an explicit input.type GeneratorParam;
neither is related to this change.)

Testing

correctness_ir_equality, correctness_simplify, correctness_bounds,
correctness_cse, correctness_lots_of_dimensions, and
correctness_constant_expr all pass.

🤖 Generated with Claude Code

abadams and others added 5 commits September 6, 2026 16:32
Each Expr node's make() method now fills in BaseExprNode::hash, an
ultra-simple multiply-add combination of the node type and the
hashes/values of its arguments. equal()/graph_equal() use it to
short-circuit on a hash mismatch before doing a full recursive
comparison, and less_than()/graph_less_than() use it directly to
order nodes when hashes differ, since that ordering is arbitrary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Move the hash from a separate BaseExprNode::hash field into a union
with IRNode::node_type: the low 8 bits are the node type (as before)
and the upper 24 bits are the hash, so this doesn't grow IRNode (the
comment already noted these bits were free padding). Stmt nodes leave
the upper bits zero.

IRNode::set_hash keeps the low byte's node type intact while masking
in a newly-computed 32-bit hash's upper 24 bits (its low bits are of
poor quality due to the multiply-add construction, so they're
discarded rather than shifted into the result). Add Type::hash() (a
memcpy of its first 4 bytes) so type-bearing nodes like Cast can fold
their type into the hash instead of just passing their child's hash
through unchanged, which would otherwise collide with equal-typed
child nodes of the same kind.

IREquality.h now compares IRNode::hash directly instead of node_type
followed by a separate BaseExprNode hash check, since a hash mismatch
already implies a node_type mismatch.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous big-endian branch kept args_hash's low 24 bits (the
low-quality end of a multiply-add hash) instead of discarding them.
Shift right by 8 first to keep the high-quality high bits, matching
what the little-endian branch already does by masking.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
set_hash keeps only the high 24 bits of its argument. The previous
IntImm/UIntImm hash sliced the 64-bit value into two 32-bit halves and
combined them, which put all the entropy of small values (the common
case) in the low bits that set_hash then throws away, making every
small IntImm/UIntImm of a given sign collide.

Multiply the value by a large odd 64-bit constant and keep the high
32 bits of the product instead (Knuth multiplicative hashing), which
mixes the low bits of the value into the high bits of the result even
when the value itself is small.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@abadams

abadams commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

@mcourteaux please check if this hash serves your needs in #9400, instead of having to compute one on the fly

Comment thread src/Expr.h
* 32 free bits in between the ref count and the Type field anyway. We use
* the first 8 to store the node type, and the next 24 as a hash of the
* children of the node, to make syntactic comparisons faster. */
union {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Why not use a bitfield here?

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.

because I want "hash" to mean the entire 32 bits.

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.

(otherwise I'd have to manually incorporate the node_type bits into the hash in every make method)

@abadams

abadams commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

If I use this to swap our expr-keyed maps and sets to be unordered_maps and unordered_sets, lowering time gets worse, and if I use ankerl::unordered_dense set and maps, lowering time doesn't change. So for this PR I'll leave this hash as something operator< uses for a regular map or set.

@abadams

abadams commented Sep 7, 2026

Copy link
Copy Markdown
Member Author

A custom map/set optimized for the sizes we see in practice was only 1.5% faster than this branch, so not worth the code complexity.

@codecov

codecov Bot commented Sep 7, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.11%. Comparing base (a3690b3) to head (2b43673).

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9432      +/-   ##
==========================================
- Coverage   70.12%   70.11%   -0.01%     
==========================================
  Files         261      261              
  Lines       79405    79462      +57     
  Branches    19362    19365       +3     
==========================================
+ Hits        55684    55717      +33     
+ Misses      17896    17891       -5     
- Partials     5825     5854      +29     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mcourteaux

Copy link
Copy Markdown
Contributor

Reading this, I'm wondering if we can do something similar with a style of structural "hash". So not really a hash, but a number that summarizes the IR node type hierarchy up to some depth N. If you can then query this number to depth k <= N, you might speed up IRMatcher rules. Even is collision rate is bad, you might still reject 50% of all matching rules based on some bit operators of such a "structural description number".

What if you have 32 bits, and every depth level takes up 8 bits. 8 bits to "hash" the node types and their nesting structure. If you now have an IRMatcher rule of depth 2, you check the lower 16 bits?

@mcourteaux
mcourteaux merged commit a95f4de into main Sep 8, 2026
28 checks passed
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.

3 participants