Từ đầu sê-ri tới giờ tôi cứ coi RAM như một khối phẳng, chạm chỗ nào cũng tốn ngần ấy thời gian. Trên nhiều máy chủ, giả định đó sai, và thủ phạm có tên: NUMA — Non-Uniform Memory Access.

Hình dung một tòa nhà công ty nhiều tầng. Mỗi phòng ban ngồi một tầng, và có tủ hồ sơ riêng ngay trong phòng. Cần hồ sơ của tủ mình thì với tay là lấy được. Nhưng cần hồ sơ nằm ở tủ của phòng ban tầng khác, bạn phải cuốc bộ xuống thang máy, sang tầng kia, rồi quay lại — chậm hơn hẳn. Máy nhiều socket hoạt động đúng vậy: mỗi CPU vật lý có một mớ RAM gắn thẳng vào nó (node "local", nhanh), còn RAM gắn vào CPU khác là "remote", muốn đọc phải đi qua đường nối giữa các socket, thường chậm hơn tầm 1,5 tới 2 lần. Hệ điều hành xử lý chuyện này bằng luật first-touch: một trang nhớ mới cấp sẽ nằm ở node của luồng nào chạm vào nó đầu tiên. Nên trên máy NUMA, muốn nhanh thì phải ghim luồng chạy gần dữ liệu của nó.

Nghe thì đáng sợ. Nhưng có một chi tiết quan trọng: cái tòa nhà nhiều tầng ấy chỉ tồn tại trên máy nhiều socket. Còn máy một CPU — đa số laptop, máy bàn, máy ảo, và mấy con ARM như máy tôi đang đo — chỉ là một căn phòng duy nhất, mọi tủ hồ sơ đều ngay cạnh. Không có local với remote gì hết. Cách duy nhất để biết máy mình thuộc loại nào là đo, chứ không phải đoán.

Máy này có 0 node

Trên Linux, các NUMA node được liệt kê trong /sys/devices/system/node/. Tôi ngó vào cái container gcc:13 trên máy ARM của mình, và nó cho ra: 0 node. Máy ảo này không trình bày topology NUMA nào cả — với hệ điều hành, bộ nhớ ở đây là một vùng phẳng, không có khái niệm gần xa. Nghĩa là tôi không thể đo chênh lệch local–remote, đơn giản vì nó không tồn tại. Và tôi nói thẳng vậy, chứ không nặn ra một con số cho có.

Để chắc chắn, tôi đo thêm cái có thể đo. Tôi cho một luồng đuổi con trỏ qua vùng 64 MB (đủ lớn để vượt cache, lấy đúng độ trễ RAM thật), rồi ghim nó lần lượt lên từng lõi trong cả 10 lõi và đo độ trễ mỗi lần truy cập:

Mọi lõi cho ra khoảng 73,7 đến 78,2 ns, chênh nhau vỏn vẹn 6,1%. Nếu máy này thật sự có NUMA, hẳn vài lõi "ở socket khác" phải chậm hơn hẳn. Đằng này mọi lõi chạm RAM tốn như nhau. Cái 6% lệch nhỏ kia là nhiễu cộng với chút khác biệt giữa lõi P và lõi E, chứ không phải gần với xa. Đây là bằng chứng đo được cho một vùng nhớ duy nhất, phẳng lì.

Hai cái bẫy ngược nhau

Tôi bước vào bài này với cái đầu quen tay của người xài laptop: "RAM là RAM, lõi nào chạm cũng thế, khỏi nghĩ dữ liệu nằm đâu". Trên chính máy này thì đúng thật. Nhưng nó chỉ đúng vì máy một node. Bê nguyên trực giác đó lên một máy chủ hai hay bốn socket là toi: ở đó remote chậm hơn local rành rành, và cái luồng nào chạm dữ liệu trước lại âm thầm quyết định dữ liệu nằm node nào, kéo theo tốc độ. Không đo topology trước thì đừng mang giả định đi đâu cả.

Nhưng có một cái bẫy ngược, hay gặp ở người vừa đọc xong một bài về NUMA và thấy nó ghê gớm quá: "thế thì code nào cũng phải ghim node, phải numactl, phải lo first-touch". Cũng sai nốt. Đa số máy — laptop, VM, phần lớn ARM một socket như cái tôi đo — chẳng có NUMA: /sys/devices/system/node ra 0 node, làm gì có node nào mà ghim. Trên những máy đó, mọi mẹo tối ưu NUMA đều là công cốc, 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ả ở đó vẫn phải đo mới biết luồng với dữ liệu có lệch node hay không.

Ngoài đời thì sao

Việc đầu tiên, và gần như duy nhất, là kiểm topology của máy đích trước khi bận tâm tới NUMA. ls /sys/devices/system/node/ đếm số nodeN, hoặc numactl --hardware, hoặc lscpu | grep -i numa. Một node thì gạt hết chuyện NUMA sang bên. Nhiều node thì mới tính tiếp. Rất nhiều dịch vụ ngày nay chạy trên VM một node, nơi tối ưu NUMA chẳng đem lại gì.

Còn nếu bạn thật sự đứng trước một máy chủ nhiều socket — cơ sở dữ liệu lớn, máy tính hiệu năng cao — thì hãy tôn trọng first-touch: để mỗi luồng tự khởi tạo phần dữ liệu của nó cho trang rơi đúng node, ghim luồng vào node đó, và tránh cái kiểu một luồng khởi tạo cả mảng khổng lồ rồi các luồng khác è cổ đọc remote. Đây không phải chuyện lạ với ngôn ngữ bậc cao: JVM có hẳn cờ -XX:+UseNUMA để chia heap theo node cho các server lớn, PostgreSQL và mấy hệ big data đều có tài liệu riêng về pin bộ nhớ theo NUMA. Chênh local–remote gấp rưỡi tới gấp đôi, ở quy mô đó là đáng tiền.

Muốn tự kiểm thì nhanh thôi: gõ ls /sys/devices/system/node/. Thấy mỗi node0 (hay chẳng thấy thư mục node nào) thì máy bạn là một vùng phẳng, quên NUMA đi. Thấy node0, node1,… thì mới là hàng NUMA thật. Cái đáng nhớ là đừng bê quy tắc tối ưu của lớp máy này áp lên lớp máy khác — "bộ nhớ" có thể phẳng hay chia node tùy máy, và chỉ có đo mới nói cho bạn biết máy trước mặt thuộc loại nào.