3 cách tiếp cận khi đo lường hiệu năng Python
Khoảng 6 tháng trước, cụm crawler và xử lý dữ liệu của team mình bắt đầu hụt hơi khi lượng job tăng vọt từ 20.000 lên hơn 150.000 records/ngày. API phản hồi chậm, worker nghẽn liên tục. Khi code Python chạy ì ạch, phản xạ đầu tiên của nhiều anh em thường là tìm cách đo xem hàm nào ngốn thời gian nhất. Nhìn chung, chúng ta có 3 hướng đi:
- Manual timing (
time.perf_counter()): Cắm cờ bấm giờ thủ công trước và sau khối code để tính chênh lệch. - Deterministic Profiling (tiêu chuẩn là
cProfile): Ghi nhận tuyệt đối từng lượt gọi hàm và thời gian chạy từ lúc bắt đầu tới lúc kết thúc. - Statistical/Sampling Profiling (tiêu biểu là
pyinstrument): Lấy mẫu định kỳ. Cứ mỗi 1ms, công cụ ngắt nhẹ chương trình để chụp snapshot stack trace hiện tại.
Thực tế trên môi trường production: Chọn công cụ nào?
1. Bấm giờ thủ công: Nhanh nhưng dễ nát code
Cách này tiện khi cần kiểm tra nhanh một hàm độc lập. Tuy nhiên, khi hệ thống phình to, việc rải log khắp nơi rất dễ gây rối codebase. Chưa kể, bạn sẽ phải dọn lại code sau khi debug xong và rất dễ bỏ sót các hàm con nằm sâu trong third-party packages.
2. cProfile: Quá chi tiết thành ra nhiễu
cProfile được tích hợp sẵn trong Python standard library. Nhưng khi áp dụng vào một dự án web hay data pipeline thực tế, nó để lộ hai vấn đề lớn:
- Overhead quá nặng: Do phải bắt từng function call, cProfile kéo tụt tốc độ app từ 2 đến 5 lần. Việc này làm sai lệch hoàn toàn hành vi của ứng dụng dưới tải thực tế.
- Mù trước I/O block: Theo mặc định, cProfile đo CPU time. Nếu hệ thống mất 4 giây chờ database lock hoặc gọi REST API bên ngoài, bảng kết quả của cProfile sẽ chẻ nhỏ thành hàng nghìn call nội bộ khó hiểu thay vì chỉ thẳng vào dòng code đang bị treo.
3. Pyinstrument: Lấy mẫu nhẹ và trực diện
Kỹ thuật sampling giúp Pyinstrument giải quyết bài toán này mượt mà hơn:
- Overhead dưới 5%: Tác động tài nguyên rất thấp. Bạn hoàn toàn có thể bật nó trên staging hoặc trích 1% traffic thật trên production để bắt lỗi mà không sợ sập service.
- Đo chuẩn Wall-clock time: Pyinstrument theo dõi đúng thời gian người dùng phải chờ. Mọi tác vụ từ
time.sleep, network request cho đến database query đều được ghi nhận sòng phẳng. - Call tree gom nhóm thông minh: Tự động ẩn bớt các call frame rác của framework và highlight trực tiếp nhánh ngốn thời gian nhất (hot path).
Khi nào nên dùng Pyinstrument?
Nếu bạn đang viết thuật toán mã hóa, xử lý ma trận thuần CPU và cần đếm chính xác hàm chạy đúng bao nhiêu triệu lần, hãy chọn cProfile.
Ngược lại, nếu bạn làm web service (FastAPI, Django), bot tự động hóa hay ETL pipeline — nơi 80% độ trễ đến từ network I/O và database — Pyinstrument thực tế và tiết kiệm thời gian hơn nhiều. Nó chỉ đích danh file và số dòng đang nghẽn.
Hướng dẫn sử dụng Pyinstrument thực tế
Bước 1: Cài đặt thư viện
Gói cài đặt có sẵn C extension đã pre-build, cài qua pip chỉ mất vài giây:
pip install pyinstrument
Bước 2: Đo nhanh qua CLI không cần sửa code
Giả sử bạn có script process_data.py đang chạy chậm. Bạn không cần chèn bất kỳ dòng code debug nào, chỉ cần chạy qua CLI:
pyinstrument process_data.py
Pyinstrument sẽ xuất cây phân cấp màu trực tiếp trên terminal, bôi đỏ các nhánh chiếm trên 50% tổng thời gian:
_ ._ __/__ _ _ _ _ _/_ Recorded: 14:10:02 Duration: 3.421s
/_//_/) /_// / /_| (/_// (_)/ / /_ Samples: 3412 CPU time: 0.420s
/ /
Program: process_data.py
3.421 main process_data.py:28
└─ 3.210 fetch_and_parse process_data.py:12
├─ 2.980 get requests/api.py:64
│ └─ 2.978 send requests/sessions.py:635 [I/O wait]
└─ 0.230 BeautifulSoup.__init__ bs4/__init__.py:215
Nhìn vào kết quả, bạn thấy ngay 2.98s (khoảng 87% thời gian) nằm ở lệnh requests.get() chờ mạng. Thay vì tối ưu parser BeautifulSoup, giải pháp đúng ở đây là chuyển sang httpx bất đồng bộ hoặc gom batch request.
Bước 3: Nhúng trực tiếp vào code với context manager
Để đo một block xử lý cụ thể trong service layer, cách gọn nhất là dùng class Profiler:
import time
import requests
from pyinstrument import Profiler
def sync_worker():
profiler = Profiler()
profiler.start()
# Đoạn logic nghi ngờ gây chậm
responses = []
for _ in range(3):
res = requests.get("https://httpbin.org/delay/1", timeout=5)
responses.append(res.status_code)
time.sleep(0.3) # Giả lập xử lý dữ liệu
profiler.stop()
profiler.print()
# Xuất báo cáo HTML để chia sẻ nội bộ
with open("profile_report.html", "w", encoding="utf-8") as f:
f.write(profiler.output_html())
if __name__ == "__main__":
sync_worker()
Bước 4: Xuất báo cáo HTML tương tác
Với call stack sâu từ 15-20 tầng, đọc trên terminal sẽ mỏi mắt. Bạn nên xuất thẳng ra file HTML:
pyinstrument -r html -o profile_report.html process_data.py
File HTML này cho phép bạn click bung/gập từng function call, xem tỷ lệ phần trăm và lọc bớt các frame hệ thống.
Bước 5: Tích hợp Middleware cho FastAPI
Với backend service, bạn có thể tạo một middleware để kích hoạt profiler theo query param khi test trên staging:
import asyncio
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse
from pyinstrument import Profiler
app = FastAPI()
@app.middleware("http")
async def profile_request_middleware(request: Request, call_next):
# Kích hoạt khi có query ?profile=true
if request.query_params.get("profile") == "true":
profiler = Profiler(interval=0.001, async_mode="enabled")
profiler.start()
response = await call_next(request)
profiler.stop()
return HTMLResponse(content=profiler.output_html())
return await call_next(request)
@app.get("/api/v1/orders")
async def get_orders():
# Giả lập truy vấn database mất 400ms
await asyncio.sleep(0.4)
return {"status": "success", "data": ["order_101", "order_102"]}
Chỉ cần mở trình duyệt và gõ /api/v1/orders?profile=true. Toàn bộ cây gọi hàm, thời gian query và async task switch sẽ hiển thị chi tiết ngay trước mắt.

