Con số này đã xuất hiện gián tiếp ở rất nhiều phần trước — phần 24 với mmap, phần 26 với kích thước gói, phần 41 với log, phần 42 với vDSO. Bài này đo thẳng nó.

Ba thế giới: hàm, vDSO, và nhân

Bảng

Ba triệu lần gọi mỗi loại, ba lần đo:

Gọi cái gì Ba lần đo (ns) So với gọi hàm
Vòng lặp rỗng 0,27 / 0,27 / 0,34
Gọi hàm thường 0,73 / 0,81 / 0,77
Gọi hàm qua con trỏ 0,75 / 0,78 / 0,78 1,0×
clock_gettime qua vDSO 13,30 / 14,92 / 15,98 19×
close(-1)EBADF 128,10 / 141,47 / 134,43 174×
getpid() qua libc 128,06 / 137,41 / 171,68 181×
read(0 byte) từ /dev/null 144,81 / 137,90 / 144,58 182×
write(1 byte) ra /dev/null 147,09 / 148,08 / 168,56 198×

close(-1) là phép đo quan trọng nhất trong bảng

close(-1) truyền một số hiệu tệp không hợp lệ. Nhân kiểm tra, thấy sai, trả về EBADF. Nó không làm bất cứ việc gì.

Và nó tốn 128 ns.

Đó là sàn — cái giá của việc đi vào nhân rồi đi ra, không kèm bất kỳ công việc hữu ích nào. Mọi lời gọi hệ thống khác trong bảng đều nằm trong khoảng 128–168 ns, tức là phần "công việc thật" của read, write, getpid chiếm chưa tới 30% tổng chi phí.

Nói lại lần thứ hai: giá của một lời gọi hệ thống gần như không phụ thuộc vào việc nó làm gì. Đó là lý do mọi tối ưu I/O trong loạt bài này đều có cùng một hình dạng — gom nhiều việc vào ít lời gọi hơn, chứ không phải làm mỗi lời gọi nhanh hơn.

174 lần

Một lời gọi hàm thường tốn 0,77 ns. Một lời gọi hệ thống tốn 134 ns.

Trong 134 ns đó, CPU làm những việc mà một lời gọi hàm không phải làm:

  • Chuyển từ chế độ người dùng sang chế độ nhân (svc trên ARM, syscall trên x86).
  • Đổi sang ngăn xếp của nhân, lưu thanh ghi.
  • Tra bảng lời gọi hệ thống, kiểm tra tham số.
  • Chạy các bộ lọc seccomp nếu có.
  • Làm ngược lại tất cả để quay ra.

Trên CPU có bật KPTI — biện pháp chống Meltdown — còn thêm một lần đổi bảng trang ở mỗi chiều, và con số này tăng đáng kể. Máy tôi đo báo meltdown: Not affected, nên 134 ns đã là trường hợp thuận lợi.

Một chi tiết nhỏ đáng chú ý: gọi hàm qua con trỏ tốn đúng bằng gọi hàm trực tiếp (0,78 so với 0,77 ns). Việc tránh hàm ảo hay con trỏ hàm "vì chúng chậm" không có cơ sở đo lường nào ở quy mô này — CPU đoán nhánh gián tiếp rất tốt khi đích không đổi.

vDSO nằm giữa hai thế giới

13–16 ns. Gấp 19 lần một lời gọi hàm, nhưng rẻ hơn một lời gọi hệ thống 9 lần.

Phần 42 đã giải thích cơ chế: nhân ánh xạ một trang chỉ đọc chứa giờ vào mọi tiến trình, và clock_gettime đọc thẳng trang đó. Không có chuyển chế độ nào.

13 ns còn lại không phải phí — đó là công việc thật: đọc bộ đếm phần cứng, nhân với hệ số, cộng phần bù. Nếu chỉ đọc một số nguyên có sẵn thì CLOCK_MONOTONIC_COARSE cho 2,36 ns, đúng như phần 42 đo.

getpid() không còn được nhớ đệm

128–172 ns cho getpid() — bằng đúng một lời gọi hệ thống thật.

Glibc từng nhớ đệm giá trị này trong biến toàn cục, và getpid() khi đó tốn dưới 1 ns. Bộ nhớ đệm bị gỡ bỏ ở glibc 2.25 (năm 2017), vì nó sai trong các trường hợp dùng clone() trực tiếp — số hiệu tiến trình đã đổi mà giá trị nhớ đệm thì không.

Nghĩa là mã nguồn cũ gọi getpid() trong vòng lặp — chuyện rất thường gặp trong thư viện ghi log — đột ngột chậm đi hàng trăm lần khi nâng cấp glibc, mà không dòng mã nào thay đổi.

Cách chữa: gọi một lần, giữ lại trong biến, và đọc lại sau fork.

Ba việc rút ra được

Đếm số lời gọi hệ thống, không đếm số byte. Với 134 ns mỗi lời gọi, một dịch vụ gọi một triệu lần mỗi giây tiêu 134 ms CPU mỗi giây — 13% một nhân — chỉ để đi vào và đi ra khỏi nhân.

perf stat -e raw_syscalls:sys_enter -p <pid> -- sleep 10

Phần 37 đo lệnh này chỉ làm chậm 1,4 lần, nên nó chạy được trên dịch vụ thật.

Mọi tối ưu I/O đều là cùng một tối ưu. Bộ đệm của stdio, writev, gom trong io_uring, đọc theo khối 64 KB thay vì 4 KB, giữ kết nối sống — tất cả đều nhắm vào việc chia 134 ns cho nhiều đơn vị công việc hơn.

Ngưỡng đáng nhớ: 130 ns. Nếu một thao tác của bạn rẻ hơn thế, đừng để nó đi qua nhân. Nếu nó đắt hơn nhiều, chi phí lời gọi hệ thống không phải chỗ đáng tối ưu.

seccomp cộng thêm 29 ns

Container thường chạy với một bộ lọc seccomp chặn hàng chục lời gọi hệ thống. Bộ lọc đó chạy ở mỗi lời gọi.

grep Seccomp /proc/self/status
# Seccomp: 0 = tat, 1 = che do nghiem ngat, 2 = co bo loc BPF

Đo close(-1) một triệu lần trong cùng một container, chỉ đổi một cờ:

Cấu hình Seccomp: ns mỗi lời gọi
Mặc định của Docker 2 133,0
--security-opt seccomp=unconfined 0 103,9

29,1 ns, tức 28% chi phí của một lời gọi hệ thống.

Con số đó là cái giá của việc chạy bộ lọc BPF cho mọi lời gọi, và nó tỷ lệ với độ dài bộ lọc — hồ sơ mặc định của Docker liệt kê hơn 300 lời gọi.

Đừng tắt seccomp để lấy lại 29 ns đó. Nó là một trong những lớp bảo vệ có tỷ lệ lợi ích trên chi phí cao nhất của container. Con số này chỉ để bạn biết rằng mọi phép đo lời gọi hệ thống chạy trong container đều đã bao gồm nó — và con số trên kim loại trần không có bộ lọc sẽ thấp hơn khoảng một phần tư.

Thử ba mươi giây

Đo sàn của chính máy bạn:

cat > /tmp/sc.c <<'EOF'
#include <stdio.h>
#include <unistd.h>
#include <time.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
  return t.tv_sec+t.tv_nsec/1e9;}
__attribute__((noinline)) static long f(long x){ return x+1; }
int main(void){
  long N=2000000; volatile long s=0; double t;
  t=now(); for(long i=0;i<N;i++) s+=f(i);
  printf("goi ham   : %7.2f ns\n",(now()-t)/N*1e9);
  t=now(); for(long i=0;i<N;i++) s+=close(-1);
  printf("close(-1) : %7.2f ns   (s=%ld)\n",(now()-t)/N*1e9,s);
  return 0;
}
EOF
cc -O2 -o /tmp/sc /tmp/sc.c && /tmp/sc
grep Seccomp /proc/self/status
grep . /sys/devices/system/cpu/vulnerabilities/meltdown

Chia dòng hai cho dòng một. Tỷ lệ đó là hệ số nhân giữa "làm việc trong tiến trình" và "nhờ nhân làm hộ", và nó là con số cần cầm theo mỗi khi thiết kế một vòng lặp nóng.

Phần sau: máy ảo, container và chạy thẳng — đo cùng một tải trên ba nơi.