Bỏ qua

ARM và NEON

Bộ vi xử lý ARM cung cấp năng lượng cho mọi điện thoại thông minh, hầu hết máy tính bảng, máy tính xách tay Apple, và một tỷ lệ ngày càng tăng các máy chủ trung tâm dữ liệu. File này bao gồm kiến trúc ARM, lập trình SIMD NEON với C++ intrinsics, SVE/SVE2 cho xử lý vector có khả năng mở rộng, cụ thể về Apple Silicon, và các ví dụ kernel vector hóa thực tế

  • Nếu bạn sở hữu iPhone, MacBook, hoặc dùng các instance AWS Graviton, bạn đang chạy ARM. Hiệu suất năng lượng của ARM khiến nó chiếm ưu thế trong di động và nhúng, và ngày càng cạnh tranh trong máy chủ và suy luận ML. Hiểu SIMD trên ARM cho phép bạn viết mã chạy nhanh trên phần cứng mà hầu hết mọi người thực sự dùng.

  • Để có một ví dụ thực tế về kernel SIMD ARM trong sản xuất, hãy xem Cactus — một công cụ AI độ trễ thấp cho thiết bị di động và wearable: github.com/cactus-compute/cactus. Cactus triển khai các kernel ARM NEON và NPU tùy chỉnh cho attention, lượng tử hóa KV-cache, và chunked prefill, đạt tốc độ suy luận nhanh nhất trên CPU ARM với RAM thấp hơn 10x so với các engine khác. Kiến trúc ba tầng (Engine → Graph → Kernels) của nó là một ví dụ cụ thể về cách các khái niệm SIMD trong file này được dùng để xây dựng hạ tầng ML sản xuất.

Kiến trúc ARM Cơ bản

  • ARM là kiến trúc RISC (Reduced Instruction Set Computer — Máy tính với tập lệnh rút gọn) (chương 13). Các đặc điểm chính:

    • Kiến trúc load-store: các chỉ thị số học chỉ thao tác trên thanh ghi, không bao giờ trực tiếp trên bộ nhớ. Để cộng hai số từ bộ nhớ, bạn phải: (1) nạp chúng vào thanh ghi, (2) cộng các thanh ghi, (3) lưu kết quả về bộ nhớ. Điều này đơn giản hơn x86 (có thể cộng một thanh ghi và một vị trí bộ nhớ trong một chỉ thị) nhưng cho phép pipeline hóa sạch sẽ hơn.

    • Chỉ thị có độ rộng cố định: mọi chỉ thị ARMv8 (AArch64) đều chính xác 32 bit. Điều này làm việc giải mã nhanh và có thể dự đoán (không như chỉ thị có độ dài thay đổi của x86 có thể dài 1-15 byte).

    • 32 thanh ghi đa năng (x0-x30, mỗi thanh 64-bit) cộng với con trỏ stack (sp) và thanh ghi zero (xzr). So với 16 thanh ghi đa năng của x86. Nhiều thanh ghi hơn = ít truy cập bộ nhớ hơn = mã nhanh hơn.

    • 32 thanh ghi SIMD/dấu phẩy động (v0-v31, mỗi thanh 128-bit) cho các phép toán NEON và dấu phẩy động.

// Assembly ARM (chỉ xem qua -- bạn sẽ dùng intrinsics, không phải assembly)
// Cộng hai thanh ghi
add x0, x1, x2    // x0 = x1 + x2

// Nạp từ bộ nhớ
ldr x0, [x1]      // x0 = *x1 (nạp 64 bit từ địa chỉ trong x1)

// NEON: cộng bốn số thực
fadd v0.4s, v1.4s, v2.4s  // v0 = v1 + v2 (bốn số thực 32-bit)
  • Bạn sẽ không viết assembly. Bạn sẽ dùng intrinsics: các hàm C/C++ ánh xạ 1:1 tới các chỉ thị cụ thể. Trình biên dịch xử lý việc cấp phát thanh ghi, lập lịch, và các chi tiết cấp thấp khác.

NEON: SIMD 128-bit

  • NEON là extension SIMD của ARM. Mỗi thanh ghi NEON rộng 128-bit và có thể chứa:
Kiểu dữ liệu Số phần tử mỗi thanh ghi Ký hiệu
float32 4 float32x4_t
float16 8 float16x8_t
int32 4 int32x4_t
int16 8 int16x8_t
int8 16 int8x16_t
  • 128 bit hẹp hơn AVX của x86 (256-bit) hay AVX-512 (512-bit). Nhưng ARM bù đắp bằng hiệu suất năng lượng xuất sắc và tính sẵn có rộng rãi.

NEON Intrinsics: Cơ bản

  • Các NEON intrinsic tuân theo quy ước đặt tên: v[operation][qualifier]_[type]
#include <arm_neon.h>

// Nạp 4 số thực từ bộ nhớ vào thanh ghi NEON
float32x4_t a = vld1q_f32(ptr);        // vld1q = vector load 1, q = 128-bit (quad)

// Lưu 4 số thực từ thanh ghi NEON ra bộ nhớ
vst1q_f32(out_ptr, a);                   // vst1q = vector store 1, q = 128-bit

// Số học
float32x4_t c = vaddq_f32(a, b);        // c = a + b (4 số thực)
float32x4_t d = vmulq_f32(a, b);        // d = a * b (4 số thực)
float32x4_t e = vfmaq_f32(c, a, b);     // e = c + a * b (fused multiply-add, 4 số thực)

// So sánh (trả về mask: tất cả 1 nếu đúng, tất cả 0 nếu sai)
uint32x4_t mask = vcgtq_f32(a, b);      // mask[i] = (a[i] > b[i]) ? 0xFFFFFFFF : 0

// Chọn phần tử dựa trên mask (như numpy.where)
float32x4_t result = vbslq_f32(mask, a, b);  // result[i] = mask[i] ? a[i] : b[i]

// Reduce: tính tổng cả 4 phần tử thành một scalar
float total = vaddvq_f32(a);             // total = a[0] + a[1] + a[2] + a[3]
  • vfmaq_f32 (fused multiply-add) là chỉ thị SIMD quan trọng nhất cho ML. Nó tính \(c = c + a \times b\) trong một chỉ thị với một bước làm tròn duy nhất (chính xác hơn so với nhân riêng rồi cộng). Các tích vô hướng, nhân ma trận, và tích chập đều được xây dựng từ FMA.

Ví dụ Thực hành: Tích Vô hướng (Dot Product) Vector hóa

  • Tích vô hướng là vòng lặp trong cùng của phép nhân ma trận. Hãy viết nó bằng C++ vô hướng và sau đó vector hóa với NEON.
#include <arm_neon.h>

// Tích vô hướng dạng vô hướng
float dot_scalar(const float* a, const float* b, int n) {
    float sum = 0.0f;
    for (int i = 0; i < n; i++) {
        sum += a[i] * b[i];
    }
    return sum;
}

// Tích vô hướng vector hóa với NEON
float dot_neon(const float* a, const float* b, int n) {
    float32x4_t sum_vec = vdupq_n_f32(0.0f);  // khởi tạo 4 bộ cộng dồn thành 0

    int i = 0;
    for (; i + 4 <= n; i += 4) {
        float32x4_t va = vld1q_f32(a + i);     // nạp 4 phần tử từ a
        float32x4_t vb = vld1q_f32(b + i);     // nạp 4 phần tử từ b
        sum_vec = vfmaq_f32(sum_vec, va, vb);   // sum_vec += va * vb
    }

    // Reduce 4 bộ cộng dồn thành một scalar
    float sum = vaddvq_f32(sum_vec);

    // Xử lý các phần tử còn lại (nếu n không chia hết cho 4)
    for (; i < n; i++) {
        sum += a[i] * b[i];
    }

    return sum;
}
  • Các khái niệm C++ quan trọng:

    • const float*: một con trỏ tới dữ liệu float chỉ đọc. const hứa hẹn chúng ta sẽ không sửa đổi dữ liệu qua con trỏ này.
    • a + i: số học con trỏ. a + i trỏ tới phần tử thứ \(i\) của mảng (giống &a[i]).
    • "Vòng lặp dọn dẹp" ở cuối xử lý trường hợp \(n\) không phải bội số của 4. Đây là một mẫu phổ biến trong mã SIMD: xử lý phần lớn theo các khối đã vector hóa, sau đó dùng mã vô hướng cho phần còn lại.
  • Tại sao lại dùng 4 bộ cộng dồn trong sum_vec: thay vì một accumulator vô hướng duy nhất, chúng ta dùng 4 accumulator độc lập (mỗi lane SIMD một cái). Điều này tránh sự phụ thuộc dữ liệu: mỗi FMA của mỗi lần lặp phụ thuộc vào sum_vec, nhưng với 4 lane độc lập, CPU có thể pipeline các FMA. Cuối cùng, ta reduce 4 tổng riêng phần thành một.

