Phần trước đo memcpy; phần này đo người anh em memset (ghi một giá trị lên n byte) — và qua nó, một trong những mẹo hệ điều hành thú vị nhất: trang zero. Câu hỏi khởi đầu rất đời thường: khi bạn calloc một mảng 512 MB (cấp phát zero), có phải máy phải thật sự ghi 512 MB số 0, tốn thời gian tỉ lệ kích thước? Trực giác nói có. Đo nói không — và lý do hé lộ cách hệ điều hành quản lý bộ nhớ lười biếng một cách tài tình. Tôi đo trong container gcc:13 trên host ARM (trang 4 KB), và con số cho thấy cái giá của việc zero bộ nhớ không nằm ở chỗ bạn tưởng.

memset và zero trang

memset chỉ ghi — và hệ điều hành zero trang một cách lười

memset giống memcpy nhưng chỉ ghi, không đọc nguồn — nên nó có thể nhanh hơn memcpy (memcpy phải đọc nguồn lẫn ghi đích, tiêu gấp đôi băng thông). Tốc độ memset cũng là một đường cong theo kích thước: khối nhỏ overhead-bound, khối lớn chạm trần băng thông ghi của RAM.

Nhưng mẹo lớn hơn nằm ở tầng hệ điều hành. Khi bạn xin một vùng nhớ lớn (qua mmap hoặc calloc), hệ điều hành không lập tức cấp phát và ghi 0 cho từng trang. Nó ánh xạ mọi trang mới vào một trang-zero chung (một trang vật lý toàn số 0), ở chế độ sao-chép-khi-ghi (copy-on-write). Nên calloc một vùng khổng lồ gần như tức thì — chưa hề có byte 0 nào được ghi thật. Cái giá bị hoãn lại tới lúc bạn chạm (ghi) vào một trang: khi đó CPU bẫy một page fault, hệ điều hành mới cấp một trang vật lý thật và gán cho bạn. Đây là chi phí first-touchliên quan tới trang và TLB. Tôi đo cả hai: memset theo kích thước, và cái giá first-touch ẩn.

Đo: calloc 512 MB tức thì, first-touch chậm 1,90 lần

Host ARM (trang 4 KB), g++ -O2, buffer 512 MB:

(zero trang / first-touch)
   calloc 512 MB (zero lười)        |  0,003 ms  | chưa ghi thật -> gần như tức thì
   memset lần ĐẦU (eager)            |  38 GB/s   | first-touch: page fault + cấp trang
   memset lại (WARM, trang đã cấp)   |  73 GB/s   | ghi thuần
   -> eager / warm = 1,90x ; calloc nhanh hơn ghi thật ~4300x

(memset throughput theo kích thước, buffer warm)
   size    8 B    |   6,7 GB/s   | quá nhỏ, overhead
   size    4 KB   | 137,1 GB/s   | ĐỈNH (L1, chỉ ghi -> cao hơn memcpy 117)
   size   16 MB   |  72,8 GB/s   | trần băng thông GHI RAM

Đọc ra ba điều. Một (calloc lười, gần như miễn phí): calloc 512 MB xong trong 0,003 ms — gần như tức thì, ~4300 lần nhanh hơn việc ghi 0 thật (14 ms). Vì hệ điều hành không ghi 512 MB số 0; nó chỉ ánh xạ các trang vào trang-zero chung. "Cấp phát và zero một mảng khổng lồ" hóa ra gần như không tốn gì — cho tới khi bạn động vào.

Hai (first-touch mới là cái giá thật): ghi lần đầu vào các trang mới (memset eager) chỉ đạt 38 GB/s, còn ghi lại các trang đó (warm, đã cấp) đạt 73 GB/s — first-touch chậm 1,90 lần. Vì mỗi trang 4 KB đụng đến lần đầu phải trả một page fault + việc hệ điều hành cấp một trang vật lý thật. Cái giá "miễn phí" của calloc không biến mất — nó dời sang lần chạm đầu tiên, rải đều trên các trang bạn thực sự dùng.

Ba (memset chỉ ghi nên nhanh hơn memcpy): đường cong theo kích thước giống memcpy — 8 B chỉ 6,7 GB/s (overhead), đỉnh 137,1 GB/s ở 4 KB (L1). Đáng chú ý: đỉnh memset (137) cao hơn đỉnh memcpy (117 ở phần trước), vì memset chỉ ghi còn memcpy phải đọc nguồn lẫn ghi đích. Ở 16 MB (vượt cache), memset đạt 72,8 GB/s — trần băng thông ghi của RAM.

Một lần tôi đo hớ: "zero mảng lớn tốn theo kích thước" và "chạm mới cũng như chạm cũ"

Tôi vào đo với mô hình ngây thơ: "calloc hay zero một mảng lớn thì máy phải ghi từng byte 0, nên tốn thời gian tỉ lệ kích thước — zero 1 GB thì phải mất thời gian ghi 1 GB". Đo phá tan: calloc 512 MB xong trong 0,003 ms, ~4300 lần nhanh hơn ghi thật, vì hệ điều hành zero lười qua trang-zero sao-chép-khi-ghi — nó chưa ghi gì cả. Rất nhiều "cấp phát vùng lớn đã zero" trong thực tế gần như miễn phí ngay lúc gọi; ai đo thời gian ngay sau calloc sẽ tưởng zeroing "nhanh thần kỳ" mà không hiểu rằng nó chưa xảy ra.

