Ở bài về lỗi trang, ta bắt gặp một bất ngờ: chạm 64 MB bộ nhớ chỉ gây 32 lỗi trang thay vì 16.384, vì Linux lót vùng đó bằng trang lớn (huge page) 2 MB thay cho trang 4 KB thông thường. Bài này đo lợi ích chính của trang lớn: giảm số lần TLB miss. Nhưng phép đo đầu tiên của tôi cho hai con số gần y hệt và suýt khiến tôi kết luận trang lớn là vô dụng — cho tới khi tôi đổi một biến mà tôi quên là quan trọng.
TLB và vì sao trang lớn giúp
CPU không truy cập bộ nhớ bằng địa chỉ ảo — nó phải dịch địa chỉ ảo sang địa chỉ vật lý qua bảng trang, một thao tác tốn kém. Để không dịch lại mỗi lần, kết quả dịch được cache trong một bộ nhớ đệm siêu nhanh gọi là TLB (translation lookaside buffer). Nhưng TLB rất nhỏ — chỉ vài trăm mục.
Với trang 4 KB, mỗi mục TLB phủ đúng 4 KB. Một chương trình có tập làm việc lớn — truy cập rải rác trên hàng trăm megabyte — sẽ cần nhiều mục TLB hơn số TLB có, nên liên tục TLB miss: mỗi lần miss, CPU phải "đi bộ" bảng trang trong RAM để dịch lại, tốn hàng chục nano giây. Trang lớn 2 MB giải quyết bằng cách cho một mục TLB phủ 512 lần nhiều bộ nhớ hơn: cùng vài trăm mục, TLB giờ phủ được cả gigabyte, nên số miss giảm mạnh. Bật trang lớn qua transparent huge pages, hoặc yêu cầu tường minh bằng madvise(MADV_HUGEPAGE).
Đo: nhanh 30%... nếu truy cập đúng cách
Tôi dựng một vùng 512 MB và chạy một chuỗi "đuổi con trỏ" (pointer-chase): mỗi phần tử chứa chỉ số phần tử kế tiếp, và tôi nhảy theo chuỗi 20 triệu bước, ghim lõi 0. So hai cấu hình: vùng lót bằng trang nhỏ 4 KB (MADV_NOHUGEPAGE) và bằng trang lớn 2 MB (MADV_HUGEPAGE), với chuỗi nhảy ngẫu nhiên theo trang để ép TLB miss:
| Truy cập ngẫu nhiên, 512 MB | Thời gian/bước |
|---|---|
| Trang nhỏ 4 KB | 28,83 ns |
| Trang lớn 2 MB | 20,33 ns |
Trang lớn nhanh hơn khoảng 30% (28,83 → 20,33 ns mỗi bước). Và smaps xác nhận điều đang đo là thật: dòng AnonHugePages bằng 0 cho bản trang nhỏ, và bằng 512 MB (trọn cả vùng) cho bản trang lớn. Cái nhanh đó đến từ việc ít phải đi bộ bảng trang hơn — đúng như lý thuyết TLB.
Một lần tôi đo hớ: mẫu truy cập là biến ẩn
Nhưng đây không phải phép đo đầu tiên của tôi. Lần đầu, tôi dùng truy cập tuần tự — chuỗi con trỏ đi thẳng a[0] → a[1] → a[2] → ... — vì nó đơn giản. Kết quả:
Trang nhỏ, tuần tự: 1,18 ns/bước
Trang lớn, tuần tự: 1,31 ns/bước
Hai con số gần y hệt, thậm chí trang lớn còn nhỉnh chậm hơn một chút. Tôi suýt gõ thẳng vào bài: "trang lớn chẳng giúp gì, chỉ là huyền thoại được thổi phồng".
May là con số 1 ns/bước quá nhỏ khiến tôi nghi ngờ — nó nhỏ hơn cả một lần chạm RAM thật, nghĩa là gần như mọi truy cập đều trúng cache. Lý do: truy cập tuần tự không hề ép TLB. Khi bạn đi qua bộ nhớ liền mạch, hai cơ chế che sạch chi phí TLB: prefetch phần cứng đoán trước và nạp sẵn các dòng cache tiếp theo, và cục bộ không gian khiến mỗi trang 4 KB chỉ bị TLB miss một lần rồi phục vụ 512 lần truy cập kế trong cùng trang đó — một cú miss chia đều cho 512 lần dùng thì gần như bằng không. Với mẫu tuần tự, trang lớn và trang nhỏ không thể khác nhau, vì bản thân TLB chưa bao giờ là nút thắt.
Chỉ khi tôi đổi sang truy cập ngẫu nhiên trên vùng lớn hơn tầm phủ của TLB — mỗi bước nhảy sang một trang khác không đoán trước được — thì áp lực TLB mới thật sự xuất hiện, và lợi ích của trang lớn mới lộ ra (28,83 xuống 20,33 ns). Bài học đo lường: mẫu truy cập là một biến ẩn quyết định trang lớn có đáng hay không — đo nhầm mẫu (tuần tự) giấu sạch hiệu ứng. Nếu tôi chỉ đo tuần tự và dừng lại, tôi đã tuyên bố sai rằng trang lớn vô dụng, trong khi thật ra tôi chưa hề tạo ra tình huống mà nó có ích. (Bên lề, tôi còn vấp một lỗi cờ khiến cả hai lần chạy đều dùng mẫu ngẫu nhiên — tôi phát hiện chỉ vì cái nhãn in ra ghi "rnd" thay vì "seq"; luôn kiểm công cụ đã làm đúng điều mình bảo trước khi tin con số nó cho.)
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: trang lớn giúp các ứng dụng có tập làm việc lớn và truy cập rải rác — không phải mọi chương trình. Cơ sở dữ liệu (chỉ mục cây B, bảng băm lớn), máy ảo (bảng trang khách), tính toán khoa học trên ma trận lớn truy cập theo cột — đây là những nơi TLB miss là nút thắt thật, và trang lớn cắt được đáng kể. Ngược lại, một chương trình duyệt mảng tuần tự, hay chỉ dùng ít bộ nhớ, sẽ không thấy khác biệt. Đừng bật trang lớn khắp nơi rồi kỳ vọng phép màu; hãy đo với đúng mẫu truy cập của bạn.
Hệ quả thứ hai: khi đo bất kỳ tối ưu nào liên quan cache/TLB, mẫu truy cập là tham số bậc nhất. Cùng một cấu trúc dữ liệu, cùng một vùng nhớ, mà duyệt tuần tự và duyệt ngẫu nhiên cho hai thế giới hiệu năng khác nhau — vì prefetch và các tầng cache che hoặc phơi bày chi phí tùy theo bạn truy cập thế nào. Một benchmark tuần tự sẽ nói dối về hành vi của một tải thực tế truy cập ngẫu nhiên, và ngược lại. Luôn đo với mẫu càng giống tải thật càng tốt.
Hệ quả thứ ba là bài học mang theo: một tính năng "không có tác dụng" có thể chỉ đang được đo trong điều kiện nó không thể có tác dụng. Con số mang theo: trang lớn 2 MB làm truy cập ngẫu nhiên trên 512 MB nhanh ~30% (28,83 xuống 20,33 ns/bước) nhờ giảm TLB miss, xác nhận bằng AnonHugePages trong smaps; nhưng với truy cập tuần tự thì trang lớn và trang nhỏ bằng nhau (1,18 so 1,31 ns) vì prefetch che hết TLB — mẫu truy cập quyết định trang lớn có đáng hay không. Trước khi kết luận một tối ưu vô dụng, hãy hỏi: mình đã đo nó trong tình huống nó lẽ ra có ích chưa?
Thử ba mươi giây
Cấp một mảng lớn (vài trăm MB), rồi đo thời gian duyệt nó hai cách: tuần tự (for i: sum += a[i]) và ngẫu nhiên (nhảy theo một hoán vị, mỗi bước sang một trang khác). Bạn sẽ thấy truy cập tuần tự nhanh gấp hàng chục lần ngẫu nhiên trên cùng dữ liệu — chênh lệch đó chính là chi phí TLB miss cộng cache miss mà mẫu ngẫu nhiên phơi ra. Rồi nếu hệ thống bật transparent huge pages (cat /sys/kernel/mm/transparent_hugepage/enabled thấy [always] hoặc [madvise]), thử madvise(MADV_HUGEPAGE) cho vùng đó và đo lại mẫu ngẫu nhiên: bạn sẽ thấy nó nhanh lên, còn mẫu tuần tự thì không đổi. Đó là toàn bộ câu chuyện trang lớn, hiện ra chỉ khi bạn truy cập đúng cách để hỏi đúng câu.