Nguyên tắc Cơ bản về Phần cứng¶
Trước khi viết mã SIMD hay GPU, bạn cần hiểu phần cứng mà bạn đang lập trình. File này đề cập đến lý do tại sao tính song song lại thay thế tốc độ xung nhịp, cách CPU hiện đại thực thi chỉ thị, SIMD là gì, mô hình roofline để suy luận về hiệu năng, và bức tranh tổng quan về các kiến trúc chip
- Trong hàng thập kỷ, phần mềm trở nên nhanh hơn miễn phí: mua một CPU mới với tốc độ xung nhịp cao hơn, và chương trình của bạn chạy nhanh hơn mà không cần đổi dòng code nào. Kỷ nguyên đó kết thúc vào khoảng năm 2005. Hiểu tại sao nó kết thúc, và điều gì đã thay thế nó, là điều thiết yếu cho bất kỳ ai muốn viết mã nhanh.
Cái kết của Hiệu năng Miễn phí¶
-
Định luật Moore (1965) quan sát rằng số lượng bóng bán dẫn (transistor) trên một chip tăng gấp đôi khoảng mỗi hai năm. Điều này đúng trong 60 năm. Nhiều bóng bán dẫn hơn nghĩa là bóng bán dẫn nhỏ hơn, nghĩa là tốc độ xung nhịp cao hơn, nghĩa là chương trình nhanh hơn.
-
Nhưng vào khoảng năm 2005, tốc độ xung nhịp chạm tường tại ~4 GHz. Vấn đề là công suất. Công suất tiêu thụ của một chip xấp xỉ:
-
với \(C\) là điện dung (tỉ lệ với số bóng bán dẫn), \(V\) là điện áp, và \(f\) là tần số xung nhịp. Để tăng tần số, bạn phải tăng điện áp (để chuyển mạch transistor nhanh hơn). Nhưng công suất tỉ lệ với \(V^2 \cdot f\), nên một tăng nhỏ về tần số gây ra tăng lớn về công suất (và nhiệt). Ở 4 GHz, chip đã đạt ngưỡng 100+ watt. Lên 8 GHz sẽ cần làm mát không khả thi.
-
Giải pháp: thay vì làm cho một lõi nhanh hơn, hãy đặt nhiều lõi trên cùng một chip. Một chip 4 lõi ở 3 GHz dùng công suất tương tự một lõi đơn ở 4.5 GHz nhưng có thể làm lượng công việc song song gấp 4x. Đây là lý do mọi CPU hiện đại đều có nhiều lõi, và tại sao tính song song (SIMD, đa luồng, tính toán GPU) là con đường duy nhất để đạt thêm hiệu năng.
-
Hệ quả cho ML: một bước huấn luyện mất 10 phút trên một lõi không thể nhanh hơn bằng cách mua CPU nhanh hơn. Nó chỉ có thể nhanh hơn bằng cách dùng nhiều lõi hơn (song song hóa dữ liệu, chương 6), đơn vị SIMD rộng hơn (chương này), hoặc GPU (hàng ngàn lõi).
Cách CPU Hiện đại Thực thi Chỉ thị¶
-
Một lõi CPU hiện đại phức tạp hơn nhiều so với mô hình fetch-decode-execute đơn giản từ chương 13. Nó sử dụng một vài thủ thuật để thực thi nhiều chỉ thị hơn trên mỗi chu kỳ:
-
Thực thi superscalar: CPU có nhiều đơn vị thực thi (ALU, FPU, đơn vị load/store) và có thể thực thi nhiều chỉ thị độc lập đồng thời. Một lõi hiện đại có thể thực thi 4-6 chỉ thị mỗi chu kỳ nếu chúng không phụ thuộc lẫn nhau.
-
Thực thi ngoài thứ tự (OoO): CPU không thực thi chỉ thị theo thứ tự chương trình. Nó nhìn trước trong luồng chỉ thị, tìm các chỉ thị có đầu vào đã sẵn sàng, và thực thi chúng ngay lập tức, bất kể vị trí của chúng. Điều này ẩn độ trễ: trong khi một chỉ thị chờ dữ liệu từ bộ nhớ (100+ chu kỳ), CPU thực thi các chỉ thị khác đã sẵn sàng.
-
Dự đoán rẽ nhánh (branch prediction): các rẽ nhánh có điều kiện (câu lệnh
if, điều kiện vòng lặp) tạo ra sự không chắc chắn: CPU không biết đường nào sẽ được chọn cho đến khi điều kiện được đánh giá. Thay vì dừng lại, CPU dự đoán kết quả và thực thi đầu cơ (speculatively) theo đường đã dự đoán. Nếu dự đoán đúng (>95% thời gian với bộ dự đoán hiện đại), không lãng phí thời gian. Nếu sai, công việc đầu cơ bị loại bỏ và đường đúng được thực thi (phạt ~15 chu kỳ). -
Thực thi đầu cơ (speculative execution): một mở rộng của dự đoán rẽ nhánh. CPU thực thi các chỉ thị có thể không cần đến, đánh cược rằng chúng sẽ được dùng. Điều này lấp đầy pipeline và giữ các đơn vị thực thi bận rộn.
-
Tất cả các kỹ thuật này đều là tự động — CPU làm chúng mà không cần sự can thiệp của lập trình viên. Nhưng chúng chỉ giúp ích với tính song song cấp chỉ thị (ILP): các chỉ thị độc lập trong một luồng đơn. Đối với tính song song cấp dữ liệu (cùng một phép toán trên nhiều phần tử dữ liệu), chúng ta cần SIMD.
SIMD: Single Instruction, Multiple Data (Một chỉ thị, Nhiều dữ liệu)¶
-
SIMD là ý tưởng áp dụng một chỉ thị lên nhiều phần tử dữ liệu đồng thời. Thay vì cộng hai số, hãy cộng hai vector 4 (hoặc 8, hoặc 16) số trong một chỉ thị duy nhất.
-
Không có SIMD (vô hướng):
// Cộng hai mảng từng phần tử: 4 chỉ thị add
for (int i = 0; i < 4; i++) {
c[i] = a[i] + b[i]; // một phép cộng mỗi lần lặp
}
- Với SIMD (vector hóa):
// Cộng hai mảng: 1 chỉ thị SIMD làm tất cả 4 phép cộng
#include <immintrin.h> // SIMD intrinsics của x86
__m128 va = _mm_load_ps(a); // nạp 4 số thực vào thanh ghi 128-bit
__m128 vb = _mm_load_ps(b); // nạp 4 số thực vào thanh ghi khác
__m128 vc = _mm_add_ps(va, vb); // cộng cả 4 cặp đồng thời
_mm_store_ps(c, vc); // lưu 4 kết quả
- Phiên bản SIMD thực hiện cùng công việc với 1/4 số chỉ thị. Đây là mức tăng tốc lý thuyết 4x, đạt được bằng cách xử lý 4 số thực mỗi chỉ thị thay vì 1.
Thanh ghi Vector¶
- Các chỉ thị SIMD hoạt động trên thanh ghi vector: các thanh ghi rộng chứa nhiều phần tử dữ liệu.
| Độ rộng thanh ghi | Số thực (32-bit) | Số thực độ chính xác kép (64-bit) | Tên |
|---|---|---|---|
| 128-bit | 4 | 2 | SSE (x86), NEON (ARM) |
| 256-bit | 8 | 4 | AVX/AVX2 (x86) |
| 512-bit | 16 | 8 | AVX-512 (x86) |
| Thay đổi (128-2048) | thay đổi | thay đổi | SVE/SVE2 (ARM) |
-
Thanh ghi càng rộng = càng nhiều song song. Một chỉ thị AVX-512 512-bit xử lý 16 số thực cùng lúc, tăng tốc lý thuyết 16x so với mã vô hướng. Trong thực tế, mức tăng thấp hơn do giới hạn băng thông bộ nhớ (bạn có thể tính toán nhanh hơn khả năng cấp dữ liệu cho CPU).
-
Đối với ML: một phép nhân ma trận các giá trị float32 hưởng lợi rất lớn từ SIMD. Vòng lặp trong cùng (tích vô hướng của hai vector) ánh xạ trực tiếp đến các chỉ thị SIMD nhân-cộng dồn (multiply-accumulate). Đây là lý do các thư viện BLAS (mà NumPy và PyTorch gọi) được tối ưu rất mạnh với SIMD.
Mô hình Roofline¶
-
Làm sao bạn biết mã của mình có nhanh hay không? Mô hình roofline cung cấp một khuôn khổ bằng cách mô tả hiệu năng dựa trên hai giới hạn phần cứng:
-
Đỉnh tính toán (FLOPS): số phép toán dấu phẩy động tối đa mỗi giây. Với CPU 4 GHz có AVX 256-bit (8 số thực mỗi chỉ thị) và 2 đơn vị FMA: \(4 \times 10^9 \times 8 \times 2 = 64\) GFLOPS.
-
Đỉnh băng thông bộ nhớ (byte/giây): tốc độ dữ liệu có thể được di chuyển từ bộ nhớ tới CPU. Một CPU hiện đại có thể có 50 GB/s băng thông bộ nhớ.
-
Cường độ tính toán (arithmetic intensity) của mã bạn là tỉ lệ giữa tính toán và truy cập bộ nhớ:
-
Nếu cường độ tính toán thấp (ít phép tính trên mỗi byte tải vào), mã của bạn bị giới hạn bởi bộ nhớ (memory-bound): nó dành hầu hết thời gian chờ dữ liệu. Làm tính toán nhanh hơn (SIMD rộng hơn, xung nhịp cao hơn) sẽ không giúp ích.
-
Nếu cường độ tính toán cao (nhiều phép tính trên mỗi byte), mã của bạn bị giới hạn bởi tính toán (compute-bound): nó dành hầu hết thời gian để tính toán. Bộ nhớ nhanh hơn cũng không giúp ích.
-
Công thức roofline:
-
Phép nhân ma trận có cường độ tính toán cao: \(O(n^3)\) phép tính trên \(O(n^2)\) dữ liệu, nên cường độ \(\approx O(n)\). Với ma trận lớn, nó bị giới hạn bởi tính toán. Đây là lý do GPU (tính toán cao) thống trị các khối lượng công việc ML nặng về ma trận.
-
Các phép toán theo phần tử (ReLU, cộng, nhân) có cường độ tính toán thấp: 1 phép tính trên mỗi phần tử được tải. Những phép toán này bị giới hạn bởi bộ nhớ. Làm GPU nhanh hơn không giúp ích; bạn cần bộ nhớ nhanh hơn (hoặc hợp nhất (fuse) các phép toán này với các phép tính nặng khác để tránh các chuyến khứ hồi bộ nhớ riêng lẻ).
-
Mô hình roofline giải thích tại sao kernel fusion (hợp nhất kernel) lại quan trọng: kết hợp một matmul với cộng bias và ReLU thành một kernel duy nhất tránh việc ghi kết quả trung gian vào bộ nhớ và đọc lại chúng, biến ba phép toán bị giới hạn bởi bộ nhớ thành một phép toán bị giới hạn bởi tính toán.
Độ trễ (Latency) vs Thông lượng (Throughput)¶
-
Độ trễ là thời gian để hoàn thành một phép toán. Thông lượng là số phép toán hoàn thành trên một đơn vị thời gian.
-
Một phép so sánh: xe buýt có độ trễ cao (chờ ở mọi trạm) nhưng thông lượng cao (chở 50 người một lúc). Taxi có độ trễ thấp (đi thẳng đến đích) nhưng thông lượng thấp (chở 1-4 người).
-
GPU là xe buýt: độ trễ cao mỗi phép toán (mỗi chỉ thị mất nhiều chu kỳ để hoàn thành) nhưng thông lượng khổng lồ (hàng ngàn lõi xử lý đồng thời). CPU là taxi: độ trễ thấp (thực thi ngoài thứ tự, dự đoán rẽ nhánh, cache sâu giảm thiểu độ trễ) nhưng thông lượng hạn chế (4-64 lõi).
-
Đây là lý do GPU tốt hơn cho huấn luyện ML (thông lượng quan trọng: xử lý hàng triệu mẫu) và CPU tốt hơn cho các tác vụ hệ điều hành (độ trễ quan trọng: phản hồi ngay một lần nhấn phím).
-
Pipeline (đường ống) chuyển đổi độ trễ thành thông lượng. Nếu một chỉ thị mất 5 chu kỳ nhưng pipeline bắt đầu một chỉ thị mới mỗi chu kỳ, thông lượng là 1 chỉ thị mỗi chu kỳ (dù mỗi chỉ thị mất 5 chu kỳ để hoàn thành). Đây là cùng nguyên lý với pipeline CPU từ chương 13, nhưng nó áp dụng ở mọi cấp độ: các đơn vị SIMD, bộ điều khiển bộ nhớ, và lõi GPU đều sử dụng pipeline.
Bức tranh Kiến trúc Chip¶
- Phần cứng bạn viết mã cho nó quyết định các chỉ thị SIMD có sẵn:
x86 (Intel, AMD)¶
-
Thống trị máy bàn, laptop, và CPU trung tâm dữ liệu. SIMD: SSE (128-bit), AVX/AVX2 (256-bit), AVX-512 (512-bit). Intel AMX cung cấp các đơn vị nhân ma trận chuyên dụng cho khối lượng công việc AI.
-
Điểm mạnh: hiệu năng một lõi cao nhất, SIMD rộng nhất, hệ sinh thái phần mềm trưởng thành (MKL, oneDNN).
- Điểm yếu: tiêu thụ điện năng cao, tập lệnh phức tạp, đắt.
ARM¶
-
Thống trị di động (mọi điện thoại thông minh), đang phát triển trong máy chủ (AWS Graviton, Ampere Altra) và laptop (dòng Apple M). SIMD: NEON (128-bit), SVE/SVE2 (có thể mở rộng, 128-2048 bit).
-
Điểm mạnh: hiệu suất năng lượng xuất sắc (hiệu năng trên mỗi watt), lõi tùy chỉnh (Apple M4 cạnh tranh với Intel ở hiệu năng một lõi với lượng điện nhỏ hơn nhiều).
- Điểm yếu: SIMD hẹp hơn (NEON chỉ 128-bit, dù SVE có thể rộng hơn), hệ sinh thái phần mềm nhỏ hơn cho HPC.
Apple Silicon (M1/M2/M3/M4)¶
-
Dựa trên ARM với các bổ sung tùy chỉnh. Bao gồm AMX (Apple Matrix eXtensions) — các đơn vị nhân ma trận không có tài liệu chính thức mà Accelerate framework dùng cho các phép toán BLAS. Kiến trúc bộ nhớ hợp nhất: CPU và GPU dùng chung một bộ nhớ vật lý, loại bỏ nút thắt cổ chai sao chép CPU↔GPU.
-
Đối với ML: Neural Engine của Apple (16 lõi, bộ tăng tốc ML chuyên dụng) và bộ nhớ hợp nhất làm cho chip dòng M có khả năng đáng ngạc nhiên cho suy luận ML tại chỗ và huấn luyện quy mô nhỏ. Không có CUDA: bạn phải dùng Metal (API GPU của Apple) hoặc MLX (framework ML của Apple).
RISC-V¶
-
ISA mã nguồn mở. Không có phí cấp phép (không như ARM). Ngày càng phát triển trong các hệ thống nhúng, IoT, và nghiên cứu. SIMD: extension "V" (vector) cung cấp xử lý vector có thể mở rộng tương tự ARM SVE.
-
Đối với ML: chưa cạnh tranh được với x86/ARM cho khối lượng công việc ML, nhưng hãy theo dõi. Một số startup tăng tốc AI sử dụng lõi RISC-V.
GPU (NVIDIA, AMD, Intel)¶
- Được đề cập sâu trong các file 04-05. Hàng ngàn lõi đơn giản tối ưu cho thông lượng. NVIDIA thống trị ML với CUDA; AMD cạnh tranh với ROCm; Intel tham gia với GPU Arc và bộ tăng tốc Gaudi.
TPU (Google)¶
- ASIC tùy chỉnh được thiết kế riêng cho ML. Mảng systolic tối ưu cho phép nhân ma trận. Được đề cập trong file 05.
Giới hạn Nhiệt và Công suất¶
-
Hiệu năng cuối cùng bị giới hạn bởi công suất và làm mát:
-
TDP (Thermal Design Power — Công suất Thiết kế Nhiệt): công suất duy trì tối đa mà chip có thể tiêu thụ. CPU laptop có thể có TDP 15W; CPU máy chủ 250W; GPU trung tâm dữ liệu 700W (NVIDIA B200).
-
Dark silicon: tại bất kỳ thời điểm nào, một phần đáng kể bóng bán dẫn phải được tắt để nằm trong ngân sách nhiệt. Một chip có thể dùng tất cả bóng bán dẫn đồng thời, nhưng nó sẽ nóng chảy.
-
Hiệu suất năng lượng (FLOPS/watt) ngày càng là thước đo quan trọng, không phải FLOPS thô. Đây là lý do:
- ARM đang chiếm lĩnh trung tâm dữ liệu (FLOPS/watt tốt hơn x86).
- TPU cạnh tranh với GPU dù có đỉnh FLOPS thấp hơn (FLOPS/watt tốt hơn nhiều cho khối lượng công việc ML).
- Lượng tử hóa (INT8, FP8) không chỉ về bộ nhớ: nó cũng giảm năng lượng cho mỗi phép tính.
-
Đối với ML quy mô lớn: huấn luyện một frontier model tiêu thụ megawatt năng lượng trong nhiều tháng. Chi phí điện có thể vượt quá chi phí phần cứng. Hiệu suất năng lượng ảnh hưởng trực tiếp đến kinh tế của nghiên cứu AI.
Thực hành: Đo Hiệu năng trong C++¶
- Để suy luận về hiệu năng, bạn cần đo nó. Dưới đây là một thiết lập benchmark C++ tối thiểu:
#include <iostream>
#include <chrono>
#include <vector>
// Phép cộng vô hướng
void add_scalar(const float* a, const float* b, float* c, int n) {
for (int i = 0; i < n; i++) {
c[i] = a[i] + b[i];
}
}
int main() {
const int N = 1 << 24; // ~16 triệu phần tử
std::vector<float> a(N, 1.0f), b(N, 2.0f), c(N);
// Làm nóng (điền cache, kích hoạt scaling tần số)
add_scalar(a.data(), b.data(), c.data(), N);
// Đo benchmark
auto start = std::chrono::high_resolution_clock::now();
for (int trial = 0; trial < 100; trial++) {
add_scalar(a.data(), b.data(), c.data(), N);
}
auto end = std::chrono::high_resolution_clock::now();
double elapsed = std::chrono::duration<double>(end - start).count();
double total_bytes = 3.0 * N * sizeof(float) * 100; // đọc a, đọc b, ghi c
double bandwidth = total_bytes / elapsed / 1e9; // GB/s
std::cout << "Time: " << elapsed << " s\n";
std::cout << "Bandwidth: " << bandwidth << " GB/s\n";
return 0;
}
-
Các khái niệm C++ quan trọng trong mã này:
#include <vector>: mảng động (std::vector<float>) — giốnglistPython nhưng có kiểu và liên tiếp trong bộ nhớ.a.data(): trả về con trỏ thô (float*) tới mảng nền — cần cho SIMD intrinsics.std::chrono: bộ đếm thời gian độ phân giải cao cho benchmark.-O3: mức tối ưu hóa trình biên dịch tối đa. Trình biên dịch có thể tự động vector hóa vòng lặp (dùng SIMD tự động).-march=nativekích hoạt tất cả chỉ thị SIMD mà CPU bạn hỗ trợ.
-
Tại sao cần làm nóng (warm up): lần chạy đầu lấp đầy cache và có thể kích hoạt scaling tần số CPU (turbo boost). Các lần chạy sau đại diện hơn.
-
Tại sao cần đo băng thông: với các phép toán bị giới hạn bởi bộ nhớ (như cộng theo phần tử), thước đo ý nghĩa là băng thông (GB/s), không phải FLOPS. Nếu băng thông đo được gần giới hạn phần cứng (~50 GB/s cho DDR5), bạn đang bị giới hạn bởi bộ nhớ và SIMD sẽ không giúp ích nhiều (nút thắt là bộ nhớ, không phải tính toán).
Bài tập Lập trình (dùng CoLab hoặc notebook)¶
-
Tính cường độ tính toán của các phép toán ML phổ biến và phân loại chúng là memory-bound hay compute-bound.
import jax.numpy as jnp def arithmetic_intensity(flops, bytes_transferred): return flops / bytes_transferred # ReLU theo phần tử: 1 so sánh mỗi phần tử, đọc + ghi n = 1024 relu_flops = n # 1 op mỗi phần tử relu_bytes = 2 * n * 4 # đọc input + ghi output (float32) print(f"ReLU: {arithmetic_intensity(relu_flops, relu_bytes):.2f} FLOPS/byte → memory-bound") # Nhân ma trận: 2*n^3 op, đọc 2*n^2 + ghi n^2 float matmul_flops = 2 * n**3 matmul_bytes = 3 * n**2 * 4 # đọc A + đọc B + ghi C print(f"Matmul ({n}×{n}): {arithmetic_intensity(matmul_flops, matmul_bytes):.0f} FLOPS/byte → compute-bound") # Layer norm: ~5n op (mean, var, normalize), đọc + ghi ln_flops = 5 * n ln_bytes = 2 * n * 4 print(f"LayerNorm: {arithmetic_intensity(ln_flops, ln_bytes):.2f} FLOPS/byte → memory-bound") # Convolution 3x3: 2*9*C_in*C_out*H*W, đọc kernel + feature map + ghi output C_in, C_out, H, W = 64, 128, 32, 32 conv_flops = 2 * 9 * C_in * C_out * H * W conv_bytes = (9 * C_in * C_out + C_in * H * W + C_out * H * W) * 4 print(f"Conv3x3: {arithmetic_intensity(conv_flops, conv_bytes):.0f} FLOPS/byte → compute-bound") -
Chứng minh tại sao tính song song quan trọng. So sánh thực thi tuần tự (sequential) vs song song (NumPy) khi kích thước dữ liệu tăng.
import numpy as np import time for n in [1000, 10000, 100000, 1000000, 10000000]: a = np.random.randn(n).astype(np.float32) b = np.random.randn(n).astype(np.float32) # "Tuần tự" (vòng lặp Python) start = time.time() c = [a[i] * b[i] for i in range(min(n, 100000))] # giới hạn 100K cho hợp lý seq_time = time.time() - start if n > 100000: seq_time *= n / 100000 # ngoại suy # "Song song" (NumPy, dùng SIMD + đa luồng bên trong) start = time.time() c = a * b par_time = time.time() - start print(f"n={n:>10,} sequential={seq_time:.4f}s parallel={par_time:.6f}s " f"speedup={seq_time/par_time:.0f}x")