Guide to PHI Nodes in OpenVAF MIR¶
This guide explains PHI nodes: what they are, how they work in SSA (Static Single Assignment) form, how they're represented in OpenVAF's MIR, and how they translate to JAX's functional programming model.
Table of Contents¶
- What Are PHI Nodes?
- How PHI Nodes Work
- PHI Nodes in OpenVAF MIR
- Converting PHI Nodes to JAX
- Debugging PHI Node Issues
What Are PHI Nodes?¶
PHI (φ) nodes are a fundamental construct in Static Single Assignment (SSA) form, an intermediate representation used by compilers. They solve a key problem: how do you track which value a variable has when control flow merges from multiple paths?
The Problem PHI Nodes Solve¶
Consider this Verilog-A snippet:
if (TYPE == 1)
ids = ids_nmos; // NMOS branch
else
ids = ids_pmos; // PMOS branch
// What is `ids` here?
After the if-else, ids could be either ids_nmos or ids_pmos depending on which branch executed. In SSA form, every variable can only be assigned once, so we can't write:
PHI nodes resolve this by explicitly representing the merge:
The PHI node says: "The value of ids depends on which predecessor block we came from."
Why SSA Form?¶
SSA form is used by most high-quality optimizing compilers (LLVM, GCC, OpenVAF) because it makes optimizations easier:
- Dead code elimination: Unused definitions are obvious
- Constant propagation: Each value has one definition to trace
- Register allocation: No need to track "live ranges" of variables
How PHI Nodes Work¶
Semantics¶
A PHI node: 1. Is placed at the start of a basic block (before any other instructions) 2. Selects a value based on control flow — which predecessor we came from 3. Executes instantaneously — conceptually happens "on the edge" between blocks
Means:
- If we came from block_a, use v1
- If we came from block_b, use v2
- If we came from block_c, use v3
Example: Loop Counter¶
block_entry:
v0 = 0
br block_loop
block_loop:
v1 = phi [v0, block_entry], [v2, block_loop] // i = 0 or i+1
v2 = add v1, 1
cond = lt v2, 10
br_if cond, block_loop, block_exit
The PHI node tracks the loop counter: on first iteration it's v0 (0), on subsequent iterations it's v2 (the incremented value).
Example: Conditional Assignment¶
Becomes:
block_entry:
cond = gt a, 0
br_if cond, block_true, block_false
block_true:
v1 = sqrt a
br block_merge
block_false:
v2 = 0.0
br block_merge
block_merge:
v3 = phi [v1, block_true], [v2, block_false]
v4 = mul v3, 2.0
PHI Nodes in OpenVAF MIR¶
Data Structure¶
From openvaf/mir/src/instructions.rs:
pub struct PhiNode {
pub args: ValueList, // Pool-allocated list of SSA values
pub blocks: PhiMap, // Maps predecessor Block → index into args
}
Key design choices: - Memory efficient: Values stored in external pool, not inline - B-forest mapping: O(log n) lookup for predecessor values - Sorted iteration: Deterministic block ordering
Supporting Types¶
The PhiMap uses a B-forest (balanced tree forest) data structure for efficient predecessor lookups.
Text Representation¶
When you see MIR output (via openvaf-viz or debug prints):
This means:
- v17 is the result of the PHI node
- Coming from block0 → use v16
- Coming from block1 → use v18
- Coming from block2 → use v19
PHI Node Creation (Braun Algorithm)¶
OpenVAF uses the Braun et al. (2013) algorithm for efficient SSA construction. PHI nodes are created when a variable is used in a block that has multiple predecessor blocks with different definitions.
From openvaf/mir_build/src/ssa.rs:
ZeroOneOrMore::More => {
// Predecessors disagree on value - create PHI node
let mut args = ValueList::new();
let mut blocks = Map::new();
for (pred_block, pred_val) in predecessors {
let i = args.push(pred_val, &mut pool);
blocks.insert(pred_block, i);
}
// Insert PHI at block entry point
cursor.at_first_insertion_point(dest_block)
.build(PhiNode { blocks, args });
}
PHI Node Optimization¶
PHI nodes can be simplified when all inputs are identical:
// From openvaf/mir_opt/src/simplify.rs
pub fn simplify_phi(&mut self, phi: PhiNode) -> Option<Value> {
let mut iter = self.func.dfg.phi_edges(&phi);
if let Some((_, all_eq_val)) = iter.next() {
// If ALL edges have the same value, eliminate the PHI
if iter.all(|(_, val)| self.map_val(val) == all_eq_val) {
return Some(all_eq_val); // Replace PHI with single value
}
}
None
}
Converting PHI Nodes to JAX¶
The Equivalence: SSA ≈ Functional Programming¶
There's a well-known correspondence between SSA form and functional programming, established by Appel's seminal paper "SSA is Functional Programming":
| SSA Concept | Functional Equivalent |
|---|---|
| Basic block | Named function |
| PHI node | Function parameter |
| Jump to block | Tail call to function |
| PHI edge value | Argument in tail call |
In functional terms, each basic block becomes a function, and PHI nodes become the parameters to those functions. The call sites determine which values reach the PHI.
If-Conversion: PHI to Select¶
For JAX specifically, we use if-conversion (also called predication) to transform control flow with PHI nodes into data-parallel select operations. This is necessary because:
- JAX traces programs into a dataflow graph (jaxpr)
jnp.whereevaluates both branches (no lazy short-circuiting)- GPU execution benefits from branchless code
The transformation converts:
Into:
The Algorithm¶
The general if-conversion algorithm for PHI nodes:
- Identify the condition that determines which predecessor was taken
- Compute both branch values (even if one won't be used)
- Use select to merge based on the condition
For a 2-way PHI (if-else):
# MIR: v_result = phi [v_true, block_true], [v_false, block_false]
# JAX: v_result = jnp.where(cond, v_true, v_false)
For an N-way PHI (switch/case):
# MIR: v_result = phi [v0, block0], [v1, block1], [v2, block2]
# JAX: v_result = jnp.select([cond0, cond1, cond2], [v0, v1, v2], default)
# Or: v_result = lax.switch(index, [lambda: v0, lambda: v1, lambda: v2])
JAX Control Flow Primitives¶
| Primitive | Use Case | Evaluation |
|---|---|---|
jnp.where(cond, t, f) |
2-way select | Both branches evaluated |
jnp.select(conds, vals) |
N-way select | All branches evaluated |
lax.cond(cond, t_fn, f_fn) |
Scalar condition | Lazy (one branch) |
lax.switch(idx, fns) |
N-way scalar | Lazy (one branch) |
For openvaf_jax, we typically use jnp.where because:
- Device evaluation is batched (conditions are arrays)
- Both branches contain useful gradient information
- GPUs prefer branchless code
Nested PHI Nodes¶
Complex control flow creates nested PHI nodes:
This creates PHI nodes at two levels, which translate to nested jnp.where:
Performance Considerations¶
Eager evaluation: jnp.where always computes both branches. This is usually fine for numerical code but can be wasteful for:
- Expensive computations only needed in one branch
- Branches with different numerical stability requirements
Solution for expensive branches: Use lax.cond when:
- Condition is a scalar (not batched)
- One branch is significantly more expensive
- You need lazy evaluation for correctness
Debugging PHI Node Issues¶
Why PHI Nodes Matter for openvaf_jax¶
When OpenVAF compiles Verilog-A to JAX Python code, PHI nodes become jnp.where() calls. If the condition is wrong, or if one branch returns 0.0 when it shouldn't, the PHI node propagates that error through the entire computation.
Common PHI Node Bugs¶
-
Zero from wrong branch: A PHI has
[v3, block_unused]wherev3 = 0.0. If control flow incorrectly takes this path, output goes to zero. -
TYPE parameter issues: NMOS/PMOS selection depends on a PHI that chooses based on
TYPE == 1. If TYPE isn't propagated correctly, wrong branch executes. -
Constant folding artifacts: Optimizer inlines a constant into one PHI edge but not others, creating asymmetry.
-
Dead branch pollution: A branch that "shouldn't" execute still contributes values that get selected by
jnp.where.
Debugging Tools¶
MIR Analysis Script¶
# Find all PHI nodes
uv run scripts/analyze_mir_cfg.py model.va --func eval --find-phis
# Trace what value feeds into a specific PHI
uv run scripts/analyze_mir_cfg.py model.va --func eval --trace-value v12345
# See which block defines a suspicious value
uv run scripts/analyze_mir_cfg.py model.va --func eval --analyze-block block4654
# Find branch points (conditionals)
uv run scripts/analyze_mir_cfg.py model.va --func eval --branches
MIRInspector Class¶
from vajax.debug import MIRInspector
inspector = MIRInspector(va_path)
inspector.print_phi_summary('eval') # Show all PHI nodes
# Find PHIs that have v3 (often 0.0) as an operand
inspector.find_phi_nodes_with_value('v3')
Typical Debugging Workflow¶
- Identify symptom: JAX returns wrong current or zeros
- Compare outputs: Use
ModelComparatorto find discrepancy - Check cache: Look for inf/nan or wrong temperature values
- Inspect PHIs: Find PHI nodes with suspicious zero operands
- Trace control flow: Identify which branch condition is wrong
- Fix translation: Usually a parameter mapping or type issue
Example: Zero Current Bug¶
Symptom: OSDI returns 1mA drain current, JAX returns 1e-15A
Investigation:
inspector = MIRInspector(va_path)
zero_phis = inspector.find_phi_nodes_with_value('v3') # v3 = 0.0
# Found: v789 = phi [v788, block_nmos], [v3, block_pmos]
Root cause: TYPE parameter wasn't passed correctly, so JAX took the PMOS branch (which returns 0 for an NMOS device).
Fix: Ensure TYPE is included in the parameter array passed to the compiled function.
Summary¶
| Concept | Description |
|---|---|
| Purpose | Merge values from different control flow paths in SSA form |
| Syntax | v_result = phi [v1, block1], [v2, block2], ... |
| Semantics | "Select value based on which predecessor we came from" |
| Placement | Always at the start of a basic block |
| In JAX | Becomes jnp.where(condition, val_true, val_false) |
| Algorithm | If-conversion / predication transforms branches to selects |
| Common bug | One PHI edge has 0.0 and wrong branch is taken |
References¶
- Appel, A. W. (1998). SSA is Functional Programming. ACM SIGPLAN Notices.
- Braun, M. et al. (2013). Simple and Efficient Construction of Static Single Assignment Form. CC 2013.
- Chuang, W. et al. (2003). Phi-Predication for Light-Weight If-Conversion. CGO 2003.
- Kelsey, R. A. (1995). A Correspondence between Continuation Passing Style and Static Single Assignment Form. IR'95.
- JAX Documentation: Control Flow