Mở đầu: chồng lấp thay vì ép mọi lệnh theo nhịp lệnh chậm nhất

Bài 3 kết thúc bằng một pitfall gai góc: datapath đơn chu kỳ ép TẤT CẢ lệnh — kể cả một ADD đơn giản — chạy theo nhịp của lệnh CHẬM NHẤT (LW). Giải pháp không phải "làm ADD chậm hơn cho công bằng" mà ngược lại: chia nhỏ mỗi lệnh thành 5 bước (giai đoạn) và cho nhiều lệnh CHỒNG LẤP nhau — lệnh sau bắt đầu bước 1 ngay khi lệnh trước vừa xong bước 1. Đây là pipeline, và cái giá của việc chồng lấp là: các lệnh đang "chạy song song" đôi khi cần DỮ LIỆU của nhau — xung đột dữ liệu (data hazard).


📚 Điều kiện tiên quyết
Bắt buộc đọc Bài 3 (datapath đơn chu kỳ, mã hoá RV32I — pipeline bài này chồng lấp CHÍNH các giai đoạn đã học ở đó).

1. Cơ chế Pipeline 5 giai đoạn

Mỗi lệnh RV32I đi qua đúng 5 bước: IF (Instruction Fetch — đọc lệnh), ID (Instruction Decode — giải mã + đọc thanh ghi), EX (Execute — tính toán qua ALU), MEM (Memory access — chỉ LW/SW thật sự dùng), WB (Write Back — ghi kết quả vào thanh ghi). Pipeline đặt MỘT khối phần cứng riêng cho mỗi giai đoạn, nối bằng các thanh ghi đệm liên giai đoạn (pipeline registers) — khi lệnh A đang ở EX, lệnh B (nạp sau A đúng 1 chu kỳ) đã có thể ở ID, và lệnh C đã có thể ở IF — cả 3 lệnh "sống" trong CPU CÙNG một lúc.

pipeline_5stage.txt (5 giai đoạn & chồng lấp qua các chu kỳ)
Chu ky:      1    2    3    4    5    6    7
Lenh A:      IF   ID   EX   MEM  WB
Lenh B:           IF   ID   EX   MEM  WB
Lenh C:                IF   ID   EX   MEM  WB

# Tai chu ky 3: A dang o EX, B dang o ID, C dang o IF - CA 3 lenh
# "song" dong thoi trong CPU, moi lenh o MOT giai doan khac nhau.
⚠️ Cạm bẫy: lầm tưởng pipeline làm 1 lệnh chạy nhanh hơn
Pipeline tối ưu THÔNG LƯỢNG (số lệnh hoàn thành mỗi giây khi chạy NHIỀU lệnh liên tục), KHÔNG tối ưu ĐỘ TRỄ của một lệnh đơn lẻ — một lệnh riêng lẻ vẫn phải đi trọn 5 giai đoạn (5 chu kỳ), không hề nhanh hơn (thậm chí có thêm overhead thanh ghi đệm giữa các giai đoạn). Lợi ích CHỈ xuất hiện khi có NHIỀU lệnh chồng lấp — giống một dây chuyền lắp ráp: một chiếc xe đơn lẻ không ra xưởng nhanh hơn, nhưng hàng nghìn xe/giờ thì có.

2. Tính toán hiệu năng Pipeline

Thời gian chạy $N$ lệnh trên pipeline $S$ giai đoạn, xung nhịp $t_{clk}$, có $stallCycles$ chu kỳ "bong bóng" chèn thêm do hazard:

$$T = (N + S - 1 + stallCycles) \times t_{clk}$$

Số hạng $(S-1)$ là độ trễ "làm đầy" pipeline lúc khởi động — 4 chu kỳ đầu tiên (với $S=5$) chưa có đủ 5 lệnh chồng lấp cùng lúc. Verified bằng số thật: 1 triệu lệnh, pipeline 5 giai đoạn, xung nhịp 2GHz ($t_{clk}=0,5$ns) — KHÔNG có stall mất đúng 500.002 ns; CÙNG chương trình đó nhưng có 200.000 chu kỳ stall (vd do hazard) mất 600.002 ns — chênh lệch đúng bằng $200.000 \times 0,5\text{ns} = 100.000\text{ns}$.

