← Back to Blog
C++PerformanceCompile-time

C++ 編譯期優化場景

從 constexpr 到 LUT 生成 - 五個實用的編譯期技巧,把能在編譯期做的事移到編譯期。

Item 1 - constexpr 變數 vs #define vs const

三者的差異

cpp
#define kWL_COST 1.0              // ❌ 預處理器替換,無型別、無作用域
const double kWL_COST = 1.0;      // ⚠ 有型別,但不保證編譯期可用
constexpr double kWL_COST = 1.0;  // ✅ 有型別、有作用域、編譯期求值

為什麼 const 不夠?

cpp
const int x = rand();  // ← 合法!const 只保證「不可修改」,不保證編譯期已知
constexpr int y = 42;  // ← 保證編譯期確定值

constexpr 才能用在:if constexpr、template non-type parameter、static_assert

Repo 實例

cpp
// src/utils/config.hpp
constexpr double kWL_COST = 1.0;
constexpr double kVIA_COST = 1.0;
constexpr size_t kMAX_BBOX_PADDING = 20;
constexpr bool kBUDGET_REWARD = true;

唯一用 #define 的地方 - 必須做 #if 條件編譯:

cpp
#define GRAPH_FOURIER_FEATURE 1   // 需要 #if,constexpr 做不到

編譯產物對比

x86asm
=== const double COST = 1.0; (-O0) ===

f(x):
    load  COST → reg        ← 從記憶體讀取(可能 cache miss)
    mul   x, reg
    ret

=== constexpr double COST = 1.0; (-O0) ===

f(x):
    mul   x, 1.0            ← 值直接嵌入指令,零記憶體存取
    ret
const 的值存在記憶體,用時要 load;constexpr 的值直接嵌進指令。

Item 2 - constexpr 函數 - 編譯期求值

核心特性

  • 所有參數都是編譯期已知 → 整個函數在編譯期求值,結果變 constant
  • 參數是 runtime 值 → 退化為普通函數,沒有額外成本
  • 同一份 source code,完全不同的機器碼

Repo 實例 - Sorting Network

cpp
// src/utils/math.hpp
template <typename T>
inline constexpr std::pair<T, T> TwoMedians(T a, T b, T c, T d) {
  if (a > b) std::swap(a, b);
  if (c > d) std::swap(c, d);
  if (a > c) std::swap(a, c);
  if (b > d) std::swap(b, d);
  if (b > c) std::swap(b, c);
  return {b, c};
}

為什麼適合 constexpr?- 只有比較和 swap,no heap allocation、no I/O

Repo 實例 - Bitwise 方向反轉

cpp
// src/utils/direction.hpp
constexpr AxialDir ReverseDir(AxialDir dir) {
  constexpr uint32_t hor_mask = AxialDir::kLeft | AxialDir::kRight;   // 0b0011
  constexpr uint32_t ver_mask = AxialDir::kTop | AxialDir::kBottom;   // 0b1100
  uint32_t dir_val = static_cast<uint32_t>(dir);
  return static_cast<AxialDir>(
      dir_val ^ ((dir_val & hor_mask) ? hor_mask : ver_mask));
}

ReverseDir(kLeft) 在編譯期直接展開為 kRight,不產生任何指令。

編譯產物對比

x86asm
=== 編譯期:constexpr auto result = TwoMedians(3, 1, 4, 2); ===

result.first  = 2        ← 直接寫常數,函數體完全消失
result.second = 3        ← 沒有任何比較指令

=== Runtime:auto result = TwoMedians(a, 1, 4, 2); ===

if a > 1:  swap(a, 1)    ← 生成 5 組比較 + 交換
if 4 > 2:  swap(4, 2)
if a > 4:  swap(a, 4)
...

Item 3 - static_assert - 編譯期 Bug 攔截

三種 Assert 的比較

方式時機生產環境成本
static_assert編譯期不可能出錯(build 不過)
assertRuntime (debug)Release 可能被關掉Debug only
throw / .at()Runtime (always)會觸發每次都檢查

Repo 實例 - LUT 大小保護

cpp
// src/utils/fast_sincos.hpp
static constexpr size_t TABLE_SIZE = 4096;
static_assert((TABLE_SIZE & (TABLE_SIZE - 1)) == 0,
              "TABLE_SIZE must be a power of 2 for bitmask optimization");

後面用 & (TABLE_SIZE - 1) 取餘數 - 如果改成 4000 就 build 不過

Repo 實例 - STL Concept 檢查

cpp
// src/layer/dir_layer.hpp
static_assert(std::bidirectional_iterator<iterator>);

少實作 operator-- → 編譯期直接報錯,不會到 runtime 才發現不能用 std::prev()

編譯產物對比

x86asm
=== static_assert((4096 & 4095) == 0, "..."); ===

; 輸出:(什麼都沒有)
; 完全不產生任何機器碼,只在編譯階段做檢查

=== runtime assert((TABLE_SIZE & (TABLE_SIZE - 1)) == 0); ===

    load  TABLE_SIZE → reg
    sub   reg, 1
    and   reg, TABLE_SIZE
    cmp   reg, 0
    jne   crash              ← 每次 runtime 都要花幾條指令

Item 4 - if constexpr + requires - 編譯期分支消除

先理解問題

你想寫一個泛型函數,同時處理兩種容器:

std::map<int, int> 的元素  →  pair<int,int>  →  取 .first
std::set<int>      的元素  →  int            →  直接回傳

為什麼普通 if 不行?

cpp
auto get_key = [](const auto& elem) {
  if (/* 某條件 */) {
    return elem.first;   // ← int 沒有 .first!
  } else {
    return elem;
  }
};

你可能覺得:set 不會走 if 分支,那就沒問題吧?

