Suốt hai mươi lăm phần, sê-ri này đo — và mọi con số đều dựa vào việc đọc đồng hồ. Nhưng bản thân việc đọc đồng hồ đầy cạm bẫy, và nếu làm sai thì mọi phép đo sau đều sai theo. Phần này lật ngược ống kính vào chính công cụ đo: bộ đếm chu kỳ/thời gian của CPU, những cái bẫy khi đọc nó, và vì sao cách sê-ri này đo (một vòng lớn, lấy min, warmup) là đúng. Tôi đo trong container gcc:13 trên host ARM, và ba cạm bẫy hiện ra rất rõ.
Cạm bẫy 1: "bộ đếm chu kỳ" không đếm chu kỳ
ARM có thanh ghi cntvct_el0 — một bộ đếm thời gian đọc được từ user space — và cntfrq_el0 cho biết tần số của nó. Nhiều người tưởng đọc nó là "đếm số chu kỳ CPU". Đọc thử ra sự thật:
Host ARM (trong VM), đọc bằng lệnh mrs:
cntfrq_el0 (tần số bộ đếm timer) : 24 000 000 Hz = 24 MHz (CỐ ĐỊNH)
CPU thật (đo bằng chuỗi add) : ~4,44 GHz (0,225 ns mỗi add)
-> 1 tick cntvct = 1/24MHz = 41,67 ns = ~185 chu kỳ CPU !
Bộ đếm timer chạy ở 24 MHz cố định — không đổi dù CPU tăng/giảm tần số. Còn CPU chạy ~4,44 GHz (đo bằng một chuỗi phép cộng phụ thuộc, mỗi phép 1 chu kỳ = 0,225 ns, phần 17). Nghĩa là một tick của cntvct bằng 41,67 ns, tức khoảng 185 chu kỳ CPU — không phải một. "Tick timer" và "chu kỳ CPU" là hai đơn vị khác nhau, chênh nhau gần hai trăm lần. Muốn ra thời gian bạn phải nhân với 1/cntfrq; muốn chu kỳ CPU thật cần một bộ đếm khác (PMCCNTR, thường không cho user space đọc). Gọi cntvct là "cycle counter" rồi coi mỗi tick là một chu kỳ là sai gấp 185 lần.
Cạm bẫy 2: đọc đồng hồ không miễn phí
Mỗi lần đọc đồng hồ tốn thời gian, và số đó không nhỏ:
đọc cntvct_el0 (lệnh mrs) : 0,228 ns/lần (~1 chu kỳ CPU)
steady_clock::now() : 12,95 ns/lần (~57 chu kỳ CPU !)
Đọc trực tiếp cntvct rất rẻ (~1 chu kỳ). Nhưng std::chrono::steady_clock::now() — thứ hầu hết code C++ dùng — tốn tới 12,95 ns mỗi lần gọi (~57 chu kỳ). Hệ quả then chốt: nếu bạn đo một đoạn code ngắn hơn overhead của đồng hồ, kết quả là rác — phần lớn thời gian bạn đo là chi phí gọi đồng hồ, không phải code. Đo một phép cộng (0,2 ns) bằng cách gọi now() trước và sau (13 ns mỗi lần) thì bạn đang đo now(), không đo phép cộng. Đây là lý do không bao giờ đo một thao tác đơn lẻ; phải đo một vòng hàng triệu lần rồi chia, để overhead loãng ra gần như bằng không.
Cạm bẫy 3: out-of-order và ảo hóa
Còn hai cái bẫy tinh vi. Một: lõi thực thi ngoài thứ tự có thể sắp xếp lại lệnh quanh phép đọc đồng hồ — code bạn tưởng nằm giữa hai lần đọc có thể bị đẩy ra ngoài, làm phép đo lệch. Đo vi mô chính xác cần một rào cản (isb/barrier) để cố định thứ tự. Hai: trong máy ảo/Docker, bộ đếm có thể bị ảo hóa — giá trị và tần số không phản ánh phần cứng thật (ở đây 24 MHz là tần số timer ảo mà VM trình ra). Luôn báo trung thực rằng số đo lấy trong môi trường nào.
Một lần tôi đo hớ: "bộ đếm chu kỳ đếm chu kỳ" và "gọi clock miễn phí"
Tôi vào đo với hai giả định tiện lợi. Thứ nhất: "đọc cntvct là đếm chu kỳ CPU, cứ lấy hiệu hai lần đọc là ra số chu kỳ". Đo phá tan: cntvct chạy 24 MHz, một tick = 41,67 ns = ~185 chu kỳ CPU. Nếu tôi coi mỗi tick là một chu kỳ, tôi sai 185 lần. Bộ đếm này đo thời gian (theo tần số cố định của nó), không đo chu kỳ CPU; hai thứ chỉ trùng nhau nếu CPU cũng chạy đúng 24 MHz — điều không bao giờ đúng với CPU hiện đại chạy GHz và đổi tần số liên tục.
Thứ hai: "gọi hàm lấy thời gian là miễn phí, đo đoạn nào cũng được". Sai: steady_clock::now() tốn ~13 ns mỗi lần (~57 chu kỳ). Đo một đoạn ngắn hơn thế thì con số nhận được chủ yếu là overhead của chính đồng hồ, cộng với nhiễu (ngắt hệ điều hành, đổi tần số, out-of-order). Đây chính là lý do phương pháp của cả sê-ri là: đo một vòng nhiều triệu lần rồi chia (overhead loãng đi), lấy min qua nhiều lần chạy (loại nhiễu ngẫu nhiên — thời gian nhỏ nhất là lần ít bị quấy rầy nhất), và warmup trước (để cache, tần số, dự đoán nhánh ổn định). Không có mấy bước ấy, mọi con số nhỏ đều đáng ngờ.
Bài học đo lường: đọc đồng hồ có cạm bẫy. (1) 'bộ đếm chu kỳ' cntvct_el0 là timer TẦN SỐ CỐ ĐỊNH (đo: 24 MHz) — 1 tick = 41,67 ns = ~185 chu kỳ CPU (CPU ~4,44 GHz), KHÔNG phải 1; nhân 1/cntfrq ra thời gian. (2) gọi clock tốn: cntvct 0,228 ns nhưng steady_clock::now() 12,95 ns (~57 ck) — đo đoạn ngắn hơn = rác. (3) OOO cần barrier; VM ảo hóa counter. CÁCH ĐÚNG: đo một vòng triệu lần rồi chia, lấy MIN nhiều lần, warmup. 'Bộ đếm chu kỳ = chu kỳ CPU' và 'gọi clock miễn phí' đều SAI. Nếu tin "tick = chu kỳ" tôi sai 185 lần; nếu tin "clock miễn phí" tôi đo rác trên đoạn ngắn.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: hiểu đơn vị của đồng hồ mình dùng. cntvct/QueryPerformanceCounter/clock_gettime cho thời gian (nhân tần số bộ đếm ra ns), không cho chu kỳ CPU. rdtsc trên x86 cũng là bộ đếm tần số cố định (invariant TSC), không phải cycle count thật. Đọc tài liệu để biết tick của bạn là bao nhiêu giây, đừng đoán.
Hệ quả thứ hai: đo một vòng, không đo một thao tác. Vì mỗi lần đọc đồng hồ tốn cả chục nanô giây, hãy chạy thao tác cần đo hàng triệu lần trong một vòng rồi chia tổng thời gian — để overhead đồng hồ và nhiễu loãng đi. Lấy min qua nhiều lần chạy (không phải trung bình — min gần với "chi phí thuần" nhất), và warmup để mọi thứ ổn định trước khi bấm giờ.
Hệ quả thứ ba là tinh thần đo lường: công cụ đo cũng cần được đo và hiểu — nếu không, mọi kết luận đều xây trên cát. Con số mang theo: tick timer (cntvct 24 MHz, 41,67 ns) != chu kỳ CPU (~0,225 ns); steady_clock::now() ~13 ns/lần; đo vòng lớn + min + warmup mới đáng tin. Cả sê-ri này đo được là nhờ tôn trọng chính những cạm bẫy ấy — và bạn cũng nên, mỗi khi định tin một con số hiệu năng.
Thử ba mươi giây
Trên một máy ARM, đọc cntfrq_el0 bằng asm volatile("mrs %0, cntfrq_el0":"=r"(v)) — bạn sẽ thấy một con số như 24 000 000 (24 MHz), không phải tần số CPU vài GHz. Rồi đo tần số CPU thật bằng một chuỗi phép cộng phụ thuộc (for(i) asm("add %0,%0,#1")) chia thời gian ra ns mỗi add ≈ một chu kỳ — bạn sẽ thấy ~0,2 ns, tức vài GHz. Chia hai con số: một tick timer bằng cả trăm chu kỳ CPU. Cuối cùng, đo overhead của steady_clock::now(): gọi nó vài chục triệu lần trong một vòng và chia — bạn sẽ thấy cả chục nanô giây mỗi lần gọi, đủ để nuốt trọn một thao tác ngắn nếu bạn dại dột đo từng cái một. Ba mươi giây đó cho bạn thấy điều mà "đọc đồng hồ là biết thời gian" giấu đi: bộ đếm có đơn vị riêng (không phải chu kỳ CPU), việc đọc nó tốn tiền, và chỉ khi đo một vòng lớn nhiều lần rồi lấy min, bạn mới thấy được con số thật thay vì tiếng ồn của chính cái đồng hồ.