Phần trước đo được một lời gọi hệ thống tốn 100 ns, và tôi đã nói thẳng rằng không tách được phần nào trong đó là chi phí ảo hoá. Bài này tách. Cùng một file C, cùng cờ -O2, chạy ở ba nơi trên cùng một chiếc máy — và kết quả không giống bất kỳ suy đoán nào tôi mang vào lúc bắt đầu.
Bảng số liệu
Máy: Apple M4 10 nhân. Ba nơi:
- Chạy thẳng — macOS arm64, biên dịch bằng Apple clang 21.
- Container arm64 —
debian:12-slim, bên trong máy ảolinuxkitcủa Docker Desktop, clang 14. - Container x86_64 — cùng ảnh Debian nhưng
--platform linux/amd64, tức có thêm một lớp phiên dịch kiến trúc.
Mỗi con số là trung vị của ba lần chạy độc lập.
| Phép đo | Chạy thẳng | Container arm64 | Container x86_64 |
|---|---|---|---|
| Vòng lặp rỗng | 0,23 ns | 0,23 ns | 0,31 ns |
| Gọi hàm thường | 0,69 ns | 0,70 ns | 6,26 ns |
| Chuỗi nhân–cộng | 0,91 ns | 0,91 ns | 0,31 ns (loại) |
clock_gettime |
13,59 ns | 11,41 ns | 85,46 ns |
write(/dev/null, 1B) |
340,76 ns | 114,69 ns | 201,61 ns |
| Đọc tuần tự 64 MB | 69,92 GB/s | 63,61 GB/s | 11,76 GB/s |
memset 64 MB |
73,16 GB/s | 72,97 GB/s | 0,42 GB/s |
memcpy 64 MB |
48,39 GB/s | 46,85 GB/s | 13,51 GB/s |
Ba lần chạy ở nơi 2 và nơi 3 gần như trùng khít: write cho 114,82 / 114,69 / 114,56 ns, memset cho 0,42 / 0,42 / 0,43 GB/s.
Điều đáng nhớ
Nói lại theo cách khác, vì đây là chỗ trực giác hay sai:
Cái vỏ container gần như miễn phí đối với CPU và bộ nhớ. Gọi hàm 0,70 so với 0,69 ns. memset 72,97 so với 73,16 GB/s. Chênh lệch dưới 3%, tức nằm trong nhiễu của chính phép đo. Namespace và cgroup là cấu hình của nhân, không phải một lớp chắn giữa chương trình và phần cứng — khi mã của bạn đang cộng số hoặc chép byte, nó chạy trên đúng cái CPU đó với đúng tốc độ đó.
Cái đắt là đổi hệ điều hành, và nó đắt theo chiều ngược với điều bạn nghĩ. write(/dev/null, 1 byte) tốn 340,76 ns khi chạy thẳng trên macOS, nhưng chỉ 114,69 ns trong container Linux — Linux nhanh gấp 2,97 lần, dù nó còn phải chui qua một lớp máy ảo. Con đường có nhiều lớp hơn lại về đích trước.
Điều đó nói lên rằng cái tôi đo không phải "chi phí ảo hoá" mà là chi phí của hai đường vào nhân khác nhau. Darwin và Linux xử lý một lời gọi hệ thống theo hai cách khác nhau, và khoảng cách giữa chúng lớn hơn khoảng cách mà lớp ảo hoá tạo ra.
Còn phiên dịch kiến trúc thì tính tiền rất không đều. Đây là phần tôi không đoán trước được:
| Việc | Chậm bao nhiêu lần |
|---|---|
memset |
174× |
| Gọi hàm thường | 8,9× |
clock_gettime |
7,5× |
| Đọc tuần tự | 5,4× |
memcpy |
3,5× |
write() |
1,76× |
Từ 1,76 lần tới 174 lần trong cùng một chương trình. Không có một "hệ số chậm" nào để nhân vào cả.
Vì sao
Ba lớp, ba cơ chế khác nhau, nên chúng tính tiền khác nhau.
Namespace và cgroup chỉ đổi cách nhân nhìn tiến trình: nó thấy bảng tiến trình nào, hệ thống tệp nào, được dùng bao nhiêu bộ nhớ. Không có lớp phiên dịch nào chen vào giữa lệnh máy và CPU. Vì thế mọi phép đo thuần tính toán và thuần bộ nhớ đều không phân biệt được hai cột đầu.
Đường vào nhân thì phụ thuộc hệ điều hành. Đây cũng là chỗ tôi phải nói rõ giới hạn: cột 1 là Darwin, cột 2 là Linux. Tôi không so được "Linux trong VM" với "Linux chạy thẳng", vì trên máy Mac không tồn tại Linux chạy thẳng. Cái tôi so được là hai hệ điều hành, và Linux thắng đủ xa để bù luôn cả phần ảo hoá.
Phiên dịch kiến trúc giải thích được bảng chênh lệch ở trên, một khi biết cái gì đang bị dịch. Docker Desktop dùng QEMU cho linux/amd64 — tôi biết chắc điều đó vì trong một lần chạy hỏng, thông báo lỗi ghi nguyên văn qemu: uncaught target signal 11. QEMU dịch dòng lệnh ở không gian người dùng, còn phần thân của một lời gọi hệ thống vẫn chạy bằng mã máy thật trong nhân. Nên write() chỉ chậm 1,76 lần: phần đắt nhất của nó không bị dịch.
memset nằm ở đầu kia. memset của glibc trên x86-64 dùng các lệnh SIMD rộng và họ lệnh rep stos, tức là một lệnh làm rất nhiều việc. Đó chính là loại lệnh mà trình phiên dịch xử lý tệ nhất: một lệnh máy nguồn nở ra thành cả một vòng lặp lệnh đích. Kết quả là 0,42 GB/s — chậm hơn 174 lần so với chính máy đó khi chạy mã bản địa.
Nghĩa là gì trong thực tế
- Đừng đổ lỗi cho container khi dịch vụ chậm. Nếu tải của bạn là tính toán hoặc xử lý bộ nhớ, container không lấy đi gì đáng kể. Hãy tìm ở chỗ khác: cấu hình cgroup giới hạn CPU, tầng lưu trữ, hay mạng.
- Chạy ảnh khác kiến trúc là quyết định về hiệu năng, không phải về tiện lợi. Một ảnh
linux/amd64trên máy Apple Silicon vẫn chạy — và đó chính là cái bẫy, vì nó chạy có vẻ ổn cho tới khi gặp đúng đoạn mã nặng vềmemset,memcpyhay SIMD. Lúc đó không phải chậm 20%, mà là chậm hai bậc độ lớn. - Đo lại chính tải của bạn, đừng mượn hệ số của người khác. Bảng trên trải từ 1,76× tới 174× cho cùng một chương trình. Bất kỳ ai nói "chạy emulation chậm khoảng N lần" đều đang tóm tắt một thứ không tóm tắt được.
- Nếu buộc phải chạy ảnh khác kiến trúc lâu dài, thứ đáng đo trước tiên là tỷ lệ thời gian nằm trong nhân: phần đó gần như không bị phạt.
Chỗ tôi không kết luận được
Cột 2 đo cả máy ảo lẫn container gộp lại. Trên macOS mọi container đều nằm trong VM linuxkit; tôi không có cách nào tách hai lớp đó bằng chiếc máy này. Muốn tách thật thì phải đo trên một máy chủ Linux vật lý — điều tôi không làm được ở đây, và tôi không định đoán bừa con số đó.
Hai cột đầu dùng hai phiên bản clang khác nhau (21 và 14). Với các phép đo bộ nhớ và syscall thì điều đó gần như không ảnh hưởng, vì tốc độ do phần cứng và nhân quyết định. Với phép đo CPU thuần thì nó ảnh hưởng, và đó dẫn thẳng tới con số tôi phải loại.
Con số 0,31 ns của chuỗi nhân–cộng trên x86_64 là sai và tôi bỏ nó. Cách phát hiện nằm ngay trong chính bảng kết quả: cùng lần chạy đó, một lời gọi hàm tốn 6,26 ns còn một phép nhân có phụ thuộc chỉ tốn 0,31 ns. Không có cỗ máy nào mà phép nhân rẻ hơn lời gọi hàm hai mươi lần. Lời giải thích duy nhất hợp lý là clang trên x86-64 đã biến đổi vòng lặp thành thứ khác — nên hai cột không còn chạy cùng một phép tính, và so sánh chúng là vô nghĩa.
Ba sai lầm khác trong lúc đo, và cách chúng lộ ra.
Thứ nhất, bản đầu tiên của chương trình đọc b[0] sau khi đã free(b). Trên macOS nó chạy im re; trong container glibc nó Segmentation fault cả ba lần. Cùng một mã nguồn, một nơi sai im lặng và một nơi sai ồn ào — thứ tự may mắn, vì nếu chỉ chạy trên macOS tôi đã không bao giờ biết.
Thứ hai, khi đó tôi ghi kết quả vào một đường ống, và printf chuyển sang chế độ đệm đầy. Chương trình chết là toàn bộ kết quả trong bộ đệm mất sạch, nên nhìn log tôi tưởng nó chết ngay từ dòng đầu. Chạy lại bằng stdbuf -o0 thì thấy nó thực ra chạy xong hết rồi mới chết ở dòng cuối.
Thứ ba, nghiêm trọng nhất: phiên bản đầu đo được memset 380 GB/s. Máy này có băng thông bộ nhớ khoảng 120 GB/s, nên con số đó là bất khả thi — trình biên dịch đã suy luận xuyên qua memset và bỏ đi 19 trong 20 lần lặp. Cách chữa là gọi qua một con trỏ hàm volatile, để trình biên dịch không còn biết mình đang gọi memset:
static void *(* volatile vmemset)(void*,int,size_t) = memset;
Sau khi sửa: 70 GB/s, và kiểm tổng trên toàn bộ vùng nhớ khớp chính xác giá trị mong đợi. Bài học tôi rút ra và sẽ mang sang các phần sau: mọi phép đo bộ nhớ phải kèm một kiểm tổng đọc lại toàn bộ dữ liệu, không phải chỉ một byte — byte tôi chọn kiểm ban đầu tình cờ đúng cả khi phép sao chép không hề chạy.
Thử ba mươi giây
cat > /tmp/m.c <<'EOF'
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static void *(* volatile vmemset)(void*,int,size_t) = memset;
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; int IT=20; char*b=malloc(SZ);
vmemset(b,0,SZ);
long t0=now(); for(int r=0;r<IT;r++) vmemset(b,r+1,SZ); long t1=now();
unsigned long s=0; for(size_t i=0;i<SZ;i+=4096) s+=(unsigned char)b[i];
printf("memset %.2f GB/s kiem tong=%lu (phai la %lu)\n",
(double)SZ*IT/(t1-t0), s, (unsigned long)IT*(SZ/4096));
return 0;}
EOF
# ban dia
docker run --rm -v /tmp/m.c:/m.c:ro debian:12-slim sh -c \
'apt-get -qq update && apt-get -qq install -y clang && clang -O2 -o /m /m.c && /m'
# khac kien truc - cung mot lenh, them --platform
docker run --rm --platform linux/amd64 -v /tmp/m.c:/m.c:ro debian:12-slim sh -c \
'apt-get -qq update && apt-get -qq install -y clang && clang -O2 -o /m /m.c && /m'
Hai lệnh chỉ khác nhau đúng một cờ. Nếu kiểm tổng không khớp, hãy vứt con số GB/s đi trước khi kịp tin nó.