Bài trước ta học đọc assembly Plan 9. Bài này ta viết một hàm assembly từ đầu — khai báo, cài đặt, chạy thật. Nhưng câu hỏi quan trọng hơn "làm sao viết" là "khi nào nên viết". Và câu trả lời trung thực, có đo đạc, khác với kỳ vọng của nhiều người: với hầu hết code, viết assembly thủ công lỗ, không lãi. Bài này chỉ cả hai — cách viết đúng, và vì sao thường không đáng.

Cấu trúc: khai báo Go + cài đặt .s

Một hàm assembly gồm hai nửa. Nửa Go: khai báo hàm không có thân trong file .go — chỉ chữ ký, báo compiler biết cài đặt nằm nơi khác:

// lib.go
package main

func Cong(a, b int64) int64   // không thân → cài đặt ở lib_arm64.s

Nửa assembly: cài đặt trong file .s cùng package, tên file có hậu tố kiến trúc (_arm64.s, _amd64.s) để build đúng CPU:

// lib_arm64.s
#include "textflag.h"

// func Cong(a, b int64) int64
TEXT ·Cong(SB), NOSPLIT, $0-24
	MOVD a+0(FP), R0     // R0 = a  (tham số 1 tại offset 0)
	MOVD b+8(FP), R1     // R1 = b  (tham số 2 tại offset 8)
	ADD  R1, R0, R0      // R0 = a + b
	MOVD R0, ret+16(FP)  // ghi kết quả tại offset 16
	RET

Ảnh chụp đoạn mã Go và assembly nền tối minh hoạ viết một hàm assembly Plan 9 từ đầu, khai báo hàm không thân trong go cài đặt trong file s cùng package assembler nối hai nửa lại, một khai báo Go hàm không thân lib.go package main func Cong a b int64 int64 không thân cài đặt ở lib_arm64.s tên file s có hậu tố kiến trúc arm64 amd64 để build đúng CPU hàm không thân báo cho compiler biết cài đặt nằm ở nơi khác, hai cài đặt assembly lib_arm64.s include textflag.h định nghĩa NOSPLIT func Cong a b int64 int64 TEXT chấm giữa Cong SB NOSPLIT 0 trừ 24 MOVD a cộng 0 FP R0 R0 bằng a tham số 1 tại offset 0 MOVD b cộng 8 FP R1 R1 bằng b tham số 2 tại offset 8 ADD R1 R0 R0 R0 bằng a cộng b MOVD R0 ret cộng 16 FP ghi kết quả tại offset 16 RET chấm giữa Cong là ký hiệu dấu chấm giữa bằng tên package hiện tại 0 trừ 24 bằng frame 0 byte 24 byte tham số a 8 cộng b 8 cộng ret 8 tham số kết quả đọc ghi qua FP theo đúng offset, ba quy tắc offset FP phải khớp khai báo Go Cong a b int64 int64 a cộng 0 FP int64 bằng 8 byte b cộng 8 FP ret cộng 16 FP sau hai tham số sai offset đọc nhầm ô nhớ kết quả rác hoặc panic chạy go vet để bắt lỗi lệch offset kiểm asmdecl, bốn cờ TEXT quan trọng NOSPLIT bỏ prologue kiểm stack hàm nhỏ không gọi hàm khác frame trừ args khai kích thước frame và vùng tham số NOSPLIT chỉ an toàn khi hàm không cần thêm stack sai là tràn stack âm thầm

Hình 1: Cấu trúc một hàm assembly. ·Cong là tên package hiện tại; $0-24 là frame 0 byte, tham số 24 byte; tham số/kết quả đọc-ghi qua FP theo offset khớp khai báo Go.

Ba quy tắc phải nhớ

·Name (dấu chấm giữa) = tên package hiện tại. Ký tự · (middle dot, U+00B7) trước tên hàm nghĩa là "hàm này thuộc package đang biên dịch". TEXT ·Cong(SB) khai hàm Cong của package hiện tại.

$frame-args khai kích thước. $0-24 nghĩa là frame cục bộ 0 byte, vùng tham số 24 byte. Với Cong(a, b int64) int64: a (8) + b (8) + ret (8) = 24.

Offset FP phải khớp khai báo Go. a+0(FP), b+8(FP), ret+16(FP) — mỗi int64 là 8 byte, xếp liên tiếp, kết quả nằm sau các tham số. Sai offset là đọc nhầm ô nhớ → kết quả rác hoặc panic. Chạy go vet để bắt lỗi lệch offset (nó có bộ kiểm asmdecl đối chiếu .s với khai báo Go).

Chạy thật: nó hoạt động

Đo thật, gọi từ Go như hàm thường:

Cong(3, 4)        -> 7       // 3 + 4
NhanDoiCong1(10)  -> 21      // 10*2 + 1

Hàm assembly biên dịch và chạy đúng. Nhưng câu hỏi thật là: có đáng không?

Đo trung thực: hàm nhỏ thường lỗ

