Dữ liệu & CPU/Capstone — Tăng tốc một vòng lặp nóng bằng hiểu biết CPU
22/23
Bài 22 / 23~28 phútCPU hiện đạiMiễn phí lượt xem

Capstone — Tăng tốc một vòng lặp nóng bằng hiểu biết CPU

Bài tổng hợp Course 1: đo một vòng lặp nóng, chẩn đoán điểm nghẽn (branch khó đoán, phụ thuộc dữ liệu), tối ưu branch-friendly và đo lại để chứng minh cải thiện.

Mini-challenge khép lại toàn bộ Course 1. Bạn sẽ bắt đầu từ một vòng lặp thực tế xử lý mảng số nguyên, đo đúng cách, chẩn đoán vì sao chậm bằng kiến thức pipeline và branch prediction, rồi tối ưu và đo lại để chứng minh cải thiện bằng số.

Đây là quy trình kỹ sư hiệu năng dùng hàng ngày: measure → diagnose → optimize → re-measure. Không đoán mò, không "cảm giác nhanh hơn".

🎯 Đề bài

Bài toán

Cho mảng data gồm 10 triệu phần tử int ngẫu nhiên trong khoảng từ 0 đến 255. Nhiệm vụ: tính tổng các phần tử có giá trị vượt 128 (tức là giá trị lớn hơn 128).

// Compute sum of elements greater than threshold (128).
int sum = 0;
for (int i = 0; i < data.length; i++) {
    if (data[i] > 128) {
        sum += data[i];
    }
}

Vòng lặp đơn giản — nhưng trên dữ liệu ngẫu nhiên, nó chạy chậm hơn đáng kể so với tiềm năng phần cứng. Nhiệm vụ của bạn:

  1. Đo thời gian đúng cách — warmup JIT, lặp nhiều lần, dùng System.nanoTime() hoặc JMH.
  2. Chẩn đoán điểm nghẽn — giải thích tại sao vòng lặp chậm bằng kiến thức branch prediction và pipeline.
  3. Tối ưu — thử ít nhất một trong hai cách: sắp xếp dữ liệu trước khi duyệt, hoặc viết phiên bản branchless (không rẽ nhánh).
  4. Đo lại — báo cáo speedup so với phiên bản gốc.

Yêu cầu

  • Mảng có ít nhất 10 triệu phần tử int, khởi tạo ngẫu nhiên bằng java.util.Random.
  • Benchmark: warmup tối thiểu 3 vòng, đo tối thiểu 5 vòng, báo cáo thời gian trung bình và min.
  • Giải thích ngắn (comment hoặc prose): tại sao phiên bản tối ưu nhanh hơn, kết nối với cơ chế CPU đã học.

🔍 Input / Process / Output

Mô tả
InputMảng 10 triệu int trong khoảng [0, 255], khởi tạo ngẫu nhiên
Process (naive)Duyệt tuần tự, rẽ nhánh if value > 128 → branch predictor đoán sai ~50% do phân phối ngẫu nhiên đều
Process (optimized)Sort trước → phần đầu toàn phần tử nhỏ hơn 128, phần sau toàn lớn hơn → branch predictor đoán đúng gần 100%; hoặc branchless dùng phép nhân/mask thay if
OutputTổng các phần tử vượt 128 (giống nhau cả hai phiên bản — đây là kiểm chứng đúng/sai) + thời gian chạy từng phiên bản + speedup

📦 Concept mapping

Bài này tổng hợp toàn bộ learning outcomes của Course 1. Mỗi bước thực hành gắn với một bài lý thuyết đã học:

Bước thực hànhConcept liên quanBài tương ứng
Khởi tạo mảng int[] ngẫu nhiênBiểu diễn số nguyên nhị phân, bộ nhớ liên tụcModule 1 — Dữ liệu
CPU fetch và decode lệnh vòng lặpFetch-execute cycle, thanh ghi, instruction pointerBài 01 — Clock và IPC
Vòng lặp chạy qua pipeline nhiều giai đoạnPipeline, IPC, throughput vs latencyBài 01 — Clock và IPC
Branch if value > 128 gây pipeline flushBranch prediction, misprediction penaltyBài 03 — Branch prediction
Dữ liệu ngẫu nhiên khiến predictor đoán sai ~50%Phân phối đầu vào ảnh hưởng branch patternBài 03 — Branch prediction
Sort trước → predictor đoán đúng gần 100%Branch-friendly data layoutBài 03 — Branch prediction
Warmup JIT trước khi đoJIT compile và thời gian ổn địnhBài 04 — Đo lường
Lặp nhiều lần, báo cáo mean + minĐo đúng cách, loại bỏ nhiễuBài 04 — Đo lường
Branchless: thay if bằng phép nhânTránh pipeline stall, tận dụng ALUBài 03 — Branch prediction