pipeline_timing.js (trích engine dùng chung cpu-core.js)
function pipelineTime(numInstructions, numStages, stallCycles, clockPeriodNs) {
  return (numInstructions + numStages - 1 + stallCycles) * clockPeriodNs;
}
function pipelineCPI(numInstructions, stallCycles) {
  return (numInstructions + stallCycles) / numInstructions; // CPI ly tuong = 1
}
// Verified: pipelineTime(1_000_000, 5, 0, 0.5) === 500002 (ns)
// Verified: pipelineTime(1_000_000, 5, 200_000, 0.5) === 600002 (ns)
// Verified: pipelineCPI(1_000_000, 200_000) === 1.2 (CPI ly tuong la 1)
⚠️ Đừng quên chu kỳ "làm đầy" pipeline khi $N$ nhỏ
Với $N=1$ triệu lệnh, số hạng $(S-1)=4$ hoàn toàn không đáng kể (4 phần triệu). Nhưng với một đoạn mã CHỈ có 5 lệnh, $(S-1)=4$ chiếm gần 44% tổng số chu kỳ ($T$ tính bằng chu kỳ $= 5+4=9$, so với lý tưởng $N=5$) — bỏ qua số hạng này khi ước lượng hiệu năng đoạn mã NGẮN là sai số đáng kể, dù với chương trình dài thì gần như không ảnh hưởng.

3. Xung đột dữ liệu (Data Hazards)

Khi lệnh sau CHỒNG LẤP với lệnh trước, nó có thể cần đọc/ghi một thanh ghi mà lệnh trước cũng đang đọc/ghi — 3 kiểu xung đột kinh điển:

Kiểu Ý nghĩa Có xảy ra trong pipeline cơ bản?
RAW (Read-After-Write) Lệnh sau ĐỌC thanh ghi mà lệnh trước chưa kịp GHI xong Có — xung đột THẬT, cần forwarding/stall
WAR (Write-After-Read) Lệnh sau GHI thanh ghi mà lệnh trước chưa kịp ĐỌC Không — pipeline cơ bản đọc ở ID (sớm), ghi ở WB (muộn), thứ tự tự nhiên đã đúng
WAW (Write-After-Write) 2 lệnh cùng GHI 1 thanh ghi sai thứ tự Không — pipeline cơ bản hoàn thành WB đúng thứ tự chương trình (in-order)

Forwarding (chuyển tiếp dữ liệu): thay vì CHỜ lệnh trước ghi xong vào thanh ghi rồi mới đọc lại, kết quả ALU được "đi tắt" thẳng từ đầu ra EX của lệnh trước sang đầu vào EX của lệnh sau — verified: chuỗi 3 lệnh RV32I thật ADDI x1,x0,20 / ADD x2,x1,x1 / SUB x3,x2,x1 (2 hazard RAW liên tiếp) khi CÓ forwarding cho đúng 0 stall — hoàn toàn không mất chu kỳ nào; khi TẮT forwarding, mỗi hazard cần đúng 2 stall (chờ tới khi WB xong), tổng 4 stall.

detect_hazards.js (trích engine dùng chung cpu-core.js)
function detectHazards(instrs, forwardingEnabled) {
  let totalStalls = 0;
  const hazards = [];
  for (let i = 1; i < instrs.length; i++) {
    const prev = instrs[i - 1], curr = instrs[i];
    const prevWritesReg = /* prev la R/I/ILOAD va rd != 0 */;
    if (!prevWritesReg) continue;
    const reads = curr.rs1 === prev.rd || curr.rs2 === prev.rd;
    if (!reads) continue;
    if (prev.type === 'ILOAD') { totalStalls += 1; /* load-use: LUON 1 stall */ }
    else if (!forwardingEnabled) { totalStalls += 2; /* RAW, khong forwarding */ }
    // co forwarding + khong phai load-use -> 0 stall, khong lam gi them
  }
  return { totalStalls, hazards };
}
// Verified: chuoi RAW that CO forwarding -> 0 stall; KHONG forwarding -> 4 stall
⚠️ Cạm bẫy chính: Load-use hazard — forwarding KHÔNG cứu được
Forwarding chuyển kết quả EX → EX kế tiếp, nhưng LW chỉ có dữ liệu SẴN SÀNG ở giai đoạn MEM (chậm hơn EX đúng 1 giai đoạn). Nếu lệnh NGAY SAU dùng thẳng kết quả đó, forwarding vẫn không kịp — bắt buộc chèn đúng 1 chu kỳ stall, dù bật hay tắt forwarding. Verified thật: LW x1, 0(x2) rồi ADD x3, x1, x1 (dùng ngay $x_1$) — bật forwarding vẫn cho đúng 1 stall, không thể về 0 như hazard RAW thường.

4. Phân tích ví dụ thực tế & tính toán Stall

Giản đồ thời gian (pipeline clock grid) của ví dụ load-use — verified 1 stall đúng bằng cách "trễ" giai đoạn EX của lệnh ADD lại đúng 1 chu kỳ để chờ dữ liệu từ MEM của LW:

Lệnh C1 C2 C3 C4 C5 C6 C7
LW x1,0(x2) IF ID EX MEM WB
ADD x3,x1,x1 IF ID bubble EX MEM WB

