Minimizing Constant Loading Overhead in RISC Architectures (GCC & Clang)
The Problem: When Compiler Optimizations Inflate Constant Materialization
In Reduced Instruction Set Computer (RISC) architectures like ARMv7/AArch64, RISC-V, and custom architectures like MRISC32, 32-bit and 64-bit immediate constants rarely fit inside a single instruction word. Constructing a 32-bit literal often requires a multi-instruction sequence (such as LUI followed by ADDI in RISC-V, or MOVW followed by MOVT in ARM).
To avoid this penalty, developers frequently organize bitwise operations to reuse a single constant held in a register across multiple steps. However, compilers (GCC and LLVM/Clang) frequently thwart these efforts via intermediate representation (IR) canonicalization and aggressive constant propagation.
Why Does the Compiler Break Constant Reuse?
Consider an idiom like this:
const uint32_t m = 011111111111;
s = (x >> 2) & m;
t = (x & ~m) >> 1;
Logically, you expect the compiler to generate m once in a register, use it with an AND instruction, and use it with an ANDN (bit clear, like ARM's BIC or RISC-V Zbb's andn) instruction. Instead, compiler mid-ends (such as LLVM's InstCombine pass) aggressively canonicalize expressions: shifting an AND expression is rewritten as (x >> 1) & (~m >> 1).
Once reassociated, the compiler folds (~m >> 1) at compile time into a completely separate 32-bit literal. Instead of executing an ANDN with an existing register, the backend is now forced to synthesize an entirely new constant, resulting in extra LDI/LOADHI/ADD instructions and increased register pressure.
Solution 1: The Zero-Cost Optimization Barrier (Best Practice)
Declaring variables as volatile forces them onto the stack, swapping the constant-generation penalty for an even slower memory access. The standard, industry-proven way to stop constant propagation in GCC and Clang without spilling to memory is to use an inline assembly optimization barrier.
static inline uint32_t hide_constant(uint32_t val) {
__asm__ ("" : "+r" (val));
return val;
}
The "+r" constraint tells the compiler that the value is held in a general-purpose register and may be modified by the assembly code. Because the assembly string is empty (""), no actual instructions are emitted, but the compiler is strictly prevented from propagating the known value of val downstream.
Applying the Barrier to the Reproducer
uint32_t func2_optimized(uint32_t x) {
uint32_t m = hide_constant(011111111111);
uint32_t s, t;
s = (x >> 2) & m;
t = (x & ~m) >> 1;
s = s + t;
return x - s;
}
With the barrier in place:
- The compiler cannot fold
~mwith subsequent shift operations. mis loaded or synthesized into a register exactly once.- The architecture's native
BIC,ANDN, or separateNOT/ANDinstructions will reuse the register holdingm.
Solution 2: Restructuring the Expression to Discourage Sinking
If inline assembly cannot be used (e.g., in strictly conforming code across non-GCC/Clang compilers), you can rewrite the algebraic steps to preserve the dependency without exposing an immediate shift across an inverted mask.
Rather than clearing bits and shifting: (x & ~m) >> 1, perform the shift first and compensate the mask:
uint32_t func2_alternative(uint32_t x) {
const uint32_t m = 011111111111;
uint32_t s = (x >> 2) & m;
// Shift x first, then apply the negation/masking
uint32_t t = (x >> 1) & (m * 2);
return x - (s + t);
}
Depending on target arithmetic capabilities, (m << 1) might be computed via a single register shift instruction (e.g., LSL r5, r4, #1) rather than a full 32-bit literal synthesis, reducing the overall code size and pipeline bubbles.
Solution 3: Target-Specific Compiler Flags and Built-Ins
Modern compilers feature passes designed to mitigate constant duplication, though their success varies by architecture:
- LLVM Constant Hoisting: Clang implements a pass called
-mllvm -enable-hoist-constants(or passes likeConstantHoistingPass). This pass aggregates expensive constant generation, but it often does not undo IR transformations that convert bit-clears to shifted bitmasks. - Architecture Intrinsics: If targeting ARM, using GCC/Clang built-ins can prevent the expression tree from collapsing. For instance, on ARMv7-A/v8-A, you can use the assembly intrinsic for bit-clear:
#if defined(__ARM_NEON) || defined(__arm__)
#include <arm_acle.h>
// Forces the compiler to emit a BIC instruction directly
// rather than breaking it into NOT and AND
#endif
Summary
Compilers optimize for IR regularity (canonicalization) rather than hardware-specific immediate costs. When writing low-level bit-manipulation routines on RISC targets:
- Use
__asm__("" : "+r"(m))to freeze literal values in registers after they are generated. - Prefer this inline asm idiom over
const volatileto avoid memory spills. - Verify the output using Compiler Explorer (Godbolt) across your target instruction set (e.g., RISC-V
Zbb, ARMThumb-2, or MRISC32) to ensure no secondary constant pools are being synthesized.