Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

用 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 秒只差一個語言〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。