▶️ Starter code

Chạy được ngay với Java 17+. Không cần thư viện ngoài. JMH là lựa chọn tốt hơn cho benchmark nghiêm túc — ở đây dùng System.nanoTime() cho đơn giản, với đủ warmup và lặp lại để kết quả có nghĩa.

import java.util.Arrays;
import java.util.Random;

public class BranchBenchmark {

    static final int SIZE      = 10_000_000;
    static final int THRESHOLD = 128;
    static final int WARMUP    = 3;
    static final int RUNS      = 5;

    // --- Data setup ---

    static int[] randomData() {
        int[] data = new int[SIZE];
        Random rng = new Random(42); // fixed seed for reproducibility
        for (int i = 0; i < SIZE; i++) {
            data[i] = rng.nextInt(256); // values in [0, 255]
        }
        return data;
    }

    // --- Version 1: naive branch ---

    static long sumNaive(int[] data) {
        long sum = 0;
        for (int v : data) {
            if (v > THRESHOLD) {
                sum += v;
            }
        }
        return sum;
    }

    // --- Version 2: sorted (branch-friendly) ---
    // TODO: sort a copy of data, then call sumNaive on it.
    // Hint: Arrays.sort() — but measure sort cost separately.
    static long sumSorted(int[] sorted) {
        // TODO: same loop as sumNaive — the trick is the DATA, not the loop.
        return 0;
    }

    // --- Version 3: branchless ---
    // TODO: replace the if-branch with an arithmetic expression.
    // Hint: int mask = -(v > THRESHOLD ? 1 : 0) gives 0xFFFFFFFF or 0x00000000.
    // Then sum += v & mask  accumulates v when condition is true, 0 otherwise.
    static long sumBranchless(int[] data) {
        // TODO
        return 0;
    }

    // --- Benchmark harness ---

    static long benchmark(String label, int[] data, java.util.function.LongSupplier fn) {
        // Warmup: let JIT compile and stabilize
        for (int i = 0; i < WARMUP; i++) fn.getAsLong();

        long best = Long.MAX_VALUE;
        long total = 0;
        for (int i = 0; i < RUNS; i++) {
            long t0 = System.nanoTime();
            long result = fn.getAsLong();
            long elapsed = System.nanoTime() - t0;
            total += elapsed;
            if (elapsed < best) best = elapsed;
            if (i == 0) System.out.printf("  [%s] first run result = %d%n", label, result);
        }
        long avg = total / RUNS;
        System.out.printf("  [%s] avg = %d ms, best = %d ms%n",
            label, avg / 1_000_000, best / 1_000_000);
        return avg;
    }

    public static void main(String[] args) {
        int[] data = randomData();
        int[] sortedData = data.clone();
        Arrays.sort(sortedData);

        System.out.println("=== Branch Prediction Benchmark ===");
        System.out.println("Array size: " + SIZE + ", threshold: " + THRESHOLD);
        System.out.println();

        long t1 = benchmark("naive",      data,       () -> sumNaive(data));
        long t2 = benchmark("sorted",     sortedData, () -> sumSorted(sortedData));
        long t3 = benchmark("branchless", data,       () -> sumBranchless(data));

        System.out.println();
        System.out.printf("Speedup sorted    vs naive: %.2fx%n", (double) t1 / t2);
        System.out.printf("Speedup branchless vs naive: %.2fx%n", (double) t1 / t3);
    }
}
javac BranchBenchmark.java
java -server BranchBenchmark

Dành 20–30 phút để hoàn thiện sumSorted, sumBranchless, và giải thích tại sao mỗi phiên bản nhanh hơn.

💡 Gợi ý

💡 Gợi ý — đọc khi bị kẹt

Mức 1 — Nhận diện điểm nghẽn

