Bỏ qua

x86 và AVX

Bộ vi xử lý x86 từ Intel và AMD thống trị các máy chủ trung tâm dữ liệu nơi phần lớn huấn luyện ML diễn ra. File này bao gồm sự tiến hóa của SIMD x86, lập trình intrinsics AVX/AVX2, AVX-512, Intel AMX cho phép toán ma trận, căn chỉnh bộ nhớ, các cạm bẫy về hiệu năng, và profiling -- những công cụ để ép hiệu năng tối đa từ các CPU máy chủ phổ biến nhất thế giới.

  • Nếu việc huấn luyện của bạn chạy trên các VM đám mây (AWS, GCP, Azure), gần như chắc chắn nó đang chạy trên x86. Ngay cả huấn luyện nặng về GPU cũng có các nút thắt CPU: tải dữ liệu, tiền xử lý, tổng hợp gradient, và checkpointing đều chạy trên CPU. Tối ưu hóa những thứ này với SIMD x86 có thể giảm đáng kể thời gian huấn luyện đầu cuối.

Sự tiến hóa của SIMD x86

  • SIMD x86 đã tiến hóa qua các thanh ghi vector ngày càng rộng hơn:
Thế hệ Năm Độ rộng thanh ghi Thanh ghi Đặc điểm chính
MMX 1997 64-bit 8 (mm0-7) Chỉ số nguyên, chia sẻ với FPU
SSE 1999 128-bit 8 (xmm0-7) 4 số thực, thanh ghi riêng
SSE2 2001 128-bit 8/16 2 số thực độ chính xác kép, phép toán số nguyên
AVX 2011 256-bit 16 (ymm0-15) 8 số thực, chỉ thị 3 toán hạng
AVX2 2013 256-bit 16 Số nguyên 256-bit, FMA, gather
AVX-512 2017 512-bit 32 (zmm0-31) 16 số thực, thanh ghi mask, scatter
AMX 2023 Thanh ghi tile 8 tile Nhân ma trận (BF16, INT8)
  • Mỗi thế hệ tăng gấp đôi thông lượng cho mã đã vector hóa. Mã viết với SSE intrinsics chạy trên mọi CPU x86 sản xuất từ 2001. AVX2 yêu cầu CPU 2013+. AVX-512 là Intel Xeon và một số chip tiêu dùng. AMX là mới nhất (Sapphire Rapids trở đi).

  • Tương thích ngược: thanh ghi SSE x86 (xmm) là 128 bit thấp của thanh ghi AVX (ymm), vốn là 256 bit thấp của thanh ghi AVX-512 (zmm). Mã SSE cũ chạy trên CPU mới mà không cần sửa đổi.

Lập trình AVX2

  • AVX2 hoạt động trên các thanh ghi 256-bit (YMM), xử lý 8 số thực hoặc 4 số thực độ chính xác kép đồng thời. Nó là điểm ngọt cho mã hiệu năng cao khả chuyển: có mặt trên hầu hết mọi CPU x86 hiện đại (2013+).

Quy ước Đặt tên Intrinsics

  • Mọi intrinsics x86 tuân theo mẫu: _mm[width]_[operation]_[type]

    • _mm = MMX/SSE (128-bit), _mm256 = AVX (256-bit), _mm512 = AVX-512 (512-bit)
    • Operation: add, mul, fmadd, load, store, set, v.v.
    • Type: ps = packed single (float32), pd = packed double (float64), epi32 = packed int32, si256 = số nguyên 256-bit
#include <immintrin.h>  // tất cả intrinsics SIMD x86

// Kiểu dữ liệu
__m256  a;   // thanh ghi 256-bit chứa 8 float32
__m256d b;   // thanh ghi 256-bit chứa 4 float64
__m256i c;   // thanh ghi 256-bit chứa số nguyên (8x32, 16x16, hoặc 32x8)

Nạp và Lưu Dữ liệu

// Nạp 8 số thực từ bộ nhớ
__m256 v = _mm256_loadu_ps(ptr);      // nạp không căn chỉnh (hoạt động với mọi địa chỉ)
__m256 v = _mm256_load_ps(ptr);       // nạp căn chỉnh (ptr phải căn chỉnh 32 byte, nhanh hơn)

// Lưu 8 số thực ra bộ nhớ
_mm256_storeu_ps(out_ptr, v);          // lưu không căn chỉnh
_mm256_store_ps(out_ptr, v);           // lưu căn chỉnh

// Phát sóng một giá trị tới cả 8 lane
__m256 ones = _mm256_set1_ps(1.0f);    // [1, 1, 1, 1, 1, 1, 1, 1]

// Đặt các giá trị riêng lẻ (hiếm khi cần)
__m256 v = _mm256_set_ps(7,6,5,4,3,2,1,0);  // lưu ý: thứ tự đảo ngược!

// Xóa thanh ghi
__m256 z = _mm256_setzero_ps();

Số học

__m256 c = _mm256_add_ps(a, b);        // c[i] = a[i] + b[i]
__m256 d = _mm256_mul_ps(a, b);        // d[i] = a[i] * b[i]
__m256 e = _mm256_sub_ps(a, b);        // e[i] = a[i] - b[i]
__m256 f = _mm256_div_ps(a, b);        // f[i] = a[i] / b[i] (chậm hơn mul)

// Fused Multiply-Add: r = a * b + c (một chỉ thị, một lần làm tròn)
__m256 r = _mm256_fmadd_ps(a, b, c);   // CHỈ THỊ QUAN TRỌNG NHẤT CHO ML

// Min và max
__m256 mn = _mm256_min_ps(a, b);       // min(a[i], b[i]) — hữu ích để cắt (clip)
__m256 mx = _mm256_max_ps(a, b);       // max(a[i], b[i]) — hữu ích cho ReLU

Ví dụ Thực hành: Tích Vô hướng AVX2

#include <immintrin.h>

float dot_avx2(const float* a, const float* b, int n) {
    __m256 sum = _mm256_setzero_ps();  // 8 bộ cộng dồn khởi tạo thành 0

    int i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        sum = _mm256_fmadd_ps(va, vb, sum);  // sum += va * vb
    }

    // Reduce theo chiều ngang: tính tổng 8 phần tử của sum
    // Bước 1: cộng 128 bit cao vào 128 bit thấp
    __m128 hi = _mm256_extractf128_ps(sum, 1);
    __m128 lo = _mm256_castps256_ps128(sum);
    __m128 sum128 = _mm_add_ps(hi, lo);        // 4 tổng riêng phần

    // Bước 2: cộng ngang trong thanh ghi 128-bit
    sum128 = _mm_hadd_ps(sum128, sum128);       // [a+b, c+d, a+b, c+d]
    sum128 = _mm_hadd_ps(sum128, sum128);       // [a+b+c+d, ...]

    float result = _mm_cvtss_f32(sum128);       // trích xuất scalar

    // Dọn dẹp vô hướng
    for (; i < n; i++) {
        result += a[i] * b[i];
    }

    return result;
}
  • Tại sao việc reduce theo chiều ngang lại xấu xí: SIMD được thiết kế cho các phép toán dọc (lane 0 với lane 0, lane 1 với lane 1). Các phép toán theo chiều ngang (tính tổng qua các lane) chống lại phần cứng. Đây là lý do tích vô hướng có đoạn reduce xấu xí ở cuối. Vòng lặp vector hóa thì sạch; phần reduce là boilerplate.

  • Hiệu năng: so với phiên bản NEON (file 02), AVX2 xử lý 8 số thực mỗi lần lặp so với 4 của NEON. Với các vector dài, đây là mức tăng tốc 2x so với NEON (bỏ qua giới hạn băng thông bộ nhớ).

Ví dụ Thực hành: Softmax AVX2 (Đơn giản hóa)

  • Softmax yêu cầu: tìm max, trừ đi nó, mũ hóa, tính tổng, chia. Đây là bước tìm max:
float vector_max_avx2(const float* data, int n) {
    __m256 max_vec = _mm256_set1_ps(-INFINITY);

    int i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 v = _mm256_loadu_ps(data + i);
        max_vec = _mm256_max_ps(max_vec, v);
    }

    // Reduce 8 max thành 1
    __m128 hi = _mm256_extractf128_ps(max_vec, 1);
    __m128 lo = _mm256_castps256_ps128(max_vec);
    __m128 max128 = _mm_max_ps(hi, lo);

    // Xáo trộn và max để tìm max duy nhất
    max128 = _mm_max_ps(max128, _mm_shuffle_ps(max128, max128, 0b01001110));
    max128 = _mm_max_ps(max128, _mm_shuffle_ps(max128, max128, 0b10110001));

    float result = _mm_cvtss_f32(max128);

    for (; i < n; i++) {
        result = result > data[i] ? result : data[i];
    }

    return result;
}
  • Chỉ thị _mm_shuffle_ps sắp xếp lại các phần tử trong một thanh ghi. Hằng số nhị phân 0b01001110 kiểm soát phần tử nào đi đâu. Đây được gọi là một hoán vị (permutation), và nó kết nối trực tiếp với ma trận hoán vị (chương 2): xáo trộn các lane SIMD là tương đương phần cứng của việc nhân với một ma trận hoán vị.

