CPU Profiling Bằng perf_event: Lấy Mẫu Stack, Nền Của Flame Graph

K
Kai··5 min read

Part V cưỡng chế. Part VI quay lại quan sát, nhưng ở tầng hiệu năng — câu hỏi "CPU đang bận làm gì, thời gian đi đâu". Bài đầu là kỹ thuật nền tảng nhất: CPU profiling bằng lấy mẫu, qua loại chương trình eBPF perf_event.

Lấy mẫu: thay vì đếm mọi thứ, chụp định kỳ

Đo độ trễ ở Bài 7 dùng kprobe trên từng lần gọi hàm — chính xác nhưng tốn khi sự kiện dày. Profiling đi đường khác: lấy mẫu thống kê. Vài chục tới vài trăm lần mỗi giây, ta đóng băng mỗi CPU và ghi lại stack đang chạy lúc đó. Hàm nào xuất hiện trong nhiều mẫu thì hàm đó chiếm nhiều thời gian CPU — không cần đo từng lần gọi, chỉ cần đủ mẫu để thống kê đúng.

Cơ chế là perf_event: nhân có hạ tầng perf cung cấp các bộ đếm, trong đó có bộ đếm "đồng hồ CPU" (PERF_COUNT_SW_CPU_CLOCK) nổ đều theo tần số đặt trước. eBPF gắn một chương trình loại perf_event vào bộ đếm đó; mỗi lần nó nổ, chương trình chạy trên CPU vừa bị ngắt, chụp stack hiện tại và gộp lại.

   Bộ đếm perf (CPU clock) nổ 99 lần/giây, trên MỖI CPU
            │
            ▼
   chương trình eBPF perf_event chạy (ngữ cảnh ngắt)
            │
            ├── bpf_get_stackid() -> lưu stack vào một stackmap
            └── @[stack] = count()    (gộp: stack giống nhau cộng dồn)
            │
            ▼
   userspace đọc map: stack nào nhiều mẫu = nóng nhất

Profile một node ở 99Hz

bpftrace gói cơ chế trên trong probe profile. Lấy mẫu stack nhân 99 lần/giây trên mọi CPU, gộp theo stack:

sudo bpftrace -e 'profile:hz:99 { @[kstack] = count(); }'

Vì sao 99 chứ không phải 100? Để tránh lock-step — nhiều hoạt động định kỳ của nhân chạy ở bội số của 100Hz (timer tick), lấy mẫu đúng 100Hz dễ rơi vào nhịp với chúng và méo kết quả. 99Hz là số nguyên tố gần đó, phá nhịp. (Mẹo của Brendan Gregg.)

Cho node chạy một ít tải (dd if=/dev/zero of=/dev/null) rồi profile, các stack nhân nóng hiện ra:

@[
    rep_stos_alternative+75
    vfs_read+186
    ksys_read+113
    __x64_sys_read+25
    do_syscall_64+128
    entry_SYSCALL_64_after_hwframe+118
]: 95

@[
    pv_native_safe_halt+11
    arch_cpu_idle+9
    default_idle_call+48
    do_idle+127
    cpu_startup_entry+41
    start_secondary+296
]: 383

Cả hai stack đọc ra ngay được. Stack đầu là dd đọc /dev/zero: từ entry_SYSCALL_64__x64_sys_readvfs_readrep_stos_alternative (lệnh x86 ghi 0 hàng loạt để điền buffer) — đường đọc kernel, 95 mẫu chỉ riêng một biến thể offset. Stack thứ hai là vòng idle: do_idle → arch_cpu_idle → pv_native_safe_halt — CPU đang ngủ, 383 mẫu. Profiling phơi ra cả hai: nơi CPU làm việc thật và nơi nó rảnh.

Gộp theo tiến trình: ai ngốn CPU

Đổi khóa từ stack sang tên tiến trình để xem ai chiếm CPU:

sudo bpftrace -e 'profile:hz:99 { @samples[comm] = count(); }'
@samples[kubelet]:       11
@samples[containerd-shim]: 9
@samples[cilium-agent]:   4
@samples[swapper/1]:    215
@samples[swapper/0]:    237
@samples[dd]:           479

dd chiếm áp đảo (479 mẫu) — đúng tải ta tạo. swapper/0swapper/1tiến trình idle của CPU 0 và 1 (mỗi CPU có một swapper); mẫu rơi vào swapper nghĩa là CPU đó đang rảnh — cộng lại ~452 mẫu, khớp với hai lõi gần như không tải. Còn lại là hoạt động nền của cụm: kubelet, containerd-shim, cilium-agent. Một bức tranh phân bổ CPU toàn node, lấy được trong vài giây, gần như không nhiễu lên hệ thống.

Đây thật sự là eBPF

Đếm chương trình eBPF trong khi profiler chạy:

during profiling: 141 progs; perf_event progs: 1
after:            140 progs

Đúng một chương trình perf_event được nạp thêm trong lúc profile (140 → 141), rồi gỡ khi thoát (về 140) — bpftrace profile chính là một chương trình eBPF perf_event gắn vào bộ đếm CPU clock, không phải công cụ riêng. (So với Bài 0: node luôn về 140 chương trình nền của Cilium.)

Nền của flame graph

Dữ liệu @[kstack] = count() là đầu vào của flame graph. Mỗi stack được "gấp" thành một dòng func1;func2;func3 <count>, rồi công cụ flamegraph.pl vẽ thành biểu đồ: trục ngang là tỉ lệ mẫu (độ rộng = thời gian CPU), trục dọc là độ sâu stack. Hàm rộng ở đỉnh là nơi CPU thực sự cháy. Toàn bộ phép gộp đã làm trong nhân (stackmap + count); userspace chỉ nhận về danh sách stack đã đếm — nên profile được cả production mà không sập tải. Đây là cách Parca, Pyroscope, perf cờ-eBPF dựng profiler liên tục cho cả cụm.

🧹 Dọn dẹp

bpftrace tự gỡ chương trình perf_event + stackmap khi thoát; không có gì để dọn tay, node về 140 chương trình. Lệnh ở github.com/nghiadaulau/ebpf-from-scratch, thư mục 17-cpu-profiling.

Tổng kết

CPU profiling không đo từng lần gọi mà lấy mẫu thống kê: gắn một chương trình eBPF loại perf_event vào bộ đếm CPU-clock của nhân, mỗi lần nổ (99 lần/giây trên mỗi CPU) thì chụp stack hiện tại bằng bpf_get_stackid vào một stackmap và gộp count() ngay trong nhân. Profile node thật thấy dd đọc /dev/zero qua vfs_read → rep_stos_alternative, lõi rảnh nằm trong vòng idle do_idle → pv_native_safe_halt; gộp theo tiến trình ra dd 479 mẫu, swapper/0,1 (idle task mỗi CPU) ~452. Dùng 99Hz (số nguyên tố) để tránh lock-step với timer 100Hz của nhân. Đếm chương trình xác nhận đây là eBPF (140→141, đúng 1 perf_event). Dữ liệu stack-đã-đếm này chính là đầu vào của flame graph, và vì gộp trong nhân nên profile được production liên tục — nền của Parca/Pyroscope.

Bài 18 hỏi câu ngược lại: không phải "CPU bận gì" mà "tại sao tiến trình không chạy" — đo thời gian off-CPU (chờ I/O, chờ khóa, chờ lượt CPU) bằng các tracepoint scheduler, để thấy độ trễ mà profiling on-CPU không bao giờ thấy.

Related Posts