Cho tới giờ sê-ri này coi RAM như một khối phẳng với một độ trễ. Trên nhiều máy chủ, giả định đó sai — vì NUMA (Non-Uniform Memory Access). Trên máy nhiều socket (nhiều CPU vật lý), RAM được chia thành các node, mỗi node gắn với một CPU. Một lõi truy cập RAM của node mình (local) thì nhanh; truy cập RAM của node khác (remote) phải đi qua đường nối liên socket, chậm hơn hẳn. Với những máy đó, nơi dữ liệu nằm và lõi nào chạm nó quyết định tốc độ. Nhưng — như phần SMT/Hyperthreading đã cho thấy một lần — không phải máy nào cũng có tính năng này. Tôi đo trong container gcc:13 trên host ARM, và trước hết phải kiểm xem máy này có NUMA không, rồi báo cáo trung thực.
NUMA: RAM có node local và remote — nhưng chỉ trên máy nhiều socket
Ý tưởng NUMA: khi một máy có nhiều CPU vật lý (socket), mỗi CPU có bộ điều khiển bộ nhớ riêng và một phần RAM gắn trực tiếp vào nó. Đó là node local của CPU đó — truy cập nhanh. RAM gắn vào CPU khác là remote — muốn đọc phải đi qua đường liên kết giữa các socket, thêm độ trễ (thường remote chậm hơn local 1,5–2 lần). Hệ điều hành quản việc này bằng first-touch: một trang nhớ mới được cấp ở node của luồng chạm nó đầu tiên (giống chi phí first-touch của trang). Nên trên máy NUMA, muốn nhanh phải ghim luồng chạy gần dữ liệu của nó, và cấp phát sao cho dữ liệu nằm ở node sẽ dùng nó.
Nhưng đây là điểm mấu chốt: NUMA chỉ xuất hiện trên máy nhiều socket. Một máy một CPU (đa số laptop, máy để bàn, máy ảo, và các máy ARM như host này) chỉ có một vùng nhớ phẳng — không có khái niệm local/remote. Cách duy nhất để biết máy bạn thuộc loại nào là đo topology, không phải đoán. Tôi kiểm chính máy đang chạy.
Đo: 0 node, và độ trễ RAM đồng nhất từ mọi lõi
Host ARM (AArch64) trong VM, g++ -O2:
Số NUMA node (/sys/devices/system/node): 0 -> bộ nhớ PHẲNG, không có NUMA
Độ trễ RAM (đuổi con trỏ 64MB, defeat prefetch) đo từ TỪNG lõi (ghim affinity):
lõi 0 |73,7 lõi 1 |75,6 lõi 2 |76,9 lõi 3 |78,2 lõi 4 |76,8
lõi 5 |76,8 lõi 6 |76,0 lõi 7 |75,9 lõi 8 |76,9 lõi 9 |76,8 (ns/truy cập)
-> min 73,7 / max 78,2 ns = chênh CHỈ 6,1% giữa các lõi
Đọc ra hai điều. Một (không có NUMA): kiểm /sys/devices/system/node — nơi Linux liệt kê các NUMA node — ra 0 node. Máy này (host ARM trong VM) không trình bày topology NUMA: bộ nhớ là một vùng phẳng, không phân biệt local/remote. Tôi không thể đo chênh lệch local–remote vì nó không tồn tại ở đây — và tôi nói thẳng vậy, thay vì bịa một con số.
Hai (chứng minh tính đồng nhất): để xác nhận, tôi đo độ trễ RAM thật — đuổi con trỏ qua một vùng 64 MB (vượt cache, đánh bại prefetch để lấy độ trễ thật) — ghim luồng lần lượt lên từng lõi trong 10 lõi. Kết quả: mọi lõi cho 73,7–78,2 ns, chênh nhau chỉ 6,1%. Nếu máy này có NUMA, một số lõi (ở "socket khác") sẽ chậm hơn hẳn (remote); ở đây mọi lõi truy cập RAM với cùng độ trễ. 6% chênh nhỏ đó là nhiễu và khác biệt lõi P/E, không phải local/remote. Đây là bằng chứng đo được cho "một vùng nhớ duy nhất, phẳng".
Một lần tôi đo hớ: "RAM luôn đều" và "code nào cũng phải lo NUMA"
Tôi vào đo với mô hình quen thuộc từ máy để bàn: "RAM là RAM, mọi lõi truy cập tốn như nhau, khỏi phải nghĩ dữ liệu nằm đâu". Trên chính máy này, đo cho thấy điều đó đúng (chênh 6,1% giữa các lõi). Nhưng nó chỉ đúng vì máy này một node — trên một máy chủ NUMA nhiều socket, giả định đó sai: truy cập remote chậm hơn local đáng kể, và lõi nào chạm dữ liệu trước (first-touch) quyết định dữ liệu nằm node nào, từ đó quyết định tốc độ. Không thể mang thẳng trực giác "RAM đều" từ laptop sang máy chủ NUMA — phải đo topology trước.
Nhưng đo cũng chỉnh niềm tin ngược, hay gặp ở người vừa đọc về NUMA: "NUMA quan trọng lắm, code nào cũng phải ghim node, dùng numactl, lo first-touch". Sai — đa số máy (laptop, máy ảo, phần lớn máy ARM một socket như host này) không có NUMA: /sys/devices/system/node ra 0 node, không có node nào để ghim. Trên những máy đó, mọi "tối ưu NUMA" là thừa — bạn tối ưu cho một cấu trúc không tồn tại. NUMA chỉ đáng lo trên máy chủ nhiều socket, và ngay cả ở đó cũng phải đo mới biết dữ liệu và luồng có lệch node không. Quy tắc: đo topology trước, tối ưu sau.
Bài học đo lường: NUMA (RAM local/remote) CHỈ có trên máy nhiều socket; HĐH dùng first-touch để đặt trang ở node của luồng chạm đầu. Máy đo (host ARM AArch64/VM) kiểm /sys/devices/system/node ra 0 node -> bộ nhớ PHẲNG. Đo độ trễ RAM từ 10 lõi: 73,7-78,2 ns, chênh CHỈ 6,1% -> đồng nhất, không local/remote. 'Mọi truy cập RAM tốn như nhau' SAI trên máy chủ NUMA (remote chậm hơn, first-touch quyết định) nhưng ĐÚNG ở máy 1 node này; 'code nào cũng phải lo NUMA' SAI (đa số máy 1 socket không có NUMA, tối ưu thừa — đo topology trước). Nếu tin "RAM luôn đều" tôi bị sốc trên máy chủ NUMA; nếu tin "phải lo NUMA khắp nơi" tôi tối ưu cho thứ không tồn tại trên máy một node.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: kiểm topology của máy đích trước khi nghĩ tới tối ưu NUMA — đừng đoán. Đọc /sys/devices/system/node/ (đếm số nodeN), hoặc chạy numactl --hardware / lscpu nếu có. Một node → bộ nhớ phẳng, bỏ qua mọi thứ NUMA. Nhiều node → mới cần cân nhắc. Đa số dịch vụ chạy trên máy ảo một-node, nơi tối ưu NUMA không mang lại gì.
Hệ quả thứ hai: trên máy chủ NUMA nhiều socket thật, hãy tôn trọng first-touch và ghim luồng gần dữ liệu. Nếu bạn thật sự chạy trên máy hai/bốn socket (CSDL lớn, máy tính hiệu năng cao), hãy để mỗi luồng chạm/khởi tạo phần dữ liệu của chính nó (first-touch đặt trang đúng node), ghim luồng vào node đó (numactl, affinity), và tránh một luồng khởi tạo toàn bộ mảng rồi các luồng khác đọc remote. Chênh local–remote có thể là 1,5–2 lần — đáng kể ở quy mô đó.
Hệ quả thứ ba là tinh thần đo lường: "bộ nhớ" có thể phẳng hoặc phân node tùy máy — và chỉ đo topology mới cho biết, y như đo mới biết máy có SMT hay không. Con số mang theo: NUMA chỉ trên máy nhiều socket (remote chậm hơn local, first-touch quyết định); máy 1 node (đo được ở đây: 0 node, 10 lõi đều ~76 ns) thì RAM phẳng, tối ưu NUMA thừa. Kiểm /sys/devices/system/node trước. Đừng chép quy tắc tối ưu của một lớp máy sang lớp máy khác — đo chính máy bạn chạy.
Thử ba mươi giây
Chạy ls /sys/devices/system/node/ trên máy của bạn (hoặc numactl --hardware, hoặc lscpu | grep -i numa): nếu chỉ thấy một node0 (hoặc không có thư mục node nào), máy bạn là một vùng nhớ phẳng — không có NUMA, mọi tối ưu ghim-node là thừa. Nếu thấy node0, node1, … thì máy bạn là NUMA nhiều node. Rồi, nếu muốn đo tính đồng nhất: viết một chương trình đuổi con trỏ qua một mảng lớn hơn cache (để lấy độ trễ RAM thật), và ghim luồng (dùng sched_setaffinity) lần lượt lên từng lõi, đo ns mỗi truy cập. Trên máy một node như host tôi đo, mọi lõi cho gần bằng nhau (ở đây 73,7–78,2 ns, chênh 6%) — bằng chứng bộ nhớ phẳng. Trên máy NUMA thật, bạn sẽ thấy các lõi thuộc "socket khác" so với chỗ cấp mảng chậm hơn hẳn — đó là chi phí remote. Ba mươi giây đó cho bạn thấy điều mà "RAM là RAM" giấu đi: bộ nhớ có thể không đồng nhất, nhưng chỉ trên một số máy — nên hãy đo topology của máy mình trước khi lo (hay bỏ qua) NUMA.