CPU Pinning và NUMA Topology trên Proxmox VE: Tối ưu hiệu suất máy ảo độ trễ thấp

Virtualization tutorial - IT technology blog
Virtualization tutorial - IT technology blog

Vấn đề: Máy ảo chạy chậm dù CPU còn rảnh

Lần đầu mình để ý chuyện này là khi chạy một VM chuyên xử lý dữ liệu real-time — CPU usage của host chỉ tầm 30%, vậy mà latency cứ nhảy lên cao bất thường theo kiểu spike ngắn rồi lại về. Log không có gì, RAM đủ, disk I/O bình thường. Mãi sau mới phát hiện ra thủ phạm: scheduler của hypervisor đang di chuyển vCPU của VM qua lại giữa các CPU core vật lý liên tục, kéo theo cache miss và NUMA penalty.

Mình chạy homelab với Proxmox VE quản lý 12 VM và container — đây là playground để test mọi thứ trước khi đưa lên production. Chính môi trường này giúp mình phát hiện ra: với workload nhạy cảm về độ trễ — database, game server, xử lý âm thanh/video real-time — để Linux kernel tự quyết định vCPU chạy trên core nào là chưa đủ tốt.

Giải pháp nằm ở CPU Pinning kết hợp NUMA-aware topology. Không phức tạp để cấu hình — nhưng hiểu sai bản chất thì dễ áp dụng nhầm chỗ.

Khái niệm cốt lõi

CPU Pinning là gì?

Khi bạn tạo VM với 4 vCPU, Proxmox (thực ra là QEMU + KVM) tạo ra 4 thread tương ứng. Mặc định, kernel Linux của host tự lên lịch chạy các thread này trên bất kỳ core vật lý nào còn rảnh. Mỗi lần scheduler di chuyển một thread sang core khác, CPU cache của core cũ bị bỏ phí — core mới phải load lại dữ liệu từ đầu. Với workload thông thường, chi phí này không đáng kể. Nhưng với workload latency-sensitive, mỗi cache miss tích lũy thành vấn đề thật — đặc biệt khi nó xảy ra hàng nghìn lần mỗi giây.

CPU Pinning (hay CPU affinity) là việc gắn cứng mỗi vCPU của VM vào một tập con cố định các CPU core vật lý. Thread của vCPU đó sẽ chỉ chạy trên các core được chỉ định, không đi lung tung nữa. Cache được giữ ấm, latency ổn định hơn.

NUMA là gì và tại sao quan trọng?

Server nhiều socket — hoặc CPU dạng nhiều chiplet như AMD EPYC/Ryzen Threadripper — không có bộ nhớ dùng chung đồng đều. Mỗi socket có RAM riêng gắn trực tiếp, đây là local memory và truy cập rất nhanh. Ngược lại, khi core ở socket 0 cần đọc dữ liệu từ RAM của socket 1, nó phải đi qua liên kết liên-socket (Intel QPI hoặc AMD Infinity Fabric). Đây là remote memory — latency cao hơn 2–3 lần so với local access.

Kiến trúc này gọi là NUMA — Non-Uniform Memory Access. Vấn đề xuất hiện khi cấu hình không đồng bộ: pin vCPU vào core ở node 0 nhưng RAM lại cấp phát từ node 1, bạn vẫn chịu NUMA penalty trên mọi lần đọc bộ nhớ. Một nửa giải pháp đôi khi còn tệ hơn không làm gì — vì bạn tưởng đã xong trong khi bottleneck vẫn còn đó.

Cấu hình đúng phải đồng bộ cả hai: pin vCPU vào các core cùng một NUMA node, và đảm bảo RAM của VM được cấp phát từ chính NUMA node đó.

Thực hành chi tiết

Bước 1: Khảo sát topology CPU của host

Trước khi pin gì cả, cần biết host có bao nhiêu NUMA node và core nào thuộc node nào.

# Xem tổng quan NUMA topology
numactl --hardware

# Hoặc dùng lscpu cho thông tin chi tiết hơn
lscpu | grep -E 'NUMA|CPU\(s\)|Thread|Core|Socket'

# Xem từng NUMA node có những CPU nào
cat /sys/devices/system/node/node0/cpulist
cat /sys/devices/system/node/node1/cpulist

Ví dụ output của numactl --hardware trên server 2 socket, mỗi socket 8 core (16 thread với HT):

available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 64432 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 64432 MB
node distances:
node   0   1
  0:  10  21
  1:  21  10

Con số 21 là NUMA distance — truy cập RAM từ node kia tốn gấp 2.1 lần. Ghi nhớ: nếu VM dùng core 0–7, RAM phải đến từ node 0 mới tránh được penalty.

Bước 2: Cấu hình CPU Pinning trong Proxmox

Proxmox không có UI trực tiếp cho CPU pinning (tính đến PVE 8.x). Cần chỉnh thẳng file cấu hình VM:

# File cấu hình VM có ID là 100
nano /etc/pve/qemu-server/100.conf

Thêm dòng sau (ví dụ VM có 4 vCPU, pin vào core 0–3 của NUMA node 0):