Nhưng đo cũng chỉnh niềm tin ngược: "bộ nhớ mới cấp thì chạm cũng nhanh như bộ nhớ đã dùng, cấp xong là dùng ngay full tốc độ". Sai — lần chạm đầu (first-touch) mỗi trang mới trả một page fault + việc cấp trang vật lý, nên ghi lần đầu (38 GB/s) chậm 1,90 lần ghi lại (73 GB/s). Cái giá calloc "tiết kiệm" được lúc gọi quay lại ở lần chạm đầu, phân tán trên các trang. Nếu bạn đo một vòng lặp lần đầu duyệt một mảng vừa cấp, bạn đang đo cả chi phí first-touch lẫn công việc thật — dễ kết luận sai rằng "code lần đầu chậm bất thường".

Bài học đo lường: HĐH zero trang LƯỜI: calloc/mmap vùng lớn ánh xạ mọi trang vào MỘT trang-zero chung (CoW), calloc 512 MB = 0,003 ms (~tức thì, ~4300x nhanh hơn ghi thật) — chưa ghi 0 thật. Cái giá dời sang FIRST-TOUCH: ghi lần đầu mỗi trang mới trả page fault + cấp trang (đo eager 38 GB/s vs warm 73 GB/s = 1,90x). memset chỉ ghi nên đỉnh 137 GB/s (> memcpy 117). 'calloc/zero mảng lớn tốn theo kích thước' SAI (zero lười, gần free tới khi chạm); 'chạm bộ nhớ mới cấp cũng nhanh như đã dùng' SAI (first-touch chậm 1,90x). Nếu tin "zero tốn theo kích thước" tôi hiểu sai calloc nhanh; nếu tin "chạm mới như chạm cũ" tôi quy nhầm chi phí first-touch cho code.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: dùng calloc (hoặc mmap ẩn danh) khi cần vùng lớn đã zero — đừng malloc rồi memset(0). calloc để hệ điều hành zero lười, chỉ trả giá cho các trang bạn thực sự chạm; còn malloc + memset(0) ép ghi 0 lên toàn bộ ngay lập tức (first-touch tất cả), tốn thời gian tỉ lệ kích thước dù bạn chỉ dùng một phần. Với mảng thưa (cấp lớn, dùng ít), calloc thắng đậm.

Hệ quả thứ hai: biết chi phí first-touch — và chạm trước (pre-touch/warm-up) vùng nhớ nóng nếu cần độ trễ ổn định. Lần đầu duyệt một vùng vừa cấp chậm hơn vì page fault; nếu bạn cần hiệu năng ổn định ngay từ đầu (hệ thống thời gian thực, benchmark, vùng đệm dùng lại), hãy chạm trước toàn bộ (ghi một lượt) để trả hết page fault trước, rồi vòng nóng chạy full tốc độ. Đây cũng là lý do các allocator/pool giữ lại bộ nhớ đã chạm thay vì trả về HĐH.

Hệ quả thứ ba là tinh thần đo lường: chi phí "zero bộ nhớ" không nằm ở lệnh ghi 0 mà ở việc cấp trang — và hệ điều hành hoãn nó tới lần chạm đầu. Con số mang theo: calloc vùng lớn ~tức thì (zero lười, trang-zero CoW); first-touch mỗi trang mới trả page fault (đo eager 38 vs warm 73 GB/s = 1,90x); memset chỉ ghi -> đỉnh 137 GB/s. Dùng calloc; pre-touch nếu cần độ trễ ổn định. Cái rẻ bất ngờ (calloc tức thì) và cái đắt ẩn (first-touch) là hai mặt của cùng một mẹo hệ điều hành — đo mới thấy cả hai.

Thử ba mươi giây

Gọi calloc xin một mảng thật lớn (ví dụ 1 GB) và bấm giờ ngay lời gọi đó: bạn sẽ thấy nó xong gần như tức thì, dù về lý thuyết phải ghi 1 GB số 0 — vì hệ điều hành chưa ghi gì, chỉ ánh xạ vào trang-zero. Giờ memset toàn bộ mảng đó (chạm lần đầu) và bấm giờ: lần này chậm hẳn, vì mỗi trang 4 KB phải bẫy page fault và được cấp trang thật. Rồi memset mảng đó lần thứ hai (các trang đã cấp): bạn sẽ thấy nó nhanh hơn lần đầu (trên máy tôi ~1,9 lần), vì không còn page fault — giờ chỉ là ghi thuần chạm trần băng thông RAM. Cuối cùng, so malloc(1GB)+memset(0) với calloc(1GB): cái đầu tốn thời gian ghi thật ngay, cái sau gần như miễn phí tới khi bạn chạm. Ba mươi giây đó cho bạn thấy điều mà "zero bộ nhớ tốn theo kích thước" giấu đi: hệ điều hành zero lười, và cái giá thật của bộ nhớ mới không phải lệnh ghi 0 mà là lần chạm đầu tiên — nên hãy dùng calloc, và pre-touch khi bạn cần tốc độ ổn định ngay từ nhịp đầu.