Người ta hay hình dung ba cách chạy phần mềm — chạy thẳng, trong container, trong máy ảo — như ba mức "xa phần cứng" tăng dần, mỗi mức lấy đi một ít tốc độ. Nghe gọn, nhưng nó sai gần như mọi chỗ. Sự thật giống một mê cung hơn: có những cánh cửa bạn tưởng dày mà bước qua chẳng tốn gì, và có một cánh cửa trông vô hại — chạy nhầm kiến trúc CPU — mà bước qua thì như phải phiên dịch từng chữ một cuốn từ điển. Bài này tôi đo cả ba cửa, 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.
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. (Đo trên Apple M4 thật; các con số là băng thông bộ nhớ và thời gian syscall, không phải perf counter phần cứng.)
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.
Ba 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ửa này dày trông thấy mà bước qua chẳng tốn gì.
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, 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 tính tiền khác nhau
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 một lệnh làm rất nhiều việc — đúng loại lệnh mà trình phiên dịch xử lý tệ nhất, vì 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 công việc hằng ngày
- Đừ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.
Cùng một cái bẫy "lớp trừu tượng tính tiền không đều", đổi ngăn xếp
Chuyện "một lớp trừu tượng có chi phí rất không đều tuỳ loại thao tác, nên không có một hệ số chung để nhân" không riêng gì emulation kiến trúc — nó là bản chất của mọi lớp phiên dịch.
Trong thế giới Java, đây đúng là câu chuyện của JIT và interpreter. Bytecode chạy qua interpreter chậm hơn mã đã JIT không phải một hệ số cố định: một vòng lặp số học nóng, sau khi JIT vào cuộc, gần bằng C; còn code chạy một lần (khởi động, phản chiếu) thì gánh trọn chi phí interpreter. Và giống hệt bài này, chi phí không đều: một thao tác được vector hoá bởi JIT nhanh gấp bội cùng thao tác chạy interpreter, trong khi một lời gọi JNI (ra khỏi JVM, như "vào nhân" ở đây) lại ít bị ảnh hưởng bởi việc JIT đã ấm hay chưa.
Trong Go, cùng bài học hiện ra khi cross-compile và cgo. Một binary Go thuần cross-compile sang kiến trúc khác chạy bản địa (nhanh), nhưng bật cgo là kéo theo toolchain C và có thể phải emulate nếu chạy sai nền — và chi phí ấy tập trung ở ranh giới cgo, không rải đều. Đây đúng tinh thần "đo tỷ lệ thời gian nằm ở phần bị phạt".
Còn ở cơ sở dữ liệu, họ hàng gần nhất là chi phí của một lớp truy vấn trừu tượng: một ORM không làm mọi truy vấn chậm đi một hệ số cố định — một findById đơn giản gần như không phạt, nhưng một vòng lặp sinh N+1 query (đã đo ở bài trace-DB) thì phạt gấp bội, còn một truy vấn phân tích nặng thì chi phí ORM biến mất trong thời gian thực thi SQL. Không có "ORM chậm X%".
Mẫu số chung: chi phí của một lớp trừu tượng không bao giờ là một hệ số nhân duy nhất — nó tập trung ở đúng loại thao tác mà lớp đó phải làm việc nhiều nhất, và biến mất ở loại thao tác nó chỉ đi ngang qua. Nên "chạy trong X chậm khoảng N lần" gần như luôn là một câu tóm tắt sai một thứ không tóm tắt được. Cách duy nhất biết cái giá thật là đo chính tải của mình, và nhìn xem thời gian dồn vào loại thao tác bị phạt nặng hay loại được đi ngang. Câu hỏi đúng không phải "lớp này chậm bao nhiêu" mà là "tải của tôi nằm nhiều ở phần lớp này phải phiên dịch, hay phần nó để yên".
Muốn tự thấy cái bẫy 174 lần bằng một cờ, chạy thử:
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ó.