Hình dung một nhà kho có hai cánh, mỗi cánh có một tổ công nhân và kệ hàng riêng. Công nhân lấy hàng ở kệ cánh mình thì nhanh; phải chạy sang cánh kia lấy thì chậm hơn. Muốn tối ưu, bạn xếp hàng sao cho mỗi tổ dùng đúng kệ gần mình. Nhưng — và đây là điều dễ quên — trước khi bàn chuyện đó, phải nhìn sơ đồ mặt bằng: nếu nhà kho của bạn thực ra chỉ là một sàn mở duy nhất, thì "xếp hàng theo cánh" là vô nghĩa, chẳng có cánh nào để tối ưu cả. Đó chính là chuyện của NUMA — và bài học lớn nhất hôm nay là kiểm sơ đồ trước.
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 nói thẳng 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ó — đúng hai cánh nhà kho. 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. Đúng cái nhà kho một-sàn.
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.
Hai niềm tin đối nghịch, cùng sai
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ý.
Đâ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à nói thẳng: ở 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êu rõ để bạn không nhầm.
Về đến code hằng ngày
Điều đầ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.
Điều 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.
Chỗ này đáng biết là NUMA và first-touch âm thầm chi phối hiệu năng của rất nhiều hệ thống lớn. Các database (PostgreSQL, cả tài liệu của Oracle/SQL Server) đều có hướng dẫn NUMA riêng, và người ta hay chạy numactl --interleave=all cho tiến trình database lớn để rải bộ nhớ đều các node thay vì dồn một node rồi bị remote. JVM có cờ -XX:+UseNUMA để bộ thu gom rác đặt object theo node của luồng. Các runtime dữ liệu lớn và HPC (thư viện tính toán, framework in-memory) đặc biệt quan tâm chuyện "chạm đúng chỗ". Nhưng — và đây là mấu chốt của cả bài — tất cả những cái đó chỉ có ý nghĩa sau khi bạn xác nhận máy thật sự có nhiều node. Trên một VM đám mây một-node hay một container như của tôi, chúng là no-op. Đo trước, tối ưu sau.
Muốn tự thấy thì 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. Và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.