Mấy bài vừa rồi ta thấy compiler làm đủ thứ phép màu: inline, devirtualize, xoá mã chết, xoá kiểm biên. Nhưng tất cả xảy ra ở đâu? Câu trả lời là SSA — Static Single Assignment, biểu diễn trung gian mà compiler Go dùng để "suy nghĩ" về code. Và điều tuyệt vời: bạn xem được từng bước bằng một biến môi trường. Bài này mổ xẻ SSA bằng công cụ thật, đi qua các pha, và thấy một hàm co lại thế nào.

SSA là gì và vì sao "single assignment"

SSA là dạng biểu diễn code mà mỗi biến chỉ được gán đúng một lần. Thay vì x bị ghi đè nhiều lần, mỗi lần gán tạo một "value" mới (v1, v2...). Điều này khiến compiler dễ suy luận: mỗi value có một nguồn duy nhất, nên phân tích luồng dữ liệu (biến này từ đâu ra, có ai dùng không) trở nên đơn giản. Đó là nền tảng cho mọi tối ưu.

SSA của Go dùng các "op" có kiểu và độc lập kiến trúc ở giai đoạn đầu:

Arg        // tham số a, b
Add64      // phép cộng 64-bit (generic, chưa gắn CPU cụ thể)
Mul64      // phép nhân 64-bit
Const64    // hằng số
MakeResult // giá trị trả về

Chỉ đến pha lower gần cuối, các op generic này mới đổi thành lệnh CPU thật (ADD, MUL của arm64/amd64).

Ảnh chụp đoạn mã Go nền tối minh hoạ SSA xem compiler Go suy nghĩ về code, SSA Static Single Assignment là biểu diễn trung gian nơi mọi tối ưu diễn ra GOSSAFUNC vẽ từng pha ra HTML, một hàm ví dụ có nhiều thứ để tối ưu func tinh a b int x bằng a cộng b y bằng a cộng b biểu thức trùng CSE gộp z bằng x nhân 2 nhân 2 strength reduction thành dịch bit if false nhánh chết DCE xoá z cộng bằng 1000 return y cộng z, hai xem SSA một dòng lệnh GOSSAFUNC bằng tinh go build ssa.go dumped SSA for tinh to ssa.html mở ssa.html mỗi cột là một pha thấy code biến đổi qua từng bước công cụ số một để hiểu vì sao code sinh ra như vậy, ba SSA dùng op có kiểu độc lập kiến trúc Arg tham số a b Add64 phép cộng 64-bit generic chưa gắn CPU Mul64 phép nhân 64-bit Const64 hằng số MakeResult giá trị trả về mỗi biến chỉ được gán đúng một lần Static Single Assignment mỗi value có một nguồn duy nhất pha lower mới đổi op generic thành lệnh CPU thật ADD MUL của arm64, bốn chuỗi pha một phần trong 50 pha generic cse gộp biểu thức trùng a cộng b opt deadcode xoá mã chết if false prove chứng minh biên bỏ kiểm tra thừa nilcheckelim bỏ kiểm nil thừa lower op generic thành lệnh CPU cụ thể regalloc gán value vào thanh ghi

Hình 1: Hàm ví dụ và cách xem SSA. GOSSAFUNC=tinh sinh ssa.html — mỗi cột một pha. SSA dùng op có kiểu (Add64, Mul64...) rồi lower thành lệnh CPU.

Xem SSA: một dòng lệnh

Công cụ then chốt là biến môi trường GOSSAFUNC:

GOSSAFUNC=tinh go build ssa.go
# -> dumped SSA for tinh to ./ssa.html

Mở ssa.html, bạn thấy một bảng: mỗi cột là một pha compiler, hiển thị SSA của hàm sau pha đó. Bạn thấy tận mắt code biến đổi qua từng bước — value nào bị gộp, nhánh nào bị xoá, op generic đổi thành lệnh CPU lúc nào. Đây là công cụ số một để hiểu vì sao compiler sinh ra mã như vậy.

50 pha, và pha nào đắt nhất

Đo thật trên hàm tinh, compiler chạy 50 pha SSA tuần tự. Các pha tốn thời gian nhất:

Ảnh chụp bảng kết quả đo thật nền tối 50 pha SSA biến hàm thành 3 lệnh GOSSAFUNC bằng tinh dump HTML cộng go build gcflags trừ S Go 1.23 arm64 10 core, sáu pha SSA tốn thời gian nhất trong 50 pha regalloc 5542 ns gán value vào thanh ghi đắt nhất lower 4875 ns op generic thành lệnh CPU cụ thể opt 3667 ns tối ưu tổng quát generic cse 2708 ns gộp biểu thức chung sccp 2083 ns lan truyền hằng có điều kiện expand calls 2042 ns khai triển lời gọi tổng 50 pha chạy tuần tự trên SSA của mỗi hàm regalloc và lower đắt nhất vì phải quyết định thanh ghi và ánh xạ sang kiến trúc thật, kết quả hàm tinh 2 cộng cộng 1 nhân cộng nhánh chết thành 3 lệnh ADD R0 R1 R1 R1 bằng a cộng b CSE tính một lần y bằng a cộng b bị gộp ADD R1 dịch trái 1 R1 R0 R0 bằng a cộng b cộng a cộng b nhân 2 nhân 2 thành dịch bit RET CSE gộp hai a cộng b thành một DCE xoá sạch if false strength reduction đổi nhân 2 thành R1 dịch trái 1 rồi gộp vào một ADD không MUL không nhánh chỉ 2 phép cộng, cốt lõi SSA biểu diễn trung gian mỗi biến gán đúng một lần xem bằng GOSSAFUNC bằng tinh go build ssa.html mỗi cột 1 pha số pha 50 pha chạy trên mỗi hàm op generic Add64 Mul64 Const64 rồi lower thành lệnh CPU kết quả CSE cộng DCE cộng strength reduction thành 3 lệnh

Hình 2: Sáu pha đắt nhất trong 50 pha (regalloc 5.542 ns, lower 4.875 ns...) và kết quả cuối — hàm tinh co còn 3 lệnh.

  • regalloc (5.542 ns): gán value vào thanh ghi — đắt nhất, vì bài toán phân bổ thanh ghi phức tạp.
  • lower (4.875 ns): đổi op generic thành lệnh CPU cụ thể.
  • opt (3.667 ns), generic cse (2.708 ns), sccp (2.083 ns), expand calls (2.042 ns).

Chuỗi pha gồm những cái ta đã gặp: generic cse (gộp biểu thức chung), opt deadcode (xoá mã chết), prove (chứng minh biên → bỏ kiểm tra thừa), nilcheckelim (bỏ kiểm nil thừa), rồi lower và regalloc.

Kết quả: hàm co còn 3 lệnh

Hàm tinh có 2 phép cộng (a+b hai lần), 1 phép nhân (x*2), và một nhánh chết (if false). Sau 50 pha, assembly cuối chỉ còn:

ADD  R0, R1, R1       // R1 = a+b   — CSE: tính MỘT lần (y=a+b bị gộp)
ADD  R1<<1, R1, R0    // R0 = (a+b) + (a+b)*2   — *2 thành dịch bit
RET

Ba lệnh. Ba tối ưu ta đã học hợp lực:

  • CSE (common subexpression elimination) gộp hai a+b thành một — không tính y = a+b riêng.
  • DCE xoá sạch nhánh if false.
  • Strength reduction đổi x*2 thành R1<<1 (dịch bit, rẻ hơn nhân) rồi gộp vào một lệnh ADD dùng toán hạng dịch của arm64.

Không còn lệnh MUL, không nhánh — chỉ hai phép cộng. Nhìn qua ssa.html, bạn thấy chính xác pha nào làm mỗi biến đổi này.

Ứng dụng thực tế

Hiểu vì sao một tối ưu không xảy ra. Khi bạn nghĩ code nên được tối ưu mà benchmark nói không, mở ssa.html xem SSA qua từng pha — thường lộ ra lý do (một biến escape, một lời gọi chặn inline, một điều kiện không chứng minh được). Đây là cách gỡ lỗi hiệu năng ở tầng sâu nhất.

Học cách compiler "nhìn" code. Đọc SSA vài lần thay đổi cách bạn viết code nóng — bạn bắt đầu thấy biểu thức nào gộp được, nhánh nào loại được, giá trị nào giữ trong thanh ghi. Đây là kiến thức nền cho tối ưu vi mô đúng đắn.

Đối chiếu với assembly. SSA (nhất là cột genssa cuối) gần như ánh xạ 1-1 với assembly cuối cùng. Đọc SSA thường dễ hơn assembly thô vì nó giữ tên op có nghĩa và cấu trúc khối cơ bản.

Đánh đổi cần cân nhắc

GOSSAFUNC là công cụ học/gỡ lỗi, không phải thứ dùng hằng ngày. Đọc 50 pha SSA tốn thời gian và chỉ đáng cho một hàm bạn thực sự cần hiểu sâu. Với tối ưu thường ngày, -gcflags=-m (inline/escape) và benchmark đã đủ; chỉ xuống SSA khi chúng không giải thích được.

Đừng viết code "chiều SSA" một cách mù quáng. Compiler đã rất giỏi; viết code rõ ràng thường cho SSA tốt hơn code "thông minh" vặn vẹo. Ví dụ như bài BCE cho thấy, chiêu reslice có thể phản tác dụng. Đọc SSA để hiểu, không phải để micro-optimize từng dòng.

SSA thay đổi giữa các phiên bản Go. Số pha, tên pha, và cả quyết định tối ưu đổi theo phiên bản (Go liên tục thêm pha mới như PGO, cải thiện BCE). Kiến thức SSA của một phiên bản không cố định — dùng nó để hiểu nguyên lý, và đo lại trên phiên bản bạn thực sự chạy.

Ba ý mang về

  1. SSA là biểu diễn trung gian nơi mọi tối ưu diễn ra, với đặc tính mỗi biến gán đúng một lần (single assignment) giúp compiler suy luận dễ — xem tận mắt bằng GOSSAFUNC=tinh go build sinh ssa.html, mỗi cột một pha.
  2. Compiler chạy ~50 pha SSA trên mỗi hàm: đo thật, đắt nhất là regalloc (5.542 ns, phân bổ thanh ghi) và lower (4.875 ns, đổi op generic thành lệnh CPU) — SSA dùng op có kiểu độc lập kiến trúc (Add64, Mul64) rồi mới ánh xạ sang lệnh thật.
  3. Nhiều tối ưu hợp lực co hàm lại: đo thật hàm tinh (2 cộng + 1 nhân + nhánh chết) thành 3 lệnh — CSE gộp a+b trùng, DCE xoá if false, strength reduction đổi *2 thành dịch bit; đọc SSA để hiểu vì sao, không phải để vặn vẹo code.

Phần sau ta đi tới tầng cuối cùng — mã máy: Phần sau mổ xẻ cách đọc assembly Plan 9 của Go — cú pháp lạ (toán hạng ngược, pseudo-register), cách khớp nó với code nguồn, và vì sao Go dùng cú pháp riêng này.