C++ 編譯期優化場景
從 constexpr 到 LUT 生成 - 五個實用的編譯期技巧,把能在編譯期做的事移到編譯期。
Item 1 - constexpr 變數 vs #define vs const
三者的差異
#define kWL_COST 1.0 // ❌ 預處理器替換,無型別、無作用域
const double kWL_COST = 1.0; // ⚠ 有型別,但不保證編譯期可用
constexpr double kWL_COST = 1.0; // ✅ 有型別、有作用域、編譯期求值為什麼 const 不夠?
const int x = rand(); // ← 合法!const 只保證「不可修改」,不保證編譯期已知
constexpr int y = 42; // ← 保證編譯期確定值constexpr 才能用在:if constexpr、template non-type parameter、static_assert
Repo 實例
// 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 條件編譯:
#define GRAPH_FOURIER_FEATURE 1 // 需要 #if,constexpr 做不到編譯產物對比
=== 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 ← 值直接嵌入指令,零記憶體存取
retconst的值存在記憶體,用時要 load;constexpr的值直接嵌進指令。
Item 2 - constexpr 函數 - 編譯期求值
核心特性
- 所有參數都是編譯期已知 → 整個函數在編譯期求值,結果變 constant
- 參數是 runtime 值 → 退化為普通函數,沒有額外成本
- 同一份 source code,完全不同的機器碼
Repo 實例 - Sorting Network
// 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 方向反轉
// 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,不產生任何指令。
編譯產物對比
=== 編譯期: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 不過) | 零 |
assert | Runtime (debug) | Release 可能被關掉 | Debug only |
throw / .at() | Runtime (always) | 會觸發 | 每次都檢查 |
Repo 實例 - LUT 大小保護
// 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 檢查
// src/layer/dir_layer.hpp
static_assert(std::bidirectional_iterator<iterator>);少實作 operator-- → 編譯期直接報錯,不會到 runtime 才發現不能用 std::prev()。
編譯產物對比
=== 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 不行?
auto get_key = [](const auto& elem) {
if (/* 某條件 */) {
return elem.first; // ← int 沒有 .first!
} else {
return elem;
}
};你可能覺得:set 不會走 if 分支,那就沒問題吧?
錯。 C++ 規則:兩個分支都必須能編譯,即使 runtime 永遠走不到。
error: 'int' has no member named 'first'if constexpr 在做什麼?
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 結果
// 對 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
// 需要寫兩個 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 搞定:
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 在做什麼?
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 爆炸。
// ❌ 傳統做法:很麻煩
// 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 每次計算
=== 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 KB32 KB ≈ 很多 CPU 的 L1 data cache 大小。表小到可以全部放進 L1 → memory load 很快(~4 cycle)。如果開 1M table → cache miss → 反而變慢。
工程判斷:LUT 適合高頻熱路徑 + 允許精度誤差 + 輸入範圍固定。
進階:C++20 真正的編譯期 LUT
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 variable | Header-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 IIFE | Header-only LUT / 一次性初始化 | 啟動時一次 |
核心觀念
把能在編譯期做的事移到編譯期 → Runtime 成本為零。