# Số lượng vCPU
cores: 4
sockets: 1

# CPU type — host để dùng tất cả tính năng CPU vật lý
cpu: host

# Pin từng vCPU (vcpu0 → core0, vcpu1 → core1, ...)
affinity0: 0
affinity1: 1
affinity2: 2
affinity3: 3

Từ Proxmox VE 8.1+, có thể dùng tham số affinity dạng range cho gọn hơn:

affinity: 0-3

Sau khi lưu, khởi động lại VM để áp dụng. Kiểm tra lại bằng:

# Lấy PID của process QEMU của VM 100
ps aux | grep 'qemu.*100'

# Xem affinity hiện tại của các thread QEMU
taskset -cp <PID>

Bước 3: Cấu hình NUMA Topology cho VM

Pinning CPU mới đi được một nửa đường. Bước tiếp theo là khai báo NUMA topology — để VM nhận biết môi trường NUMA và buộc QEMU/KVM cấp phát RAM từ đúng node.

Chỉnh lại file cấu hình VM:

# Bật NUMA emulation
numa: 1

# Khai báo NUMA node 0 cho VM:
# cpus = vCPU nào thuộc node này (0-3)
# memory = lượng RAM (MB) cấp cho node này
# hostnodes = NUMA node vật lý nào cung cấp RAM
# policy = memory allocation policy
numa0: cpus=0-3,memory=8192,hostnodes=0,policy=bind

policy=bind là tham số then chốt: kernel host bị ép chỉ cấp RAM từ hostnodes=0, kể cả khi node 0 đang chịu áp lực — không được phép lấy từ node khác.

Nếu VM có nhiều vCPU hơn và muốn span sang NUMA node 1 (ví dụ VM 8 vCPU trên server 2 socket):

cores: 8
sockets: 2
numa: 1
affinity: 0-3,8-11
numa0: cpus=0-3,memory=8192,hostnodes=0,policy=bind
numa1: cpus=4-7,memory=8192,hostnodes=1,policy=bind

Lưu ý: affinity: 0-3,8-11 nghĩa là vCPU 0–3 dùng physical core 0–3 (node 0), vCPU 4–7 dùng physical core 8–11 (node 1). Đối chiếu với output numactl --hardware ở bước 1 để map đúng.

Bước 4: Cô lập CPU core khỏi scheduler host (nâng cao)

Ngay cả khi đã pin, nếu host OS vẫn chạy các process khác trên cùng core đó, vẫn có interruption. Với yêu cầu latency cực thấp, nên cô lập hoàn toàn các core dành cho VM:

# Thêm vào GRUB_CMDLINE_LINUX trong /etc/default/grub
# Cô lập core 0-3 khỏi scheduler Linux của host
isolcpus=0-3 nohz_full=0-3 rcu_nocbs=0-3

# Áp dụng
update-grub
reboot

Sau khi reboot, kiểm tra:

cat /sys/devices/system/cpu/isolated

Nên ra 0-3. Các core này giờ gần như chỉ phục vụ VM được pin vào đó.

Bước 5: Xác nhận hiệu quả

Bên trong VM, chạy benchmark latency đơn giản:

# Cài cyclictest (thường có trong rt-tests)
apt install rt-tests

# Chạy latency test 60 giây
cyclictest --mlockall --smp --priority=80 --interval=200 --distance=0 -D 60

So sánh kết quả trước và sau khi cấu hình pinning. Với setup đúng, giá trị Max latency thường giảm đáng kể — từ vài ms xuống còn vài trăm µs trong điều kiện load.

Một vài điều cần nhớ trước khi áp dụng

  • Không nên pin tất cả VM: Pinning làm giảm flexibility của scheduler. Chỉ áp dụng cho VM thực sự cần latency thấp, các VM còn lại cứ để scheduler tự xử lý.
  • Tính toán core budget: Host 16 core mà bạn pin 8 core cho 2 VM — còn lại 8 core cho host OS và các VM khác. Đừng over-commit.
  • Hyperthreading: Với workload compute nặng, cân nhắc tắt HT hoặc chỉ dùng physical core (không dùng sibling thread) để tránh resource contention trong cùng core vật lý.
  • Live migration bị ảnh hưởng: VM có CPU pinning sẽ không live-migrate được sang host khác nếu host đích không có cùng topology. Cần unpin trước khi migrate.

Kết luận

CPU pinning và NUMA topology không phải thứ bạn cần cấu hình cho mọi VM. Nhưng khi workload thật sự đòi hỏi latency ổn định, bỏ qua hai kỹ thuật này là lãng phí phần cứng đang có. Mình từng bỏ qua phần này suốt một thời gian dài vì nghĩ đó là chuyện của data center lớn, cho đến khi chính homelab của mình chứng minh ngược lại.

Bắt đầu bằng numactl --hardware, chọn một VM để test, rồi pin từng bước. Quan sát latency thay đổi thế nào — số liệu từ server của bạn, với workload thật, sẽ thuyết phục hơn bất kỳ bài benchmark tổng hợp nào.

Share: