Linux CPU Pinning: Cách tối ưu hóa triệt để hiệu năng phần cứng

Linux tutorial - IT technology blog
Linux tutorial - IT technology blog

Tại sao bộ điều phối Linux mặc định không phải lúc nào cũng là lựa chọn tốt nhất

Bộ điều phối (scheduler) của Linux là một kiệt tác kỹ thuật. Với 99% khối lượng công việc, nó thực hiện xuất sắc nhiệm vụ phân bổ các tác vụ trên tất cả các core có sẵn để ngăn chặn tình trạng thắt nút cổ chai ở bất kỳ CPU đơn lẻ nào. Tuy nhiên, nếu bạn đang quản lý các nền tảng giao dịch tần suất cao, máy chủ game độ trễ thấp hoặc các cơ sở dữ liệu PostgreSQL khổng lồ, việc tự động cân bằng này thực tế có thể gây hại.

Vấn đề nằm ở việc chuyển ngữ cảnh (context switching). Khi nhân (kernel) di chuyển một tiến trình từ Core 0 sang Core 1, tiến trình đó sẽ để lại dữ liệu bộ nhớ đệm L1 và L2 đang “nóng”. Việc truy xuất lại dữ liệu đó từ bộ nhớ đệm L3 hoặc RAM hệ thống sẽ làm tăng thêm khoảng 50 đến 200 nano giây độ trễ. Dù con số này có vẻ nhỏ, nhưng những lần đình trệ nhỏ (micro-stalls) này sẽ tích tụ rất nhanh. Bằng cách triển khai CPU pinning và tôn trọng các ranh giới NUMA (Non-Uniform Memory Access), bạn có thể khóa các ứng dụng quan trọng của mình vào các luồng phần cứng cụ thể.

Bắt đầu nhanh: Pinning một tiến trình trong 5 phút

Công cụ đơn giản nhất để quản lý CPU affinity là taskset. Nó là một phần của gói util-linux tiêu chuẩn và hoạt động trên hầu hết các bản phân phối hiện đại.

Kiểm tra Affinity hiện tại

Để xem các core nào mà một tiến trình đang chạy được phép sử dụng, hãy lấy PID của nó và chạy:

taskset -p 1234

Đầu ra có thể sẽ là một mặt nạ thập lục phân (hexadecimal mask). Ví dụ, f trên hệ thống 4 core có nghĩa là tiến trình có thể chạy ở bất cứ đâu.

Khởi chạy một tiến trình mới trên các Core cụ thể

Giả sử bạn có một script Python xử lý việc nạp dữ liệu nặng. Để giới hạn nó ở core 0 và 1, hãy sử dụng cờ -c (cpu-list):

taskset -c 0,1 python3 ingest_data.py

Thay đổi Affinity cho một tiến trình đang chạy

Nếu cơ sở dữ liệu của bạn đang tải nặng và bạn muốn chuyển nó sang core 2 và 3 để nhường chỗ cho các tác vụ khác:

# Giả sử PID là 5678
taskset -cp 2,3 5678

Yếu tố NUMA: Tại sao tính cục bộ của bộ nhớ đệm lại quan trọng

Các máy chủ đa socket hiện đại, như những máy chạy chip AMD EPYC hoặc Intel Xeon kép, sử dụng kiến trúc NUMA. Trong các hệ thống này, bộ nhớ không phải là một vùng tài nguyên đồng nhất duy nhất. Thay vào đó, mỗi socket CPU có RAM cục bộ riêng. Mặc dù CPU 0 về mặt kỹ thuật có thể truy cập bộ nhớ gắn với CPU 1, nhưng việc đó yêu cầu phải đi qua kết nối liên thông (như Infinity Fabric của AMD), điều này gây ra sự sụt giảm hiệu năng đáng kể.

Trực quan hóa cấu trúc phần cứng của bạn

Trước khi thực hiện pinning, bạn phải xác định core nào thuộc về bank RAM nào. Chạy lscpu và tìm phần NUMA:

lscpu | grep -i numa

Bạn có thể thấy kết quả như thế này:

NUMA node0 CPU(s):   0-7,16-23
NUMA node1 CPU(s):   8-15,24-31

Nếu bạn pin một ứng dụng vào Core 0 nhưng dữ liệu của nó nằm ở bộ nhớ của Node 1, bạn sẽ thấy thông lượng sụt giảm nghiêm trọng. Đây là lý do tại sao numactl là thiết yếu.

Ràng buộc bộ nhớ và CPU cùng nhau

Công cụ numactl đảm bảo tiến trình của bạn nằm trên cùng một node cho cả tính toán và bộ nhớ:

# Chạy ứng dụng trên NUMA node 0 cho cả CPU và RAM
numactl --cpunodebind=0 --membind=0 ./my_high_perf_app

Cách ly nâng cao: Tạo các Cpuset chuyên dụng

Mặc dù taskset rất tốt cho các bản sửa lỗi nhanh, cpuset (thông qua cgroups) cho phép bạn phân vùng máy chủ của mình một cách chuyên nghiệp. Bạn có thể “rào chắn” các core cụ thể một cách hiệu quả để hệ điều hành không sử dụng chúng cho các tác vụ hệ thống chung, để lại toàn bộ chúng cho ứng dụng của bạn.

Bước 1: Thiết lập cgroup

Trên các hệ thống sử dụng cgroup v2, bạn có thể tạo một nhóm chuyên dụng cho ứng dụng ưu tiên cao của mình. Đầu tiên, hãy bật bộ điều khiển (controller):

mkdir /sys/fs/cgroup/production_app
echo "+cpuset" > /sys/fs/cgroup/cgroup.subtree_control

Bước 2: Dự phòng phần cứng

Tiếp theo, xác định CPU và node bộ nhớ nào mà nhóm này được phép chạm vào:

echo "2-3" > /sys/fs/cgroup/production_app/cpuset.cpus
echo "0" > /sys/fs/cgroup/production_app/cpuset.mems

Bước 3: Di chuyển tiến trình

Chỉ cần ghi ID tiến trình vào tệp cgroup.procs để di chuyển nó vào môi trường biệt lập:

echo 1234 > /sys/fs/cgroup/production_app/cgroup.procs

Thiết lập Pinning vĩnh viễn với systemd

Các lệnh thủ công là tốt để thử nghiệm, nhưng các dịch vụ production nên được tích hợp affinity vào cấu hình. Bạn có thể thực hiện việc này trực tiếp trong phần [Service] của tệp unit systemd.

Mở tệp dịch vụ của bạn (ví dụ: /etc/systemd/system/redis.service) và thêm các dòng sau:

[Service]
ExecStart=/usr/bin/redis-server
CPUAffinity=0 1
NUMAPolicy=bind
NUMAMask=0

Áp dụng các thay đổi bằng systemctl daemon-reload và khởi động lại dịch vụ của bạn. Điều này đảm bảo rằng ngay cả sau khi khởi động lại, dịch vụ của bạn vẫn quay trở lại các core đã chỉ định.

Bài học từ thực tế: Các thực hành tốt nhất

Sau nhiều năm quản lý các cụm Kubernetes lưu lượng cao và các node cơ sở dữ liệu bare-metal, tôi nhận thấy rằng pinning là một con dao hai lưỡi. Rất dễ để trở nên quá phấn khích và vô tình làm hệ điều hành thiếu hụt tài nguyên cần thiết cho các ngắt mạng (networking interrupts) hoặc I/O đĩa.

1. Theo dõi các NUMA Miss

Sử dụng numastat -p <PID> để theo dõi hiệu năng. Nếu bộ đếm numa_miss đang tăng lên, ứng dụng của bạn đang phải truy cập xuyên qua bo mạch chủ để lấy dữ liệu từ một bank RAM từ xa. Đây là dấu hiệu rõ ràng cho thấy chiến lược pinning của bạn không khớp với phần cứng.

2. Sự cách ly tối thượng: isolcpus

Nếu bạn có một tiến trình không thể chịu đựng được dù chỉ một mili giây bị can thiệp, hãy sử dụng tham số kernel isolcpus. Bằng cách chỉnh sửa /etc/default/grub và thêm isolcpus=2,3, bạn yêu cầu kernel Linux không bao giờ điều phối bất cứ thứ gì trên các core đó theo mặc định. Chúng sẽ ở trạng thái nhàn rỗi cho đến khi bạn gán một tiến trình cho chúng một cách thủ công bằng taskset.

3. Cẩn thận với Hyper-threading

Đừng quên rằng Core 0 và Core 1 có thể là hai luồng logic chia sẻ cùng một core vật lý. Đối với các tác vụ bị giới hạn bởi tính toán (compute-bound), việc pin hai luồng nặng vào cùng một core vật lý sẽ khiến chúng tranh giành các đơn vị thực thi giống nhau. Luôn kiểm tra /sys/devices/system/cpu/cpu0/topology/thread_siblings_list để xác định ID logic nào chia sẻ chung phần cứng vật lý.

Share: