Bỏ qua

Tại sao lại là C++ và Cách thức Hoạt động của các Framework ML

Mọi lời gọi jnp.matmul, mọi torch.nn.Linear, mọi lệnh np.dot trong cuốn sách này thực chất đều đang chạy mã C++ và CUDA ở tầng bên dưới. File này vén bức màn bí mật đó lên: tại sao các framework ML lại được xây dựng theo cách này, những kiến thức cơ bản nhanh về C++ dành cho kỹ sư Python, khi nào cần tự viết kernel C++ tùy chỉnh, và cách gắn kết chúng vào Python — cây cầu nối giữa đoạn mã bạn viết và phần cứng mà nó chạy trên đó.

  • Bạn đã dành 15 chương để viết Python. Bạn import JAX, gọi jax.grad, chạy các vòng lặp huấn luyện, và xây dựng các mô hình. Tất cả đều có cảm giác là Python. Nhưng sự thật là: hầu như không có phép tính thực tế nào xảy ra trong Python cả.

  • Khi bạn viết output = model(input) trong PyTorch hay output = jnp.matmul(W, x) trong JAX, Python hầu như không làm gì cả. Nó chỉ xây dựng một mô tả về phép tính (một đồ thị các phép toán), sau đó chuyển giao cho một backend C++/CUDA thực hiện công việc thực sự. Python là vô-lăng; C++ là động cơ.

Tại sao lại là Frontend Python, Backend C++

  • Kiến trúc hai ngôn ngữ này tồn tại vì Python và C++ đều giỏi ở những việc trái ngược nhau:
Python C++
Tốc độ phát triển Nhanh (kiểu động, REPL, không cần biên dịch) Chậm (kiểu tĩnh, header, thời gian biên dịch)
Tốc độ thực thi Chậm hơn ~100x so với C (thông dịch, GIL) Tốc độ sát phần cứng (đã biên dịch, không overhead)
Kiểm soát bộ nhớ Tự động (GC), không kiểm soát được bố cục Thủ công, kiểm soát chính xác từng byte
Truy cập phần cứng Không có (không SIMD, không GPU, không bộ nhớ tùy chỉnh) Đầy đủ (intrinsics, CUDA, assembly nội tuyến)
Hệ sinh thái Giàu có cho ML (notebook, trực quan hóa, dữ liệu) Giàu có cho hệ thống (OS, driver, engine)
  • Nhận thức cốt lõi: dùng mỗi ngôn ngữ cho thế mạnh của nó. Python đảm nhiệm những phần mà năng suất con người quan trọng (thiết kế thử nghiệm, tinh chỉnh siêu tham số, khám phá dữ liệu). C++ đảm nhiệm những phần mà hiệu năng máy móc quan trọng (nhân ma trận, tích chập, các kernel attention).

  • Một phép nhân ma trận duy nhất jnp.matmul(A, B) với \(A\) kích thước \(4096 \\times 4096\) thực hiện khoảng 137 tỷ phép toán dấu phẩy động. Trong Python thuần (vòng lặp lồng nhau), việc này mất khoảng 30 phút. Trong C++ được tối ưu với SIMD AVX-512 và đa luồng, nó mất khoảng 10 mili-giây. Đó là sự khác biệt 180.000 lần. Không có mẹo Python nào lấp được khoảng cách này.

Cấu trúc của các Framework ML

  • Mọi framework ML lớn đều tuân theo cùng một kiến trúc:
Mã người dùng (Python)
Tầng API Python (torch.nn, jax.numpy, numpy)
Tầng dispatch / trình biên dịch JIT (torch.compile, XLA, NumPy dispatch)
Thư viện kernel C++ (ATen/PyTorch, XLA, BLAS/LAPACK)
Backend đặc thù phần cứng (CUDA, cuDNN, MKL, oneDNN, Metal)
Phần cứng (đơn vị SIMD CPU, nhân GPU, MXU của TPU)

NumPy

  • Lõi của NumPy được viết bằng C. Khi bạn gọi np.dot(A, B), Python gọi một hàm C, hàm này lại gọi BLAS (Basic Linear Algebra Subprograms — Chương trình Con cơ sở Đại số Tuyến tính), thường là Intel MKL hoặc OpenBLAS. BLAS là mã C và Fortran được tối ưu thủ công, sử dụng các chỉ thị SIMD, các mẫu truy cập bộ nhớ nhận thức về cache, và đa luồng. Hàng thập kỷ tối ưu hóa đã được đổ vào để làm cho phép nhân ma trận trở nên nhanh.

  • NumPy chỉ chạy trên CPU. Nó không dùng GPU. Nhưng trên CPU, nó cực kỳ nhanh vì ủy quyền cho triển khai BLAS tốt nhất hiện có.

PyTorch

  • Công cụ tính toán của PyTorch là ATen (A Tensor Library — Thư viện Tensor), được viết bằng C++. ATen triển khai ~2000 phép toán trên tensor (add, matmul, conv2d, softmax, ...), mỗi phép toán đều có backend CPU và CUDA.

  • Khi bạn gọi torch.matmul(A, B):

    1. Python dispatch sang hàm C++ của ATen.
    2. ATen kiểm tra thiết bị (CPU hay CUDA) và kiểu dữ liệu (dtype).
    3. Trên CPU: gọi MKL/OpenBLAS. Trên GPU: gọi cuBLAS (BLAS được NVIDIA tối ưu cho GPU).
    4. Kết quả được bọc trong một đối tượng tensor Python và trả về.
  • torch.compile (PyTorch 2.0+) đi xa hơn: nó truy vết mã Python của bạn, xây dựng một đồ thị tính toán, và biên dịch nó bằng Triton (cho GPU) hoặc C++/OpenMP (cho CPU). Mã đã biên dịch sẽ hợp nhất (fuse) các phép toán, loại bỏ overhead của Python, và có thể nhanh hơn 2-5x so với chế độ eager.

JAX

  • JAX biên dịch các hàm Python sang XLA (Accelerated Linear Algebra — Đại số Tuyến tính Tăng tốc), trình biên dịch của Google dành cho khối lượng công việc ML. Khi bạn jax.jit một hàm:

    1. JAX truy vết hàm, capture các phép toán thành một đồ thị tính toán XLA (HLO — High Level Operations — Các Phép toán Mức Cao).
    2. XLA tối ưu đồ thị: hợp nhất các phép toán, loại bỏ tính toán dư thừa, tối ưu bố cục bộ nhớ.
    3. XLA biên dịch sang backend đích: CPU (qua LLVM), GPU (qua CUDA/PTX), hoặc TPU (qua các chỉ thị đặc thù TPU).
    4. Mã đã biên dịch chạy trực tiếp trên phần cứng mà không có sự can dự nào của Python.
  • Đó là lý do jax.jit lại quan trọng đến thế: không có nó, mỗi phép toán là một chuyến đi khứ hồi Python→C++ riêng biệt. Có nó, toàn bộ hàm là một kernel duy nhất đã được biên dịch.

Kiến thức C++ Cơ bản nhanh dành cho Kỹ sư Python

  • Bạn không cần trở thành chuyên gia C++. Bạn chỉ cần hiểu đủ để đọc mã kernel, viết các extension đơn giản, và hiểu các thảo luận về hiệu năng. Dưới đây là những điều cốt lõi.

Kiểu dữ liệu và Biến

// C++ yêu cầu khai báo kiểu tường minh (khác với Python)
int count = 0;           // số nguyên 32-bit
float loss = 0.5f;       // số thực 32-bit
double lr = 3e-4;        // số thực 64-bit
bool training = true;    // boolean

// Mảng (kích thước cố định, cấp phát trên stack)
float weights[1024];     // 1024 số thực, liên tiếp trong bộ nhớ

// Con trỏ: một biến giữ địa chỉ bộ nhớ
float* ptr = weights;    // ptr trỏ đến phần tử đầu tiên của weights
float val = ptr[42];     // truy cập phần tử 42 qua con trỏ
// ptr[42] tương đương với *(ptr + 42)
  • Con trỏ là khác biệt khái niệm lớn nhất so với Python. Trong Python, mọi thứ đều là tham chiếu và bạn không bao giờ nghĩ đến địa chỉ bộ nhớ. Trong C++, con trỏ cho bạn truy cập trực tiếp vào bộ nhớ — mạnh mẽ nhưng nguy hiểm (con trỏ treo, tràn bộ đệm).

Hàm

// Khai báo hàm: kiểu_trả_về tên_hàm(tham_số_kiểu tên_tham_số)
float relu(float x) {
    return x > 0.0f ? x : 0.0f;
}

// Truyền theo tham chiếu (tránh copy các đối tượng lớn)
void scale_vector(std::vector<float>& vec, float factor) {
    for (size_t i = 0; i < vec.size(); i++) {
        vec[i] *= factor;
    }
}

// Tham chiếu const: chỉ đọc, không copy
float sum(const std::vector<float>& vec) {
    float total = 0.0f;
    for (float x : vec) {  // vòng lặp for theo range (như for x in vec của Python)
        total += x;
    }
    return total;
}

Bộ nhớ: Stack vs Heap

// Cấp phát stack: nhanh, vòng đời tự động (giải phóng khi hàm trả về)
float buffer[256];   // 256 số thực trên stack

// Cấp phát heap: thủ công, tồn tại qua cả hàm
float* data = new float[n];   // cấp phát n số thực trên heap
// ... dùng data ...
delete[] data;                 // BẠN phải giải phóng (không có bộ thu gom rác)

// C++ hiện đại: smart pointer (tự động dọn dẹp, giống tham chiếu Python)
#include <memory>
auto data = std::make_unique<float[]>(n);  // tự động giải phóng khi ra khỏi phạm vi
  • Quy tắc then chốt: stack nhanh nhưng bị giới hạn (thường 1-8 MB). Các mảng lớn (tensor, feature map) phải nằm trên heap. Trong Python, mọi thứ đều nằm trên heap và GC lo việc dọn dẹp. Trong C++, bạn tự quản lý (hoặc dùng smart pointer).

Template (Generics — kiểu tổng quát)

// Một hàm hoạt động với mọi kiểu số
template <typename T>
T add(T a, T b) {
    return a + b;
}

add<float>(1.5f, 2.5f);   // trả về 4.0f
add<int>(3, 4);             // trả về 7
  • Template là cách các thư viện C++ (như ATen) viết mã hoạt động với float16, float32, float64, ... mà không cần nhân đôi triển khai.

Tinh túy Thư viện Chuẩn

#include <vector>      // mảng động (như list của Python)
#include <string>      // kiểu chuỗi
#include <unordered_map>  // hash map (như dict của Python)
#include <algorithm>   // sort, find, transform, ...
#include <cmath>       // các hàm toán học

std::vector<float> vec = {1.0f, 2.0f, 3.0f};
vec.push_back(4.0f);            // thêm phần tử
float first = vec[0];           // truy cập theo chỉ số
size_t len = vec.size();        // độ dài

std::unordered_map<std::string, int> counts;
counts["hello"] = 5;            // chèn
if (counts.count("hello")) { }  // kiểm tra tồn tại

Khi nào cần Tự viết Kernel C++

  • Hầu hết kỹ sư ML không bao giờ cần viết C++. Các phép toán có sẵn của framework bao phủ 99% trường hợp sử dụng. Bạn chỉ nên cân nhắc viết C++ tùy chỉnh khi:

  • Phép toán của bạn không tồn tại trong framework: một hàm kích hoạt mới, một mẫu attention tùy chỉnh, một hàm mất mát đặc biệt không thể biểu diễn thành tổ hợp của các op hiện có.

  • Hợp nhất (fuse) các phép toán để tăng hiệu năng: mô hình của bạn thực hiện relu(layernorm(matmul(x, W) + b)). Mỗi phép toán khởi chạy một kernel riêng, đọc và ghi bộ nhớ, rồi đồng bộ. Một kernel hợp nhất làm tất cả trong một lượt, tránh các chuyến khứ hồi bộ nhớ. Điều này có thể nhanh hơn 2-5x.

  • Giảm dung lượng bộ nhớ: một kernel tùy chỉnh có thể tính toán gradient mà không cần lưu mọi activation trung gian (checkpointing gradient ở cấp độ kernel).

  • Nhắm đến phần cứng mới: một bộ tăng tốc mới (ví dụ: Cerebras, Groq) có thể chưa được framework hỗ trợ. Bạn phải viết kernel trực tiếp.

  • Với các trường hợp 1-2, Triton (chương 16, file 05) thường là đủ và dễ dàng hơn nhiều so với việc viết CUDA C trực tiếp. Chỉ khi Triton không thể biểu đạt những gì bạn cần thì mới hạ xuống CUDA C.

Cách gắn kết C++ vào Python

  • Viết C++ mới chỉ là một nửa công việc. Bạn cũng cần gọi nó từ Python.

pybind11 (Đa mục đích)

  • pybind11 tạo các binding Python cho các hàm C++ với ít boilerplate:
// my_ops.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;

// Một phép toán tùy chỉnh đơn giản
py::array_t<float> custom_relu(py::array_t<float> input) {
    auto buf = input.request();
    float* ptr = static_cast<float*>(buf.ptr);
    size_t n = buf.size;

    auto result = py::array_t<float>(n);
    float* out = static_cast<float*>(result.request().ptr);

    for (size_t i = 0; i < n; i++) {
        out[i] = ptr[i] > 0 ? ptr[i] : 0;
    }
    return result;
}

PYBIND11_MODULE(my_ops, m) {
    m.def("custom_relu", &custom_relu, "Custom ReLU operation");
}
# Biên dịch
pip install pybind11
c++ -O3 -shared -std=c++17 -fPIC $(python3 -m pybind11 --includes) my_ops.cpp -o my_ops$(python3-config --extension-suffix)
# Dùng từ Python
import my_ops
import numpy as np

x = np.array([-1.0, 2.0, -3.0, 4.0], dtype=np.float32)
y = my_ops.custom_relu(x)
print(y)  # [0. 2. 0. 4.]

PyTorch C++ Extensions

  • PyTorch cung cấp một cách thức hợp lý để thêm các op tùy chỉnh:
// custom_op.cpp
#include <torch/extension.h>

torch::Tensor custom_gelu(torch::Tensor x) {
    return x * 0.5 * (1.0 + torch::erf(x / std::sqrt(2.0)));
}

PYBIND11_MODULE(TORCH_EXTENSION_NAME, m) {
    m.def("custom_gelu", &custom_gelu, "Custom GELU activation");
}
# Tải và biên dịch ngay lập tức
from torch.utils.cpp_extension import load

custom_ops = load(
    name="custom_ops",
    sources=["custom_op.cpp"],
    extra_cflags=["-O3"],
)

x = torch.randn(1000)
y = custom_ops.custom_gelu(x)
  • torch.utils.cpp_extension.load biên dịch mã C++, tạo một shared library, và nạp nó như một module Python, tất cả trong một lời gọi. Đây là cách dễ nhất để thử nghiệm các op C++ tùy chỉnh trong PyTorch.

JAX Custom Calls

  • JAX sử dụng các lời gọi tùy chỉnh XLA. Quy trình phức tạp hơn (bạn đăng ký một hàm C với XLA), nhưng khái niệm là như nhau: viết C/C++, gắn kết nó, rồi gọi từ Python.

  • Với hầu hết người dùng JAX, Pallas (được đề cập trong file 05) là lựa chọn tốt hơn: nó cho phép bạn viết các kernel GPU bằng cú pháp gần giống Python mà XLA biên dịch, mà không cần rời khỏi hệ sinh thái JAX.

Bức tranh tổng thể

  • File này đã giải thích tầng nằm giữa Python và phần cứng. Các file còn lại trong chương này đi sâu hơn:

    • File 01: chính phần cứng (kiến trúc CPU, kiến trúc GPU, hệ thống bộ nhớ)
    • File 02-03: lập trình SIMD trên CPU (ARM NEON, x86 AVX) — nơi bạn viết C++ sử dụng các đơn vị vector của CPU
    • File 04: lập trình GPU với CUDA — nơi bạn viết C++ chạy trên hàng ngàn nhân GPU
    • File 05: Triton, Pallas, và lập trình GPU ở mức cao hơn — nơi bạn viết Python được biên dịch thành các kernel GPU
  • Sự tiến triển phản ánh cái thang trừu tượng: intrinsics C++ (thấp nhất, nhiều quyền kiểm soát nhất) → CUDA (đặc thù GPU) → Triton/Pallas (kiểu Python, đã biên dịch) → JAX/PyTorch (cao nhất, tự động). Mỗi cấp độ đổi quyền kiểm soát lấy sự tiện lợi. Hiểu các cấp độ thấp hơn giúp bạn sử dụng tốt hơn các cấp độ cao hơn.

Bài tập lập trình (biên dịch bằng g++ hoặc clang++)

  1. Viết chương trình C++ đầu tiên của bạn. Cấp phát một mảng, điền dữ liệu, tính tổng, và đo thời gian. Bài này giới thiệu biên dịch, mảng, con trỏ, và đo thời gian.

    // task1_basics.cpp
    // Biên dịch: g++ -O3 -o task1 task1_basics.cpp
    // Chạy: ./task1
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    
    int main() {
        const int N = 10'000'000;  // C++ cho phép dùng ' làm dấu phân cách chữ số
        std::vector<float> data(N);
    
        // Điền mảng
        for (int i = 0; i < N; i++) {
            data[i] = static_cast<float>(i) * 0.001f;
        }
    
        // Tính tổng
        auto start = std::chrono::high_resolution_clock::now();
        float sum = 0.0f;
        for (int i = 0; i < N; i++) {
            sum += data[i];
        }
        auto end = std::chrono::high_resolution_clock::now();
        double elapsed = std::chrono::duration<double, std::milli>(end - start).count();
    
        std::cout << "Sum: " << sum << std::endl;
        std::cout << "Time: " << elapsed << " ms" << std::endl;
        std::cout << "Elements: " << N << std::endl;
        std::cout << "Throughput: " << (N * sizeof(float)) / elapsed / 1e6 << " GB/s" << std::endl;
    
        return 0;
    }
    

  2. Viết một hàm C++ tính ReLU trên một mảng, sau đó xây dựng một binding Python bằng pybind11. Gọi nó từ Python và so sánh tốc độ với NumPy.

    // task2_relu.cpp
    // Biên dịch: c++ -O3 -shared -std=c++17 -fPIC $(python3 -m pybind11 --includes) \
    //          task2_relu.cpp -o my_relu$(python3-config --extension-suffix)
    
    #include <pybind11/pybind11.h>
    #include <pybind11/numpy.h>
    namespace py = pybind11;
    
    py::array_t<float> cpp_relu(py::array_t<float> input) {
        auto buf = input.request();
        float* ptr = static_cast<float*>(buf.ptr);
        int n = buf.size;
    
        auto result = py::array_t<float>(n);
        float* out = static_cast<float*>(result.request().ptr);
    
        for (int i = 0; i < n; i++) {
            out[i] = ptr[i] > 0.0f ? ptr[i] : 0.0f;
        }
        return result;
    }
    
    PYBIND11_MODULE(my_relu, m) {
        m.def("relu", &cpp_relu, "C++ ReLU");
    }
    
    # test_relu.py — chạy sau khi đã biên dịch module C++ ở trên
    import numpy as np
    import time
    import my_relu  # module C++ đã biên dịch
    
    x = np.random.randn(10_000_000).astype(np.float32)
    
    # C++ ReLU
    start = time.time()
    for _ in range(100):
        y_cpp = my_relu.relu(x)
    cpp_time = (time.time() - start) / 100
    
    # NumPy ReLU
    start = time.time()
    for _ in range(100):
        y_np = np.maximum(x, 0)
    np_time = (time.time() - start) / 100
    
    print(f"C++ ReLU:   {cpp_time*1000:.2f} ms")
    print(f"NumPy ReLU: {np_time*1000:.2f} ms")
    print(f"Match: {np.allclose(y_cpp, y_np)}")
    

  3. Viết một chương trình C++ minh họa tại sao bố cục bộ nhớ lại quan trọng. So sánh mẫu truy cập theo hàng (row-major) và theo cột (column-major) và đo sự chênh lệch hiệu năng.

    // task3_layout.cpp
    // Biên dịch: g++ -O3 -o task3 task3_layout.cpp
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    
    int main() {
        const int N = 4096;
        std::vector<float> matrix(N * N, 1.0f);
    
        // Truy cập row-major: địa chỉ bộ nhớ liên tiếp (thân thiện với cache)
        auto start = std::chrono::high_resolution_clock::now();
        float sum_row = 0.0f;
        for (int i = 0; i < N; i++) {
            for (int j = 0; j < N; j++) {
                sum_row += matrix[i * N + j];  // truy cập stride-1
            }
        }
        auto end = std::chrono::high_resolution_clock::now();
        double row_ms = std::chrono::duration<double, std::milli>(end - start).count();
    
        // Truy cập column-major: truy cập stride-N (thù địch với cache)
        start = std::chrono::high_resolution_clock::now();
        float sum_col = 0.0f;
        for (int j = 0; j < N; j++) {
            for (int i = 0; i < N; i++) {
                sum_col += matrix[i * N + j];  // truy cập stride-N (cache miss!)
            }
        }
        end = std::chrono::high_resolution_clock::now();
        double col_ms = std::chrono::duration<double, std::milli>(end - start).count();
    
        std::cout << "Row-major (cache-friendly): " << row_ms << " ms" << std::endl;
        std::cout << "Col-major (cache-hostile):  " << col_ms << " ms" << std::endl;
        std::cout << "Slowdown: " << col_ms / row_ms << "x" << std::endl;
        std::cout << "(Both sums: " << sum_row << ", " << sum_col << ")" << std::endl;
    
        return 0;
    }