Đúng công thức: 2 lệnh, 5 giai đoạn, 1 stall → $T = (2+5-1+1) = 7$ chu kỳ — khớp với cột cuối cùng (WB của ADD tại chu kỳ 7) trong bảng trên. CPI thực tế của đoạn mã 2 lệnh này: $CPI = (2+1)/2 = 1,5$ — cao hơn hẳn CPI lý tưởng $=1$ vì tỷ trọng stall trên tổng số lệnh RẤT lớn khi đoạn mã ngắn (đúng pitfall Mục 2 vừa nêu).

load_use_cpi.js (tính CPI cho ví dụ load-use, dùng đúng engine)
const seq = [assembleRV32I('LW', {rd:1, rs1:2, imm:0}), assembleRV32I('ADD', {rd:3, rs1:1, rs2:1})]
  .map(decodeRV32I);
const { totalStalls } = detectHazards(seq, true); // forwarding bat, van 1 stall
const T = pipelineTime(2, 5, totalStalls, 0.5);     // = 7 chu ky (don vi: t_clk)
const cpi = pipelineCPI(2, totalStalls);            // = 1.5
// Verified: totalStalls=1, T=(2+5-1+1)=7, CPI=(2+1)/2=1.5

5. Thực hành: Mô phỏng Pipeline 5 giai đoạn RISC-V tương tác

Máy tính thời gian/CPI bên dưới dùng ĐÚNG công thức đã verify — đổi số lệnh/stall/xung nhịp để xem $T$ và CPI thay đổi trực tiếp. Bộ mô phỏng hazard cho phép bật/tắt forwarding trên 2 chuỗi lệnh RV32I thật (RAW và load-use) để tự tay thấy con số stall đổi đúng như Mục 3:

🧮 Máy tính thời gian/CPI & Mô phỏng Hazard

Máy tính T & CPI

Mô phỏng Hazard

🛠️ Công cụ mô phỏng độc lập
Muốn xem TỪNG chu kỳ pipeline thật sự chạy (không chỉ đếm stall) — đặc biệt với các đoạn mã tự viết phức tạp hơn — hãy thử RISC-V Pipeline & Cache L1 Simulator, công cụ mô phỏng trực quan riêng dùng lại xuyên suốt series (đã có sẵn từ trang Lộ trình), hiển thị từng lệnh chồng lấp qua 5 giai đoạn theo thời gian thực, kèm bật/tắt forwarding để nghe/thấy hazard trực tiếp.

Tóm lược

  • ✅ Pipeline chồng lấp nhiều lệnh qua 5 giai đoạn (IF-ID-EX-MEM-WB) để tăng THÔNG LƯỢNG, không rút ngắn độ trễ 1 lệnh đơn lẻ.
  • ✅ Verified: $T=(N+S-1+stall) \times t_{clk}$ — 1 triệu lệnh không stall = 500.002ns, có 200.000 stall = 600.002ns ở 2GHz.
  • ✅ RAW là hazard THẬT duy nhất trong pipeline cơ bản (WAR/WAW tự động đúng thứ tự nhờ đọc sớm ở ID, ghi muộn ở WB, in-order commit).
  • ✅ Verified: forwarding giải quyết RAW ALU-ALU hoàn toàn (0 stall); không forwarding cần 2 stall/hazard.
  • ✅ Pitfall: load-use hazard (LW rồi dùng ngay) forwarding KHÔNG cứu được — verified luôn cần đúng 1 stall.

Trắc nghiệm ôn tập

Câu 1

Pipeline 5 giai đoạn giúp cải thiện điều gì so với datapath đơn chu kỳ?

Câu 2

Verified: 1 triệu lệnh không stall mất 500.002ns, có 200.000 stall mất 600.002ns (2GHz). Chênh lệch 100.000ns đến từ đâu?

Câu 3

Vì sao WAR và WAW KHÔNG xảy ra trong pipeline cơ bản (dù RAW vẫn xảy ra)?

Câu 4

Verified: LW x1,0(x2) rồi ADD x3,x1,x1 (dùng ngay x1) — forwarding vẫn cần đúng 1 stall. Vì sao forwarding không giải quyết được hoàn toàn như hazard RAW thường?

Tải file code thực hành minh họa bài học

File JavaScript CPUJS — thư viện kiến trúc máy tính mini dùng xuyên suốt cả 12 bài, Bài 4 vừa thêm pipelineTime(), pipelineCPI(), detectHazards() — công thức thời gian/CPI có stall và bộ phát hiện hazard RAW/load-use, kèm self-test đối chiếu đúng mọi con số trong bài (chạy node cpu-core.js, không cần cài thêm gì):

Tải về cpu-core.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 3: Hợp Ngữ RISC-V & Đường Đi Của Dữ Liệu (Datapath) Bài 5: Dự Đoán Nhánh & Lỗ Hổng Bảo Mật Spectre Quay lại Lộ trình Kiến Trúc Máy Tính