Câu hỏi chẩn đoán: với dữ liệu ngẫu nhiên phân phối đều trong [0, 255], xác suất phần tử vượt 128 là bao nhiêu? (Xấp xỉ 50%.) Branch predictor không có pattern nào để học — kết quả if (v > 128) thay đổi hoàn toàn ngẫu nhiên từ phần tử này sang phần tử khác. Mỗi lần đoán sai, pipeline flush và mất 10–20 cycle. Với 10 triệu phần tử và ~50% tỉ lệ đoán sai, đây là hàng triệu lần flush.

Mức 2 — Sửa bằng sort

sumSorted về cơ bản là cùng vòng lặp sumNaive, chỉ khác ở dữ liệu đầu vào. Sau khi sort tăng dần, nửa đầu mảng toàn phần tử từ 0 đến 128 (branch luôn false), nửa sau toàn phần tử từ 129 đến 255 (branch luôn true). Predictor nhận ra pattern dài hàng triệu lần liên tiếp → đoán đúng gần 100%. Không flush pipeline.

static long sumSorted(int[] sorted) {
    long sum = 0;
    for (int v : sorted) {
        if (v > THRESHOLD) {
            sum += v;
        }
    }
    return sum;
}

Chú ý: sort tốn thêm thời gian O(n log n). Nếu bạn sort một lần rồi query nhiều lần, chi phí sort được phân bổ — lúc đó đáng. Trong benchmark này, tách riêng thời gian sort ra ngoài để so sánh công bằng.

Mức 3 — Branchless

Thay if (v > THRESHOLD) sum += v bằng phép tính không có rẽ nhánh:

static long sumBranchless(int[] data) {
    long sum = 0;
    for (int v : data) {
        int mask = -(v > THRESHOLD ? 1 : 0); // 0xFFFFFFFF or 0x00000000
        sum += (v & mask);
    }
    return sum;
}

Biểu thức -(v > THRESHOLD ? 1 : 0) vẫn dùng ternary — nhưng JIT/CPU thường biên dịch thành lệnh CMOV (conditional move) thay vì branch, tránh flush pipeline. Phép v & mask: nếu mask là 0xFFFFFFFF (tất cả bit 1) thì giữ nguyên v; nếu mask là 0 thì kết quả là 0. Không có branch nào để đoán sai.

✅ Lời giải

✅ Lời giải — xem sau khi đã thử
import java.util.Arrays;
import java.util.Random;

public class BranchBenchmark {

    static final int SIZE      = 10_000_000;
    static final int THRESHOLD = 128;
    static final int WARMUP    = 3;
    static final int RUNS      = 5;

    static int[] randomData() {
        int[] data = new int[SIZE];
        Random rng = new Random(42);
        for (int i = 0; i < SIZE; i++) {
            data[i] = rng.nextInt(256);
        }
        return data;
    }

    // Version 1: naive branch on random data.
    // Branch predictor cannot learn the pattern -> ~50% misprediction rate.
    static long sumNaive(int[] data) {
        long sum = 0;
        for (int v : data) {
            if (v > THRESHOLD) {
                sum += v;
            }
        }
        return sum;
    }

    // Version 2: same loop, but data is pre-sorted.
    // After sort: first half has values <= 128 (branch always false),
    // second half has values > 128 (branch always true).
    // Predictor learns the long monotone pattern -> near 0% misprediction.
    static long sumSorted(int[] sorted) {
        long sum = 0;
        for (int v : sorted) {
            if (v > THRESHOLD) {
                sum += v;
            }
        }
        return sum;
    }

    // Version 3: branchless via bitmask.
    // -(cond ? 1 : 0) -> 0xFFFFFFFF when true, 0x00000000 when false.
    // JIT or CPU emits CMOV instead of a branch instruction -> no pipeline flush.
    static long sumBranchless(int[] data) {
        long sum = 0;
        for (int v : data) {
            int mask = -(v > THRESHOLD ? 1 : 0);
            sum += (v & mask);
        }
        return sum;
    }

