Mở đầu: một nhân chạy nhanh hơn bằng cách phát nhiều lệnh cùng lúc

Bài 5 giữ pipeline luôn ĐẦY lệnh bằng cách đoán trước hướng rẽ nhánh. Nhưng pipeline vô hướng (scalar) của Bài 4 vẫn chỉ PHÁT đúng 1 lệnh/chu kỳ, và luôn theo ĐÚNG thứ tự chương trình. CPU hiệu năng cao hiện đại đi xa hơn: phát nhiều lệnh mỗi chu kỳ (superscalar), và cho phép lệnh SAU chạy xong TRƯỚC lệnh trước nếu không có phụ thuộc dữ liệu THẬT — gọi là thực thi ngoài thứ tự (Out-of-Order execution, OOO). Cái giá: phải giải quyết phụ thuộc dữ liệu GIẢ sinh ra do số thanh ghi kiến trúc hữu hạn, và phải đảm bảo trạng thái kiến trúc cuối cùng vẫn ĐÚNG như chạy tuần tự — bài này xây thuật toán kinh điển giải quyết cả hai: Tomasulo.


📚 Điều kiện tiên quyết
Bắt buộc đọc Bài 4 (pipeline, hazard RAW) và Bài 5 (dự đoán nhánh). OOO xây THẲNG trên 2 bài này, KHÔNG cần kiến thức về cache hay bộ nhớ ảo (sẽ học ở Bài 7-8).

1. Song song cấp lệnh (ILP) & CPU Superscalar

ILP (Instruction-Level Parallelism) đo mức độ các lệnh LIỀN KỀ trong chương trình có thể chạy song song mà không đổi kết quả. CPU vô hướng (scalar) phát 1 lệnh/chu kỳ; CPU siêu vô hướng (superscalar) có nhiều đơn vị chức năng (ALU, bộ nhân, bộ tải/lưu) và có thể phát/thực thi NHIỀU lệnh đồng thời — nhưng tận dụng được bao nhiêu phụ thuộc HOÀN TOÀN vào việc chương trình có đủ lệnh ĐỘC LẬP hay không.

⚠️ Cạm bẫy: superscalar không tự động tăng tốc mọi chương trình
Hiệu năng thực tế của một CPU superscalar bị NGHẼN bởi các phụ thuộc dữ liệu THẬT (RAW — lệnh sau cần kết quả của lệnh trước) — dù có 8 đơn vị chức năng, một chuỗi lệnh phụ thuộc dây chuyền (mỗi lệnh cần kết quả lệnh ngay trước) vẫn phải chạy gần như TUẦN TỰ, không thể song song hoá dù phần cứng cho phép. Verified ở Mục 4: cùng 3 lệnh, chuỗi RAW thật (mỗi lệnh phụ thuộc lệnh trước) mất 11 chu kỳ, trong khi phiên bản có phụ thuộc GIẢ (WAR/WAW, loại bỏ được) chỉ mất 9 chu kỳ.

2. Phụ thuộc dữ liệu giả & Đổi tên thanh ghi (Register Renaming)

Ngoài RAW (hazard THẬT, đã học ở Bài 4), tồn tại 2 loại phụ thuộc chỉ xuất hiện vì CHƯƠNG TRÌNH tái sử dụng cùng MỘT tên thanh ghi kiến trúc cho 2 giá trị KHÔNG liên quan nhau về mặt logic:

Kiểu Ý nghĩa Có phải phụ thuộc THẬT?
RAW (Read-After-Write) Lệnh sau ĐỌC giá trị lệnh trước GHI ra Có — phụ thuộc dữ liệu thật, KHÔNG thể loại bỏ
WAR (Write-After-Read) Lệnh sau GHI đè thanh ghi mà lệnh TRƯỚC đang đọc giá trị CŨ Không — phụ thuộc GIẢ, chỉ vì trùng TÊN thanh ghi
WAW (Write-After-Write) 2 lệnh cùng GHI một thanh ghi, phải giữ đúng thứ tự "ai ghi sau thắng" Không — phụ thuộc GIẢ, chỉ vì trùng TÊN thanh ghi

Đổi tên thanh ghi (register renaming) loại bỏ hoàn toàn WAR/WAW: mỗi lần một lệnh GHI vào thanh ghi kiến trúc, nó được gán một "phiên bản" MỚI (trong Tomasulo là chỉ số ROB — Reorder Buffer), và Register Alias Table (RAT) ghi nhớ "thanh ghi X hiện đang trỏ tới phiên bản nào". Lệnh sau đọc/ghi thanh ghi đó sẽ tự động dùng ĐÚNG phiên bản tương ứng — không còn tranh chấp vật lý nào giữa các "phiên bản" khác nhau của cùng một tên thanh ghi.

register_renaming_concept.txt (RAT — Register Alias Table)
// RAT[r] = null      -> the CORRECT value of register r lives in regFile
// RAT[r] = robIndex   -> the NEWEST value of r is still "in flight" in the ROB
//                        at position robIndex, NOT yet committed

// When ISSUING an instruction that writes to register dest:
//   RAT[dest] = newlyAllocatedRobIndex   // "RENAME" - create a NEW VERSION
// -> later instructions reading "dest" get this NEW VERSION through the RAT,
//    while earlier ones (which read "dest" at issue time, before the rename)
//    keep their OLD value - untouched by the overwriting instruction.
⚠️ Cạm bẫy: nhầm RAW (không loại bỏ được) với WAR/WAW (loại bỏ được)
RAW là ràng buộc DỮ LIỆU thật — nếu lệnh B cần kết quả TÍNH TOÁN của lệnh A, không có mẹo đổi tên nào giúp B chạy được TRƯỚC KHI A tính xong (đó là vi phạm luận lý chương trình, không phải giới hạn phần cứng). Ngược lại, WAR/WAW chỉ là ràng buộc về TÊN — đổi tên thanh ghi giải quyết HOÀN TOÀN, không đổi thứ tự tính toán chút nào. Verified: instr2 (WAR trên R2 với instr1 MUL) bắt đầu thực thi ở chu kỳ 3, RẤT LÂU trước khi instr1 hoàn tất ở chu kỳ 6 — chứng minh renaming loại bỏ stall hoàn toàn.

3. Thuật toán Tomasulo: Reservation Station + CDB + ROB

Tomasulo (IBM 360/91, 1967) là thuật toán OOO đầu tiên và vẫn là nền tảng của CPU OOO hiện đại (x86, ARM). 3 thành phần chính:

  • Reservation Station (RS) — mỗi đơn vị chức năng có vài "trạm đặt chỗ" giữ lệnh đang chờ toán hạng; khi CẢ 2 toán hạng sẵn sàng, RS tự động bắt đầu thực thi (không cần đợi lệnh trước xong nếu không phụ thuộc).
  • CDB (Common Data Bus) — bus dữ liệu DÙNG CHUNG để broadcast kết quả: mọi RS đang chờ giá trị đó "nghe lỏm" (snoop) CDB và tự cập nhật toán hạng ngay khi giá trị xuất hiện. Vì là tài nguyên DÙNG CHUNG, CHỈ 1 lệnh được ghi kết quả (write-result) mỗi chu kỳ — lệnh THUA phải đợi thêm.
  • ROB (Reorder Buffer) — bộ đệm giữ MỌI lệnh đang "bay" theo ĐÚNG thứ tự chương trình; dù các lệnh hoàn tất TÍNH TOÁN ngoài thứ tự, commit (ghi chính thức vào thanh ghi kiến trúc) luôn lấy từ ĐẦU ROB — đảm bảo trạng thái kiến trúc cuối cùng giống hệt chạy tuần tự.