Ví dụ Thực hành: ReLU Vector hóa

#include <arm_neon.h>

void relu_neon(const float* input, float* output, int n) {
    float32x4_t zero = vdupq_n_f32(0.0f);

    int i = 0;
    for (; i + 4 <= n; i += 4) {
        float32x4_t x = vld1q_f32(input + i);
        float32x4_t result = vmaxq_f32(x, zero);  // max(x, 0) = ReLU
        vst1q_f32(output + i, result);
    }

    // Dọn dẹp vô hướng
    for (; i < n; i++) {
        output[i] = input[i] > 0 ? input[i] : 0;
    }
}
  • vmaxq_f32 tính giá trị lớn nhất theo từng phần tử của hai vector. Vì một vector là toàn số không, đây chính xác là ReLU. Không có rẽ nhánh, không so sánh — chỉ một chỉ thị duy nhất.

I8MM: Nhân Ma trận Số nguyên

  • I8MM (Int8 Matrix Multiply — Nhân Ma trận INT8) là một extension ARMv8.6 thêm các chỉ thị chuyên dụng cho nhân ma trận INT8 với tích lũy INT32 — chính xác những gì suy luận ML đã lượng tử hóa cần.

  • Chỉ thị chính là SMMLA (Signed Matrix Multiply-Accumulate — Nhân-Cộng dồn Ma trận Có dấu): nó nhận hai khối 8×2 giá trị INT8 và cộng dồn kết quả vào một khối 2×2 INT32:

#include <arm_neon.h>

// I8MM: nhân hai vector INT8 8 phần tử, cộng dồn vào 4 kết quả INT32
// Phép tính này tạo một tile 2x2 của ma trận đầu ra từ các tile đầu vào 2x8 x 8x2
void matmul_i8mm_tile(const int8_t* A, const int8_t* B, int32_t* C) {
    // Nạp 8 byte từ A (2 hàng, mỗi hàng 4 phần tử, đóng gói)
    int8x16_t va = vld1q_s8(A);   // 16 byte = 2 hàng × 8 phần tử
    int8x16_t vb = vld1q_s8(B);   // 16 byte = 2 hàng × 8 phần tử

    // Nạp bộ cộng dồn hiện tại (2x2 = 4 giá trị int32)
    int32x4_t acc = vld1q_s32(C);

    // Chỉ thị I8MM: acc += A_tile × B_tile^T
    // Tính đầu ra 2×2 từ đầu vào 2×8 × 8×2
    acc = vmmlaq_s32(acc, va, vb);  // CHỈ THỊ I8MM

    vst1q_s32(C, acc);
}
  • Tại sao I8MM quan trọng: không có I8MM, INT8 matmul trên NEON yêu cầu các phép nhân mở rộng (vmull) và tiếp theo là các phép cộng từng cặp — nhiều chỉ thị cho mỗi phần tử đầu ra. Với I8MM, phần cứng thực hiện một tích vô hướng 8 phần tử (2×8 × 8×2 = 2×2) trong một chỉ thị duy nhất. Đối với khối lượng công việc suy luận INT8, điều này nhanh hơn 4-8x so với NEON thường.

  • Tính khả dụng: Apple M1+ (tất cả Apple Silicon), ARM Cortex-A510/A710/X2+ (ARMv9), AWS Graviton3+. Kiểm tra với #ifdef __ARM_FEATURE_MATMUL_INT8.

  • Đối với suy luận ML: các mô hình lượng tử hóa INT8 (chương 18) chạy trên máy chủ ARM (Graviton) hoặc Apple Silicon hưởng lợi rất lớn từ I8MM. Các framework như ONNX Runtime và llama.cpp phát hiện I8MM tại thời gian chạy và tự động sử dụng kernel tối ưu.

SME và SME2: Scalable Matrix Extension (Extension Ma trận có Khả năng Mở rộng)

  • SME (Scalable Matrix Extension) là câu trả lời của ARM cho Intel AMX và NVIDIA Tensor Cores: phần cứng chuyên dụng cho các phép toán ma trận. SME2 (ARMv9.2) mở rộng nó thêm nữa.

  • SME giới thiệu thanh ghi tile ZA: các ma trận 2D được lưu trong phần cứng, lên đến SVL×SVL byte (với SVL là độ dài vector streaming, thường 128-512 bit mỗi chiều). Không giống NEON (vector 1D) hay thậm chí SVE (vector 1D có thể mở rộng), SME thao tác trên các tile 2D một cách tự nhiên.

  • Mô hình lập trình có hai chế độ:

    • Chế độ thường: thực thi ARM tiêu chuẩn (NEON, SVE hoạt động bình thường).
    • Chế độ Streaming SVE: được vào bằng smstart, kích hoạt các chỉ thị SME. Các chỉ thị SVE cũng hoạt động ở chế độ này nhưng có thể dùng độ rộng thanh ghi khác.
#include <arm_sme.h>

// SME2: tích lũy outer product cho nhân ma trận
// Tích lũy A_col × B_row vào thanh ghi tile ZA
void sme2_matmul_outer(const float* A_col, const float* B_row, int K) {
    // Vào chế độ streaming
    // smstart;  // (thực hiện qua compiler intrinsic hoặc inline asm)

    // Xóa bộ cộng dồn tile ZA
    svzero_za();

    for (int k = 0; k < K; k++) {
        // Nạp một cột của A và một hàng của B vào thanh ghi SVE
        svfloat32_t a = svld1_f32(svptrue_b32(), &A_col[k * SVL]);
        svfloat32_t b = svld1_f32(svptrue_b32(), &B_row[k * SVL]);

        // Outer product: ZA += a × b^T
        // Điều này tích lũy một tile SVL×SVL trong một chỉ thị
        svmopa_za32_f32_m(0, svptrue_b32(), svptrue_b32(), a, b);
    }

    // Lưu tile ZA ra bộ nhớ
    // svst1_za(...);

    // Thoát chế độ streaming
    // smstop;
}
  • Các khái niệm chính:

    • svmopa (outer product accumulate): chỉ thị SME cốt lõi. Nó tính tích ngoài đầy đủ của hai vector và cộng dồn vào tile ZA. Với SVL=512 bit (16 số thực), đây là tích ngoài 16×16 — 256 phép FMA trong một chỉ thị.
    • ZA tile: tồn tại qua nhiều chỉ thị trong chế độ streaming. Bạn tích lũy nhiều outer product (một mỗi lần lặp K) vào cùng một tile, xây dựng một tile nhân ma trận đầy đủ.
    • Chế độ streaming: các chỉ thị SME chỉ hoạt động trong chế độ streaming. Overhead của việc vào/thoát chế độ streaming nghĩa là SME phù hợp nhất cho tính toán ma trận kéo dài, không phải các đợt ngắn.
  • Các bổ sung của SME2: các phép toán đa vector (xử lý 2 hoặc 4 vector SVE đồng thời), các phép toán tile bổ sung, và tích hợp cải thiện với chế độ thường.

  • Tính khả dụng: ARM Neoverse V2 (AWS Graviton4), một số chip di động sắp ra mắt. Chưa có trên Apple Silicon (tính đến 2026). SME vẫn còn ở giai đoạn đầu — hầu hết các framework ML chưa có kernel tối ưu SME.

  • Sự tiến hóa: NEON (vector 128-bit, theo từng phần tử) → I8MM (tile ma trận INT8) → SVE (vector có thể mở rộng) → SME (tile ma trận 2D có thể mở rộng). Mỗi thế hệ tiến gần hơn tới các phép toán ma trận gốc trong phần cứng.

SVE và SVE2: Scalable Vector Extensions (Các Extension Vector có Khả năng Mở rộng)

  • NEON có độ rộng cố định 128-bit. SVE (Scalable Vector Extension) giới thiệu lập trình vector-length agnostic (VLA) (không phụ thuộc vào độ dài vector): bạn viết mã một lần, và nó chạy trên phần cứng với bất kỳ độ rộng vector nào (128 đến 2048 bit). Phần cứng quyết định độ rộng tại thời gian chạy.
