8.2 效能調優實戰
在入門系列中,我們學習了效能優化的原則和工具。本章將這些知識應用於 .claude/lib 的實際程式碼,展示如何從「發現問題」到「驗證效果」的完整流程。
學習目標
完成本章後,你將能夠:
- 使用 cProfile 分析真實程式碼的效能瓶頸
- 判斷哪些程式碼值得優化
- 應用正則表達式預編譯提升效能
- 使用
functools.lru_cache實現有效的快取策略 - 根據查詢模式選擇適當的資料結構
先備知識
本章假設你已經閱讀:
- 入門系列 3.8 效能迷思與優化策略 - 效能測量工具與優化原則
如果你還不熟悉 cProfile、timeit 或「過早優化是萬惡之源」這句話的含義,請先閱讀入門系列。
效能分析流程
優化的正確流程是:
11. 測量基準效能
2 ↓
32. 找出瓶頸(cProfile)
4 ↓
53. 針對瓶頸優化
6 ↓
74. 驗證優化效果
8 ↓
95. 評估維護成本最重要的原則:先測量,後優化。沒有測量數據的優化是盲目的。
真實案例:Hook 驗證器
我們以 .claude/lib/hook_validator.py 為例。這個工具用來驗證 Hook 腳本是否符合專案規範,核心功能是透過正則表達式檢查程式碼內容。
1class HookValidator:
2 """Hook 合規性驗證器"""
3
4 # 共用模組導入模式
5 HOOK_IO_PATTERNS = [
6 r"from\s+hook_io\s+import",
7 r"from\s+lib\.hook_io\s+import",
8 ]
9
10 HOOK_LOGGING_PATTERNS = [
11 r"from\s+hook_logging\s+import",
12 r"from\s+lib\.hook_logging\s+import",
13 ]
14
15 # ... 更多模式 ...
16
17 def _has_import(self, content: str, patterns: list[str]) -> bool:
18 """檢查是否有符合任一模式的導入"""
19 return any(
20 re.search(pattern, content)
21 for pattern in patterns
22 )這段程式碼有什麼效能問題?讓我們用 cProfile 來找出答案。
步驟 1:測量基準效能
首先,建立測試環境並測量原始版本的效能:
1import cProfile
2import pstats
3import re
4from pstats import SortKey
5
6def generate_test_content(num_lines: int = 500) -> str:
7 """生成測試用的 Hook 腳本內容"""
8 lines = [
9 '#!/usr/bin/env python3',
10 '"""Test hook script"""',
11 'import os',
12 'import sys',
13 'from hook_io import read_hook_input, write_hook_output',
14 'from hook_logging import setup_hook_logging',
15 '',
16 ]
17
18 # 加入更多程式碼行
19 for i in range(num_lines):
20 if i % 10 == 0:
21 lines.append(f'def function_{i}():')
22 lines.append(f' """Function {i}"""')
23 lines.append(f' x_{i} = {i}')
24
25 return '\n'.join(lines)
26
27def benchmark_original(content: str, iterations: int = 100):
28 """測量原始版本的效能"""
29
30 # 原始實作:每次呼叫都重新編譯正則表達式
31 patterns = [
32 r"from\s+hook_io\s+import",
33 r"from\s+lib\.hook_io\s+import",
34 ]
35
36 def has_import_original(content: str, patterns: list[str]) -> bool:
37 return any(
38 re.search(pattern, content)
39 for pattern in patterns
40 )
41
42 # 執行效能分析
43 profiler = cProfile.Profile()
44 profiler.enable()
45
46 for _ in range(iterations):
47 has_import_original(content, patterns)
48
49 profiler.disable()
50
51 stats = pstats.Stats(profiler)
52 stats.sort_stats(SortKey.CUMULATIVE)
53 stats.print_stats(10)
54
55 return stats
56
57# 執行測試
58content = generate_test_content(1000)
59print("=== 原始版本效能 ===")
60benchmark_original(content)執行後的輸出類似:
1=== 原始版本效能 ===
2 501 function calls in 0.0234 seconds
3
4 Ordered by: cumulative time
5
6 ncalls tottime percall cumtime percall filename:lineno(function)
7 100 0.001 0.000 0.023 0.000 test.py:30(has_import_original)
8 200 0.022 0.000 0.022 0.000 {method 'search' of 're.Pattern'}
9 100 0.000 0.000 0.000 0.000 {built-in method builtins.any}觀察結果:re.search 佔用了大部分時間。
步驟 2:找出瓶頸
從 cProfile 結果可以看到,re.search 被呼叫了 200 次(100 次迭代 x 2 個 pattern)。
問題在於:re.search(pattern, content) 每次呼叫時都會重新編譯正則表達式。
雖然 Python 的 re 模組有內部快取(最近使用的 pattern 會被快取),但:
- 快取有大小限制(預設 512 個)
- 查詢快取本身也有開銷
- 在 Hook 驗證器中有多達 20+ 個不同的 pattern
正則表達式預編譯
問題:重複編譯的開銷
來看 hook_validator.py 中的實際程式碼:
1class HookValidator:
2 """Hook 合規性驗證器"""
3
4 # 模式定義為字串列表
5 HOOK_IO_PATTERNS = [
6 r"from\s+hook_io\s+import",
7 r"from\s+lib\.hook_io\s+import",
8 ]
9
10 HOOK_LOGGING_PATTERNS = [
11 r"from\s+hook_logging\s+import",
12 r"from\s+lib\.hook_logging\s+import",
13 ]
14
15 CONFIG_LOADER_PATTERNS = [
16 r"from\s+config_loader\s+import",
17 r"from\s+lib\.config_loader\s+import",
18 ]
19
20 GIT_UTILS_PATTERNS = [
21 r"from\s+git_utils\s+import",
22 r"from\s+lib\.git_utils\s+import",
23 ]
24
25 # 輸出函式使用模式
26 OUTPUT_PATTERNS = [
27 r"write_hook_output\s*\(",
28 r"create_pretooluse_output\s*\(",
29 r"create_posttooluse_output\s*\(",
30 ]
31
32 # 不推薦的輸出模式
33 BAD_OUTPUT_PATTERNS = [
34 r'print\s*\(\s*json\.dumps\s*\(',
35 r'sys\.stdout\.write\s*\(\s*json\.dumps\s*\(',
36 ]
37
38 def _has_import(self, content: str, patterns: list[str]) -> bool:
39 """檢查是否有符合任一模式的導入"""
40 return any(
41 re.search(pattern, content) # 每次都要編譯!
42 for pattern in patterns
43 )每次呼叫 _has_import 時,所有 pattern 都會被重新處理。
解決方案:預編譯
將字串 pattern 改為預編譯的 re.Pattern 物件:
1import re
2from typing import Pattern
3
4class HookValidatorOptimized:
5 """優化版 Hook 驗證器"""
6
7 # 預編譯的正則表達式模式
8 HOOK_IO_PATTERNS: list[Pattern] = [
9 re.compile(r"from\s+hook_io\s+import"),
10 re.compile(r"from\s+lib\.hook_io\s+import"),
11 ]
12
13 HOOK_LOGGING_PATTERNS: list[Pattern] = [
14 re.compile(r"from\s+hook_logging\s+import"),
15 re.compile(r"from\s+lib\.hook_logging\s+import"),
16 ]
17
18 CONFIG_LOADER_PATTERNS: list[Pattern] = [
19 re.compile(r"from\s+config_loader\s+import"),
20 re.compile(r"from\s+lib\.config_loader\s+import"),
21 ]
22
23 GIT_UTILS_PATTERNS: list[Pattern] = [
24 re.compile(r"from\s+git_utils\s+import"),
25 re.compile(r"from\s+lib\.git_utils\s+import"),
26 ]
27
28 OUTPUT_PATTERNS: list[Pattern] = [
29 re.compile(r"write_hook_output\s*\("),
30 re.compile(r"create_pretooluse_output\s*\("),
31 re.compile(r"create_posttooluse_output\s*\("),
32 ]
33
34 BAD_OUTPUT_PATTERNS: list[Pattern] = [
35 re.compile(r'print\s*\(\s*json\.dumps\s*\('),
36 re.compile(r'sys\.stdout\.write\s*\(\s*json\.dumps\s*\('),
37 ]
38
39 def _has_import(self, content: str, patterns: list[Pattern]) -> bool:
40 """檢查是否有符合任一模式的導入"""
41 return any(
42 pattern.search(content) # 直接使用預編譯的 pattern
43 for pattern in patterns
44 )效能測量
比較預編譯和非預編譯的效能差異:
1import re
2import time
3
4def compare_regex_performance():
5 """比較預編譯 vs 非預編譯的效能"""
6
7 content = generate_test_content(1000)
8 iterations = 1000
9
10 # 字串 pattern
11 str_patterns = [
12 r"from\s+hook_io\s+import",
13 r"from\s+lib\.hook_io\s+import",
14 ]
15
16 # 預編譯 pattern
17 compiled_patterns = [re.compile(p) for p in str_patterns]
18
19 # 測試 1:使用字串 pattern
20 start = time.perf_counter()
21 for _ in range(iterations):
22 any(re.search(p, content) for p in str_patterns)
23 str_time = time.perf_counter() - start
24
25 # 測試 2:使用預編譯 pattern
26 start = time.perf_counter()
27 for _ in range(iterations):
28 any(p.search(content) for p in compiled_patterns)
29 compiled_time = time.perf_counter() - start
30
31 print(f"字串 pattern: {str_time:.4f}s")
32 print(f"預編譯 pattern: {compiled_time:.4f}s")
33 print(f"加速比: {str_time / compiled_time:.2f}x")
34
35compare_regex_performance()典型輸出:
1字串 pattern: 0.1234s
2預編譯 pattern: 0.0987s
3加速比: 1.25x在這個例子中,預編譯帶來約 20-30% 的效能提升。雖然不是「快 10 倍」的驚人結果,但:
- 改動成本極低:只需要加上
re.compile() - 無風險:行為完全相同
- 累積效果:當有更多 pattern 時,效果更明顯
快取策略:lru_cache
適用場景
functools.lru_cache 適合用於:
- 純函式:相同輸入總是產生相同輸出
- 計算昂貴:函式執行需要較長時間
- 重複呼叫:同樣的參數會被多次呼叫
實作範例:分支保護檢查
來看 .claude/lib/git_utils.py 中的 is_protected_branch() 函式:
1import fnmatch
2
3# 保護分支列表(支援 glob 模式)
4PROTECTED_BRANCHES = [
5 "main",
6 "master",
7 "develop",
8 "release/*",
9 "production",
10]
11
12def is_protected_branch(branch: str) -> bool:
13 """
14 檢查是否為保護分支
15
16 Args:
17 branch: 分支名稱
18
19 Returns:
20 bool: 如果是保護分支返回 True
21 """
22 for pattern in PROTECTED_BRANCHES:
23 if fnmatch.fnmatch(branch, pattern):
24 return True
25 return False這個函式:
- 是純函式:相同的
branch總是返回相同結果 - 會被重複呼叫:在 Hook 執行期間可能檢查同一個分支多次
加入 lru_cache
1from functools import lru_cache
2import fnmatch
3
4PROTECTED_BRANCHES = [
5 "main",
6 "master",
7 "develop",
8 "release/*",
9 "production",
10]
11
12@lru_cache(maxsize=128)
13def is_protected_branch(branch: str) -> bool:
14 """
15 檢查是否為保護分支(帶快取)
16
17 Args:
18 branch: 分支名稱
19
20 Returns:
21 bool: 如果是保護分支返回 True
22 """
23 for pattern in PROTECTED_BRANCHES:
24 if fnmatch.fnmatch(branch, pattern):
25 return True
26 return False快取命中率分析
1from functools import lru_cache
2import fnmatch
3
4@lru_cache(maxsize=128)
5def is_protected_branch(branch: str) -> bool:
6 for pattern in PROTECTED_BRANCHES:
7 if fnmatch.fnmatch(branch, pattern):
8 return True
9 return False
10
11def analyze_cache_performance():
12 """分析快取命中率"""
13
14 # 模擬真實的呼叫模式
15 branches = [
16 "main", "main", "main", # 重複檢查 main
17 "feat/new-feature",
18 "main",
19 "fix/bug-123",
20 "main",
21 "release/1.0",
22 "feat/new-feature", # 重複
23 ]
24
25 # 清除快取統計
26 is_protected_branch.cache_clear()
27
28 for branch in branches:
29 result = is_protected_branch(branch)
30 print(f"檢查 {branch}: {result}")
31
32 # 查看快取統計
33 info = is_protected_branch.cache_info()
34 print(f"\n快取統計:")
35 print(f" 命中: {info.hits}")
36 print(f" 未命中: {info.misses}")
37 print(f" 命中率: {info.hits / (info.hits + info.misses) * 100:.1f}%")
38 print(f" 快取大小: {info.currsize}/{info.maxsize}")
39
40analyze_cache_performance()輸出:
1檢查 main: True
2檢查 main: True
3檢查 main: True
4檢查 feat/new-feature: False
5檢查 main: True
6檢查 fix/bug-123: False
7檢查 main: True
8檢查 release/1.0: True
9檢查 feat/new-feature: False
10
11快取統計:
12 命中: 5
13 未命中: 4
14 命中率: 55.6%
15 快取大小: 4/128lru_cache 的注意事項
1# 1. 參數必須是可雜湊的(hashable)
2@lru_cache
3def process(data: list): # 錯誤!list 不可雜湊
4 pass
5
6@lru_cache
7def process(data: tuple): # 正確,tuple 可雜湊
8 pass
9
10# 2. 注意快取大小
11@lru_cache(maxsize=None) # 無限制,可能耗盡記憶體
12def expensive_function(x):
13 pass
14
15@lru_cache(maxsize=128) # 建議設定合理上限
16def expensive_function(x):
17 pass
18
19# 3. 可以手動清除快取
20expensive_function.cache_clear()
21
22# 4. 查看快取統計
23info = expensive_function.cache_info()
24# CacheInfo(hits=10, misses=5, maxsize=128, currsize=5)資料結構選擇
O(n) vs O(1) 的差異
在 hook_validator.py 中,檢查測試檔案是否存在:
1def check_test_exists(self, hook_path: Path) -> list[ValidationIssue]:
2 """檢查對應的測試檔案是否存在"""
3 issues = []
4
5 hook_name = hook_path.stem
6 test_name = f"test_{hook_name.replace('-', '_')}.py"
7
8 # 測試檔案可能在這些位置
9 possible_test_paths = [
10 self.project_root / ".claude" / "lib" / "tests" / test_name,
11 self.project_root / ".claude" / "hooks" / "tests" / test_name,
12 ]
13
14 test_exists = any(p.exists() for p in possible_test_paths)
15 # ...如果需要檢查多個檔案是否在某個集合中:
1# 不好的做法:用 list,O(n) 查詢
2existing_tests = [
3 "test_branch_verify.py",
4 "test_hook_io.py",
5 "test_config_loader.py",
6 # ... 可能有幾十個
7]
8
9def has_test_slow(test_name: str) -> bool:
10 return test_name in existing_tests # O(n)
11
12# 好的做法:用 set,O(1) 查詢
13existing_tests_set = {
14 "test_branch_verify.py",
15 "test_hook_io.py",
16 "test_config_loader.py",
17 # ...
18}
19
20def has_test_fast(test_name: str) -> bool:
21 return test_name in existing_tests_set # O(1)真實案例:測試檔案存在性檢查
1import time
2from pathlib import Path
3
4def compare_data_structures():
5 """比較 list vs set 的查詢效能"""
6
7 # 模擬測試檔案列表
8 test_files_list = [f"test_hook_{i}.py" for i in range(100)]
9 test_files_set = set(test_files_list)
10
11 # 要查詢的檔案
12 queries = [f"test_hook_{i}.py" for i in range(50, 150)] # 50 個存在,50 個不存在
13
14 iterations = 10000
15
16 # 測試 list
17 start = time.perf_counter()
18 for _ in range(iterations):
19 for q in queries:
20 _ = q in test_files_list
21 list_time = time.perf_counter() - start
22
23 # 測試 set
24 start = time.perf_counter()
25 for _ in range(iterations):
26 for q in queries:
27 _ = q in test_files_set
28 set_time = time.perf_counter() - start
29
30 print(f"List 查詢: {list_time:.4f}s")
31 print(f"Set 查詢: {set_time:.4f}s")
32 print(f"加速比: {list_time / set_time:.1f}x")
33
34compare_data_structures()輸出:
1List 查詢: 0.8234s
2Set 查詢: 0.0123s
3加速比: 66.9x當資料量為 100 個元素時,set 比 list 快約 60-70 倍。隨著資料量增加,差距會更大。
何時使用哪種資料結構
| 操作 | list | set | dict |
|---|---|---|---|
| 查詢元素是否存在 | O(n) | O(1) | O(1) |
| 依索引存取 | O(1) | N/A | N/A |
| 依鍵存取 | N/A | N/A | O(1) |
| 保持順序 | Yes | No* | Yes** |
| 允許重複 | Yes | No | Keys: No |
* Python 3.7+ 的 set 實際上保持插入順序,但這是實作細節,不是語言保證。 ** Python 3.7+ 的 dict 保證保持插入順序。
選擇指南:
- 需要頻繁查詢「是否存在」→ 用
set - 需要依索引存取 → 用
list - 需要鍵值對應 → 用
dict - 需要去重 → 用
set
優化的代價
每個優化都有代價,需要評估是否值得。
維護成本
| 優化技術 | 效能提升 | 程式碼複雜度 | 維護成本 |
|---|---|---|---|
| 正則表達式預編譯 | 20-30% | 低 | 低 |
| lru_cache | 視命中率而定 | 低 | 中(需注意快取失效) |
| list → set | 數十倍 | 低 | 低 |
| 自訂資料結構 | 視情況 | 高 | 高 |
何時不該優化
1# 不值得優化的情況
2
3# 1. 執行次數很少
4def setup_once():
5 """只在程式啟動時執行一次"""
6 config = load_all_configs() # 即使慢也只執行一次
7 return config
8
9# 2. 已經夠快了
10def check_single_file(path: str) -> bool:
11 """檢查單一檔案是否存在"""
12 return Path(path).exists() # 0.0001s,不需要優化
13
14# 3. I/O 才是真正的瓶頸
15def process_files(paths: list[str]):
16 for path in paths:
17 content = Path(path).read_text() # 瓶頸在這裡
18 result = process(content) # 這裡快 10 倍也沒用優化前檢查清單
在優化之前,問自己:
- 這段程式碼是瓶頸嗎? - 用 cProfile 確認
- 執行頻率高嗎? - 每秒執行一次 vs 每天執行一次
- 優化後維護成本增加多少? - 複雜度 vs 效能
- 有更簡單的解決方案嗎? - 演算法改進 vs 微優化
思考題
在什麼情況下,預編譯正則表達式反而可能降低效能?
lru_cache不適合用在什麼樣的函式上?如果一個函式既需要快取,又需要在某些條件下強制重新計算,你會如何設計?
為什麼說「先測量,後優化」很重要?能舉一個反例嗎?
實作練習
練習 1:分析現有程式碼
用 cProfile 分析你自己專案中的一段程式碼,找出效能瓶頸。
1import cProfile
2import pstats
3from pstats import SortKey
4
5# 替換為你要分析的函式
6def your_function():
7 pass
8
9profiler = cProfile.Profile()
10profiler.enable()
11
12# 執行多次以獲得可靠數據
13for _ in range(100):
14 your_function()
15
16profiler.disable()
17stats = pstats.Stats(profiler)
18stats.sort_stats(SortKey.CUMULATIVE)
19stats.print_stats(10)練習 2:實作帶統計的快取
擴展 lru_cache,加入更詳細的統計資訊:
1from functools import wraps
2from typing import Callable, TypeVar
3from collections import defaultdict
4import time
5
6T = TypeVar('T')
7
8def cached_with_stats(maxsize: int = 128):
9 """
10 帶統計資訊的快取裝飾器
11
12 統計項目:
13 - 命中/未命中次數
14 - 平均執行時間
15 - 各參數的呼叫次數
16 """
17 def decorator(func: Callable[..., T]) -> Callable[..., T]:
18 # 你的實作
19 pass
20 return decorator練習 3:效能比較
比較以下三種檢查字串是否包含多個關鍵字的方法:
1keywords = ["error", "warning", "critical", "fatal"]
2
3# 方法 1:多個 in 檢查
4def method1(text: str) -> bool:
5 return any(kw in text for kw in keywords)
6
7# 方法 2:正則表達式
8import re
9pattern = re.compile("|".join(keywords))
10def method2(text: str) -> bool:
11 return bool(pattern.search(text))
12
13# 方法 3:set 交集
14keywords_set = set(keywords)
15def method3(text: str) -> bool:
16 words = set(text.lower().split())
17 return bool(words & keywords_set)
18
19# 測試並比較效能
20# ...延伸閱讀
入門系列
- 3.8 效能迷思與優化策略 - 效能優化的基礎知識
進階系列
- 模組四:CPython 內部機制 - 理解 Python 執行原理
- 案例:效能分析實戰 - cProfile 深入應用
官方文件
外部資源
- High Performance Python - O’Reilly 書籍
- line_profiler - 行級效能分析工具