Showing posts with label compiler_optimize. Show all posts
Showing posts with label compiler_optimize. Show all posts

Sep 12, 2026

C++ template code COMDAT folding

Reference:
Reducing C++ template bloat by factoring out the type-dependent portions of the function


COMDAT folding (also known as Identical Code Folding or ICF/Safe ICF) is a link-time optimization where the linker detects functions or read-only data sections that compile into byte-for-byte identical machine code, merges them into a single instance, and points all call sites to that unified copy.

When instantiates templates over types that share the same underlying memory layout and operations, the compiler often emits redundant assembly:

template <typename T>
void push_item(T* item) {
    // Operations on item...
}

// In translation unit A:
push_item<Dog>(dog_ptr);

// In translation unit B:
push_item<Cat>(cat_ptr);
Because Dog* and Cat* are both raw machine pointers (typically 8 bytes), the generated machine instructions for push_item and push_item are identical. 

Normally, C++ templates emit code into COMDAT sections (Common Data sections) with "link-once" semantics (select any). This allows the linker to discard duplicates of push_item compiled across multiple .cpp files. However, normal deduplication only matches identical symbol names. It cannot eliminate the identical assembly between push_item and push_item because their mangled symbol names differ.

How COMDAT Folding Works

During the link step, the linker inspects all candidate sections:

Content Comparison: The linker compares the byte streams, relocation targets, and alignment requirements of functions marked as foldable (typically COMDAT sections generated by inline functions, template instantiations, and virtual tables).

Section Merging: If two distinct functions (e.g., std::vector<int*>::size() and std::vector<char*>::size()) produce identical instructions and reference identical offsets, the linker discards one function body.

Symbol Redirection: The symbol table entry for the discarded function is updated to point directly to the entry point of the retained function.

Key Benefits

Reduced Binary Footprint: Prevents template-heavy code (like std::vector<T*> for hundreds of pointer types) from inflating executable size.

Instruction Cache (I-Cache) Efficiency: Multiple logically distinct types share hot cache lines instead of thrashing the instruction cache with duplicate code.


The Function Pointer Quirk

Under strict C++ rules, every distinct function must have a unique address:

assert(&push_item<Dog> != &push_item<Cat>);

When COMDAT folding collapses these functions, &push_item<Dog> == &push_item<Cat> evaluates to true. This can break code that relies on function pointer uniqueness for type-tagging or callback registries.

To handle this, linkers provide different safety levels:
LLVM (lld)  --icf=safe Only merges functions whose addresses are never taken, preserving C++ address-uniqueness guarantees.
LLVM (lld)  --icf=all Aggressively merges all identical functions, even if addresses are taken.


Instead of generating redundant machine code for hundreds of pointer instantiations and hoping the linker cleans it up with COMDAT folding / ICF, standard library implementations and runtime systems use type erasure with non-templated (or void-pointer-based) base classes.

By shifting the heavy procedural logic into a shared base class, the exposed template becomes a thin, inline wrapper that carries zero runtime binary overhead.

The Architecture: Base-Derived Split

The pattern divides a container or utility into two layers:

The Erased Base Class: Implements memory allocation, capacity resizing, buffer shifts, and element index arithmetic using void* or raw byte buffers. This code is compiled once into the runtime library or translation unit.

The Typed Template Wrapper: Derives from or wraps the base class. It only exposes strongly typed interfaces, using zero-cost casts (reinterpret_cast or static_cast) to translate between T* and void*.


// --- Shared Implementation (compiled once into binary/lib) ---
class VectorPtrBase {
protected:
    void** data_ = nullptr;
    size_t size_ = 0;
    size_t capacity_ = 0;

    void grow_and_insert(size_t index, void* element) {
        // All heavy buffer reallocation, index shifting,
        // and boundary checks happen HERE once.
        if (size_ == capacity_) {
            size_t new_cap = capacity_ == 0 ? 8 : capacity_ * 2;
            void** new_data = new void*[new_cap];
            for (size_t i = 0; i < size_; ++i) new_data[i] = data_[i];
            delete[] data_;
            data_ = new_data;
            capacity_ = new_cap;
        }
        data_[index] = element;
        ++size_;
    }

    void* get_element(size_t index) const {
        return data_[index];
    }
};

// --- Thin Typed Wrapper (specialization for any pointer type) ---
template <typename T>
class Vector<T*> : private VectorPtrBase {
public:
    void push_back(T* val) {
        // Zero-cost static cast; inlines down to a direct call to the base
        grow_and_insert(size_, static_cast<void*>(val));
    }

    T* operator[](size_t index) const {
        return static_cast<T*>(get_element(index));
    }

    size_t size() const { return size_; }
};

Why This Beats Relying Purely on COMDAT Folding

Faster Compilation Times: The compiler does not have to parse, instantiate, type-check, and generate intermediate representation (IR) / assembly for grow_and_insert across Vector<Apple*>, Vector<Banana*>, and Vector<Car*>.

Lower Linker Overhead: Linker-time ICF requires analyzing section hashes, inspecting instruction bytes, and walking relocation tables to prove equivalence. Pointer erasure eliminates the duplicate sections before the object files reach the linker.

Guaranteed Code Sharing: COMDAT folding is sensitive to subtle differences (such as debug information emission, compiler optimization levels, or platform-specific pointer calling conventions). The base-class approach enforces code sharing by construction.

Preserved Pointer Address Guarantees: Because the wrapper's member functions inline entirely into the caller or delegate to the shared base, it avoids issues where --icf=safe refuses to fold functions whose addresses were taken.

Jul 2, 2026

[C++] is inline a function always a good idea?

Reference:
A deep dive into SmallVector::push_back
https://llvm-compile-time-tracker.com/


Shrink-wrapping optimization