    static long benchmark(String label, int[] data, java.util.function.LongSupplier fn) {
        for (int i = 0; i < WARMUP; i++) fn.getAsLong();

        long best  = Long.MAX_VALUE;
        long total = 0;
        for (int i = 0; i < RUNS; i++) {
            long t0      = System.nanoTime();
            long result  = fn.getAsLong();
            long elapsed = System.nanoTime() - t0;
            total += elapsed;
            if (elapsed < best) best = elapsed;
            if (i == 0) System.out.printf("  [%s] result = %d%n", label, result);
        }
        long avg = total / RUNS;
        System.out.printf("  [%s] avg = %d ms, best = %d ms%n",
            label, avg / 1_000_000, best / 1_000_000);
        return avg;
    }

    public static void main(String[] args) {
        int[] data = randomData();
        int[] sortedData = data.clone();
        Arrays.sort(sortedData);

        System.out.println("=== Branch Prediction Benchmark ===");
        System.out.println("Array size: " + SIZE + ", threshold: " + THRESHOLD);
        System.out.println();

        long t1 = benchmark("naive",      data,       () -> sumNaive(data));
        long t2 = benchmark("sorted",     sortedData, () -> sumSorted(sortedData));
        long t3 = benchmark("branchless", data,       () -> sumBranchless(data));

        System.out.println();
        System.out.printf("Speedup sorted     vs naive: %.2fx%n", (double) t1 / t2);
        System.out.printf("Speedup branchless vs naive: %.2fx%n", (double) t1 / t3);
    }
}

Output minh hoạ thực tế (Intel Core i7-12th gen, JDK 21, tuỳ máy):

=== Branch Prediction Benchmark ===
Array size: 10000000, threshold: 128

  [naive]      avg = 18 ms, best = 17 ms
  [sorted]     avg =  5 ms, best =  4 ms
  [branchless] avg =  6 ms, best =  5 ms

Speedup sorted     vs naive: 3.60x
Speedup branchless vs naive: 3.00x

Tại sao mỗi phiên bản nhanh hơn:

Naive — chậm vì branch misprediction liên tục: Dữ liệu ngẫu nhiên phân phối đều trong [0, 255]. Khoảng 127/255 phần tử có giá trị nhỏ hơn hoặc bằng 128, 128/255 phần tử vượt 128 — gần 50-50. Branch predictor hiện đại (tournament predictor, TAGE) học pattern từ lịch sử các lần rẽ nhánh gần đây. Với chuỗi ngẫu nhiên, không có pattern nào để học — tỉ lệ đoán sai xấp xỉ 50%. Mỗi lần đoán sai: pipeline flush mất 10–20 cycle, CPU phải discard instruction đã fetch/decode sai đường, bắt đầu lại từ đúng hướng. Trên 10 triệu phần tử, khoảng 5 triệu lần flush — đây là điểm nghẽn chính.

Sorted — nhanh nhờ branch predictor đoán đúng gần 100%: Sau Arrays.sort(), mảng tăng dần từ 0 đến 255. Khi duyệt, nửa đầu toàn phần tử từ 0 đến 128 — branch if (v > 128) liên tục false hàng triệu lần. Predictor học ngay: "luôn không nhảy". Nửa sau từ 129 đến 255 — branch liên tục true. Một lần chuyển trạng thái ở điểm giao (~phần tử thứ 5 triệu) → đoán sai đúng một lần. Sau đó predictor thích nghi: "luôn nhảy". Pipeline không bao giờ bị flush sau lần chuyển đó. Throughput tăng lên bằng tốc độ bộ nhớ và ALU thực sự, không bị bottleneck bởi misprediction.

Branchless — nhanh nhờ loại bỏ branch hoàn toàn: Biểu thức -(v > THRESHOLD ? 1 : 0) không sinh ra branch instruction theo nghĩa pipeline. JIT compiler (và nhiều CPU) biên dịch điều kiện này thành lệnh CMOV (conditional move) — lệnh thực thi ở giai đoạn Execute của pipeline mà không cần đoán hướng, vì cả hai giá trị (0 và 1) đều được tính trước, chỉ chọn kết quả đúng sau khi so sánh. Không có flush. Tuy nhiên, phép nhân mask thêm một phép AND mỗi iteration, nên đôi khi chậm hơn sorted một chút; mức chênh lệch phụ thuộc vào microarchitecture và khả năng auto-vectorize (SIMD) của JIT.

Lưu ý: con số speedup biến thiên theo máy (3x–6x là phổ biến cho ví dụ này). Kết quả cuối cùng (result) của cả ba phiên bản phải giống nhau — đây là cách kiểm tra đúng/sai.

