Bài trước tôi đo memcpy, bài này tới người anh em memset — ghi một giá trị lên n byte. Nhưng thứ thú vị nhất lại không nằm ở bản thân memset, mà ở một mẹo hệ điều hành trốn phía sau nó.
Câu hỏi rất đời thường: khi bạn calloc một mảng 512 MB — vừa cấp phát vừa zero — máy có phải thật sự ghi ra 512 triệu số 0 không? Trực giác bảo có, và tốn thời gian tỉ lệ kích thước. Đo lại bảo không, một cách bất ngờ.
Hình dung một nhà hàng nhận đặt trước 500 bàn cho tối nay. Nó không đi lau sạch, bày biện cả 500 bàn ngay lúc nhận đặt — làm vậy phí công, vì đa số khách sẽ không tới. Nó chỉ ghi vào sổ "500 bàn đã đặt", rồi bàn nào có khách thật sự ngồi xuống mới dọn bàn đó. Hệ điều hành xử lý bộ nhớ y hệt. Khi bạn xin một vùng lớn qua calloc hay mmap, nó không cấp và ghi 0 cho từng trang. Nó trỏ mọi trang mới của bạn vào chung một trang vật lý toàn số 0, ở chế độ sao-chép-khi-ghi. Nên calloc xong gần như tức thì — chưa có byte 0 nào được ghi thật cả. Cái giá bị hoãn tới lúc bạn ghi vào một trang: khi đó CPU mới bẫy page fault, hệ điều hành mới cấp một trang thật. Đây là chi phí first-touch.
Con số
Tôi đo trên buffer 512 MB, máy ARM, trang 4 KB:
calloc512 MB: 0,003 mili-giây — gần như tức thì, nhanh hơn ghi 0 thật (14 ms) khoảng 4300 lần.memsetlần đầu lên vùng đó (chạm trang mới): 38 GB/s — mỗi trang phải bẫy page fault, hệ điều hành cấp trang thật.memsetlần hai lên chính vùng đó (trang đã cấp): 73 GB/s — giờ chỉ là ghi thuần, nhanh hơn lần đầu 1,90 lần.
Cái calloc "miễn phí" hóa ra không hề miễn phí — nó chỉ đẩy cái giá sang lần đầu bạn chạm vào từng trang. Ai đo thời gian ngay sau calloc rồi trầm trồ "zero nhanh thần kỳ" thực ra đang bị lừa: việc zero chưa xảy ra. Ngược lại, ai thấy một vòng lặp lần đầu duyệt mảng vừa cấp mà chậm bất thường thì đừng vội đổ cho code — phần lớn cái chậm đó là page fault đang được trả dần.
Nhân tiện, memset còn có một điểm đáng nhớ: nó chỉ ghi, không đọc nguồn như memcpy, nên đạt đỉnh cao hơn. Trên khối vừa L1 (4 KB) nó lên tới 137 GB/s, so với 137 của memcpy... à không, memcpy đỉnh chỉ 117 GB/s vì phải vừa đọc nguồn vừa ghi đích. Khối nhỏ 8 byte thì tệ (6,7 GB/s, chi phí lời gọi nuốt hết), còn khối lớn 16 MB vượt cache thì chạm trần băng thông ghi của RAM, khoảng 72,8 GB/s.
Ngoài đời thì sao
Điều rút ra đầu tiên: cần một vùng lớn đã zero thì dùng calloc (hoặc mmap ẩn danh), đừng malloc rồi memset(0). calloc để hệ điều hành zero lười, chỉ trả giá cho những trang bạn thật sự chạm. Còn malloc cộng memset(0) ép ghi 0 lên toàn bộ ngay, first-touch sạch cả vùng, tốn thời gian tỉ lệ kích thước dù bạn chỉ dùng một góc. Với mảng thưa — cấp to, dùng ít — khác biệt là rất lớn.
Điều thứ hai: nếu cần độ trễ ổn định ngay từ nhịp đầu (hệ thống thời gian thực, benchmark, buffer tái sử dụng), hãy chạm trước cả vùng một lượt để trả hết page fault trước, rồi vòng nóng mới chạy full tốc độ. Đây cũng là lý do các bộ cấp phát giữ lại bộ nhớ đã chạm thay vì trả về hệ điều hành.
Cái mẹo lười này không riêng gì C. Nó chính là lý do khi bạn nhìn top, một tiến trình có thể hiện VIRT mấy chục gig mà RES (bộ nhớ vật lý thật sự dùng) chỉ vài trăm meg — phần lớn cái "đã cấp" kia mới chỉ là lời hứa, chưa chạm thì chưa tốn RAM thật. Java hay Go thì bắt buộc phải zero mảng mới theo spec, nhưng cấp một mảng khổng lồ vẫn đụng đúng cái tường first-touch này ở tầng dưới; và trong container có giới hạn RAM, chính lần chạm đầu — không phải lúc cấp — mới là lúc bộ nhớ thật bị tính, đủ để chạm trần và bị OOM-kill.
Muốn tự thấy thì calloc 1 GB rồi bấm giờ ngay: gần như tức thì. memset cả mảng (chạm lần đầu): chậm hẳn. memset lần hai: nhanh hơn, vì page fault đã trả xong. Cuối cùng so malloc+memset(0) với calloc: cái đầu tốn ngay, cái sau gần như miễn phí cho tới khi bạn động vào. Cái giá thật của bộ nhớ mới không nằm ở lệnh ghi 0, mà ở lần đầu bạn chạm tới nó.