AVX-512

  • AVX-512 nhân đôi độ rộng lần nữa: các thanh ghi 512-bit (ZMM), xử lý 16 số thực đồng thời.
__m512 a = _mm512_loadu_ps(ptr);                // nạp 16 số thực
__m512 c = _mm512_fmadd_ps(a, b, c);            // 16 FMA cùng lúc
float sum = _mm512_reduce_add_ps(a);             // tổng ngang tích hợp sẵn (không cần reduce thủ công!)

// Phép toán mask: thao tác trên một tập hợp con các lane
__mmask16 mask = _mm512_cmpgt_ps_mask(a, zero);  // lane nào > 0?
__m512 relu = _mm512_maskz_mov_ps(mask, a);       // zero các lane âm = ReLU
  • Thanh ghi mask (__mmask16) là tính năng mạnh nhất của AVX-512. Mỗi bit kiểm soát một lane có tham gia vào một phép toán hay không. Điều này thay thế vòng lặp dọn dẹp vô hướng: lần lặp cuối dùng một mask nơi chỉ các lane hợp lệ hoạt động, xử lý mọi độ dài vector mà không cần vòng lặp vô hướng riêng.

  • Làm chậm tần số AVX-512: trên nhiều CPU Intel, dùng các chỉ thị AVX-512 khiến CPU tạm thời giảm tần số xung nhịp (để nằm trong giới hạn nhiệt). Điều này nghĩa là AVX-512 không phải lúc nào cũng nhanh hơn AVX2 cho các đợt ngắn — hình phạt tần số có thể lấn át các vector rộng hơn. Với khối lượng công việc kéo dài (như nhân ma trận), AVX-512 thắng. Với mã hỗn hợp (một ít SIMD, một ít vô hướng), các chuyển tiếp tần số có thể gây hại.

Intel AMX: Phần cứng Nhân Ma trận

  • AMX (Advanced Matrix eXtensions) thêm các đơn vị nhân ma trận chuyên dụng. Thay vì vector SIMD, AMX thao tác trên tile: các khối dữ liệu 2D (lên đến 16 hàng × 64 byte mỗi hàng).
#include <immintrin.h>

// AMX tile multiply: C += A * B (ở BF16)
// A là 16x32 BF16, B là 32x16 BF16, C là 16x16 FP32
_tile_loadd(0, a_ptr, stride_a);   // nạp tile 0 từ A
_tile_loadd(1, b_ptr, stride_b);   // nạp tile 1 từ B
_tile_dpbf16ps(2, 0, 1);           // tile 2 += tile 0 * tile 1 (matmul BF16, tích lũy FP32)
_tile_stored(2, c_ptr, stride_c);  // lưu tile 2 vào C
  • AMX thực hiện một phép nhân ma trận 16×32 × 32×16 đầy đủ trong một chỉ thị. Đây là hàng trăm phép FMA cùng lúc, được thiết kế riêng cho các phép nhân ma trận nhỏ thống trị suy luận transformer (tính điểm attention, các lớp MLP).

  • AMX hỗ trợ BF16 (bfloat16) và INT8, khớp với các độ chính xác dùng trong suy luận ML. Kết hợp với AVX-512 cho các phép toán khác, các CPU có AMX (Intel Sapphire Rapids, Emerald Rapids) có thể chạy suy luận transformer cạnh tranh với GPU phân khúc phổ thông.

Căn chỉnh Bộ nhớ

  • Truy cập bộ nhớ căn chỉnh là khi địa chỉ dữ liệu là bội số của độ rộng thanh ghi vector (16 byte cho SSE, 32 byte cho AVX, 64 byte cho AVX-512). Truy cập căn chỉnh nhanh hơn trên một số CPU và được yêu cầu bởi _mm256_load_ps (trái ngược với _mm256_loadu_ps).