Ảnh chụp bảng kết quả đo thật nền tối hàm assembly có chạy và có đáng không go run cộng go test bench Go 1.23 arm64 10 core hàm asm thủ công vs Go thuần, chạy thật hàm asm cho kết quả đúng Cong 3 4 thành 7 3 cộng 4 NhanDoiCong1 10 thành 21 10 nhân 2 cộng 1 hàm viết bằng assembly Plan 9 biên dịch và chạy đúng gọi được từ Go như hàm thường, benchmark asm vs Go thuần cùng phép a cộng b CongAsm hàm assembly 0,956 0,949 0,963 ns CongGo a cộng b Go thuần 1,323 1,331 1,340 ns số sát nhau không nói asm thắng thật bản Go được inline biến mất vào vòng lặp còn hàm asm không bao giờ inline được mỗi lần là một lời gọi thật so sánh này không cùng điều kiện đừng kết luận asm nhanh hơn cho a cộng b, sự thật quan trọng asm không inline được hàm Go nhỏ compiler inline 0 chi phí gọi hàm asm nhỏ không inline luôn có chi phí CALL với hàm số học nhỏ viết asm thường lỗ vì mất inline compiler Go đã sinh mã tối ưu cho a cộng b bài SSA asm chỉ đáng cho thứ compiler không diễn đạt được lệnh SIMD crypto hằng thời gian lệnh CPU đặc biệt, cốt lõi viết asm go khai hàm không thân cộng s cài đặt Name SB tham số qua FP theo offset khớp khai báo Go a cộng 0 b cộng 8 ret cộng 16 go vet bắt lỗi lệch offset asmdecl sự thật asm không inline hàm nhỏ thường lỗ đáng viết chỉ khi compiler không diễn đạt được SIMD crypto

Hình 2: Hàm asm chạy đúng (7, 21). Benchmark CongAsm 0,956 ns vs CongGo 1,323 ns — số sát nhau nhưng KHÔNG so cùng điều kiện: bản Go inline, hàm asm không bao giờ inline.

Benchmark Cong (asm) với CongGo (Go thuần a+b): CongAsm 0,956 ns, CongGo 1,323 ns. Nhìn thì asm nhanh hơn — nhưng đây là so sánh không cùng điều kiện và không được kết luận "asm nhanh hơn cho a+b":

  • Bản Go CongGo được inline — nó biến mất vào vòng lặp benchmark, compiler tối ưu cả khối.
  • Hàm asm Cong không bao giờ inline được — mỗi lần gọi là một CALL thật.

Sự thật cốt lõi: hàm assembly không inline được. Với một hàm số học nhỏ, đây thường là lỗ ròng — bạn mất inline (thứ đã đo ~2,7x lợi ở bài trước) để đổi lấy... không gì cả, vì compiler Go đã sinh mã tối ưu cho a+b. Con số benchmark sát nhau ở đây là do đặc thù vòng lặp, không phản ánh lợi ích thật.

Khi nào thực sự đáng viết assembly

Assembly chỉ đáng khi bạn cần thứ compiler không diễn đạt được:

Lệnh SIMD (vector). Xử lý nhiều phần tử một lúc (AVX2, NEON) — compiler Go không tự động vector hóa mạnh, nên crypto/nén/xử lý ảnh dùng asm SIMD để nhanh gấp nhiều lần. Đây là lý do phổ biến nhất trong thư viện chuẩn (crypto/*, hash/*).

Crypto hằng thời gian. Code mật mã cần không rẽ nhánh theo dữ liệu bí mật để chống tấn công thời gian. Assembly cho kiểm soát chính xác từng lệnh, đảm bảo hằng thời gian mà code cấp cao khó bảo đảm.

Lệnh CPU đặc biệt. Đọc bộ đếm chu kỳ (RDTSC), lệnh mã hóa phần cứng (AES-NI), popcount, CRC — những lệnh mà Go không expose qua hàm chuẩn.

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

Assembly làm code không portable và khó bảo trì. Mỗi kiến trúc cần một file .s riêng (_amd64.s, _arm64.s...). Quên một kiến trúc là build lỗi ở đó. Code asm khó đọc, khó sửa, dễ sai — mỗi dòng phải tự lo đúng đắn mà compiler không kiểm hộ. Chỉ trả cái giá này khi lợi ích thật sự lớn.

Compiler ngày càng giỏi, xói mòn lợi thế asm. Nhiều tối ưu từng phải viết asm giờ compiler tự làm (PGO, autovectorization đang cải thiện). Code asm viết hôm nay có thể chậm hơn code Go sau vài phiên bản khi compiler bắt kịp — mà asm thì không tự hưởng cải tiến compiler.

Luôn đo, và luôn có bản Go dự phòng. Nếu viết asm, giữ một bản Go thuần (qua build tag) làm chuẩn đối chiếu và dự phòng cho kiến trúc chưa có asm. Đo trên phần cứng thật, trên khối lượng thật — nhiều lần "tối ưu asm" hóa ra chậm hơn bản Go khi đo đúng cách.

Ba ý mang về

  1. Viết hàm assembly = khai báo Go không thân + cài đặt trong .s cùng package: dùng ·Name(SB), đọc-ghi tham số qua FP theo offset khớp khai báo (a+0, b+8, ret+16), và chạy go vet để bắt lỗi lệch offset (asmdecl) — đo thật hàm asm chạy đúng, gọi được như hàm Go thường.
  2. Hàm assembly không bao giờ inline được, nên với hàm nhỏ thường lỗ ròng: benchmark asm vs Go sát nhau nhưng không cùng điều kiện (Go inline, asm không) — compiler đã sinh mã tối ưu cho số học đơn giản, viết asm chỉ mất inline.
  3. Assembly chỉ đáng cho thứ compiler không diễn đạt được: lệnh SIMD, crypto hằng thời gian, lệnh CPU đặc biệt — không phải để "tối ưu" số học thường; luôn giữ bản Go dự phòng và đo trên phần cứng/khối lượng thật.

Phần sau ta quay lại tầng cao hơn, tập hợp các "công tắc" điều khiển compiler đã gặp rải rác: Phần sau tổng hợp các compiler directive //go: — noinline, nosplit, noescape, linkname, embed và bạn bè — mỗi cái làm gì, khi nào dùng, và cái nào nguy hiểm.