Trên các máy chủ nhiều socket, có một sự thật về bộ nhớ mà lập trình viên hay bỏ qua: không phải mọi vùng RAM đều xa CPU như nhau. Đó là NUMA (Non-Uniform Memory Access) — truy cập bộ nhớ không đồng đều. Bài này đặc biệt ở một chỗ tôi phải thành thật ngay từ đầu: môi trường đo của tôi (container ARM ảo hóa) không phơi bày NUMA, nên tôi không đo được hiệu ứng chính. Nhưng chính điều đó lại là bài học quan trọng nhất — và tôi sẽ đo đúng cái đo được: cấu trúc (topology) thật của máy, thứ bạn phải kiểm trước khi mơ tới tối ưu NUMA.
NUMA là gì, và vì sao nó quan trọng trên máy lớn
Một máy chủ hai socket có hai node NUMA: mỗi node gồm một CPU (nhiều lõi) và một dải RAM gắn trực tiếp với nó. Một lõi truy cập RAM cùng node (local) đi thẳng — nhanh. Nhưng truy cập RAM node khác (remote) phải đi qua đường liên kết giữa hai socket — chậm hơn, thường 1,5–2 lần trên phần cứng thật. Nên cùng một lệnh đọc bộ nhớ có thể nhanh hay chậm gấp đôi tùy dữ liệu nằm ở node nào so với lõi đang chạy.
Điều quyết định dữ liệu nằm ở node nào là chính sách bộ nhớ, mà mặc định của Linux là first-touch (chạm-lần-đầu): một trang được cấp trên node của CPU chạm tới nó đầu tiên, không phải CPU gọi malloc. Nhớ lại phần 6: malloc chỉ đặt chỗ ảo, trang vật lý được cấp lúc chạm — và NUMA quyết định node lúc đó, theo lõi đang chạy. Ngoài ra có mbind/set_mempolicy và công cụ numactl --membind/--cpunodebind để buộc bộ nhớ/CPU về node cụ thể.
Đo: kiểm topology trước đã
Trước khi tối ưu bất cứ gì, câu hỏi đầu tiên phải là: máy này có mấy node? Tôi đo trong container gcc:13:
numactl --hardware : available: 0 nodes () (No distance information)
ls /sys/devices/system/node/ : (trống — không có nodeN nào)
lscpu | grep -i numa : (không có dòng nào)
Môi trường này không phơi bày NUMA node nào cả — numactl báo thẳng "0 nodes", và /sys/devices/system/node/ trống rỗng. Đây là container trên nền ARM ảo hóa (Apple Silicon), nơi tầng ảo hóa trình bày bộ nhớ như một khối phẳng, không có khái niệm node. Hệ quả thẳng thắn: hiệu ứng remote/local — cái cốt lõi của NUMA — không thể quan sát được ở đây. Không có node thứ hai để so, không có "remote" để đo.
Cái tôi đo được là độ trễ truy cập bộ nhớ, và nó xác nhận sự đồng nhất:
pointer-chase 256 MB, thứ tự ngẫu nhiên (phá cache + prefetch):
103,8 ns mỗi lần truy cập (đồng nhất — một vùng bộ nhớ phẳng, không có local/remote)
Một phép "đuổi con trỏ" qua 256 MB theo thứ tự ngẫu nhiên (để mỗi truy cập đều trượt cache và đánh bại prefetch) cho 103,8 ns — đây là độ trễ RAM thật của một truy cập ngẫu nhiên. Con số này đồng nhất: không có vùng nào nhanh hơn hay chậm hơn, đúng như kỳ vọng cho một máy một-node. Nếu đây là máy hai socket thật, tôi đã có thể ghim bộ nhớ vào node xa và đo con số này tăng ~1,5–2 lần — nhưng ở đây thì không có gì để thấy.
Một lần tôi đo hớ: "cứ numactl là nhanh hơn"
Tôi vào chủ đề này với hai niềm tin. Thứ nhất: "cứ dùng numactl, cứ tối ưu NUMA thì chương trình nhanh hơn". Đo topology phá tan điều đó: máy này có 0 node — không có remote memory, nên mọi lệnh numactl --membind đều vô nghĩa, không đổi được gì. Rất nhiều máy ảo trên đám mây cũng chỉ trình bày một node; tối ưu NUMA trên chúng là công cốc. Thứ hai: "malloc quyết định trang nằm ở node nào". Sai — first-touch quyết, tức lõi chạm trang đầu tiên, không phải lõi cấp nó. Đây là cái bẫy tinh vi: một luồng khởi tạo (chạm) cả một mảng lớn ở node 0, rồi giao cho các luồng ở node 1 xử lý — thế là mọi truy cập của chúng đều remote, chậm gấp đôi, dù chương trình "nhìn" hoàn toàn hợp lý.
Bài học đo lường: trước khi tối ưu NUMA, hãy đo topology thật của máy — vì trên máy một-node (rất phổ biến ở container và VM đám mây), NUMA tuning không làm gì cả; và trên máy nhiều-node, thứ quyết định local/remote là first-touch, không phải nơi gọi malloc. Đây cũng là một dạng đo hớ về chính việc đo: nếu tôi tin lý thuyết NUMA rồi rải numactl khắp nơi mà không kiểm numactl --hardware trước, tôi đã tối ưu một thứ không tồn tại trên máy của mình. Và thành thật: ở môi trường này tôi không trưng ra được con số remote/local; con số 1,5–2 lần là điển hình trên phần cứng nhiều socket, không phải tôi đo ở đây — nói rõ để bạn không nhầm.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: kiểm topology trước khi tối ưu NUMA — numactl --hardware là câu lệnh đầu tiên. Nếu máy (hay container/VM) chỉ có một node, đừng phí công với numactl, mbind, hay cân nhắc bố trí bộ nhớ theo node — không có gì để tối ưu. NUMA tuning chỉ đáng trên máy chủ nhiều socket thật, và ở đó bạn phải đo mới biết mức lợi.
Hệ quả thứ hai: trên máy nhiều-node, để mỗi luồng tự khởi tạo (chạm) phần dữ liệu của nó. Vì first-touch đặt trang trên node của lõi chạm đầu tiên, mẫu đúng là: chia dữ liệu cho các luồng, rồi mỗi luồng tự chạm (memset/khởi tạo) phần của mình trên lõi nó sẽ chạy. Thế là dữ liệu nằm local với luồng dùng nó. Cái sai kinh điển là một luồng chính khởi tạo toàn bộ rồi phát cho các luồng khác — đẩy mọi truy cập thành remote. Kết hợp với affinity phần 19 để ghim luồng đúng node.
Hệ quả thứ ba là tinh thần đo lường: đừng tối ưu một thuộc tính phần cứng mà không xác nhận nó tồn tại trên máy của bạn. Con số mang theo: NUMA (truy cập bộ nhớ không đồng đều) chỉ có trên máy nhiều node — mỗi node có CPU + RAM riêng, remote chậm ~1,5–2× local (số điển hình, KHÔNG đo được ở container này); môi trường ARM ảo hóa này báo 0 NUMA node (numactl --hardware: available 0 nodes, /sys/.../node trống), latency đồng nhất 103,8 ns, nên NUMA tuning vô ích ở đây; và trên máy nhiều node, first-touch (CPU chạm đầu tiên) — KHÔNG phải malloc — mới quyết định trang nằm ở node nào, nên mỗi luồng phải tự chạm phần dữ liệu của mình. Đo topology trước; tối ưu một node ma là tối ưu hư vô.
Thử ba mươi giây
Chạy numactl --hardware (hoặc ls /sys/devices/system/node/) trên máy bạn — laptop, container, hay VM đám mây bạn hay deploy. Đếm số node. Rất có thể bạn thấy một node duy nhất (hoặc 0, như container của tôi) — nghĩa là mọi lời khuyên "tối ưu NUMA" bạn đọc trên mạng không áp dụng cho máy này, và numactl chẳng đổi được gì. Nếu bạn có nhiều node (một máy chủ hai socket), thử nghĩ về một chương trình đa luồng của bạn: luồng nào khởi tạo dữ liệu, và luồng nào dùng nó — có phải cùng một node không? Nếu một luồng chính chạm hết rồi phát cho các luồng khác, bạn đang trả giá remote mà không biết. Ba mươi giây chạy một câu lệnh đó tách bạn khỏi cả một lớp "tối ưu" mù quáng: NUMA chỉ là vấn đề khi máy bạn thật sự có nhiều node — hãy đo trước khi tin.