3.8 效能迷思與優化策略
3.8 效能迷思與優化策略
「Python 很慢」是程式設計社群中最常見的說法之一。本章將探討這個說法的真相、何時效能真的重要,以及如何有效地優化 Python 程式。
Python「慢」的真相
直譯語言 vs 編譯語言
Python 是直譯語言,程式碼在執行時才被轉換成機器碼:
1編譯語言(C/C++/Rust):
2原始碼 → 編譯器 → 機器碼 → 執行
3 ↑
4 一次編譯,多次執行
5
6直譯語言(Python):
7原始碼 → 直譯器 → 逐行執行
8 ↑
9 每次執行都要解釋這意味著 Python 在純計算任務上確實比編譯語言慢,通常是 10-100 倍的差距。
但這重要嗎?
讓我們看一個來自 Reddit 社群的經典回答:
「如果你要問 Python 是不是太慢,那就不關你的事。」 — Reddit 用戶 scandii
這聽起來很直接,但背後有深刻的道理:
1# 情境 1:網頁後端
2# Python 處理請求:50ms
3# 網路延遲:200ms
4# 資料庫查詢:100ms
5# 總計:350ms
6#
7# 就算 Python 快 10 倍(5ms),總時間也只變成 305ms
8# 用戶感受差異:幾乎沒有
9
10# 情境 2:命令列工具
11# 執行時間:0.5 秒
12# 用戶可接受?當然可以設計哲學的取捨
Python 的設計哲學是「開發速度 > 執行速度」:
| 面向 | Python | C++ |
|---|---|---|
| 開發時間 | 短 | 長 |
| 執行速度 | 慢 | 快 |
| 程式碼可讀性 | 高 | 中 |
| 除錯難度 | 低 | 高 |
| 學習曲線 | 緩 | 陡 |
對於大多數應用來說,開發效率和維護成本遠比執行速度重要。
真正的瓶頸在哪裡?
在優化之前,你需要先找出真正的瓶頸。以下是常見的效能瓶頸排名:
1. I/O 操作
1import time
2import requests
3
4# 網路請求:通常是最大的瓶頸
5start = time.perf_counter()
6response = requests.get("https://api.example.com/data") # 50-500ms
7print(f"網路請求: {time.perf_counter() - start:.3f}s")
8
9# 檔案讀寫
10start = time.perf_counter()
11with open("large_file.txt", "r") as f:
12 content = f.read() # 取決於檔案大小和硬碟速度
13print(f"檔案讀取: {time.perf_counter() - start:.3f}s")2. 資料庫查詢
1# 一個沒有索引的查詢可能需要幾秒鐘
2# SELECT * FROM users WHERE email = '...' # 無索引:慢
3# SELECT * FROM users WHERE id = 123 # 有索引:快
4
5# N+1 查詢問題
6for user in users:
7 orders = get_orders(user.id) # 每個用戶一次查詢 → 很慢
8
9# 應該改成
10orders = get_orders_for_users([u.id for u in users]) # 一次查詢3. 演算法複雜度
1# O(n²) vs O(n) 的差異遠大於語言差異
2
3# O(n²) - 10000 個元素需要 100,000,000 次操作
4def find_duplicates_slow(items):
5 duplicates = []
6 for i, item in enumerate(items):
7 for j, other in enumerate(items):
8 if i != j and item == other:
9 duplicates.append(item)
10 return duplicates
11
12# O(n) - 10000 個元素只需要 10000 次操作
13def find_duplicates_fast(items):
14 seen = set()
15 duplicates = []
16 for item in items:
17 if item in seen:
18 duplicates.append(item)
19 seen.add(item)
20 return duplicates瓶頸排名
1通常的效能瓶頸(由大到小):
21. 網路延遲 100-1000ms
32. 資料庫查詢 10-1000ms
43. 檔案 I/O 1-100ms
54. 演算法複雜度 視情況
65. Python 本身 0.001-1ms優化方案總覽
| 方案 | 適用場景 | 學習成本 | 效果 |
|---|---|---|---|
| 演算法優化 | 通用 | 中 | 最高 |
| NumPy/Pandas | 數值計算 | 低 | 高 |
| concurrent.futures | 並行任務 | 低 | 中-高 |
| Free-threading | CPU 並行 | 中 | 高 |
| Cython | 熱點程式碼 | 高 | 高 |
| PyPy | 通用加速 | 低 | 中 |
| asyncio | I/O 並發 | 中 | 中-高 |
1. 演算法優化
永遠是第一優先:
1# 用合適的資料結構
2items_list = [1, 2, 3, ...] # 查找 O(n)
3items_set = {1, 2, 3, ...} # 查找 O(1)
4
5# 用合適的演算法
6sorted(items) # O(n log n)
7items.sort() # O(n log n),但原地排序更省記憶體2. 使用 NumPy/Pandas
把計算交給 C 實現的函式庫:
1import numpy as np
2
3# 純 Python:慢
4def sum_squares_python(n):
5 return sum(i * i for i in range(n))
6
7# NumPy:快 10-100 倍
8def sum_squares_numpy(n):
9 arr = np.arange(n)
10 return np.sum(arr * arr)
11
12# 向量化操作是關鍵
13# 不好:Python 迴圈
14result = []
15for x in data:
16 result.append(x * 2 + 1)
17
18# 好:NumPy 向量化
19result = data * 2 + 13. 並行處理
見 3.7 並行處理 和 3.8 Free-Threading
1from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
2
3# I/O 密集:使用執行緒
4with ThreadPoolExecutor(max_workers=10) as executor:
5 results = executor.map(fetch_url, urls)
6
7# CPU 密集:使用進程(或 Free-threading)
8with ProcessPoolExecutor() as executor:
9 results = executor.map(compute_heavy, data_chunks)4. PyPy
PyPy 是 Python 的另一個實現,使用 JIT 編譯:
1# 安裝 PyPy
2# macOS: brew install pypy3
3# Ubuntu: apt install pypy3
4
5# 執行
6pypy3 your_script.pyPyPy 對於迴圈密集的程式碼特別有效:
1# 這種程式碼在 PyPy 上可能快 10-50 倍
2def compute():
3 total = 0
4 for i in range(10_000_000):
5 total += i * i
6 return totalPython 3.13-3.14 效能改進
新的直譯器
Python 3.14 引入了使用尾調用的新直譯器,在支援的編譯器上快 3-5%:
1# 需要使用 Clang 19+ 編譯,並啟用配置選項
2./configure --with-tail-call-interp增量垃圾回收
循環垃圾回收現在是增量式的,減少了長時間停頓:
1import gc
2
3# 舊版:可能造成明顯停頓
4gc.collect()
5
6# 3.14:增量回收,影響更平滑
7gc.collect(1)Free-Threading
什麼時候該優化?
「過早優化是萬惡之源」
Donald Knuth 的這句名言經常被誤解。完整的引言是:
「程式設計師花費了大量時間思考或擔心程式非關鍵部分的速度,而當考慮到除錯和維護時,這些效率的嘗試實際上會產生強烈的負面影響。我們應該忘記小的效率問題,比如說 97% 的時間:過早優化是萬惡之源。然而,我們不應該放棄那關鍵的 3% 的機會。」
優化的正確流程
11. 讓程式正確運作
2 ↓
32. 讓程式碼可讀、可維護
4 ↓
53. 測量效能(profiling)
6 ↓
74. 找出瓶頸(通常是 20% 的程式碼佔 80% 的時間)
8 ↓
95. 只優化瓶頸
10 ↓
116. 再次測量,確認改善80/20 法則
在大多數程式中:
- 20% 的程式碼佔用 80% 的執行時間
- 優化錯誤的地方不會有任何效果
效能測量工具
簡單計時
1import time
2
3def measure_time(func, *args, **kwargs):
4 """測量函式執行時間"""
5 start = time.perf_counter()
6 result = func(*args, **kwargs)
7 elapsed = time.perf_counter() - start
8 print(f"{func.__name__}: {elapsed:.6f}s")
9 return result
10
11# 使用
12result = measure_time(my_function, arg1, arg2)使用 timeit
1import timeit
2
3# 測量小段程式碼
4time_taken = timeit.timeit(
5 'sum(range(1000))',
6 number=10000
7)
8print(f"平均執行時間: {time_taken / 10000:.6f}s")
9
10# 比較兩種實現
11setup = "data = list(range(1000))"
12
13time1 = timeit.timeit('sum(data)', setup=setup, number=10000)
14time2 = timeit.timeit('sum(x for x in data)', setup=setup, number=10000)
15
16print(f"直接 sum: {time1:.4f}s")
17print(f"生成器 sum: {time2:.4f}s")使用 cProfile
1import cProfile
2import pstats
3
4# 基本用法
5cProfile.run('my_function()')
6
7# 詳細分析
8profiler = cProfile.Profile()
9profiler.enable()
10
11# 執行你的程式碼
12result = my_function()
13
14profiler.disable()
15stats = pstats.Stats(profiler)
16stats.sort_stats('cumulative')
17stats.print_stats(20) # 顯示前 20 個使用 line_profiler(逐行分析)
1pip install line_profiler1# 在函式上加上 @profile 裝飾器
2@profile
3def slow_function():
4 total = 0
5 for i in range(1000):
6 total += i * i
7 return total1kernprof -l -v your_script.py使用 memory_profiler(記憶體分析)
1pip install memory_profiler1from memory_profiler import profile
2
3@profile
4def memory_hungry_function():
5 big_list = [i for i in range(1000000)]
6 return sum(big_list)實際案例
案例 1:優化資料處理
1# 原始版本:慢
2def process_data_slow(data):
3 result = []
4 for item in data:
5 if item > 0:
6 result.append(item * 2)
7 return result
8
9# 優化版本 1:列表推導式(快 20-30%)
10def process_data_v1(data):
11 return [item * 2 for item in data if item > 0]
12
13# 優化版本 2:NumPy(大數據時快 10-100 倍)
14import numpy as np
15
16def process_data_v2(data):
17 arr = np.array(data)
18 return arr[arr > 0] * 2案例 2:快取昂貴的計算
1from functools import lru_cache
2
3# 沒有快取:每次都重新計算
4def fibonacci_slow(n):
5 if n < 2:
6 return n
7 return fibonacci_slow(n - 1) + fibonacci_slow(n - 2)
8
9# 有快取:已計算的結果會被記住
10@lru_cache(maxsize=None)
11def fibonacci_fast(n):
12 if n < 2:
13 return n
14 return fibonacci_fast(n - 1) + fibonacci_fast(n - 2)
15
16# fibonacci_slow(35) 需要幾秒鐘
17# fibonacci_fast(35) 幾乎瞬間完成案例 3:選擇正確的資料結構
1import time
2
3# 用 list 查找(O(n))
4def find_in_list(items, target):
5 return target in items
6
7# 用 set 查找(O(1))
8def find_in_set(items, target):
9 return target in items
10
11# 測試
12data_list = list(range(1_000_000))
13data_set = set(range(1_000_000))
14target = 999_999
15
16start = time.perf_counter()
17find_in_list(data_list, target)
18print(f"List 查找: {time.perf_counter() - start:.6f}s")
19
20start = time.perf_counter()
21find_in_set(data_set, target)
22print(f"Set 查找: {time.perf_counter() - start:.6f}s")
23
24# List 查找: 0.015000s(取決於位置)
25# Set 查找: 0.000001s(幾乎瞬間)思考題
- 為什麼「過早優化是萬惡之源」?什麼時候優化才是適當的?
- 在什麼情況下,Python 的「慢」確實是個問題?
- NumPy 為什麼比純 Python 迴圈快這麼多?
實作練習
- 使用
cProfile分析一個現有的 Python 程式,找出效能瓶頸 - 將一個使用 Python 迴圈的數值計算程式改寫成 NumPy 版本,比較效能差異
- 實作一個帶有快取的 API 客戶端,避免重複請求相同的資料
延伸閱讀
延伸閱讀(進階系列)
- 實戰效能優化 - 真實案例的效能調優實戰
- CPython 內部機制 - 理解 Python 的運作原理以優化效能
- Free-Threading - Python 3.13+ 無 GIL 多執行緒
- 用 C 擴展 Python - 使用 ctypes、Cython、pybind11 提升效能
- 用 Rust 擴展 Python - 使用 PyO3 建立高效能安全的擴展
上一章:並行處理