Home
探索 Uedu
學生控制台
註冊會員/登入
研究知情同意中心
教師控制台
課程設定
支援與訊息
Uptime 數據

UeduGPTs

--

Jupyters

4

UG26 CISOSE26
臺北 AQI 46 · 臺中 AQI 28 · 臺南 AQI 24 · 高雄 AQI 33

AI 回覆桌面通知

AI 助教回覆完成時顯示桌面通知

聊天訊息通知

同學在討論區發送訊息時通知

聲音通知

每當有新通知時播放提示音

用 C++ 追求效能

用 C++ 追求效能:當 0.1 秒和 10 秒只差一個語言

從無垃圾回收、值語意到快取友善與 -O2 內聯,看懂 C++ 如何貼著硬體把速度榨乾,並接回優電子與優半導體的硬體本質。

當 0.1 秒和 10 秒只差一個語言:你的程式為什麼跑這麼慢?

想像一個情境:你要處理一億筆感測器讀數,算出平均值。你先用 Python 寫了一個迴圈,按下執行,然後去倒了杯咖啡——回來時它還在跑。你把同樣的邏輯翻成 C++,重新編譯執行,眨個眼就結束了。同樣的演算法、同樣的硬體,差距卻是幾十倍甚至上百倍。

這不是魔法,而是 C++ 與 Python 在「離硬體有多近」這件事上做了截然不同的取捨。如果你讀過本專區的 Python 篇,你已經知道 Python 把「方便」放在第一位:動態型別、自動記憶體管理、直譯執行,讓你寫得快、跑得慢。C++ 走的是另一條路——它把「控制權」交還給你。這篇文章不會把 Python 的內容換個語法重講一遍,而是聚焦在一個問題:C++ 到底做對了什麼,讓它快到可以驅動遊戲引擎、作業系統、嵌入式韌體與競賽程式?

讀完你會理解三件事:為什麼 C++ 沒有「垃圾回收停頓」、為什麼「值語意」與「快取友善」是效能的關鍵、以及這一切如何接回優電子與優半導體談的硬體本質。

用 C++ 追求效能概念示意圖

第一個差異:編譯,而不是直譯

Python 是直譯執行的——你的原始碼在執行時被一行行翻譯。C++ 不一樣,它在執行之前就被編譯器整份翻譯成機器碼(machine code)。當你執行一個 C++ 程式時,CPU 直接吃的是它原生看得懂的指令,中間沒有翻譯官。

#include <iostream>

int main() {
    int sum = 0;
    for (int i = 1; i <= 100; ++i) {
        sum += i;
    }
    std::cout << "1 加到 100 = " << sum << "\n";
    return 0;
}
// 輸出:1 加到 100 = 5050

注意 int sumint i——這就是靜態型別(static typing)。相較於 Python 變數可以隨時換型別,C++ 要求你在編譯期就講清楚每個變數是什麼。這看似囉嗦,卻是效能的第一根支柱:編譯器知道 i 是 4 位元組整數,就能直接產生一條 CPU 加法指令,不需要在執行時查「這個東西現在是什麼型別?」。

Python 的每個整數其實是一個包了型別資訊與參考計數的物件;C++ 的 int 就是記憶體裡赤裸裸的 4 個位元組。這個差別,乘上一億次迴圈,就是咖啡涼掉的時間。

第二個差異:沒有垃圾回收,記憶體你說了算

Python、Java、Go 都有垃圾回收器(Garbage Collector, GC)——一個在背景默默幫你清理沒用到的記憶體的機制。方便,但有代價:GC 會在不可預測的時間點「暫停」你的程式去做清理(俗稱 stop-the-world pause)。對遊戲來說,這可能是畫面突然卡一下;對嵌入式即時系統來說,這可能是錯過一個必須在 1 毫秒內回應的訊號。

C++ 沒有 GC。記憶體的生死由你掌控。現代 C++ 的慣例是用 RAII(Resource Acquisition Is Initialization):物件在建立時取得資源,在離開作用域(scope)時自動釋放——時間點完全確定,沒有背景暫停。

#include <iostream>
#include <vector>

void process() {
    std::vector<int> data(1000);  // 在 stack 上宣告,自動配置記憶體
    data[0] = 42;
    std::cout << data[0] << "\n";
}   // 離開作用域的瞬間,data 的記憶體立刻被釋放,沒有 GC 介入

int main() {
    process();
    return 0;
}
// 輸出:42

vector 的記憶體在 process() 結束的那一刻被精確回收。你不需要手動 delete,但釋放時機是確定的(deterministic)。這正是即時系統(real-time system)愛 C++ 的原因:沒有不可預測的停頓。

