Constant Folding and Dead Code Elimination in spirv-opt
This article examines how the spirv-opt tool optimizes
GLSL-derived SPIR-V binaries through constant folding and dead code
elimination (DCE). Front-end compilers like glslang
typically produce verbose, unoptimized intermediate representation
containing redundant calculations and dead branches. By analyzing static
single assignment (SSA) definitions, control flow graphs (CFG), and
def-use chains, spirv-opt replaces statically determinable
operations with fixed values and recursively purges unreachable blocks,
unused functions, and orphaned variables to produce lean,
high-performance shader binaries.
Generating GLSL-Derived SPIR-V
When a GLSL shader is compiled into SPIR-V via a front-end such as
glslangValidator, the compiler preserves language semantics
strictly and emits explicit SSA IDs for every intermediate operation.
This output often includes literal calculations, redundant scalar
conversions, and branch instructions with compile-time known conditions.
Because GLSL front-ends prioritize fast emission and compliance over
global optimization, the module is left with substantial overhead that
spirv-opt is designed to clean up.
Constant Folding Mechanics in spirv-opt
Constant folding identifies operations where all input operands are compile-time constants and computes the results ahead of time, replacing the operation with a direct constant definition.
- Def-Use Chain Analysis:
spirv-optmaintains an internalDefUseManagerthat tracks all definitions (SPIR-V instructions producing an ID) and their usages throughout the module. During constant folding passes (such as--fold-spec-const-op-compositeor the constant propagation phases in-O), the optimizer checks whether an instruction’s operands resolve back toOpConstant,OpConstantTrue,OpConstantFalse, orOpConstantComposite. - Instruction Evaluation: If the operands are
constants, the internal folding engine evaluates the instruction using
host-side emulation of SPIR-V semantics. This covers scalar and vector
arithmetic (
OpFAdd,OpFMul), logical operations (OpLogicalAnd,OpLogicalNot), relational checks (OpFOrdGreaterThan), and composite extraction (OpCompositeExtract). - Instruction Replacement and Propagation: Once
evaluated,
spirv-optsynthesizes a new constant instruction (or reuses an existing matching one in the global constant table) and updates all references in the def-use chain to point to the new ID. The original instruction is rendered redundant, leaving it eligible for elimination.
Through Conditional Constant Propagation (CCP),
spirv-opt pairs this evaluation with branch tracking to
resolve values that are constant along specific execution paths,
propagating these values across basic blocks.
Dead Code Elimination (DCE)
Dead code elimination strips out components of the SPIR-V module that
do not contribute to observable side effects, such as outputs to render
targets, storage buffers, or atomic operations. In
spirv-opt, DCE operates at multiple granularities:
1. Dead Instruction Elimination
Using the def-use framework, passes like
--eliminate-dead-code-aggregates or dead-insert elimination
scan instructions within functions. If an instruction produces an SSA
value with zero users and the instruction itself possesses no side
effects (such as an OpFAdd whose result is never
referenced), the instruction is removed from its basic block.
2. Control Flow Graph Simplification and Dead Branch Elimination
Constant folding often simplifies an OpBranchConditional
into an effective unconditional jump when the condition resolves to a
static true or false.
- The dead branch elimination pass rewrites the conditional branch
into a standard
OpBranchtargeting only the active block. - This leaves the alternative path structurally detached from the CFG.
- The optimizer runs a dominator tree analysis to identify unreachable basic blocks. Unreachable blocks are stripped of their internal instructions, their merge instructions are resolved, and the blocks themselves are removed from the function structure.
3. Global and Structured DCE
GLSL often introduces struct definitions, global uniform blocks,
internal functions, and interface variables that end up unused in the
final shader stage. Passes such as
--eliminate-dead-functions and
--eliminate-dead-members:
- Build a call graph starting from the entry point
(
OpEntryPoint). Any function not reachable in the call graph is deleted entirely. - Scan module-level declarations (
OpVariable) in non-input/output storage classes. If an allocated global variable is never loaded from or stored to, it is removed along with any related decoration instructions (OpDecorate,OpMemberDecorate). - Remove orphaned
OpTypedeclarations and unused constant definitions left behind after other dead code removal passes have run.
The Optimization Pipeline Loop
Constant folding and dead code elimination operate synergistically.
Folding a constant can make a branch static; resolving the branch marks
an entire execution path dead; removing that dead path eliminates uses
of variables, which in turn permits further dead instruction elimination
and exposes new constant values. Consequently, standard optimization
pipelines in spirv-opt execute these passes iteratively
until a fixed point is reached, yielding a minimal, streamlined SPIR-V
module.