#include <arm_sve.h>

void add_sve(const float* a, const float* b, float* c, int n) {
    int i = 0;
    svbool_t pred = svwhilelt_b32(i, n);  // predicate: lane nào đang hoạt động

    while (svptest_any(svptrue_b32(), pred)) {
        svfloat32_t va = svld1(pred, a + i);
        svfloat32_t vb = svld1(pred, b + i);
        svst1(pred, c + i, svadd_x(pred, va, vb));

        i += svcntw();  // tiến lên theo độ rộng vector của phần cứng (theo phần tử 32-bit)
        pred = svwhilelt_b32(i, n);
    }
}
  • Thanh ghi Predicate (svbool_t) thay thế vòng lặp dọn dẹp vô hướng. Mỗi lane có một bit predicate: các lane hoạt động tham gia, các lane không hoạt động bị che đi. Chỉ thị svwhilelt_b32(i, n) tạo một predicate nơi các lane tương ứng với i, i+1, ..., n-1 là hoạt động. Điều này xử lý phần đuôi một cách tự động.

  • svcntw() trả về số phần tử 32-bit trên mỗi thanh ghi vector tại thời gian chạy. Trên CPU có SVE 256-bit, nó trả về 8. Trên SVE 512-bit, nó trả về 16. Mã của bạn tự động thích ứng.

  • SVE có sẵn trên ARM Neoverse V1/V2 (AWS Graviton3/4, một số chip máy chủ). Nó chưa có trên Apple Silicon.

Cụ thể về Apple Silicon

  • Chip dòng M của Apple (M1, M2, M3, M4) dựa trên ARM với vi kiến trúc tùy chỉnh:

  • Lõi hiệu năng và lõi hiệu quả: P-cores (Firestorm/Avalanche/v.v.) cho tính toán nặng, E-cores (Icestorm/Blizzard/v.v.) cho tác vụ nền. Bộ lập lịch gán luồng cho loại lõi phù hợp.

  • AMX (Apple Matrix eXtensions): các đơn vị nhân ma trận chuyên dụng, tách biệt khỏi NEON. AMX không có tài liệu chính thức (Apple không công bố ISA), nhưng Accelerate framework dùng nó nội bộ cho các phép toán BLAS. Khi bạn gọi np.dot trên Mac, nó đi qua Accelerate, cái dùng AMX. Bạn không thể lập trình AMX trực tiếp (mà không reverse engineering).

  • Bộ nhớ hợp nhất (Unified memory): CPU và GPU dùng chung RAM vật lý. Trên các hệ thống khác, dữ liệu phải được sao chép từ bộ nhớ CPU sang bộ nhớ GPU (qua PCIe, ~32 GB/s). Trên Apple Silicon, không cần sao chép — GPU đọc cùng bộ nhớ mà CPU đã ghi. Điều này loại bỏ một nút thắt cổ chai lớn cho khối lượng công việc ML.

  • Neural Engine: bộ tăng tốc ML chuyên dụng 16 lõi. Đạt ~30 TOPS (nghìn tỷ phép toán mỗi giây) cho suy luận INT8. Được Core ML dùng cho suy luận trên thiết bị.

  • Đối với ML trên Apple Silicon: dùng MLX (framework ML của Apple), được thiết kế cho kiến trúc bộ nhớ hợp nhất. PyTorch cũng có backend MPS (Metal Performance Shaders), dù chưa trưởng thành bằng CUDA.

Tự động Vector hóa

  • Viết SIMD intrinsics là công việc tẻ nhạt. Liệu trình biên dịch có thể tự động vector hóa mã của bạn không?

  • Có, với những lưu ý. Trình biên dịch hiện đại (GCC, Clang) có thể tự động vector hóa các vòng lặp đơn giản:

// Trình biên dịch CÓ THỂ tự động vector hóa cái này (với -O3 -march=native)
void add_auto(const float* a, const float* b, float* c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}
  • Các mẫu giúp tự động vector hóa:
    • Vòng lặp đơn giản với số lần lặp đã biết.
    • Không có phụ thuộc dữ liệu giữa các lần lặp (c[i] không phụ thuộc vào c[i-1]).
    • Truy cập bộ nhớ liên tiếp (không scatter/gather).
    • Con trỏ constrestrict (báo cho trình biên dịch biết các mảng không chồng lấn).
// restrict báo cho trình biên dịch: a, b, c trỏ đến các vùng bộ nhớ không chồng lấn
void add_restrict(const float* __restrict__ a,
                  const float* __restrict__ b,
                  float* __restrict__ c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}
  • Không có restrict, trình biên dịch phải giả định c có thể chồng lấn với a hoặc b (ghi vào c[i] có thể thay đổi a[i+1]), ngăn cản vector hóa.

  • Các mẫu ngăn cản tự động vector hóa:

    • Phụ thuộc dữ liệu: a[i] = a[i-1] + b[i] (mỗi lần lặp phụ thuộc vào lần trước).
    • Luồng điều khiển phức tạp: câu lệnh if bên trong vòng lặp (trừ khi trình biên dịch có thể chuyển thành predication).
    • Gọi hàm bên trong vòng lặp (trừ khi hàm được inline).
    • Bí danh con trỏ (mảng có thể chồng lấn, không có restrict).
  • Kiểm tra tự động vector hóa: dùng cờ trình biên dịch để xem cái gì đã được vector hóa:

# GCC: hiển thị quyết định vector hóa
g++ -O3 -march=native -fopt-info-vec-optimized code.cpp

# Clang: hiển thị báo cáo vector hóa
clang++ -O3 -march=native -Rpass=loop-vectorize code.cpp
  • Khi nào dùng intrinsics vs tự động vector hóa: bắt đầu với C++ sạch và tối ưu hóa trình biên dịch. Nếu trình biên dịch vector hóa vòng lặp của bạn, tốt. Nếu hiệu năng chưa đủ, kiểm tra báo cáo vector hóa của trình biên dịch để hiểu tại sao, và chỉ khi đó mới viết intrinsics cho vòng lặp bên trong quan trọng. Intrinsics sớm làm mã khó đọc mà không có lợi ích đảm bảo.

Bài tập Lập trình (biên dịch với g++ hoặc clang++ trên ARM — Mac dòng M hoặc Linux aarch64)

  1. Viết một tích vô hướng dạng vô hướng và một tích vô hướng vector hóa với NEON. Đo benchmark cả hai và đo mức tăng tốc.

    // task1_neon_dot.cpp
    // Biên dịch (Mac/ARM Linux): clang++ -O3 -o task1 task1_neon_dot.cpp
    // Lưu ý: NEON được kích hoạt mặc định trên AArch64, không cần cờ đặc biệt
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    #include <arm_neon.h>
    
    float dot_scalar(const float* a, const float* b, int n) {
        float sum = 0.0f;
        for (int i = 0; i < n; i++) {
            sum += a[i] * b[i];
        }
        return sum;
    }
    
    float dot_neon(const float* a, const float* b, int n) {
        float32x4_t sum_vec = vdupq_n_f32(0.0f);
        int i = 0;
        for (; i + 4 <= n; i += 4) {
            float32x4_t va = vld1q_f32(a + i);
            float32x4_t vb = vld1q_f32(b + i);
            sum_vec = vfmaq_f32(sum_vec, va, vb);
        }
        float sum = vaddvq_f32(sum_vec);
        for (; i < n; i++) sum += a[i] * b[i];
        return sum;
    }
    
    int main() {
        const int N = 10'000'000;
        std::vector<float> a(N, 1.0f), b(N, 2.0f);
    
        // Làm nóng
        volatile float s1 = dot_scalar(a.data(), b.data(), N);
        volatile float s2 = dot_neon(a.data(), b.data(), N);
    
        // Benchmark vô hướng
        auto start = std::chrono::high_resolution_clock::now();
        for (int t = 0; t < 100; t++) {
            s1 = dot_scalar(a.data(), b.data(), N);
        }
        auto end = std::chrono::high_resolution_clock::now();
        double scalar_ms = std::chrono::duration<double, std::milli>(end - start).count() / 100;
    
        // Benchmark NEON
        start = std::chrono::high_resolution_clock::now();
        for (int t = 0; t < 100; t++) {
            s2 = dot_neon(a.data(), b.data(), N);
        }
        end = std::chrono::high_resolution_clock::now();
        double neon_ms = std::chrono::duration<double, std::milli>(end - start).count() / 100;
    
        std::cout << "Scalar: " << scalar_ms << " ms (result: " << s1 << ")\n";
        std::cout << "NEON:   " << neon_ms << " ms (result: " << s2 << ")\n";
        std::cout << "Speedup: " << scalar_ms / neon_ms << "x\n";
        return 0;
    }
    

  2. Triển khai NEON ReLU và tìm max trong softmax. Luyện tập mẫu load→compute→store với các phép toán khác nhau.

    // task2_neon_ops.cpp
    // Biên dịch: clang++ -O3 -o task2 task2_neon_ops.cpp
    
    #include <iostream>
    #include <vector>
    #include <cmath>
    #include <arm_neon.h>
    
    void relu_neon(const float* in, float* out, int n) {
        float32x4_t zero = vdupq_n_f32(0.0f);
        int i = 0;
        for (; i + 4 <= n; i += 4) {
            float32x4_t x = vld1q_f32(in + i);
            vst1q_f32(out + i, vmaxq_f32(x, zero));
        }
        for (; i < n; i++) out[i] = in[i] > 0 ? in[i] : 0;
    }
    
    float max_neon(const float* data, int n) {
        float32x4_t max_vec = vdupq_n_f32(-INFINITY);
        int i = 0;
        for (; i + 4 <= n; i += 4) {
            max_vec = vmaxq_f32(max_vec, vld1q_f32(data + i));
        }
        float result = vmaxvq_f32(max_vec);
        for (; i < n; i++) result = result > data[i] ? result : data[i];
        return result;
    }
    
    int main() {
        std::vector<float> data = {-3, 1, -1, 4, 2, -5, 0, 7, -2, 3};
        std::vector<float> out(data.size());
    
        relu_neon(data.data(), out.data(), data.size());
        std::cout << "ReLU: ";
        for (float x : out) std::cout << x << " ";
        std::cout << "\n";
    
        float mx = max_neon(data.data(), data.size());
        std::cout << "Max: " << mx << " (expected: 7)\n";
        return 0;
    }
    

  3. So sánh mã tự động vector hóa với NEON intrinsics viết tay. Biên dịch với -fopt-info-vec (GCC) hoặc -Rpass=loop-vectorize (Clang) để xem trình biên dịch làm gì.

    // task3_auto_vs_manual.cpp
    // Biên dịch: clang++ -O3 -Rpass=loop-vectorize -o task3 task3_auto_vs_manual.cpp
    //    (hoặc): g++ -O3 -fopt-info-vec-optimized -o task3 task3_auto_vs_manual.cpp
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    #include <arm_neon.h>
    
    // Để trình biên dịch tự động vector hóa
    void add_auto(const float* __restrict__ a, const float* __restrict__ b,
                  float* __restrict__ c, int n) {
        for (int i = 0; i < n; i++) {
            c[i] = a[i] + b[i];
        }
    }
    
    // NEON viết tay
    void add_neon(const float* a, const float* b, float* c, int n) {
        int i = 0;
        for (; i + 4 <= n; i += 4) {
            vst1q_f32(c + i, vaddq_f32(vld1q_f32(a + i), vld1q_f32(b + i)));
        }
        for (; i < n; i++) c[i] = a[i] + b[i];
    }
    
    int main() {
        const int N = 10'000'000;
        std::vector<float> a(N, 1.0f), b(N, 2.0f), c(N);
    
        auto bench = [&](auto fn, const char* name) {
            fn(a.data(), b.data(), c.data(), N);  // làm nóng
            auto start = std::chrono::high_resolution_clock::now();
            for (int t = 0; t < 100; t++) fn(a.data(), b.data(), c.data(), N);
            auto end = std::chrono::high_resolution_clock::now();
            double ms = std::chrono::duration<double, std::milli>(end - start).count() / 100;
            std::cout << name << ": " << ms << " ms\n";
        };
    
        bench(add_auto, "Auto-vectorised");
        bench(add_neon, "Hand-written NEON");
        // Chúng sẽ rất gần — trình biên dịch tự động vector hóa vòng lặp đơn giản này tốt
        return 0;
    }