Issue đúng thứ tự RS cộng (chờ toán hạng) RS nhân (chờ toán hạng) đủ 2 toán hạng là chạy, bất kể thứ tự Đơn vị chức năng thực thi CDB — bus dùng chung, mỗi chu kỳ chỉ 1 kết quả được broadcast ROB — giữ mọi lệnh đang bay, ĐÚNG thứ tự chương trình Commit từ ĐẦU ROB — đúng thứ tự hoặc squash: xoá sạch, như chưa từng chạy RS nghe lỏm CDB chỗ trong ROB đặt ngay lúc issue
Chỉ hai đầu là đúng thứ tự chương trình — Issue và Commit. Toàn bộ khúc giữa được phép lộn xộn tuỳ ý, và đó chính là nơi tốc độ sinh ra.
tomasulo_cycle.js (trích engine dùng chung cpu-core.js)
// Every cycle, IN THIS ORDER: Commit -> Write-result (1 broadcast per cycle)
// -> decrement `remaining` on the executing RSs -> WAITING->EXECUTING once the
// operands have arrived -> Issue 1 new instruction (renaming dest via the RAT).
function runTomasulo(instructions, opts) {
  // ... see cpu-core.js for the full version - this is the per-cycle main loop ...
  // 1. COMMIT the head of the ROB if it is ready
  // 2. WRITE-RESULT: only 1 broadcast per cycle, LOWER robIndex wins (older first)
  // 3. Decrement `remaining` on the EXECUTING reservation stations
  // 4. WAITING -> EXECUTING once Qj and Qk are both null (operands arrived via CDB)
  // 5. ISSUE 1 instruction: RAT[dest] = the new robIndex (THE RENAME)
}
// Verified: the program MUL R1 / ADD R2 / SUB R1 (WAR on R2, WAW on R1)
// -> totalCycles=9, ipc=3/9=0.333, final R1=18 (from SUB, thanks to in-order commit)
⚠️ Cạm bẫy: ROB đầy gây "ROB Stall"
ROB có DUNG LƯỢNG HỮU HẠN — nếu một lệnh bị kẹt lâu (vd chờ dữ liệu từ bộ nhớ chậm — sẽ học ở Bài 7), nó chiếm giữ đúng vị trí ĐẦU ROB (vì commit phải đúng thứ tự) khiến MỌI lệnh SAU nó, dù đã tính toán xong từ lâu, cũng không thể commit và phải "xếp hàng" trong ROB. Khi ROB đầy, CPU buộc phải NGỪNG phát lệnh mới hoàn toàn (ROB stall) — dù có hàng chục đơn vị chức năng đang rảnh rỗi. Đây là lý do CPU hiện đại đầu tư ROB rất lớn (hàng trăm entry) nhưng vẫn không tránh khỏi stall khi gặp lệnh trễ cực lâu (vd cache miss xuống tận DRAM).

4. Tính toán IPC thực tế & hiệu quả thuật toán

Trước hết là đơn vị đo. IPC (Instructions Per Cycle — số lệnh hoàn tất trên mỗi chu kỳ) chính là nghịch đảo của CPIBài 4 đã dùng: \(\text{IPC} = 1/\text{CPI}\). Hai cách nói cùng một điều, chỉ khác chiều tốt — CPI càng thấp càng tốt, IPC càng cao càng tốt. Với CPU vô hướng lý tưởng, IPC tối đa là 1; superscalar sinh ra để vượt qua mốc đó. Ở quy mô 3 lệnh của các ví dụ dưới đây, IPC còn thấp vì chi phí khởi động pipeline chiếm phần lớn — điều đáng so sánh là IPC giữa các phiên bản với nhau, chứ không phải giá trị tuyệt đối.

Verified thật bằng engine: chương trình 3 lệnh MUL R1,R2,R3 / ADD R2,R4,R5 (WAR trên R2) / SUB R1,R6,R7 (WAW trên R1) — đổi tên giải quyết cả 2 phụ thuộc giả, chạy đúng 9 chu kỳ, IPC = $3/9 = \mathbf{0,333}$. R1 cuối cùng = 18 (từ SUB, đúng thứ tự chương trình nhờ in-order commit — KHÔNG phải 12 từ MUL bị ghi đè, đúng ngữ nghĩa WAW "ai ghi sau thắng"). Đáng chú ý: ADD (instr2) hoàn tất TÍNH TOÁN (writeback chu kỳ 5) TRƯỚC MUL (instr1, writeback chu kỳ 6) — hoàn thành NGOÀI thứ tự — nhưng vẫn commit ĐÚNG thứ tự chương trình (chu kỳ 7, 8, 9 cho instr1, instr2, instr3).

