Suốt hai mươi lăm phần, sê-ri này đo — và mọi con số đều dựa vào một việc tưởng chừng tầm thường: đọc đồng hồ. Nhưng đã đến lúc thú nhận rằng chính cái việc đọc đồng hồ ấy đầy cạm bẫy, và nếu làm sai thì mọi phép đo phía trên đều sai theo. Nên phần này tôi quay ố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 vẫn đo (một vòng lớn, lấy min, warmup) là đúng.
Hãy nghĩ tới cái đồng hồ bấm giây bạn cầm để đo một vận động viên chạy. Có hai vấn đề chẳng ai để ý. Một là cái đồng hồ có thể chỉ nhảy số mỗi phần mười giây — mịn tới đó thôi, nên đo một cú búng tay 20 mili-giây bằng nó là vô nghĩa. Hai là chính tay bạn bấm nút cũng mất thời gian phản xạ; nếu thứ cần đo còn ngắn hơn cái độ trễ bấm nút ấy, con số hiện ra chủ yếu là độ trễ của bạn chứ không phải của vận động viên. Bộ đếm thời gian trong CPU mắc đúng hai bệnh đó, và tệ hơn là nó còn không đo cái đơn vị mà cái tên của nó gợi ra. 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ó. Rất nhiều người (tôi từng nằm trong số đó) tưởng đọc nó ra là "đếm số chu kỳ CPU". Đọc thử là lộ ngay:
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 hay 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 hẳn nhau, lệch gần hai trăm lần. Muốn ra thời gian thì phải nhân với 1/cntfrq; muốn chu kỳ CPU thật thì 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 — một cú sai không nhỏ chút nào.
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 bé như ta tưởng:
đọ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++ thực sự 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 được 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 đúng là cái đồng hồ bấm giây với ngón tay chậm phản xạ ở đầu bài. Cũng chính là lý do cả sê-ri 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 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 hơn. 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ồ — đoạn 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ô cho chính xác cần một rào cản (isb/barrier) để cố định thứ tự. Hai: trong máy ảo hay 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). Nên luôn phải báo trung thực số đo lấy trong môi trường nào; giấu đi là tự lừa mình lẫn người đọc.
Hai giả định tiện lợi bị đo cho gãy
Tôi vào đo với hai giả định rất tiện. Thứ nhất: "đọc cntvct là đếm chu kỳ CPU, cứ lấy hiệu hai lần đọc là ra số chu kỳ". Con số dập tan: cntvct chạy 24 MHz, một tick bằng 41,67 ns bằng ~185 chu kỳ CPU. Coi mỗi tick là một chu kỳ là 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 hàng 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". Cũng 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 thêm nhiễu (ngắt hệ điều hành, đổi tần số, out-of-order). Đây chính là gốc rễ của phương pháp mà cả sê-ri bám theo: đ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). Thiếu mấy bước ấy thì mọi con số nhỏ đều đáng ngờ.
Về đến code hằng ngày
Việc đầu tiên: hiểu đơn vị của đồng hồ mình dùng. cntvct, QueryPerformanceCounter, clock_gettime đều 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 một tick của bạn là bao nhiêu giây, đừng đoán.
Việc 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ờ.
Điều an ủi là ở tầng cao, gần như không ai bắt bạn tự đọc thanh ghi timer nữa — và đó không phải tình cờ, mà vì mấy cạm bẫy này quá dễ vấp nên người ta đã đóng gói sẵn cách làm đúng. JMH của Java chẳng hạn: nó tồn tại phần lớn để lo hộ bạn đúng ba thứ trên — chạy warmup, lặp nhiều vòng, chặn JIT tối ưu mất đoạn cần đo, và cảnh báo khi phép đo ngắn tới mức chạm sàn phân giải của đồng hồ. Go có testing.B với b.N tự tăng cho tới khi vòng đủ dài để con số ổn định — chính là "đo một vòng, không đo một thao tác" được thể chế hóa. Ngay cả EXPLAIN ANALYZE của database cũng phải dè chừng: bật đo thời gian cho từng node làm truy vấn chậm đi thấy rõ trên máy mà đọc đồng hồ đắt, vì mỗi node gọi clock nhiều lần — đúng cạm bẫy thứ hai, ở qui mô câu truy vấn. Nói cách khác, mọi framework benchmark tử tế đều được xây quanh đúng những bài học này; hiểu chúng thì bạn đọc kết quả của các công cụ đó tỉnh táo hơn nhiều, và biết khi nào nên nghi ngờ một con số.
Muốn tự sờ vào thì nhanh thôi. 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ỳ — ra ~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 rồi 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 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ồ.