Năm mươi phần, mỗi phần một phép đo. Bài cuối này 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 thường gặp — và một danh sách kiểm rút ra từ chính những lần tôi đo sai trong sê-ri. 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.
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 có bậc nào bỏ trống.
Điều đáng nhớ
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.
Nói lại theo cách khác, vì đây là điều đáng nhớ nhất của cả sê-ri: 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.
Và điều đó cũng giải thích một chỗ vênh trong chính sê-ri này: 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 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 biên dịch bằng gcc.
Vì sao
Năm phép đo không đổi có một điểm chung: 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 — mã đó do nhân cung cấp, không phải do 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 nó. 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ỏ.
Phép đo thứ sáu thì khác: 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 ra 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, khác nhau ở chỗ có bao nhiêu lệnh nạp đang bay cùng lúc.
Đó cũng chính là lý do vì sao con số 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.
- 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.
- Đọ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.
- So với giới hạn vật lý.
memsetra 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. - 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.
- Đ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.
- Bỏ đệm đầu ra khi chương trình có thể chết.
stdoutghi vào đường ống là đệm đầy; mộtSegmentation faultnuốt sạch mọi thứ đã in, và bạn sẽ tưởng nó chết ở dòng đầu tiên. - Chạy trên nhiều nền trước khi tin. Một lỗi dùng bộ nhớ sau
freechạy im re trên macOS vàSegmentation faultcả ba lần trên glibc. - 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.
- 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í.
- Trong container, đọc chỉ số của container.
nprocvàloadavgnói về cả máy chủ.cpu.statvà PSI mới nói về bạn.
Nghĩa là gì trong thực tế
- 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 —
fsynctố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; một lần trượt bộ đệm 75 ns đắt bằng 109 lời gọi hàm.
- Đừng mượn số của người khác. Bảng này là của một máy ảo 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 của bảng này 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.
Chỗ tôi không kết luận được
Tôi không mở mã máy ra để xác nhận vì sao clang nhanh hơn. Lời giải thích "clang mở vòng lặp sâu hơn nên có nhiều lệnh nạp song song hơn" là suy luận từ hình dạng số liệu, không phải từ việc đọc lệnh sinh ra. Muốn chốt thì phải đọc mã hợp ngữ của cả hai, và đó là một bài khác.
Bảng này đo trong máy ảo. Con số 10 — một vòng đợi giữa hai luồng, 16,87 µs — cao hơn hẳn mức thường thấy trên máy chủ Linux vật lý, và phần 48 đã cho thấy lớp ảo hoá tính tiền nặng nhất đúng vào những thứ đi qua bộ lập lịch. Tôi để nguyên con số đó kèm cảnh báo, chứ không hiệu chỉnh nó bằng một hệ số đoán.
Con số ghi đĩa 3 137 MB/s không phải tốc độ của đĩa. Nó đi qua bộ đệm trang và hệ thống tệp chồng lớp của container. Con số dùng được để so sánh là fsync, vì nó buộc dữ liệu xuống tới nơi.
Sê-ri dừng ở đây. Nếu bạn chỉ giữ lại một câu, hãy giữ câu này: con số bạn cần gần như luôn là con số của chính chương trình bạn đang chạy, đo bằng chính nó — mọi bảng có sẵn, kể cả bảng ở trên, chỉ dùng để biết mình có đang đi lạc một bậc độ lớn hay không.
Thử 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".