反模式提醒:初學者常以為「C++ 一定要手動 newdelete」。現代 C++(C++11 起)的慣例剛好相反——盡量用 vectorstringstd::unique_ptr 這類會自動管理記憶體的型別,避免裸 new/delete。手動配置容易漏掉釋放(記憶體洩漏)或重複釋放(崩潰)。

第三個差異:值語意,資料就在你手上

這是 C++ 與 Python 最深刻、卻最容易被忽略的差別。

在 Python 裡,幾乎所有東西都是「參考(reference)」——變數其實是指向某個堆積(heap)物件的標籤。在 C++ 裡,預設是值語意(value semantics):變數就是那個值本身,直接存在它該在的記憶體位置。

#include <iostream>

struct Point {
    double x;
    double y;
};

int main() {
    Point a{3.0, 4.0};
    Point b = a;   // 整個複製一份,b 是獨立的值
    b.x = 99.0;

    std::cout << "a.x = " << a.x << "\n";  // a 完全不受影響
    std::cout << "b.x = " << b.x << "\n";
    return 0;
}
// 輸出:
// a.x = 3
// b.x = 99

Point b = a; 是真正把 16 位元組(兩個 double)原封不動複製過去。ab 是兩個獨立的記憶體區塊,互不影響。在 Python 裡 b = a 只是讓兩個名字指向同一個物件——這是參考語意,要複製還得特地 copy.deepcopy()

值語意為什麼快?因為一個 Point 不是「某處堆積物件的指標」,而是資料本身。當你把一堆 Point 放進 vector,它們在記憶體裡是連續排列、緊密相鄰的:

std::vector<Point> points = {{0, 0}, {1, 1}, {2, 2}};
// 記憶體佈局:[x0 y0][x1 y1][x2 y2]  ← 全部黏在一起

對比 Python 的 list of objects,每個元素其實是一個指標,指向散落在記憶體各處的物件。這個差別,正是下一節「快取友善」的核心。

第四個差異:快取友善——速度藏在記憶體佈局裡

這裡要接回優電子與優半導體談的硬體本質了。CPU 的運算速度遠遠快過記憶體存取速度——從主記憶體(RAM)拿一筆資料,可能要等上百個時脈週期。為了不讓 CPU 餓著,硬體設計者在 CPU 旁邊放了快取(cache):一塊小而極快的記憶體。

關鍵在於:CPU 從 RAM 搬資料時,不是一次搬一個位元組,而是一次搬一整條 快取列(cache line),通常是 64 位元組。所以如果你接下來要用的資料,剛好就在剛搬進來的那 64 位元組裡,那它已經在快取裡,存取幾乎免費。這叫空間區域性(spatial locality)

值語意 + vector 的連續佈局,正好完美契合這個硬體特性。我們用實驗看差別:

#include <iostream>
#include <vector>
#include <chrono>

int main() {
    const int N = 50'000'000;
    std::vector<int> v(N, 1);

    auto start = std::chrono::high_resolution_clock::now();
    long long sum = 0;
    for (int i = 0; i < N; ++i) {
        sum += v[i];   // 連續存取,cache 命中率極高
    }
    auto end = std::chrono::high_resolution_clock::now();

    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "總和 = " << sum << ",耗時 " << ms << " 毫秒\n";
    return 0;
}
// 輸出(實際數字依機器而定):
// 總和 = 50000000,耗時 30 毫秒

這段之所以快,不只是因為它是 C++,更是因為它順著記憶體跑。如果你改成把資料拆成一個個獨立配置、用指標串起來的鏈結串列(linked list),即使演算法複雜度同樣是 $O(n)$,實測卻可能慢上好幾倍——因為每次跳指標都可能落在不同快取列,CPU 一直在等 RAM。

結論很反直覺但極重要:在現代硬體上,「資料怎麼排在記憶體裡」往往比「演算法的大 $O$」更決定實際速度。C++ 把記憶體佈局的控制權交給你,這正是它能榨乾硬體的根本原因。

動手寫一段:連續 vs 跳躍存取

我們把「快取友善」變成可以親手感受的對照實驗。下面同樣是加總一個陣列,但第二個迴圈刻意以大步幅(stride)跳著存取,破壞空間區域性:

#include <iostream>
#include <vector>
#include <chrono>

