Dữ liệu & CPU/Pipeline và hazard — CPU làm nhiều lệnh cùng lúc thế nào
17/23
Bài 17 / 23~16 phútCPU hiện đạiMiễn phí lượt xem

Pipeline và hazard — CPU làm nhiều lệnh cùng lúc thế nào

Pipeline cho phép CPU chồng lấn nhiều lệnh như dây chuyền lắp ráp. Các loại hazard (data, control, structural) gây stall, và vì sao lệnh phụ thuộc nhau làm chậm code.

TL;DR: CPU không chờ một lệnh hoàn toàn xong mới bắt đầu lệnh tiếp theo. Thay vào đó, CPU chia mỗi lệnh thành nhiều pha (fetch, decode, execute, memory, writeback) và chạy các pha của nhiều lệnh song song — gọi là pipeline. Lý tưởng cho phép hoàn thành ~1 lệnh mỗi chu kỳ clock. Nhưng khi lệnh phụ thuộc kết quả của lệnh trước (data hazard), hoặc gặp nhánh rẽ chưa biết đi đâu (control hazard), pipeline phải dừng chờ (stall) — lãng phí chu kỳ. Hiểu điều này giúp bạn viết vòng lặp ít phụ thuộc dữ liệu hơn và thân thiện với branch prediction hơn.

Hãy thử nghĩ: nếu mỗi lệnh assembly mất 5 chu kỳ clock (fetch → decode → execute → memory → writeback), và CPU phải hoàn thành xong lệnh trước mới bắt đầu lệnh sau, thì để chạy 100 lệnh cần 500 chu kỳ. Bốn trong số 5 đơn vị phần cứng nghỉ suốt 80% thời gian. CPU đời đầu thật sự làm vậy — và chậm đúng vậy.

Bài này giải thích pipeline hoạt động ra sao, ba loại hazard phá vỡ hiệu quả pipeline, và hệ quả cụ thể lên code bạn viết hằng ngày.

1. Analogy — dây chuyền giặt là

Tưởng tượng bạn có ba mẻ quần áo cần xử lý, và ba công đoạn: giặt (30 phút), sấy (30 phút), gấp (30 phút). Cách ngây thơ: mẻ 1 giặt xong → sấy xong → gấp xong (90 phút), rồi mới bắt đầu mẻ 2. Tổng ba mẻ: 270 phút.

Cách thông minh hơn: trong khi mẻ 1 đang sấy, bỏ mẻ 2 vào máy giặt luôn. Khi mẻ 1 gấp thì mẻ 2 sấy và mẻ 3 giặt. Ba mẻ xong chỉ trong 150 phút — cùng một bộ máy móc, nhanh gấp 1.8 lần.

CPU pipeline là dây chuyền đó, áp dụng cho từng lệnh assembly.

Dây chuyền giặt làPipeline CPU
Một mẻ quần áoMột lệnh assembly
Công đoạn (giặt, sấy, gấp)Pha pipeline (fetch, decode, execute, memory, writeback)
Chồng lấn nhiều mẻChồng lấn nhiều lệnh ở các pha khác nhau
Thông lượng (throughput): số mẻ/giờThroughput CPU: số lệnh hoàn thành/chu kỳ
Khi máy giặt hỏng → dừng dây chuyềnHazard → stall, pipeline đứng chờ
💡 Cách nhớ

Pipeline không làm một lệnh chạy nhanh hơn (latency không đổi), nhưng làm nhiều lệnh cùng tiến hành song song — throughput tăng. Giống mẻ giặt vẫn mất 90 phút, nhưng dây chuyền ra 1 mẻ/30 phút thay vì 1 mẻ/90 phút.

2. Cơ chế pipeline 5 pha

CPU hiện đại (x86, ARM) chia thực thi một lệnh thành 5 pha cổ điển:

PhaTên đầy đủLàm gì
IFInstruction FetchĐọc lệnh từ instruction cache theo program counter
IDInstruction DecodeGiải mã opcode, đọc thanh ghi nguồn
EXExecuteALU tính toán (cộng, trừ, so sánh, shift...)
MEMMemory AccessĐọc hoặc ghi RAM (chỉ lệnh load/store)
WBWrite BackGhi kết quả vào thanh ghi đích

Mỗi pha mất đúng 1 chu kỳ clock. Không có pipeline: 1 lệnh = 5 chu kỳ, đơn vị khác chờ. Với pipeline 5 pha, sau 5 chu kỳ khởi động ban đầu, CPU hoàn thành 1 lệnh mỗi chu kỳ (lý tưởng).

Sơ đồ dưới minh hoạ 4 lệnh chồng lấn nhau — mỗi hàng là một lệnh, mỗi cột là một chu kỳ:

gantt
  title Pipeline 5 pha (ly tuong, khong co hazard)
  dateFormat  X
  axisFormat  Chu ky %s

  section Lenh 1
  IF  :done,  l1if,  1, 2
  ID  :done,  l1id,  2, 3
  EX  :done,  l1ex,  3, 4
  MEM :done,  l1mem, 4, 5
  WB  :done,  l1wb,  5, 6

  section Lenh 2
  IF  :done,  l2if,  2, 3
  ID  :done,  l2id,  3, 4
  EX  :done,  l2ex,  4, 5
  MEM :done,  l2mem, 5, 6
  WB  :done,  l2wb,  6, 7

  section Lenh 3
  IF  :done,  l3if,  3, 4
  ID  :done,  l3id,  4, 5
  EX  :done,  l3ex,  5, 6
  MEM :done,  l3mem, 6, 7
  WB  :done,  l3wb,  7, 8

  section Lenh 4
  IF  :done,  l4if,  4, 5
  ID  :done,  l4id,  5, 6
  EX  :done,  l4ex,  6, 7
  MEM :done,  l4mem, 7, 8
  WB  :done,  l4wb,  8, 9
Chu ky:   1    2    3    4    5    6    7    8    9
Lenh 1:  [IF] [ID] [EX] [MEM][WB]
Lenh 2:       [IF] [ID] [EX] [MEM][WB]
Lenh 3:            [IF] [ID] [EX] [MEM][WB]
Lenh 4:                 [IF] [ID] [EX] [MEM][WB]
Lenh 5:                      [IF] [ID] [EX] [MEM][WB]

Từ chu kỳ 5 trở đi, mỗi chu kỳ hoàn thành 1 lệnh. 100 lệnh mất khoảng 104 chu kỳ thay vì 500 — gần gấp 5 lần nhanh hơn.

IPC và throughput

Metric đo hiệu quả pipeline là IPC (Instructions Per Clock — số lệnh hoàn thành mỗi chu kỳ). Pipeline lý tưởng đạt IPC = 1. CPU superscalar hiện đại (bài 05) có thể vượt IPC = 4 nhờ nhiều pipeline chạy song song. Bài 01 giải thích clock và IPC nếu bạn cần ôn lại.

3. Hazard — vì sao pipeline không hoàn hảo

Ba loại tình huống phá vỡ nhịp pipeline, gọi chung là hazard:

3.1. Data hazard — lệnh chờ kết quả lệnh trước

Tình huống phổ biến nhất. Giả sử hai lệnh liên tiếp:

ADD R1, R2, R3   ; R1 = R2 + R3  (lenh A)
MUL R4, R1, R5  ; R4 = R1 * R5  (lenh B, can R1 tu lenh A)

Lệnh B cần giá trị R1 từ lệnh A. Nhưng lệnh A chỉ ghi R1 ở pha WB (chu kỳ 5), trong khi lệnh B cần đọc R1 ở pha ID (chu kỳ 3 của B). B đến sớm hơn A xong 2 chu kỳ.

Chu ky:   1    2    3    4    5    6    7    8    9
Lenh A:  [IF] [ID] [EX] [MEM][WB]
Lenh B:       [IF] [ID]  ?    ?    ?    ?        <- can WB cua A
              ^^^^^ Doc R1 tai day -- R1 chua co gia tri!

Giải pháp 1 — stall (bubble): CPU chèn chu kỳ chờ rỗng ("bubble") vào pipeline cho đến khi lệnh A ghi xong:

Chu ky:   1    2    3    4    5    6    7    8
Lenh A:  [IF] [ID] [EX] [MEM][WB]
Lenh B:       [IF] [--] [--] [ID] [EX] [MEM][WB]
                   stall stall

Mỗi bubble là 1 chu kỳ lãng phí. Với dây chuyền phụ thuộc dài, stall cộng dồn đáng kể.

Giải pháp 2 — forwarding (bypassing): phần cứng kết nối trực tiếp đầu ra của pha EX tới đầu vào của pha EX lệnh sau, không chờ WB. Điều này giảm stall xuống còn 0–1 chu kỳ trong nhiều trường hợp. CPU hiện đại đều có forwarding; phần đào sâu bên dưới giải thích rõ hơn.