tomasulo_schedule_table.txt (bảng lập lịch THẬT, verified bằng engine)
Instruction         Issue  ExecStart  Writeback  Commit
MUL R1, R2, R3        1        2          6          7
ADD R2, R4, R5        2        3          5          8
SUB R1, R6, R7        3        4          7          9

# ADD (writeback=5) finishes BEFORE MUL (writeback=6) - completion is OUT OF ORDER.
# But the commits still follow program order: 7, 8, 9 (MUL, ADD, SUB).
# 9 cycles in total, IPC = 3/9 = 0.333.

Đối chứng: CÙNG 3 phép tính đó nhưng viết thành chuỗi RAW THẬT (mỗi lệnh dùng kết quả lệnh ngay trước) — mất tới 11 chu kỳ, IPC chỉ $3/11 = \mathbf{0,273}$ — chậm hơn hẳn vì phụ thuộc thật KHÔNG có cách nào loại bỏ, đúng pitfall Mục 1.

ipc_comparison.js (đối chứng WAR/WAW vs RAW thật, dùng đúng engine)
// Program A: MUL R1 / ADD R2 (WAR) / SUB R1 (WAW) -> 9 cycles, IPC=0.333
// Program B: MUL R1 / ADD R2,R1 (real RAW) / SUB R3,R2 (real RAW)
//            -> 11 cycles, IPC=0.273 (slower, because the dependency is REAL)
// Program C: MUL R1 / ADD R4 / SUB R5 (no conflicts at all)
//            -> ALSO 9 cycles, same as program A - proof that renaming made A
//               exactly AS FAST AS having no conflict in the first place!
⚠️ Cái giá của OOO: diện tích silicon & năng lượng
Logic điều khiển OOO (bảng RAT, nhiều Reservation Station, ROB, mạng CDB broadcast tới MỌI RS mỗi chu kỳ) cực kỳ phức tạp so với pipeline vô hướng đơn giản — chiếm diện tích silicon và tiêu thụ năng lượng đáng kể, không tỷ lệ thuận với mức tăng IPC thu được (lợi ích giảm dần khi ILP sẵn có trong chương trình đã cạn — xem lại pitfall Mục 1). Đây chính là lý do các lõi tiết kiệm điện (vd nhân "LITTLE" — sẽ gặp lại ở Bài 9) thường dùng pipeline vô hướng đơn giản hơn thay vì OOO đầy đủ.

5. Vì sao phải có ROB: Ngoại lệ chính xác (Precise Exception)

Tới đây ROB đã xuất hiện hàng chục lần với vai trò "cam kết đúng thứ tự để ra đúng kết quả cuối". Nhưng nếu chỉ cần đúng kết quả cuối thì đâu cần cả một cấu trúc đắt đỏ như vậy — Mục 2 đã cho thấy đổi tên thanh ghi tự nó đủ để giữ ngữ nghĩa luồng dữ liệu. Lý do thật sự ROB tồn tại nằm ở chỗ khác, và nó chỉ lộ ra khi có chuyện bất thường xảy ra giữa chừng.

Giả sử một lệnh gây ngoại lệ (exception): chia cho 0, tràn số, hoặc phổ biến nhất — truy cập một địa chỉ chưa có trong bộ nhớ (page fault, Bài 8). CPU phải nhảy vào trình xử lý của hệ điều hành, xử lý xong rồi chạy tiếp chương trình. Muốn chạy tiếp được thì trạng thái thanh ghi lúc đó phải chính xác (precise): đúng như thể chương trình vừa chạy tuần tự tới đúng lệnh đó rồi dừng lại — không thiếu một lệnh nào trước nó, và không thừa một lệnh nào sau nó.

Nhưng CPU này thực thi NGOÀI thứ tự. Ta đã đo ở Mục 4: lệnh 2 writeback ở chu kỳ 5, lệnh 1 mãi chu kỳ 6. Vậy nếu lỗi xảy ra giữa chừng thì trạng thái thanh ghi trông ra sao? Chạy thử cả hai kiểu máy trên đúng chương trình WAR/WAW của Mục 4:

precise_exception.js (verified bằng engine — có ROB vs không ROB)
// Same program as section 4. Writeback cycles measured: [6, 5, 7]
// instr2 (ADD) finishes at cycle 5 - BEFORE instr1 (MUL) at cycle 6

// Case A: instruction 1 (MUL) faults
architecturalStateOnFault(program, { initialRegs, faultAt: 0 });
// with ROB    -> R2 = 3   (initial value: nothing has committed yet)
// without ROB -> R2 = 11  written by instr2, which has NOT run yet in program order

// Case B: instruction 2 (ADD) faults
architecturalStateOnFault(program, { initialRegs, faultAt: 1 });
// with ROB    -> R1 = 12  instr1 committed, exactly as the program requires
// without ROB -> R1 = 10  instr1 comes BEFORE the fault yet has not taken effect
⚠️ Trạng thái "không ROB" hỏng theo CẢ HAI hướng
Để ý hai kịch bản hỏng ngược nhau. Kịch bản A: một lệnh ở tương lai đã kịp ghi (R2 = 11) — trình xử lý nhìn thấy kết quả của việc chưa xảy ra. Kịch bản B: một lệnh ở quá khứ lại chưa có hiệu lực (R1 vẫn 10) — việc đã xảy ra mà chưa thấy kết quả. Trong cả hai, trạng thái thanh ghi KHÔNG tương ứng với bất kỳ thời điểm nào của chương trình tuần tự. Không phải "hơi sai một chút": nó không có nghĩa. Hệ điều hành không có cách nào chạy tiếp từ một trạng thái như vậy, nên page fault sẽ không thể phục hồi — tức là bộ nhớ ảo không thể hoạt động.

Đó chính là công việc của ROB: cho phép thực thi lộn xộn thoải mái bên trong, nhưng hiệu lực ra thanh ghi kiến trúc thì tuyệt đối theo thứ tự chương trình. Bất kỳ lúc nào cũng có một ranh giới rõ ràng — mọi thứ đã commit là quá khứ chắc chắn, mọi thứ trong ROB là tính toán chưa chính thức tồn tại.

🔍 Cùng cơ chế đó cho phép ĐOÁN SAI mà không sao — và đó là cửa vào Spectre
Bài 5 đã xây bộ dự đoán nhánh: CPU đoán hướng rẽ rồi chạy tiếp ngay chứ không chờ. Nhưng đoán thì có lúc sai — và nếu các lệnh trên nhánh sai đã kịp ghi vào thanh ghi thì không cách nào lùi lại. ROB làm cả việc này: lệnh trên nhánh đoán được nạp vào ROB và thực thi bình thường, nhưng chưa commit. Khi biết đoán sai, CPU chỉ việc xoá sạch (squash) phần ROB sau nhánh đó — như thể chưa từng chạy. Nói cách khác, cùng một cấu trúc vừa cho ngoại lệ chính xác vừa cho phép thực thi suy đoán; không có ROB thì cả OOO lẫn dự đoán nhánh đều không dùng được.

Và đây đúng là chỗ Spectre chui vào. Lệnh bị squash không để lại dấu vết trong thanh ghi — nhưng nó đã kịp làm thay đổi cache, mà cache thì không được khôi phục. Trạng thái kiến trúc sạch sẽ, trạng thái vi kiến trúc thì không. Bài 5 khai thác đúng khe hở đó, và Bài 7 sẽ cho thấy vì sao cache lại đo được từ bên ngoài.
⚠️ Giới hạn của mô hình trong bài: chỉ có phụ thuộc THANH GHI
Engine ở đây — và phần lớn cách trình bày Tomasulo trong sách — chỉ xử lý phụ thuộc qua thanh ghi. CPU thật còn phải xử lý phụ thuộc qua bộ nhớ: nếu một lệnh store chưa biết sẽ ghi vào địa chỉ nào, thì lệnh load đứng sau có được phép chạy trước không? Nếu hai địa chỉ đó trùng nhau, cho chạy trước là đọc phải dữ liệu cũ — sai. Bài toán này gọi là memory disambiguation, và CPU thật giải bằng một hàng đợi riêng (load/store queue) cộng cơ chế đoán rồi quay lui nếu đoán sai. Bài này không mô phỏng phần đó, nên đừng suy ra rằng renaming + ROB là đã đủ cho một CPU OOO hoàn chỉnh.