// Cấp phát bộ nhớ căn chỉnh
float* data = (float*)aligned_alloc(32, n * sizeof(float));  // căn chỉnh 32 byte cho AVX

// Cấp phát căn chỉnh C++
#include <new>
float* data = new (std::align_val_t(32)) float[n];

// Hoặc dùng thuộc tính trình biên dịch
alignas(32) float data[1024];
  • Trong thực tế: trên CPU hiện đại (Haswell trở đi), các lệnh nạp không căn chỉnh (loadu) gần như nhanh bằng lệnh nạp căn chỉnh khi dữ liệu không vượt qua ranh giới dòng cache. Hình phạt hiệu năng của truy cập không căn chỉnh đã phần lớn biến mất, nhưng việc chia cắt dòng cache (dữ liệu trải dài hai dòng cache 64 byte) vẫn có thể gây chậm ~2x cho lệnh nạp cụ thể đó. Cấp phát căn chỉnh tránh hoàn toàn điều này.

Các Cạm bẫy về Hiệu năng

  • Hình phạt chuyển tiếp AVX-SSE: trên các CPU Intel cũ (trước Skylake), chuyển đổi giữa các chỉ thị AVX (256-bit) và SSE (128-bit) gây ra hình phạt (~70 chu kỳ). Đây là lý do bạn nên dùng _mm256_zeroupper() (hoặc lệnh vzeroupper) trước khi trả về từ một hàm dùng AVX, để xóa 128 bit cao của các thanh ghi YMM. Các CPU hiện đại (Skylake+) không có hình phạt này.

  • Áp lực thanh ghi (Register pressure): AVX2 có 16 thanh ghi YMM. Nếu kernel của bạn dùng quá nhiều biến, trình biên dịch đẩy thanh ghi ra stack (bộ nhớ), phá hỏng hiệu năng. Giữ các vòng lặp trong cùng đơn giản với ít biến sống (live variable).

  • Phụ thuộc dữ liệu: sum = _mm256_fmadd_ps(a, b, sum) có sự phụ thuộc vào sum: mỗi lần lặp phải chờ FMA trước hoàn thành (~4-5 chu kỳ độ trễ). Sửa: dùng nhiều bộ cộng dồn độc lập và reduce ở cuối:

// Một bộ cộng dồn: bị giới hạn bởi độ trễ FMA (4-5 chu kỳ)
__m256 sum = _mm256_setzero_ps();
for (...) {
    sum = _mm256_fmadd_ps(a, b, sum);  // mỗi cái phụ thuộc vào cái trước
}

// Bốn bộ cộng dồn: thông lượng 4x (ẩn độ trễ)
__m256 sum0 = _mm256_setzero_ps();
__m256 sum1 = _mm256_setzero_ps();
__m256 sum2 = _mm256_setzero_ps();
__m256 sum3 = _mm256_setzero_ps();
for (...) {
    sum0 = _mm256_fmadd_ps(a0, b0, sum0);  // độc lập
    sum1 = _mm256_fmadd_ps(a1, b1, sum1);  // độc lập
    sum2 = _mm256_fmadd_ps(a2, b2, sum2);  // độc lập
    sum3 = _mm256_fmadd_ps(a3, b3, sum3);  // độc lập
}
sum0 = _mm256_add_ps(sum0, sum1);
sum2 = _mm256_add_ps(sum2, sum3);
sum0 = _mm256_add_ps(sum0, sum2);
  • Đây là mở rộng vòng lặp (loop unrolling) để ẩn độ trễ. CPU có thể phát các FMA liền kề nhau vì chúng ghi vào các thanh ghi khác nhau. Đây là một trong những vi tối ưu hóa có tác động nhất trong mã số học.

Profiling

  • Bộ đếm hiệu năng (Performance counters) cung cấp các phép đo ở cấp độ phần cứng:
# Linux perf (yêu cầu hỗ trợ kernel)
perf stat ./my_program                    # bộ đếm cơ bản: cycles, instructions, IPC
perf stat -e cache-misses,cache-references ./my_program  # hành vi cache
perf record -g ./my_program && perf report              # profiling đồ thị gọi