3.2. Control hazard — gặp nhánh rẽ, chưa biết đi đâu

Mỗi lệnh if, while, for trong code cấp cao đều biên dịch thành lệnh nhánh (branch) trong assembly. Khi CPU fetch lệnh nhánh, nó chưa biết điều kiện nhánh là đúng hay sai (vì lệnh nhánh chưa qua pha EX để tính điều kiện).

CPU có hai lựa chọn:

  • Dừng fetch (stall) cho đến khi biết địa chỉ đích — phạt 2–3 chu kỳ mỗi nhánh.
  • Đoán (speculate) — tiếp tục fetch theo một nhánh, nếu đoán sai thì flush (xoá) các lệnh đã fetch sai và fetch lại. Đây là branch prediction, chủ đề của bài 03.

Pipeline càng sâu, phạt khi đoán sai branch càng lớn. Pentium 4 với pipeline 20 pha, đoán sai nhánh tốn 20 chu kỳ flush — đây là lý do Pentium 4 kém hiệu quả hơn pipeline ngắn hơn của Pentium M dù clock cao hơn.

3.3. Structural hazard — tranh chấp tài nguyên phần cứng

Xảy ra khi hai lệnh cùng cần một đơn vị phần cứng cùng lúc. Ví dụ: CPU chỉ có một cổng đọc bộ nhớ, nhưng lệnh A ở pha MEM (đọc dữ liệu) và lệnh B ở pha IF (đọc lệnh) cùng muốn dùng. CPU hiện đại thường giải quyết bằng cách tách instruction cache và data cache (Harvard architecture variant), hoặc thêm nhiều cổng bộ nhớ. Structural hazard ít gặp hơn trong CPU desktop/server hiện đại nhưng vẫn xuất hiện trong vi điều khiển nhúng đơn giản.

4. Áp dụng vào code của bạn

Pipeline hazard không chỉ là lý thuyết kiến trúc — nó ảnh hưởng trực tiếp đến tốc độ vòng lặp nóng của bạn.

Chuỗi phụ thuộc dữ liệu dài là nguyên nhân phổ biến nhất gây stall:

// Tinh tong mang -- chuoi phu thuoc dai
long sum = 0;
for (int i = 0; i < arr.length; i++) {
    sum += arr[i]; // moi lenh ADD phu thuoc ket qua ADD truoc
}

Mỗi phép sum += arr[i] phụ thuộc kết quả của phép trước (phải chờ sum được ghi xong). Pipeline phải stall hoặc forwarding không giải quyết hết được, dẫn đến IPC thực tế thấp hơn nhiều so với lý tưởng.

Giải pháp: tách thành nhiều accumulator độc lập — compiler hoặc CPU có thể chạy chúng trên các đường pipeline khác nhau mà không có phụ thuộc giữa nhau:

// Nhieu accumulator -- giam phu thuoc, tang IPC thuc te
long sum0 = 0, sum1 = 0, sum2 = 0, sum3 = 0;
int i = 0;
for (; i + 3 < arr.length; i += 4) {
    sum0 += arr[i];      // khong phu thuoc sum1, sum2, sum3
    sum1 += arr[i + 1];
    sum2 += arr[i + 2];
    sum3 += arr[i + 3];
}
// Xu ly phan du
for (; i < arr.length; i++) sum0 += arr[i];
long total = sum0 + sum1 + sum2 + sum3;

Bốn biến sum0sum3 không phụ thuộc nhau, cho phép pipeline xử lý song song không bị stall chéo. Trên x86 hiện đại, kỹ thuật này có thể tăng tốc 2–4 lần cho vòng lặp số học thuần tuý.

Đo trước khi tối ưu

Kỹ thuật nhiều accumulator làm code khó đọc hơn. Compiler (GCC, Clang với -O2/-O3) thường tự vectorize và unroll vòng lặp đơn giản tốt hơn bạn viết tay. Chỉ làm tay khi profiling chỉ ra đúng vòng lặp này là bottleneck và compiler không tự tối ưu được (thường khi có aliasing hoặc điều kiện phức tạp).

Với branch hazard: tránh nhánh khó đoán trong vòng lặp nóng. Dữ liệu đã sắp xếp giúp branch prediction đoán đúng gần 100%; dữ liệu ngẫu nhiên gây miss liên tục. Bài 03 phân tích kỹ hơn với benchmark thực tế.

5. Đào sâu (tuỳ chọn)

📚 Đào sâu (tuỳ chọn)

