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:
- Đo thời gian đúng cách — warmup JIT, lặp nhiều lần, dùng
System.nanoTime()hoặc JMH. - 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.
- 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).
- Đ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ằngjava.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ả | |
|---|---|
| Input | Mả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 |
| Output | Tổ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ành | Concept liên quan | Bài tương ứng |
|---|---|---|
Khởi tạo mảng int[] ngẫu nhiên | Biểu diễn số nguyên nhị phân, bộ nhớ liên tục | Module 1 — Dữ liệu |
| CPU fetch và decode lệnh vòng lặp | Fetch-execute cycle, thanh ghi, instruction pointer | Bài 01 — Clock và IPC |
| Vòng lặp chạy qua pipeline nhiều giai đoạn | Pipeline, IPC, throughput vs latency | Bài 01 — Clock và IPC |
Branch if value > 128 gây pipeline flush | Branch prediction, misprediction penalty | Bà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 pattern | Bài 03 — Branch prediction |
| Sort trước → predictor đoán đúng gần 100% | Branch-friendly data layout | Bài 03 — Branch prediction |
| Warmup JIT trước khi đo | JIT compile và thời gian ổn định | Bà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ễu | Bài 04 — Đo lường |
Branchless: thay if bằng phép nhân | Tránh pipeline stall, tận dụng ALU | Bà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 ý
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
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
ifthà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épANDmỗ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
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