Shrink-wrapping is a compiler optimization technique designed to minimize the overhead of a function's prologue and epilogue by moving them so they execute only when absolutely necessary.

To understand why this is useful, it helps to look at how a standard function handles memory and registers at the assembly level.

The Problem: Preemptive Over-Allocation

When a function executes, it often needs to save certain CPU registers to the stack (callee-saved registers) and allocate space for local variables. This setup is called the prologue. Before the function returns, it restores those registers and cleans up the stack, which is called the epilogue.

By default, standard compilers place the prologue at the very beginning of a function and the epilogue at the very end.

If a function has a hot fast-path (e.g., an early return or a quick validation check) that doesn't actually use those registers or local variables, it still pays the CPU cycle tax of executing the prologue and epilogue.


How Shrink-Wrapping Fixes This

Shrink-wrapping analyzes the control flow graph (CFG) of a function to find the precise basic blocks where the saved registers or stack space are actually required. It then "shrinks" the scope of the prologue and epilogue, wrapping them tightly only around those specific paths.


Before Shrink-Wrapping (Standard Layout)

Function_Start:
  PUSH registers      <-- Prologue runs every single time
  ALLOCATE stack
  
  IF (condition) THEN
    GOTO Fast_Path    
  ELSE
    GOTO Slow_Path
    
Fast_Path:
  Result = 0          <-- Doesn't even need those registers!
  GOTO Function_End

Slow_Path:
  Use registers to do heavy math...
  GOTO Function_End

Function_End:
  DEALLOCATE stack    <-- Epilogue runs every single time
  POP registers
  RETURN

After Shrink-Wrapping

Function_Start:
  IF (condition) THEN
    GOTO Fast_Path    
  ELSE
    GOTO Slow_Path
    
Fast_Path:
  Result = 0          <-- Pure profit: No stack overhead, no register spills
  RETURN              <-- Direct return

Slow_Path:
  PUSH registers      <-- Prologue is "wrapped" only around the slow path
  ALLOCATE stack
  Use registers to do heavy math...
  DEALLOCATE stack
  POP registers       <-- Epilogue wrapped here too
  RETURN


Limitations: The "Rejoin" Problem

Shrink-wrapping is incredibly effective for functions with early exits. However, it can fail or get disabled if the control flow graph gets messy.

If a fast path and a slow path diverge but then rejoin later in the function before a single, shared return point, the compiler may not be able to safely decouple the stack frames.


clang and gcc cmd:
$ clang -mllvm -debug-only=shrink-wrap
$ gcc -fshrink-wrap-separate (on at -O2) 


How to solve if shrink-wrap not working?

Hand coding the tail duplication with fast and slow code path.
e.g.
LLVM_ATTRIBUTE_NOINLINE void growAndPushBack(ValueParamT Elt) {
  // in case Elt aliases storage that grow() invalidates
  // This is very much edge-case considered.
  T Tmp = Elt;
  // +1 is sufficient, while internally doing
  // exponential growth algorithm.
  this->grow(this->size() + 1);
  std::memcpy(reinterpret_cast<void *>(this->end()), &Tmp, sizeof(T));
  // size is not just +1 but applied with 
  // exponential growth algorithm.
  this->set_size(this->size() + 1);
}

void push_back(ValueParamT Elt) {
  if (LLVM_UNLIKELY(this->size() >= this->capacity()))
    return growAndPushBack(Elt);
  std::memcpy(reinterpret_cast<void *>(this->end()), &Elt, sizeof(T));
  this->set_size(this->size() + 1);
}

mov    eax, [rdi + 8]
cmp    eax, [rdi + 12]
jae    growAndPushBack         # TAILCALL
mov    rcx, [rdi]
mov    [rcx + rax*4], esi
inc    dword ptr [rdi + 8]
ret
Since there are no callee-saved registers, shrink-wrapping is unnecessary.
Moving the slow path into a separate COMDAT/section groups section as an out-of-line function further degrades its performance.

Use:
// noinline growAndPushBack is load-bearing for both Clang and GCC.
void DecodeMOVDDUPMask(unsigned n, llvm::SmallVectorImpl<int> &v) {
  for (unsigned l = 0; l < n; l += 2)
    for (unsigned i = 0; i < 2; ++i)
      v.push_back(i);
}
The noinline attribute is required here. Without it, Clang and GCC may inline the helper function, which defeats the optimization by reintroducing the prologue.

Creating a temporary copy (T Tmp = Elt) safely handles cases where Elt references the vector's own storage. While this copy is easily elided for small, trivially-copyable types, passing a larger element by reference to the out-of-line growAndPushBack() forces it to be memory-materialized. Because its address must remain stable across a non-inlined call, this defeats construct-in-place optimization for large types. However, this overhead is negligible compared to the cost of grow(), which must already copy or move all existing size() elements.

Jun 16, 2026

[Optimization] Peephole Optimizations

Reference:
https://blog.regehr.org/archives/2485

Local Peephole Optimization and Compositional Refinement

Let f be a function targeted for peephole optimization. 
We can structurally decompose f into a target redex o (the optimizable instruction sequence) and a continuation context r (the residual program), such that:
f = r ∘ o

By definition, a correct optimization yields a refined sequence o′ that refines the original sequence o, denoted as:
o ⊑ o′

To lift this local optimization to the function level, we rely on the compositionality of the refinement relation. For any valid refinement definition, the context r must act as a monotonic operator:
If x ⊑ y, then r(x) ⊑ r(y)

Applying compositionality to our decomposition:
r(o) ⊑ r(o′)

Because f = r(o), it transitively follows that:
f ⊑ r(o′)

This proves that the locally optimized function refines the original function. By inductively applying this compositionality principle, we can scale this local refinement guarantee across the entire program.