錯。 C++ 規則:兩個分支都必須能編譯,即使 runtime 永遠走不到。

text
error: 'int' has no member named 'first'

if constexpr 在做什麼?

cpp
auto get_key = [](const auto& elem) {
  if constexpr (requires { elem.first; }) {
    return elem.first;     // 只有 pair 型別才會生成這段
  } else {
    return elem;           // 只有非 pair 型別才會生成這段
  }
};

requires { elem.first; } = 編譯期語法測試:這個型別能不能寫 elem.first

普通 if vs if constexpr 的處理流程

普通 if:

    template instantiation
           ↓
    兩條分支都生成          ← 兩邊都要能編譯
           ↓
    型別檢查(兩邊都查)
           ↓
    runtime 選一條

if constexpr:

    template instantiation
           ↓
    編譯期判斷條件
           ↓
    只保留 true 的分支      ← false 分支直接丟棄(discarded statement)
           ↓
    另一條根本不存在         ← 連型別檢查都不做

實際 instantiation 結果

cpp
// 對 std::map<int,int> 呼叫時,編譯器生成:
get_key(elem):
    return elem.first;       // else 分支完全不存在

// 對 std::set<int> 呼叫時,編譯器生成:
get_key(elem):
    return elem;             // if 分支完全不存在
同一份 source code → 編譯器根據型別生成完全不同的版本,零 runtime 成本。

比喻

普通 if

兩條路都要先蓋好,走哪條到時候再說。

if constexpr

先看你是哪種車,只幫你蓋一條路。

以前怎麼做?- C++11 SFINAE

cpp
// 需要寫兩個 overload + decltype + SFINAE fallback
template<typename T>
auto get_key(const T& elem) -> decltype(elem.first) { return elem.first; }

template<typename T>
auto get_key(const T& elem) -> /* SFINAE fallback */ { return elem; }

C++20 if constexpr + requires - 一個 lambda 搞定:

cpp
auto get_key = [](const auto& elem) {
  if constexpr (requires { elem.first; }) {
    return elem.first;
  } else {
    return elem;
  }
};
if constexpr = 模板世界的 dead code elimination,不是 runtime 分支,而是型別導向的編譯期剪枝

Item 5 - static inline + IIFE - 編譯期 LUT 生成

這段 code 在做什麼?

cpp
static inline std::array<double, 4096> sin_table_ = []() {
  std::array<double, 4096> table;
  for (size_t i = 0; i < 4096; ++i) {
    table[i] = std::sin(2 * std::numbers::pi * i / 4096);
  }
  return table;
}();   // ← 注意這個 ()

拆開看兩件事:

A.  []() { ... }     ← 定義一個 lambda
B.  ()                ← 立刻呼叫它(IIFE)

為什麼要 static inline

如果在 header 裡直接寫全域變數 - 每個 .cpp include 都產生一份定義 → ODR violation → linker 爆炸。

cpp
// ❌ 傳統做法:很麻煩
// header.hpp
extern std::array<double, 4096> sin_table_;   // 宣告
// impl.cpp
std::array<double, 4096> sin_table_ = ...;    // 定義(破壞 header-only)

// ✅ C++17 解法:
static inline std::array<double, 4096> sin_table_ = ...;

inline 變數 = 多個 TU 可以定義,linker 幫你合併成同一份。跟 inline function 同概念。

查表 vs 每次計算

x86asm
=== LUT 查表:FastSin(angle) ===

    index = angle * (4096 / 2π)       ← 1 次乘法
    index = index & 4095              ← 1 次 AND(1 cycle)
    return sin_table_[index]          ← 1 次 memory load
    ; 總共 ~3 條指令

=== 每次呼叫 std::sin(angle) ===

    ; 內部做 argument reduction + polynomial approximation
    ; ~50-100 條指令,多次乘法、加法、分支
    ; 慢 10~20 倍

為什麼用 & 4095 而不是 % 4096

index % 4096   →  div 指令   ← ~20-30 cycle
index & 4095   →  and 指令   ← 1 cycle

因為 4096 = 2^12,所以 4095 = 0b111111111111。前提是 4096 必須是 2 的冪 → 所以 Item 3 才有 static_assert 保護。

Cache 行為:為什麼 4096 剛好?

4096 entries × 8 bytes (double) = 32 KB

32 KB ≈ 很多 CPU 的 L1 data cache 大小。表小到可以全部放進 L1 → memory load 很快(~4 cycle)。如果開 1M table → cache miss → 反而變慢。

工程判斷:LUT 適合高頻熱路徑 + 允許精度誤差 + 輸入範圍固定。

進階:C++20 真正的編譯期 LUT

cpp
static constexpr auto sin_table_ = []() constexpr {
  std::array<double, 4096> table{};
  for (size_t i = 0; i < 4096; ++i) {
    table[i] = /* constexpr sin implementation */;
  }
  return table;
}();

整個 table 放進 .rodata section,完全沒有 runtime initialization。但 std::sin 不是 constexpr(標準未規定),所以目前用 inline 版本。

Item 5 核心:三件事

技術解決什麼問題
inline variableHeader-only 全域變數不違反 ODR
IIFE [](){}()初始化邏輯乾淨寫在一行
LUT 查表用空間換時間 - 把 expensive 計算換成 cheap memory load

總結

技巧何時用Runtime 成本
constexpr 變數取代 #define 和 const 常數
constexpr 函數純計算函數(no I/O, no allocation)
static_assert型別 / 大小 / invariant 檢查
if constexpr泛型函數中處理不同型別
static inline IIFEHeader-only LUT / 一次性初始化啟動時一次

核心觀念

把能在編譯期做的事移到編譯期 → Runtime 成本為零。