🎓 Mở rộng

Mức 1 — Thay đổi kích thước mảng:

Thử SIZE = 1_000, 100_000, 10_000_000, 100_000_000. Khi mảng nhỏ hơn L1 cache (thường ~32 KB), toàn bộ data fit trong cache — latency thấp che khuất misprediction penalty. Khi mảng lớn hơn L3 cache (~8-32 MB tuỳ chip), cache miss trở thành bottleneck mới và misprediction ít ảnh hưởng hơn. Quan sát speedup thay đổi thế nào.

Mức 2 — Thay đổi threshold:

Thử THRESHOLD = 10 (branch luôn gần true) và THRESHOLD = 245 (branch luôn gần false). So sánh với THRESHOLD = 128 (50-50). Branch predictor học tốt hơn khi tỉ lệ lệch xa 50% — bạn có thể định lượng "independent from data layout" speedup.

Mức 3 — Đo bằng perf trên Linux:

# Compile with debug info
javac BranchBenchmark.java

# Measure branch misses via perf stat
perf stat -e branches,branch-misses java -server BranchBenchmark

Kết quả perf cho thấy trực tiếp số lần branch-misses — xác nhận định lượng chẩn đoán lý thuyết. Tỉ lệ miss rate naive ~48-52%, sorted gần 0%.

Mức 4 — Nhiều accumulator (chống dependency chain):

Nếu loop body có sum += v[i] thì phép cộng tạo chuỗi phụ thuộc tuần tự: iteration i+1 phải chờ i xong mới bắt đầu cộng. Thử tách thành 4 accumulator song song:

static long sumFourAcc(int[] data) {
    long s0 = 0, s1 = 0, s2 = 0, s3 = 0;
    int len = data.length & ~3; // round down to multiple of 4
    for (int i = 0; i < len; i += 4) {
        int m0 = -(data[i]   > THRESHOLD ? 1 : 0);
        int m1 = -(data[i+1] > THRESHOLD ? 1 : 0);
        int m2 = -(data[i+2] > THRESHOLD ? 1 : 0);
        int m3 = -(data[i+3] > THRESHOLD ? 1 : 0);
        s0 += (data[i]   & m0);
        s1 += (data[i+1] & m1);
        s2 += (data[i+2] & m2);
        s3 += (data[i+3] & m3);
    }
    // handle tail
    for (int i = len; i < data.length; i++) {
        int m = -(data[i] > THRESHOLD ? 1 : 0);
        s0 += (data[i] & m);
    }
    return s0 + s1 + s2 + s3;
}

Bốn accumulator độc lập → CPU out-of-order execution chạy chúng song song trong execution units khác nhau. Đây là kỹ thuật dependency chain breaking — nối tiếp từ kiến thức pipeline hazard đã học.

✨ Điều bạn vừa làm được

Hoàn thành capstone này, bạn đã tổng hợp toàn bộ Course 1 vào một thực hành có thể đo được:

  • Đo lường đúng cách — warmup JIT, nhiều lần lặp, báo cáo mean và best, tránh bẫy "đo lần đầu khi code còn cold".
  • Chẩn đoán bằng lý thuyết — không đoán mò, mà suy luận từ phân phối dữ liệu → tỉ lệ misprediction → pipeline flush penalty.
  • Quantify bottleneck — biết rằng 50% misprediction trên 10 triệu phần tử là hàng triệu pipeline flush, mỗi cái mất 10–20 cycle.
  • Tối ưu branch-friendly — sort dữ liệu để branch predictor học pattern dài → speedup 3-4x mà không đổi thuật toán.
  • Viết branchless — dùng bitmask để biến if thành phép tính, loại bỏ flush hoàn toàn.
  • Hiểu trade-off — sorted cần chi phí sort O(n log n) upfront; branchless thêm một phép AND mỗi iteration nhưng không cần sort; nhiều accumulator phá dependency chain nhưng tăng độ phức tạp code.
  • Kết nối lý thuyết với số thực — speedup 3-6x không phải ma thuật, mà là hệ quả trực tiếp của branch misprediction penalty và pipeline depth CPU đã học ở bài 01-03.

Bài tiếp theo: Tổng kết Course 1

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên