Hãy tưởng tượng hoá đơn điện thoại của bạn chỉ ghi mỗi một dòng: "Tổng cước gọi nội hạt: 500 nghìn". Con số ấy chính xác tuyệt đối. Nhưng nó chẳng giúp bạn tiết kiệm được đồng nào, vì bạn không biết cuộc gọi nào đắt, gọi cho ai, lúc nào. Muốn cắt giảm, bạn cần bảng kê chi tiết — từng cuộc, từng số, từng phút. Hồ sơ phẳng và biểu đồ ngọn lửa đứng đúng vào hai vị trí đó: một cái cho bạn con số tổng đúng chằn chặn nhưng câm lặng, cái kia cho bạn bảng kê để biết phải cắt ở đâu.
Phần 38 cho thấy perf đo chính xác tới 0,15 điểm phần trăm. Bài này chỉ ra vì sao con số chính xác đó vẫn có thể vô dụng — và cách sửa.
Hồ sơ phẳng, và vì sao nó câm
Chương trình mô phỏng một dịch vụ: xử lý yêu cầu (phân tích JSON, mã hoá, nén) cộng một tác vụ nền cũng nén. Cả bốn hàm cuối cùng đều gọi chung một hàm tiện ích tinh().
perf report --sort symbol
99,90% tinh
(không còn dòng nào khác trên 1%)
Con số này đúng. Và nó vô dụng. Nó nói rằng toàn bộ CPU nằm trong một hàm tiện ích được gọi từ khắp nơi. Tối ưu tinh() thì được — nhưng nếu không tối ưu được nữa thì sao? Hồ sơ phẳng không hé lộ ai đang gọi nó nhiều nhất.
Đây là hình dạng thường gặp trong mã nguồn thật: hàm tốn nhất là memcpy, malloc, json_parse, hoặc một hàm nào đó trong thư viện chuẩn. Biết tên nó không giúp bạn sửa gì — cũng như biết "tổng cước 500 nghìn" không giúp bạn cắt cuộc gọi nào.
Cùng số liệu, tách theo ngăn xếp
perf script | stackcollapse-perf.pl | sort | uniq -c | sort -rn
| Đường gọi | Số mẫu | Tỷ lệ |
|---|---|---|
main; xu_ly_yeu_cau; nen; tinh |
597 | 60,43% |
main; tac_vu_nen; nen; tinh |
247 | 25,00% |
main; xu_ly_yeu_cau; phan_tich_json; tinh |
73 | 7,39% |
main; xu_ly_yeu_cau; ma_hoa; tinh |
71 | 7,19% |
Đây rồi, giờ mọi thứ rõ như ban ngày:
- Nén chiếm 85% CPU (60,43 + 25,00), chia làm hai đường gọi.
- Nén trong đường xử lý yêu cầu chiếm 60,43% — đây là chỗ ảnh hưởng tới độ trễ người dùng.
- Nén trong tác vụ nền chiếm 25,00% — có thể dời sang giờ thấp điểm.
- Phân tích JSON và mã hoá cộng lại chỉ 14,58%.
Cùng một hàm nen() xuất hiện hai lần với hai tỷ lệ khác nhau, tuỳ ai gọi nó. Đó chính xác là thứ hồ sơ phẳng không bao giờ nói được — nó gộp cả hai lần gọi vào một con số duy nhất, đúng nhưng mù.
Kiểm chứng lại cho chắc: tỷ lệ tính từ mã nguồn là 60% / 25% / 7,5% / 7,5%. Đo được 60,43 / 25,00 / 7,39 / 7,19. Sai lệch lớn nhất 0,43 điểm — số liệu đáng tin.
Dựng biểu đồ
git clone https://github.com/brendangregg/FlameGraph
perf record -F 99 -g -p <pid> -- sleep 30
perf script | FlameGraph/stackcollapse-perf.pl | FlameGraph/flamegraph.pl > ho-so.svg
Ba bước, ba định dạng:
| Bước | Ra cái gì |
|---|---|
perf record |
tệp nhị phân perf.data |
perf script |
mỗi mẫu một khối văn bản nhiều dòng |
stackcollapse-perf.pl |
mỗi ngăn xếp một dòng: a;b;c 597 |
flamegraph.pl |
SVG tương tác |
Định dạng ở bước ba đơn giản tới mức làm được bằng awk, và nó nhận đầu vào từ mọi nguồn — Java, Go, Python, eBPF. Đó là lý do biểu đồ ngọn lửa dùng được cho mọi ngôn ngữ: cái lõi chỉ là "một dòng cho mỗi đường gọi kèm số mẫu", còn dữ liệu tới từ đâu không quan trọng.
Cách đọc
Chiều ngang là tỷ lệ, không phải thời gian. Trục X không phải dòng thời gian — nó chỉ được sắp xếp theo bảng chữ cái để các khối giống nhau gộp lại. Đọc biểu đồ ngọn lửa như một dòng thời gian là hiểu sai thường gặp nhất; tôi thấy người ta vấp cái này suốt.
Chiều cao là độ sâu ngăn xếp. Đọc từ dưới lên: mỗi tầng là một mức gọi sâu hơn.
Khối rộng ở gần đỉnh là chỗ đáng sửa. Đỉnh của một khối là nơi CPU thực sự đang chạy. Một khối rộng ở tầng thấp chỉ nghĩa là nó gọi nhiều thứ tốn kém, không phải bản thân nó tốn.
Khối hẹp không đáng quan tâm — trừ khi bạn đang tìm đuôi độ trễ, và lúc đó biểu đồ ngọn lửa CPU không phải công cụ đúng.
Bốn loại biểu đồ ngọn lửa
Loại thông thường đo CPU. Ba loại khác trả lời câu hỏi khác:
Ngoài CPU — thời gian tiến trình bị chặn, chờ khoá, chờ I/O:
perf record -e sched:sched_switch -g -p <pid> -- sleep 10
Phần 38 đã nêu: hồ sơ CPU không thấy phần chờ. Với dịch vụ tốn 90% thời gian chờ cơ sở dữ liệu, đây mới là biểu đồ cần xem.
Cấp phát bộ nhớ — ai gọi malloc nhiều nhất:
perf record -e probe_libc:malloc -g -p <pid> -- sleep 10
Biểu đồ khác biệt — chồng hai hồ sơ trước và sau một thay đổi, tô đỏ phần chậm đi và xanh phần nhanh lên. difffolded.pl trong cùng kho FlameGraph. Đây là cách tốt nhất để trả lời "thay đổi này làm gì".
Ba lỗi khi dựng
Ngăn xếp cụt. Nếu biểu đồ chỉ có hai ba tầng, phần lớn khả năng là con trỏ khung bị bỏ. Xem lại bảng ba cách lấy ngăn xếp ở phần 38.
Tên hàm là địa chỉ. Với JVM, Node, .NET thì mã sinh lúc chạy không có ký hiệu. Cần tệp ánh xạ (--perf-basic-prof cho Node, libperfmap cho Java).
Lấy mẫu quá ít. Dưới vài trăm mẫu thì các khối hẹp là nhiễu. -F 99 trong 30 giây cho khoảng 3.000 mẫu — đủ cho phần lớn việc.
Không có perf? Vẫn dựng được
Cách nghèo nhất nhưng vẫn hiệu quả — lấy ngăn xếp bằng gdb hoặc eu-stack theo chu kỳ:
for i in $(seq 1 100); do
eu-stack -p <pid> 2>/dev/null | awk '/^#/{print $3}' | tac | paste -sd';'
sleep 0.1
done | sort | uniq -c | sort -rn | head
Chậm và thô, nhưng nó dựa trên nguyên lý giống hệt: lấy mẫu ngăn xếp đủ nhiều thì phân bố mẫu hội tụ về phân bố thời gian. Với một tiến trình đang treo mà không cài được gì, đây thường là công cụ duy nhất bạn có.
Mẫu số chung: con số tổng che mất chiều "ai gây ra"
Cái khác biệt giữa hồ sơ phẳng và biểu đồ ngọn lửa — một con số tổng đúng nhưng câm, so với cùng số liệu đó tách theo ngữ cảnh gọi — lặp lại y hệt ở mọi tầng đo lường.
Trong thế giới Java, đây đúng là khác biệt giữa nhìn "method X ngốn 99% CPU" trong một profiler phẳng và mở nó ra thành cây gọi trong async-profiler / JFR. Cùng một HashMap.get có thể tốn từ hai đường hoàn toàn khác nhau; chỉ cây gọi mới tách được, và async-profiler xuất thẳng ra định dạng folded để dựng flame graph — đúng bước ba ở trên.
Trong Go, pprof cho bạn cả hai chế độ: top là hồ sơ phẳng (hàm nào tốn nhất), còn list, web, và -cum là góc nhìn theo đường gọi. Người mới thường dừng ở top rồi bối rối vì thấy runtime.mallocgc đứng đầu — mà cái cần biết là ai cấp phát nhiều tới mức gọi nó nhiều thế, và chỉ góc nhìn cumulative theo ngăn xếp mới trả lời.
Còn ở cơ sở dữ liệu, cùng câu chuyện: pg_stat_statements cho bạn con số phẳng "câu query này tốn tổng bao nhiêu", còn EXPLAIN ANALYZE mở nó ra thành cây thực thi — cùng một lần quét bảng có thể rẻ hay đắt tuỳ nó nằm dưới nút nào, join kiểu gì, lặp bao nhiêu lần. Một chỉ số tổng bảo bạn cái gì chậm; cây kế hoạch bảo bạn tại sao và ở đâu trong ngữ cảnh.
Mẫu số chung: một con số tổng gộp mọi ngữ cảnh lại thành một, nên dù chính xác tuyệt đối nó vẫn không nói được cùng một hàm/query tốn khác nhau tuỳ ai gọi — và chỗ đáng sửa gần như luôn nằm ở một đường gọi cụ thể, không ở cái tên gộp chung. Nên khi một công cụ chỉ vào một hàm tiện ích dùng-khắp-nơi, câu hỏi đúng không phải "làm sao tối ưu hàm này" mà là "ai gọi nó nhiều nhất, và đường gọi đó có cắt được không" — và chỉ góc nhìn tách theo ngăn xếp mới trả lời.
Muốn tự thấy điều đó, dựng một biểu đồ ngọn lửa dạng văn bản mà không cần cài gì ngoài perf:
p=<pid>
perf record -F 99 -g -o /tmp/f.data -p $p -- sleep 20 2>/dev/null
perf script -i /tmp/f.data 2>/dev/null | awk '
/^$/ { if (n>0) { s=fr[n]; for(i=n-1;i>=1;i--) s=s";"fr[i]; print s } n=0; next }
/^[[:space:]]/ { sym=$2; if (sym!="" && sym!~/^0x/) { n++; fr[n]=sym } }
' | sed 's/+0x[0-9a-f]*//g' | sort | uniq -c | sort -rn | head -10
Mười dòng đầu ra là mười đường gọi tốn CPU nhất của dịch vụ bạn. Nếu dòng đầu chiếm hơn một phần ba tổng số mẫu, bạn vừa tìm được chỗ đáng sửa đầu tiên — và bạn biết cả đường gọi dẫn tới nó, không chỉ tên hàm.
Phần sau: eBPF và bcc — đo những thứ công cụ khác không thấy.