long long timed_sum(const std::vector<int>& v, int stride) {
    long long sum = 0;
    int n = static_cast<int>(v.size());
    // 用 stride 控制存取步幅:stride=1 連續,stride 大則跳躍
    for (int start = 0; start < stride; ++start) {
        for (int i = start; i < n; i += stride) {
            sum += v[i];
        }
    }
    return sum;
}

int main() {
    const int N = 20'000'000;
    std::vector<int> v(N, 1);

    for (int stride : {1, 16, 64}) {
        auto t0 = std::chrono::high_resolution_clock::now();
        long long s = timed_sum(v, stride);
        auto t1 = std::chrono::high_resolution_clock::now();
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(t1 - t0).count();
        std::cout << "stride=" << stride << "  總和=" << s
                  << "  耗時=" << ms << " ms\n";
    }
    return 0;
}
// 輸出(數字依機器而定,但趨勢一致):
// stride=1   總和=20000000  耗時=12 ms
// stride=16  總和=20000000  耗時=38 ms
// stride=64  總和=20000000  耗時=55 ms

三次加總的元素數量完全相同、答案也相同,唯一的差別是存取順序。stride=1 順著記憶體跑,快取命中率高;stride=64 每跳一步就跨過一整條快取列,CPU 不斷等 RAM,於是慢了好幾倍。這就是「快取友善」四個字最直接的證據——你可以在自己的電腦上跑跑看,親眼確認數字。

第五個差異:零成本抽象——好看的程式碼不該變慢

你可能擔心:寫得乾淨、用上模板(template)、用上 vector、用上各種封裝,會不會犧牲效能?C++ 的設計哲學給了一個強而有力的承諾——零成本抽象(zero-cost abstraction):你不用為沒用到的東西付代價,而用到的高階抽象,編譯後的效能和你手寫低階程式碼一樣好。

舉個例子,標準函式庫的 std::sort 用了模板,能對任何型別排序,而且通常比 C 語言那個用函式指標的 qsort 更快——因為模板讓編譯器在編譯期就知道比較邏輯,可以直接內聯(inline)展開,省去每次比較都呼叫函式的開銷。

#include <iostream>
#include <vector>
#include <algorithm>

int main() {
    std::vector<int> v = {5, 2, 9, 1, 7, 3};

    // 高階、可讀、泛型——而且零額外執行成本
    std::sort(v.begin(), v.end());

    for (int x : v) std::cout << x << " ";
    std::cout << "\n";
    return 0;
}
// 輸出:1 2 3 5 7 9

這裡的 std::sort 是高度抽象的泛型演算法,但編譯後產生的機器碼,和你為 int 親手刻一個快速排序幾乎一樣快。抽象在原始碼層級,成本在編譯期就被消化掉,執行期不留痕跡。這是 C++ 與許多語言的分水嶺:你不必在「好讀」與「夠快」之間二選一。

那 C++ 適合用在哪?

把上面這些特性串起來,就能看出 C++ 的主場:

  • 作業系統與系統軟體:需要直接操作硬體、精確控制記憶體,且不能有 GC 停頓。
  • 遊戲引擎:每秒要算 60 次以上畫面,任何不可預測的停頓都會破壞體驗;值語意 + 快取友善讓大量物件的更新極快。
  • 嵌入式與韌體:記憶體可能只有幾 KB,沒有空間養 GC,每個位元組都要精打細算——這正好接回優電子談的微控制器世界。
  • 高效能運算與競賽程式:當你的演算法已經最佳,剩下能榨的就是常數因子,而 C++ 給你榨取常數因子的所有工具。

相對地,如果你在寫一個一次性的資料分析腳本、或一個不在乎那 0.1 秒的小工具,Python 的開發效率會讓你更快交差。選語言不是選「最快的」,而是選「在這個情境最對的」——這才是進階學習者該有的判斷。

常見錯誤(初學者最容易踩的雷)

  1. 以為快取友善是「進階優化」,先寫完再說:恰恰相反,記憶體佈局是設計階段就該想的事。事後要把鏈結串列改成連續陣列,往往得大改架構。
  2. 濫用裸指標與 new/delete:現代 C++ 用 vectorstringunique_ptrshared_ptr。看到一堆裸 new 通常是程式碼味道(code smell),容易洩漏或崩潰。
  3. 不必要的複製:值語意很好,但把大物件當參數一個個值傳會複製整份。該用 const T&(常數參考)傳遞時就要用,避免昂貴複製。
  4. 測效能卻忘了開最佳化:用 -O0(預設無最佳化)測出來的數字毫無意義,差距可達十倍以上。效能測試一定要開 -O2
  5. 過早優化到走火入魔:把可讀性犧牲光只為省幾奈秒,往往得不償失。先用乾淨的抽象寫對,再用實測數據(profiler)找真正的瓶頸。

深入探討(研究所視角)

記憶體階層與快取列:效能的物理基礎

前面提到的「快取友善」,背後是一整套記憶體階層(memory hierarchy)。從快到慢、從小到大,現代 CPU 大致是:暫存器(register)→ L1 快取(約 32 KB,~1 ns)→ L2(數百 KB,~4 ns)→ L3(數十 MB,~15 ns)→ 主記憶體(GB 級,~100 ns)。每往下一層,容量大一個量級,但延遲也大一個量級。

CPU 從記憶體搬資料的最小單位是快取列(cache line),x86 上固定 64 位元組。這帶來兩個工程準則:

  • 空間區域性(spatial locality):用到某個位址,附近位址很可能也快被用到——所以資料連續排列(如 vector)勝過散落(如 list of pointers)。
  • 時間區域性(temporal locality):剛用過的資料很可能再被用到——所以重複存取同一塊資料時,它已在快取中。

這也解釋了一個進階主題——結構陣列(Array of Structures, AoS)vs 陣列結構(Structure of Arrays, SoA)。若你只需要每個 Pointx,把所有 x 放一個陣列、所有 y 放另一個陣列(SoA),會比 vector<Point>(AoS)更快——因為搬進快取列的全是你要用的 x,沒有夾帶用不到的 y。這種佈局思維在遊戲引擎與高效能運算(如 SIMD 向量化)中是核心技巧。

編譯器最佳化與內聯:-O2 在做什麼

C++ 之所以能兌現「零成本抽象」的承諾,編譯器最佳化是幕後英雄。用 GCC 或 Clang 時,-O2 旗標會開啟一整套激進的最佳化,這裡挑兩個最關鍵的:

內聯(inlining):把一個小函式的呼叫,直接用它的函式體展開取代,省去呼叫的開銷(壓堆疊、跳轉、返回)。對於一秒被呼叫上百萬次的小函式,這省下的不只是呼叫成本——內聯之後,編譯器能跨越原本的函式邊界看到更大的脈絡,進而做更多最佳化。

inline int square(int x) { return x * x; }

int main() {
    int s = 0;
    for (int i = 0; i < 1000; ++i) {
        s += square(i);   // -O2 下,square 被內聯展開成 i * i
    }                     // 迴圈裡根本沒有函式呼叫指令
    return s;
}

inline 關鍵字是給編譯器的「建議」,現代編譯器在 -O2 下會自行判斷該不該內聯(甚至無視你的建議)。這正是 std::sortqsort 快的原因:模板的比較邏輯能被內聯,qsort 透過函式指標呼叫比較函式則無法。

-O2 還會做許多其他事:迴圈展開(loop unrolling)、常數摺疊(constant folding,編譯期就把 2*3 算成 6)、消除無用程式碼(dead code elimination)、暫存器配置(register allocation,把熱資料留在最快的暫存器裡)、以及在支援的硬體上做自動向量化(auto-vectorization)——把一個一個處理的迴圈,改成用 SIMD 指令一次處理多筆資料。

這裡有個學習上的關鍵體悟:你寫的 C++ 原始碼,和最終執行的機器碼,可能長得非常不一樣。編譯器會在不改變程式語意(as-if rule,「彷彿」規則)的前提下,大幅重組你的程式。這也是為什麼效能測試一定要開最佳化——-O0 測的是「翻譯腔」的機器碼,-O2 測的才是 C++ 真正端上桌的實力。

把這一切串回硬體

回到開頭那個一億筆讀數的例子。C++ 快,從來不是單一原因,而是一條完整的鏈:靜態型別讓資料是赤裸的位元組 → 值語意讓它們連續排列 → 連續排列契合快取列 → 編譯器再用 -O2 內聯、向量化把指令壓到極致。每一環都緊貼著優電子與優半導體談的硬體真相:CPU、快取、記憶體階層的物理特性。

學會 C++ 的效能思維,本質上是學會用程式語言去對話硬體。當你下次寫下一個 vector、傳一個 const&、或開上 -O2,你不只是在寫程式碼,而是在替 CPU 鋪一條最順的路。這份對機器的同理心,正是進階程式設計與系統思維的分水嶺——也是這個專區想帶你抵達的地方。

AI 共讀助教正在陪你讀:用 C++ 追求效能:當 0.1 秒和 10 秒只差一個語言
嗨!我是這篇文章的共讀助教,只根據〈用 C++ 追求效能:當 0.1 秒和 10 秒只差一個語言〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。