Forwarding / bypassing chi tiết: Thay vì chờ WB ghi vào register file rồi ID mới đọc, phần cứng kéo dây trực tiếp từ đầu ra EX/MEM đến đầu vào EX của lệnh sau. Ví dụ: kết quả ADD ở EX chu kỳ 3 có thể forward sang EX của lệnh MUL ở chu kỳ 4 mà không cần stall. Forwarding không giải quyết được mọi hazard (load-use hazard: lệnh load phải qua MEM mới có dữ liệu, forwarding từ MEM đến EX vẫn cần 1 stall cycle), nhưng giảm đáng kể số bubble so với không forwarding.

Pipeline sâu và Pentium 4: Intel NetBurst (Pentium 4, 2000–2006) đẩy pipeline lên 20 pha (Prescott: 31 pha) để cho phép clock cao hơn (3.8 GHz). Nhưng pipeline sâu hơn đồng nghĩa branch misprediction penalty lớn hơn (tới 20–31 chu kỳ flush), và IPC thực tế thấp vì hazard nhiều hơn. Kết quả: Pentium M (Centrino) với pipeline 12 pha và clock 1.6 GHz thường nhanh hơn Pentium 4 3.0 GHz trong workload thực tế. Đây là bài học lịch sử rằng MHz/GHz không đồng nghĩa hiệu năng.

Superscalar: CPU hiện đại không dừng ở một pipeline — chúng có 4–8 pipeline song song (superscalar), mỗi chu kỳ có thể issue nhiều lệnh cùng lúc. Bài 05 giải thích out-of-order execution và superscalar. Các hazard trên đều áp dụng cho từng pipeline trong superscalar, nhưng phức tạp hơn vì các pipeline tương tác nhau.

6. Liên hệ các bài khác

7. Tóm tắt

  • Pipeline chia mỗi lệnh thành nhiều pha (IF, ID, EX, MEM, WB) và chồng lấn nhiều lệnh, cho phép hoàn thành ~1 lệnh/chu kỳ — throughput tăng, latency một lệnh không đổi.
  • Pipeline không làm lệnh nhanh hơn mà làm nhiều lệnh tiến hành song song.
  • Data hazard: lệnh phụ thuộc kết quả lệnh trước chưa xong → stall hoặc forwarding. Chuỗi phụ thuộc dài làm giảm IPC thực tế.
  • Control hazard: lệnh nhánh chưa biết địa chỉ đích → CPU đoán (branch prediction) hoặc stall. Đoán sai → flush pipeline, tốn nhiều chu kỳ.
  • Structural hazard: tranh chấp tài nguyên phần cứng → stall; CPU hiện đại hầu như giải quyết bằng thiết kế (tách cache, nhiều cổng).
  • Pipeline sâu hơn cho phép clock cao hơn nhưng phạt misprediction lớn hơn — Pentium 4 là ví dụ về tradeoff này.
  • Viết code ít phụ thuộc dữ liệu (nhiều accumulator độc lập) và branch-friendly (dữ liệu sắp xếp) giúp pipeline hoạt động hiệu quả hơn — nhưng đo trước khi tối ưu.

8. Tự kiểm tra

Tự kiểm tra
Q1
Pipeline cải thiện latency (thời gian chạy một lệnh) hay throughput (số lệnh hoàn thành mỗi đơn vị thời gian)? Giải thích vì sao.
Pipeline cải thiện throughput, không phải latency. Một lệnh vẫn phải đi qua đủ 5 pha (IF, ID, EX, MEM, WB) nên thời gian từ khi fetch đến khi hoàn thành không đổi. Nhưng nhờ chồng lấn nhiều lệnh ở các pha khác nhau cùng lúc, sau giai đoạn khởi động pipeline, CPU hoàn thành gần 1 lệnh mỗi chu kỳ thay vì 1 lệnh mỗi 5 chu kỳ. Throughput tăng gần 5 lần, latency mỗi lệnh không giảm.
Q2
Data hazard xảy ra khi nào? Hai cách CPU giải quyết data hazard là gì?
Data hazard xảy ra khi một lệnh cần kết quả của lệnh trước chưa hoàn thành — ví dụ lệnh B cần thanh ghi R1 nhưng lệnh A vẫn đang ở pha EX chưa ghi xong R1. Cách 1: stall (bubble) — CPU chèn chu kỳ chờ rỗng vào pipeline cho đến khi lệnh A ghi xong, lãng phí chu kỳ nhưng đơn giản. Cách 2: forwarding (bypassing) — phần cứng kéo dây trực tiếp từ đầu ra EX/MEM sang đầu vào EX của lệnh sau, giảm số stall xuống tối thiểu mà không cần chờ write-back vào register file.
Q3
Vì sao Pentium 4 với pipeline 20 pha và clock 3.8 GHz lại thua Pentium M pipeline 12 pha clock 1.6 GHz trong nhiều workload thực tế?
Pipeline sâu hơn cho phép clock cao hơn (mỗi pha đơn giản hơn, hoàn thành trong chu kỳ ngắn hơn), nhưng phạt khi branch misprediction tỉ lệ với độ sâu pipeline. Pentium 4 phải flush 20 pha khi đoán sai nhánh — 20 chu kỳ lãng phí mỗi lần. Pentium M chỉ flush 12 pha. Vì code thực tế có nhiều nhánh, misprediction xảy ra thường xuyên, Pentium M có IPC thực tế cao hơn đáng kể dù clock thấp hơn. Bài học: MHz không đồng nghĩa hiệu năng thực tế; IPC và branch prediction efficiency quan trọng không kém tần số clock.
Q4
Đoạn code tính tổng mảng với biến sum đơn lẻ bị ảnh hưởng bởi loại hazard nào? Kỹ thuật nhiều accumulator giải quyết điều đó thế nào?
Vòng lặp sum += arr[i] bị ảnh hưởng bởi data hazard: mỗi lần lặp phụ thuộc kết quả của lần lặp trước (ADD phải xong, ghi sum xong, lần sau mới đọc được). Đây là chuỗi phụ thuộc dài, pipeline phải stall hoặc forwarding nhưng vẫn không tránh khỏi độ trễ. Kỹ thuật nhiều accumulator (sum0, sum1, sum2, sum3) tách thành bốn chuỗi phụ thuộc độc lập nhau — lệnh ADD cho sum0 không phụ thuộc ADD cho sum1, pipeline xử lý song song không bị chặn, IPC tăng lên gần lý tưởng.
Q5
Trong ba loại hazard (data, control, structural), loại nào gây ra bởi lệnh rẽ nhánh (branch)? CPU hiện đại xử lý thế nào?
Lệnh rẽ nhánh gây ra control hazard: CPU không biết địa chỉ lệnh tiếp theo (taken hay not-taken branch) cho đến khi lệnh nhánh qua pha EX để tính điều kiện. Trong thời gian đó pipeline đã fetch các lệnh theo sau mà chưa biết chúng có đúng không. CPU hiện đại giải quyết bằng branch prediction — đoán trước hướng rẽ và tiếp tục fetch theo hướng đó. Nếu đoán đúng, không mất chu kỳ. Nếu đoán sai, flush pipeline và fetch lại từ địa chỉ đúng, tốn nhiều chu kỳ tùy độ sâu pipeline. Bài 03 đào sâu kỹ thuật branch prediction.
Q6
Forwarding (bypassing) giải quyết được mọi data hazard không? Trường hợp nào vẫn cần stall dù có forwarding?
Forwarding không giải quyết được mọi data hazard. Trường hợp điển hình vẫn cần stall là load-use hazard: lệnh load phải qua pha MEM để đọc dữ liệu từ RAM, sau đó mới có kết quả để forward. Nếu lệnh ngay sau cần giá trị đó ở pha EX, vẫn phải stall 1 chu kỳ để MEM kịp xong trước EX của lệnh sau. Compiler thường "schedule" lại thứ tự lệnh để chèn một lệnh không phụ thuộc vào giữa, lấp đầy chu kỳ stall bằng công việc thực sự thay vì bubble rỗng.
Q7
Tại sao structural hazard ít gặp hơn trong CPU desktop/server hiện đại so với vi điều khiển đơn giản?
Structural hazard xảy ra khi hai lệnh cần cùng một đơn vị phần cứng cùng lúc. CPU desktop/server hiện đại đầu tư phần cứng để giảm thiểu: tách instruction cache và data cache (nên pha IF và MEM không tranh cùng một bus), nhân nhiều ALU, nhiều cổng read/write register file. Vi điều khiển đơn giản (MCU nhúng nhỏ) thường có một bus bộ nhớ duy nhất cho cả lệnh lẫn dữ liệu — Harvard architecture chưa được áp dụng đầy đủ — nên structural hazard xuất hiện thường hơn và pipeline phải stall khi pha IF và MEM xung đột.

Bài tiếp theo: Branch prediction

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