Hỏi "con đường này chạy được bao nhiêu km/h" là một câu thiếu vế. Cùng con đường ấy, một chiếc xe đua và một chiếc xe tải cho hai con số khác hẳn — tốc độ không phải thuộc tính của riêng con đường, nó là thuộc tính của cặp đường-và-xe. Băng thông bộ nhớ của một máy tính y hệt vậy, và cả sê-ri năm mươi phần này dẫn tôi tới đúng bài học đó một cách cụ thể tới mức đo được. Bài cuối gom lại thành hai thứ mang đi được — một bảng số nền mười con số đủ ước lượng gần như mọi quyết định hiệu năng, và một danh sách kiểm rút từ chính những lần tôi đo sai — rồi tôi chạy bảng đó hai lần với hai trình biên dịch, và một con số trong đó đổi mất một nửa.

(Các con số đo trên VM Apple M4, là thời gian và băng thông, không phải perf counter phần cứng.)

Bảng số nền

Một chương trình C duy nhất, chạy trong container debian:12-slim trên Apple M4, biên dịch bằng gcc 12.2 -O2. Trung vị của ba lần chạy:

Phép đo Kết quả
1 Gọi hàm thường 0,69 ns
2 write(/dev/null, 1 byte) 115,14 ns
3 clock_gettime qua vDSO 11,41 ns
4 Đọc tuần tự bộ nhớ 34,54 GB/s
5 memcpy 38,50 GB/s
6 Độ trễ bộ nhớ ngẫu nhiên 75,35 ns
7 Ghi đĩa tuần tự 256 MB 3 137 MB/s
8 fsync sau khi ghi 1 byte 1,245 ms
9 fork + wait 120,1 µs
10 Một vòng đợi giữa hai luồng 16,87 µs

Quy đổi ra bậc thang, lấy lời gọi hàm làm đơn vị: syscall 165 lần, độ trễ bộ nhớ ngẫu nhiên 109 lần, fork 1 043 lần, fsync 10 813 lần. Bốn con số đó là toàn bộ trực giác cần có: mỗi bậc cách nhau khoảng một bậc độ lớn, và không bậc nào bỏ trống.

Khi cùng một file nguồn cho hai con số

Sau khi có bảng, tôi biên dịch lại đúng cùng file nguồn bằng clang 14 thay vì gcc 12.2:

Phép đo gcc 12.2 clang 14 Chênh
Gọi hàm thường 0,70 ns 0,70 ns 1,00×
write(/dev/null) 115,10 ns 116,00 ns 1,01×
clock_gettime 11,38 ns 11,39 ns 1,00×
Độ trễ bộ nhớ ngẫu nhiên 74,85 ns 73,28 ns 0,98×
memcpy 40,56 GB/s 44,77 GB/s 1,10×
Đọc tuần tự bộ nhớ 34,55 GB/s 58,20 GB/s 1,68×

Năm dòng đầu không đổi. Dòng cuối đổi 1,68 lần. Đây là điều đáng nhớ nhất của cả sê-ri, nên tôi nói thẳng: không tồn tại "băng thông bộ nhớ của máy này". Chỉ tồn tại băng thông của một cặp — cái máy, và đoạn mã bạn dùng để đọc nó. Đổi trình biên dịch mà không đổi một ký tự nào trong mã nguồn, con số đã đi mất một phần ba. Điều đó cũng giải thích một chỗ vênh trong chính sê-ri: phần 48 đo được 63,61 GB/s cho đúng phép đo đó, còn bảng trên cho 34,54. Tôi từng tưởng máy lúc ấy rảnh hơn — không phải, phần 48 biên dịch bằng clang, bảng này bằng gcc.

Vì sao năm dòng kia đứng yên còn dòng cuối nhảy? Vì năm phép đo không đổi có chung một điểm: phần đắt nhất của chúng không nằm trong mã do trình biên dịch sinh ra. write() tiêu gần hết thời gian bên trong nhân. clock_gettime chạy mã của vDSO — do nhân cung cấp, không phải trình biên dịch của bạn sinh. Độ trễ bộ nhớ ngẫu nhiên là thời gian chờ vật lý của DRAM, không lệnh nào rút ngắn được. memcpy gọi vào libc, và cả hai trình biên dịch đều gọi đúng cái hàm đó của cùng một thư viện — chênh 10% chỉ là phần vỏ. Còn phép đo thứ sáu thì khác hẳn: nó là một vòng lặp for do tôi viết, và trình biên dịch được tự do sinh bất cứ thứ gì tương đương. Ở đây clang mở vòng lặp sâu hơn và phát đủ số lệnh nạp song song để giấu độ trễ bộ nhớ, còn gcc thì không — cùng một máy, cùng một RAM, chỉ khác ở chỗ có bao nhiêu lệnh nạp đang bay cùng lúc. Đó là lý do 34,54 GB/s vẫn đúng mà không hữu ích: nó đúng cho mã mà gcc sinh ra, và không nói gì về mã của bạn.

Danh sách kiểm khi đo

Mười điều dưới đây không phải lý thuyết — mỗi điều tương ứng với một lần tôi đã đo sai trong sê-ri này, kèm cách nó lộ ra.

  1. Lặp lại ít nhất ba lần. Một lần đo không phải một phép đo. Đã có lần đo được "ext4 nhanh gấp 3,2 lần xfs"; chạy lại ba lần thì hai dải trùm lên nhau.
  2. Đọc lại dữ liệu để chứng minh công việc đã xảy ra. Trình biên dịch bỏ vòng lặp là lỗi phổ biến nhất. Kiểm tổng phải quét toàn bộ vùng nhớ — có lần tôi kiểm đúng một byte, và byte đó tình cờ đúng cả khi phép sao chép không hề chạy.
  3. So với giới hạn vật lý. memset ra 380 GB/s trên một máy có băng thông khoảng 120 GB/s là bằng chứng, không phải thành tích.
  4. Kiểm tính nhất quán nội tại của chính bảng kết quả. Một lần chạy cho "lời gọi hàm 6,26 ns" và "phép nhân có phụ thuộc 0,31 ns" — không cỗ máy nào như vậy, nên con số thứ hai bị loại.
  5. Đo đúng một việc tại một thời điểm. Chạy hai phép đo song song thì cả hai đều vô nghĩa.
  6. Bỏ đệm đầu ra khi chương trình có thể chết. stdout ghi vào đường ống là đệm đầy; một Segmentation fault nuốt sạch mọi thứ đã in, và bạn sẽ tưởng nó chết ở dòng đầu tiên.
  7. Chạy trên nhiều nền trước khi tin. Một lỗi dùng bộ nhớ sau free chạy im re trên macOS và Segmentation fault cả ba lần trên glibc.
  8. Ghi lại trình biên dịch và phiên bản cùng với con số. Chính bài này là bằng chứng: thiếu dòng đó thì bảng số trở nên không tái lập được.
  9. Kiểm rằng cờ bạn truyền thật sự có tác dụng. Một lần tôi so có và không có seccomp, nhưng shell không tách từ nên cờ đi thành một chuỗi duy nhất. May là Docker báo lỗi; nếu nó lặng lẽ bỏ qua, tôi đã kết luận seccomp miễn phí.
  10. Trong container, đọc chỉ số của container. nproc và loadavg nói về cả máy chủ; cpu.stat và PSI mới nói về bạn.

Ra thực tế thì dùng bảng số nền để loại trừ, không phải để dự đoán: nếu một thao tác được cho là "chờ đĩa" mà chỉ mất 50 µs, nó không hề chạm đĩa — fsync tốn 1,245 ms. Trước khi tối ưu, xác định mình đang ở bậc nào: giảm số lời gọi hệ thống là vô ích nếu vòng lặp nóng đang chờ bộ nhớ ngẫu nhiên, vì một lần trượt bộ đệm 75 ns đắt bằng 109 lời gọi hàm. Và đừng mượn số của người khác — bảng này là của một VM trên Apple M4, con số của bạn sẽ khác, nhưng bậc thang giữa chúng thì hầu như không đổi, và đó mới là thứ đáng nhớ; cách dùng đúng nhất là chạy lại nó trên chính máy chủ của bạn rồi dán kết quả vào tài liệu vận hành.

Mẫu số chung

Cái phát hiện "không có băng thông của riêng cái máy, chỉ có băng thông của cặp máy-và-mã" không đóng khung trong C với Linux — nó là một sự thật về đo lường mà mọi tầng đều vấp.

Trong thế giới Java, đây đúng là lý do người ta không đo hiệu năng bằng một System.nanoTime() quanh vòng lặp mà phải dùng JMH: con số của cùng một đoạn Java đổi hẳn theo trạng thái JIT — chạy nguội thì interpreter, chạy đủ nóng thì C2 biên dịch lại và inline khác đi. "Tốc độ của phương thức này" vô nghĩa nếu không kèm "sau bao nhiêu vòng warmup, với JIT nào" — đúng như băng thông vô nghĩa nếu không kèm trình biên dịch.

Trong Go, go test -bench tồn tại vì cùng lý do, và cái bẫy y hệt: trình biên dịch Go có thể tối ưu bay cả vòng lặp benchmark nếu kết quả không được dùng, nên có hẳn quy ước gán vào một biến package (sink = result) để chặn — chính là phiên bản Go của "đọc lại dữ liệu để chứng minh công việc đã xảy ra" trong danh sách kiểm.

Còn ở cơ sở dữ liệu, cùng sự thật mang tên khác: "chi phí của câu query này" không tồn tại tách rời — nó là cặp câu-query-và-kế-hoạch, mà kế hoạch đổi theo thống kê bảng, theo phiên bản planner, theo index đang có. Cùng một câu SQL trên cùng dữ liệu cho EXPLAIN hai kế hoạch khác nhau sau một lần ANALYZE, đúng như cùng một file C cho hai băng thông sau khi đổi trình biên dịch.

Mẫu số chung: một con số hiệu năng không bao giờ là thuộc tính của riêng một thứ — nó luôn là thuộc tính của một cặp: cái máy và đoạn mã, phương thức và trạng thái JIT, câu query và kế hoạch — nên một con số không ghi kèm nửa kia của cặp là một con số không tái lập được. Vì thế câu hỏi đúng khi cầm bất kỳ số benchmark nào không phải "nó nhanh hay chậm" mà là "nó là số của cặp nào" — và nếu bạn không kể được nửa kia, bạn chưa thật sự đo được gì.

Muốn tự thấy khoảng cách ấy, chạy cùng một file nguồn bằng hai trình biên dịch trong ba mươi giây:

cat > /tmp/n.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static long now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
  return t.tv_sec*1000000000L+t.tv_nsec;}
int main(void){size_t SZ=64ul<<20; char*a=malloc(SZ); memset(a,7,SZ);
  unsigned long acc=0; const unsigned long*p=(const unsigned long*)a;
  long t0=now();
  for(int r=0;r<10;r++){unsigned long s=0; for(size_t i=0;i<SZ/8;i++) s+=p[i]; acc+=s;}
  long t1=now();
  printf("doc tuan tu %.2f GB/s   (acc=%lu)\n",(double)SZ*10/(t1-t0),acc);
  return 0;}
EOF

docker run --rm -v /tmp/n.c:/n.c:ro debian:12-slim sh -c '
  apt-get -qq update && apt-get -qq install -y gcc clang
  gcc -O2 -o /ng /n.c && clang -O2 -o /nc /n.c
  echo -n "gcc   : "; /ng
  echo -n "clang : "; /nc'

Hai dòng, cùng một file nguồn, cùng một máy. Khoảng cách giữa chúng chính là khoảng cách giữa "băng thông của máy" và "băng thông của mã bạn viết".