6. Thực hành: Bộ giả lập Tomasulo thu nhỏ tương tác

Chọn một chương trình mẫu bên dưới để xem bảng lập lịch Tomasulo THẬT (Issue/Exec Start/Writeback/Commit từng lệnh) — so sánh trực tiếp chương trình có WAR/WAW (renaming giải quyết) với chuỗi RAW thật (không giải quyết được) và chương trình không xung đột gì:

🗂️ Bộ giả lập Tomasulo (RS + CDB + ROB)

Tóm lược

  • ✅ CPU superscalar phát nhiều lệnh/chu kỳ, nhưng bị nghẽn bởi phụ thuộc dữ liệu THẬT (RAW) — verified: chuỗi RAW thật mất 11 chu kỳ so với 9 chu kỳ của phiên bản có WAR/WAW.
  • ✅ WAR/WAW là phụ thuộc GIẢ (chỉ vì trùng tên thanh ghi) — đổi tên thanh ghi (RAT) loại bỏ HOÀN TOÀN, khác hẳn RAW không thể loại bỏ.
  • ✅ Tomasulo = Reservation Station (chờ toán hạng) + CDB (broadcast, 1 lệnh/chu kỳ) + ROB (commit đúng thứ tự chương trình dù hoàn thành ngoài thứ tự).
  • ✅ Verified: R1 cuối cùng = 18 (từ SUB, lệnh ghi SAU trong chương trình) — in-order commit đảm bảo đúng ngữ nghĩa WAW dù ADD hoàn tất tính toán trước MUL.
  • ✅ Pitfall: ROB đầy (thường do một lệnh kẹt lâu ở đầu ROB) gây ROB stall, ngừng phát lệnh mới hoàn toàn.
  • ✅ Lý do thật sự cần ROB là ngoại lệ chính xác: verified, khi lệnh 1 gây lỗi, máy có ROB giữ R2 = 3 (chưa lệnh nào commit) còn máy không ROB đã để R2 = 11 — giá trị của một lệnh CHƯA được phép chạy.
  • ✅ Verified hướng ngược lại: lỗi ở lệnh 2 thì máy không ROB lại để R1 = 10, tức lệnh 1 nằm TRƯỚC lỗi mà chưa có hiệu lực. Trạng thái không khớp bất kỳ thời điểm nào của chương trình, nên không thể chạy tiếp.
  • ✅ Cùng cơ chế đó cho phép squash nhánh đoán sai — nên ROB là điều kiện cần của cả OOO lẫn dự đoán nhánh (Bài 5), và là lý do Spectre chỉ để lại dấu vết trong cache chứ không trong thanh ghi.

Trắc nghiệm ôn tập

Câu 1

Vì sao CPU superscalar không tự động tăng tốc MỌI chương trình lên gấp nhiều lần?

Câu 2

Đổi tên thanh ghi (register renaming) giải quyết được loại phụ thuộc nào, và KHÔNG giải quyết được loại nào?

Câu 3

Verified: trong chương trình WAR/WAW, ADD (instr2) writeback ở chu kỳ 5, TRƯỚC MUL (instr1, chu kỳ 6) — nhưng ADD lại commit ở chu kỳ 8, SAU MUL (chu kỳ 7). Vì sao?

Câu 4

"ROB Stall" xảy ra khi nào, và vì sao nó dừng CPU dù nhiều đơn vị chức năng vẫn rảnh?

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 6 vừa thêm runTomasulo(), architecturalStateOnFault(), evalInOrder() — mô phỏng chu kỳ-chính-xác thuật toán Tomasulo (Reservation Station, CDB, ROB, đổi tên thanh ghi), 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 5: Dự Đoán Nhánh & Lỗ Hổng Bảo Mật Spectre Bài 7: Phân Cấp Bộ Nhớ & Kiến Trúc Cache Quay lại Lộ trình Kiến Trúc Máy Tính

Bình luận