# Intel VTune (profiling x86 chi tiết)
vtune -collect hotspots -- ./my_program
vtune -collect memory-access -- ./my_program   # phân tích băng thông bộ nhớ
  • Những gì cần tìm kiếm:
    • IPC (Instructions Per Cycle — Chỉ thị mỗi chu kỳ): CPU được sử dụng hiệu quả đến mức nào. IPC > 2 là tốt. IPC < 1 gợi ý trạng thái tắc nghẽn bộ nhớ hoặc dự đoán rẽ nhánh sai.
    • Tỷ lệ cache miss: tỷ lệ miss L1/L2 cao cho thấy tính cục bộ dữ liệu kém. Cấu trúc lại các mẫu truy cập dữ liệu.
    • Tỷ lệ dự đoán rẽ nhánh sai: > 5% gợi ý các rẽ nhánh khó đoán. Chuyển sang mã không rẽ nhánh (so sánh SIMD + blending) nếu có thể.
    • FLOPS đạt được vs roofline: so sánh FLOPS đo được với mô hình roofline (file 01). Nếu thấp hơn roofline, có chỗ để cải thiện.

Bài tập Lập trình (biên dịch với g++ hoặc clang++ trên x86 — Intel/AMD)

  1. Viết một tích vô hướng dạng vô hướng và một tích vô hướng AVX2. Đo benchmark cả hai và đo mức tăng tốc từ SIMD rộng 8.

    // task1_avx_dot.cpp
    // Biên dịch: g++ -O3 -mavx2 -mfma -o task1 task1_avx_dot.cpp
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    #include <immintrin.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_avx2(const float* a, const float* b, int n) {
        __m256 sum = _mm256_setzero_ps();
        int i = 0;
        for (; i + 8 <= n; i += 8) {
            __m256 va = _mm256_loadu_ps(a + i);
            __m256 vb = _mm256_loadu_ps(b + i);
            sum = _mm256_fmadd_ps(va, vb, sum);
        }
        // Reduce: cộng 128 cao vào 128 thấp, sau đó cộng ngang
        __m128 hi = _mm256_extractf128_ps(sum, 1);
        __m128 lo = _mm256_castps256_ps128(sum);
        __m128 r = _mm_add_ps(hi, lo);
        r = _mm_hadd_ps(r, r);
        r = _mm_hadd_ps(r, r);
        float result = _mm_cvtss_f32(r);
        for (; i < n; i++) result += a[i] * b[i];
        return result;
    }
    
    int main() {
        const int N = 10'000'000;
        std::vector<float> a(N, 1.0f), b(N, 2.0f);
    
        volatile float s1 = dot_scalar(a.data(), b.data(), N);
        volatile float s2 = dot_avx2(a.data(), b.data(), N);
    
        auto bench = [&](auto fn, const char* name) {
            auto start = std::chrono::high_resolution_clock::now();
            volatile float s;
            for (int t = 0; t < 100; t++) s = fn(a.data(), b.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 (result: " << s << ")\n";
            return ms;
        };
    
        double t1 = bench(dot_scalar, "Scalar");
        double t2 = bench(dot_avx2,   "AVX2  ");
        std::cout << "Speedup: " << t1 / t2 << "x\n";
        return 0;
    }
    

  2. Triển khai ReLU AVX2 dùng _mm256_max_ps và so sánh với một vòng lặp vô hướng. Sau đó thử với nhiều bộ cộng dồn (mở rộng vòng lặp) để ẩn độ trễ FMA.

    // task2_avx_relu.cpp
    // Biên dịch: g++ -O3 -mavx2 -o task2 task2_avx_relu.cpp
    
    #include <iostream>
    #include <chrono>
    #include <vector>
    #include <immintrin.h>
    
    void relu_scalar(const float* in, float* out, int n) {
        for (int i = 0; i < n; i++) {
            out[i] = in[i] > 0.0f ? in[i] : 0.0f;
        }
    }
    
    void relu_avx2(const float* in, float* out, int n) {
        __m256 zero = _mm256_setzero_ps();
        int i = 0;
        for (; i + 8 <= n; i += 8) {
            __m256 x = _mm256_loadu_ps(in + i);
            _mm256_storeu_ps(out + i, _mm256_max_ps(x, zero));
        }
        for (; i < n; i++) out[i] = in[i] > 0.0f ? in[i] : 0.0f;
    }
    
    // Unrolled: xử lý 32 số thực mỗi lần lặp (4 x 8)
    void relu_avx2_unrolled(const float* in, float* out, int n) {
        __m256 zero = _mm256_setzero_ps();
        int i = 0;
        for (; i + 32 <= n; i += 32) {
            __m256 x0 = _mm256_loadu_ps(in + i);
            __m256 x1 = _mm256_loadu_ps(in + i + 8);
            __m256 x2 = _mm256_loadu_ps(in + i + 16);
            __m256 x3 = _mm256_loadu_ps(in + i + 24);
            _mm256_storeu_ps(out + i,      _mm256_max_ps(x0, zero));
            _mm256_storeu_ps(out + i + 8,  _mm256_max_ps(x1, zero));
            _mm256_storeu_ps(out + i + 16, _mm256_max_ps(x2, zero));
            _mm256_storeu_ps(out + i + 24, _mm256_max_ps(x3, zero));
        }
        for (; i + 8 <= n; i += 8) {
            _mm256_storeu_ps(out + i, _mm256_max_ps(_mm256_loadu_ps(in + i), zero));
        }
        for (; i < n; i++) out[i] = in[i] > 0.0f ? in[i] : 0.0f;
    }
    
    int main() {
        const int N = 16'000'000;
        std::vector<float> in(N), out(N);
        for (int i = 0; i < N; i++) in[i] = (float)(i % 200) - 100.0f;
    
        auto bench = [&](auto fn, const char* name) {
            fn(in.data(), out.data(), N);  // làm nóng
            auto start = std::chrono::high_resolution_clock::now();
            for (int t = 0; t < 100; t++) fn(in.data(), out.data(), N);
            auto end = std::chrono::high_resolution_clock::now();
            double ms = std::chrono::duration<double, std::milli>(end - start).count() / 100;
            double bw = 2.0 * N * sizeof(float) / ms / 1e6;  // đọc + ghi
            std::cout << name << ": " << ms << " ms (" << bw << " GB/s)\n";
        };
    
        bench(relu_scalar,        "Scalar        ");
        bench(relu_avx2,          "AVX2          ");
        bench(relu_avx2_unrolled, "AVX2 unrolled ");
        return 0;
    }
    

  3. Đo tác động của việc căn chỉnh bộ nhớ. So sánh các lệnh nạp căn chỉnh và không căn chỉnh trên các mảng lớn.

    // task3_alignment.cpp
    // Biên dịch: g++ -O3 -mavx2 -o task3 task3_alignment.cpp
    
    #include <iostream>
    #include <chrono>
    #include <cstdlib>
    #include <immintrin.h>
    
    int main() {
        const int N = 16'000'000;
    
        // Cấp phát căn chỉnh (32 byte cho AVX2)
        float* aligned = (float*)aligned_alloc(32, N * sizeof(float));
    
        // Không căn chỉnh: lệch 4 byte (1 số thực) so với ranh giới căn chỉnh
        float* raw = (float*)malloc((N + 1) * sizeof(float));
        float* unaligned = raw + 1;  // đảm bảo không căn chỉnh
    
        for (int i = 0; i < N; i++) {
            aligned[i] = 1.0f;
            unaligned[i] = 1.0f;
        }
    
        auto bench = [&](float* ptr, bool use_aligned, const char* name) {
            __m256 sum = _mm256_setzero_ps();
            // Làm nóng
            for (int i = 0; i + 8 <= N; i += 8) {
                __m256 v = use_aligned ? _mm256_load_ps(ptr + i) : _mm256_loadu_ps(ptr + i);
                sum = _mm256_add_ps(sum, v);
            }
    
            auto start = std::chrono::high_resolution_clock::now();
            for (int t = 0; t < 100; t++) {
                sum = _mm256_setzero_ps();
                for (int i = 0; i + 8 <= N; i += 8) {
                    __m256 v = use_aligned ? _mm256_load_ps(ptr + i) : _mm256_loadu_ps(ptr + i);
                    sum = _mm256_add_ps(sum, v);
                }
            }
            auto end = std::chrono::high_resolution_clock::now();
            double ms = std::chrono::duration<double, std::milli>(end - start).count() / 100;
            double bw = (double)N * sizeof(float) / ms / 1e6;
            std::cout << name << ": " << ms << " ms (" << bw << " GB/s)\n";
        };
    
        bench(aligned,   true,  "Aligned load  ");
        bench(unaligned, false, "Unaligned load");
    
        free(aligned);
        free(raw);
        return 0;
    }