Repository navigation
Closed
Labels
enhancementNew feature or requestNew feature or request
Description
Description
Global Value Numbering is a compiler optimization that eliminates redundant computations across basic blocks by assigning the same value number to expressions that compute the same value. This optimization can catch redundancies that CSE (Common Subexpression Elimination) misses due to control flow.
Current Limitation
The current CSE only works within basic blocks. Identical computations in different blocks are not detected:
if (condition) {
x = a + b * c; // Computation 1
} else {
y = a + b * c; // Same computation, different block
}
z = a + b * c; // Redundant with both aboveImplementation Details
1. Value Numbering Infrastructure
typedef struct {
enum { VN_CONST, VN_VAR, VN_BINOP, VN_UNOP, VN_PHI } type;
union {
int const_val;
var_t *var;
struct {
int op;
int vn1, vn2; // Value numbers of operands
} binop;
};
} value_expr_t;
typedef struct {
hashmap_t *expr_to_vn; // Expression → value number
hashmap_t *vn_to_leader; // Value number → representative variable
int next_vn;
} gvn_state_t;2. Algorithm Steps
void run_gvn(func_t *func) {
// Phase 1: Build dominator tree
build_dominator_tree(func);
// Phase 2: Value numbering in dominator tree order
gvn_state_t state = init_gvn_state();
for (bb in reverse_postorder(func)) {
process_block_gvn(bb, &state);
}
// Phase 3: Replace redundant computations
replace_with_leaders(&state);
}
void process_block_gvn(basic_block_t *bb, gvn_state_t *state) {
for (insn in bb->insns) {
value_expr_t expr = build_expr(insn);
int vn = lookup_or_add(state->expr_to_vn, expr);
if (has_leader(state, vn)) {
// Redundant - replace with existing value
replace_insn(insn, get_leader(state, vn));
} else {
// New value - record as leader
set_leader(state, vn, insn->rd);
}
}
}3. Hash Function for Expressions
uint32_t hash_expr(value_expr_t *expr) {
switch (expr->type) {
case VN_CONST:
return hash_int(expr->const_val);
case VN_BINOP:
// Commutative operators: sort operands
if (is_commutative(expr->binop.op)) {
int v1 = MIN(expr->binop.vn1, expr->binop.vn2);
int v2 = MAX(expr->binop.vn1, expr->binop.vn2);
return hash_combine(expr->binop.op, v1, v2);
}
return hash_combine(expr->binop.op,
expr->binop.vn1, expr->binop.vn2);
}
}Test Cases
// Cross-block redundancy
int test_gvn_basic(int a, int b, int c) {
int x;
if (c > 0) {
x = a + b; // VN = 1
use(x);
} else {
x = a + b; // Same VN = 1, should reuse
use(x);
}
int y = a + b; // Same VN = 1, should reuse
return x + y;
}
// Commutative operations
int test_gvn_commutative(int a, int b) {
int x = a + b; // VN = 1
int y = b + a; // Same VN = 1 (commutative)
return x * y; // Should optimize to x * x
}
// Complex expressions
int test_gvn_complex(int a, int b, int c) {
int t1 = a * b;
int t2 = t1 + c;
// ... other code ...
int t3 = a * b; // Should reuse t1
int t4 = t3 + c; // Should reuse t2
return t2 + t4; // Should optimize to t2 * 2
}Expected Benefits
- 10-20% reduction in redundant computations
- Better than CSE for programs with complex control flow
- Enables further constant propagation
- Reduces register pressure
Integration
- Run after SCCP (needs constant values)
- Run before DCE (creates dead code)
- Complements existing CSE pass
Success Metrics
- Redundant expressions eliminated across blocks
- No increase in compilation time > 10%
- Correct handling of side effects
- Preserves program semantics
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or request
Activity
alanhc commentedon Sep 10, 2026
I have a working prototype of this and would like to pick it up.
Rather than restate the plan, here is what building it turned up, since parts
of the description do not match the IR as it stands at 4d3b4c0.
What the pass has to beat
cse()compares operands by pointer and scans only from the head of thecurrent block, so it misses two different things. The obvious one is distance:
The less obvious one is that pointer equality is not value equality. Given
cse()collapses the first pair but not the second, becauset1andt2aredifferent
var_t. Numbering values rather than comparing names makes thesecond pair collapse for free, and that recursion is the reason to do this as
GVN rather than as a wider CSE.
Two things in the description I would do differently
"Phase 1: Build dominator tree." It is already built.
ssa_build()runsbuild_idom()andbuild_dom(), and nothing between there andoptimize()invalidates them:
inline_calls()copies only branch-free callees(
func_is_inlinable()refuses a body withthen_orelse_), so it addsinstructions but no edges. The three passes that do rewrite the CFG --
if_convert(),thread_const_branches()andstrength_reduce()-- all runafter
optimize(), which is whyrebuild_dom()sits at main.c:243 ratherthan earlier. So the pass can read
bb->dom_nextdirectly and does not needits own
rebuild_dom()."for (bb in reverse_postorder(func))." This is the one substantive
correction. Reverse postorder cannot express the scoping: it has no point at
which to discard what a block contributed, so an
ifarm's values stayvisible while the
elsearm is numbered, and the else arm reuses a value itspath never computed. The walk has to go down the dominator tree, pushing on
the way down and popping on the way back up --
bb_solve_phi_params()alreadywalks the same tree the same way for the same reason and is a usable
template.
"Run after SCCP." Worth clarifying which pass is meant.
opt-sccp.cfolds only the
load_constant+truncpattern -- the comment above itsinclude at ssa.c:13 already notes that despite the file name there is no
lattice -- and general constant folding lives in
const_folding()insideoptimize()'s instruction walk."Run before DCE" is right and comes for free:
dce_sweep()is at the endof
optimize(), so a pass placed at the top of it is already ahead.Where it goes
Its own file,
src/opt-gvn.c, alongsideopt-sccp.c, called per function nearthe top of
optimize(). It does not belong inside that function's instructionwalk: the walk visits blocks in reverse postorder and one instruction at a
time, and this needs a different traversal order.
What I have
A pass that numbers binary operations, walks the dominator tree with a scoped
table, normalizes commutative operands, and refuses a global or address-taken
operand exactly as
cse()does. On the first example above it takes the twomultiplications in
dom()down to one; the suite passes on x64, arm, arm64and riscv at both stages.
Two things I deliberately left out, since each needs its own decision:
recognized at the join. Doing that needs a fixed point over loops.
OP_loadandOP_readmay return a different valueeach time and there is no alias analysis here to prove otherwise.
Before sending anything I would like to know whether the dominator-tree
traversal is the shape you want, and whether you would rather
cse()eventually retire into this pass or stay as it is -- its array-access case
(
add+read) is load elimination rather than value numbering, so it doesnot fold in cleanly either way.
jserv commentedon Oct 15, 2026
Closing as completed. Global value numbering is implemented in VIR as
vir_gvn_with_stats()insrc/vir.c. It runs at the default optimization level after SCCP, CFG simplification and local CSE (see the pass list indocs/vir.md).Both cases from this issue, and the two from @alanhc's comment, now compile to one computation each.
--dump-irat 2cbc93f:Values are numbered, not variable names, so
t3 = t1 + candt4 = t2 + cfold oncet1andt2do. This is the recursive case that pointer-equality CSE missed.A computation that is redundant on only some paths into a join (
if (f) x = a + b; y = a + b;) is still recomputed. That is partial redundancy, and it stays open as #288. @alanhc, thank you for the analysis. The legacycse()that your prototype targeted is gone, but if you want to continue in this area, #288 is the natural next step on VIR.