Hình dung bạn ghi một mẩu nhắc cho chính mình giữa lúc làm việc: một con số nháp, cộng vài thứ, xong việc thì vứt. Bạn không cần đăng ký địa chỉ, không cần tủ hồ sơ — nó chỉ sống trong đầu bạn vài giây. Nhưng nếu bạn phải gửi mẩu giấy đó cho người khác, mọi chuyện đổi: giờ nó cần một phong bì thật, một địa chỉ thật, một nơi lưu thật. Đối tượng cục bộ trong một hàm cũng vậy — chừng nào không ai bên ngoài cần "địa chỉ" của nó, compiler chẳng buồn cấp cho nó ô nhớ nào. Và điều đó dẫn tới một chuyện làm tôi ngồi thẳng dậy khi đọc mã máy.
Bạn khai một struct cục bộ trong hàm, điền vài trường, tính một kết quả rồi trả về con số đó. Trực giác nói struct ấy chiếm chỗ trên stack, tốn vài lệnh ghi/đọc bộ nhớ. Và nếu bạn malloc nó thì chắc chắn tốn một chuyến vào bộ cấp phát heap. Tôi đo hai giả định đó trong container gcc:13 (ARM) — và thấy trình biên dịch làm biến mất cả struct lẫn cả malloc, khi nó chứng minh được một điều: đối tượng không thoát khỏi hàm.
Thoát là gì, và vì sao nó quyết định chi phí
Phân tích thoát (escape analysis) là việc compiler xét một đối tượng có "thoát" khỏi phạm vi hàm không — tức có bị trả về, gán vào biến toàn cục, hay bị lấy địa chỉ (&) truyền cho một hàm giữ tham chiếu. Nếu đối tượng không thoát, không ai bên ngoài hàm cần địa chỉ của nó, nên compiler được tự do: đặt nó trong thanh ghi thay vì bộ nhớ (kỹ thuật SROA — thay một tổng hợp bằng các vô hướng), hoặc bỏ hẳn cấp phát. Nếu nó thoát, địa chỉ phải hợp lệ cho bên ngoài, nên compiler buộc đặt nó thật trên stack (hoặc heap). Chính là mẩu nhắc-trong-đầu so với lá thư-phải-gửi.
Tôi kiểm với một struct ba trường long, dùng theo ba cách.
Đo: struct và malloc đều biến mất khi không thoát
Hàm noescape tạo struct cục bộ, điền x=a, y=2a, z=3a, trả về tổng. objdump cho thấy toàn bộ thân hàm là:
noescape:
add x0, ... (a + 2a + 3a)
lsl x0, ... (dịch)
ret
Không một lệnh ghi/đọc bộ nhớ nào. Struct hoàn toàn biến mất — SROA biến ba trường thành ba giá trị trong thanh ghi, rồi gấp hằng số thành 6*a. Trừu tượng "struct" ở đây tốn đúng số không byte bộ nhớ.
Còn heaped thì malloc struct, điền, tính tổng, free. Chắc chắn phải có malloc/free chứ? objdump:
heaped:
add x0, ...
lsl x0, ...
ret
Y hệt noescape. gcc đã xóa sạch cả malloc lẫn free: nó chứng minh vùng heap đó không thoát và không có tác dụng phụ quan sát được, nên loại bỏ hoàn toàn cấp phát (allocation elimination) — đúng cơ chế khử mã chết nhưng áp lên cả một chuyến vào bộ cấp phát. Thời gian xác nhận:
noescape (SROA, thanh ghi) : 0,675 ns/gọi
heaped (malloc/free bị xóa): 0,679 ns/gọi -> bằng noescape
Cấp phát heap mà không tốn gì, vì nó không hề xảy ra.
Giờ đến escaped: cùng struct, nhưng tôi truyền &p cho một hàm mờ đục (compiler không thấy thân). Lấy địa chỉ và giao cho bên ngoài nghĩa là đối tượng thoát — địa chỉ phải trỏ tới bộ nhớ thật. objdump giờ có stp/str ghi struct lên stack và một bl gọi hàm kia. Thời gian:
escaped (&p thoát -> stack) : 1,191 ns/gọi -> chậm 1,76 lần
Đây là chỗ tôi nhầm. Tôi tin "khai biến local là chắc chắn tốn ô nhớ stack, malloc thì tất nhiên tốn heap". Đo ra ngược: khi không thoát, compiler bỏ hẳn vùng nhớ — struct thành thanh ghi, và cả malloc/free cũng bốc hơi. Cái đắt không phải bản thân việc khai biến hay cấp phát, mà là lấy địa chỉ (&) hay trả con trỏ khiến đối tượng thoát. Chính hành động cho bên ngoài một tham chiếu — dán tem gửi đi — buộc compiler đặt đối tượng thật vào bộ nhớ.
Vì sao SROA nối với cấp phát thanh ghi
Chuyện struct tan vào thanh ghi nối thẳng với cấp phát thanh ghi và tràn stack. SROA (scalar replacement of aggregates) tách một struct không thoát thành các biến vô hướng rời — p.x, p.y, p.z trở thành ba giá trị độc lập — rồi giao cho bộ cấp phát thanh ghi xử lý như bất kỳ biến nào. Từ lúc đó, chúng được gấp hằng, khử biểu thức chung, và nếu đủ ít thì nằm gọn trong thanh ghi không bao giờ chạm bộ nhớ.
Nhưng ràng buộc "đủ ít" vẫn còn: nếu struct to (nhiều trường) hoặc hàm có nhiều biến sống cùng lúc, các vô hướng sau SROA có thể vẫn phải tràn xuống stack như mọi biến khác. Escape analysis mở cơ hội giữ trong thanh ghi; bộ cấp phát thanh ghi mới quyết định có giữ được không. Hai tối ưu bắt tay nhau: cái trước chứng minh đối tượng không cần địa chỉ bộ nhớ, cái sau tìm chỗ trong thanh ghi cho nó.
Về đến code hằng ngày
Điều đầu tiên: đừng ngại tạo struct tạm cục bộ vì sợ chi phí. Một struct điểm { double x, y; } trả về từ hàm, một tuple tạm để gom giá trị, một đối tượng nhỏ dùng rồi bỏ — nếu chúng không thoát, compiler giữ trong thanh ghi và chúng gần như miễn phí. Viết code rõ ràng với các kiểu nhỏ có nghĩa; đừng "tối ưu" bằng cách nhồi mọi thứ vào biến rời rạc cho "khỏi tốn struct". Trừu tượng zero-cost là có thật — với điều kiện không thoát.
Điều thứ hai: cẩn thận với & và trả con trỏ — chúng làm đối tượng thoát. Ngay khi bạn lấy địa chỉ một biến cục bộ và truyền cho một hàm compiler không thấy thân (một hàm ở file khác chưa nội tuyến, một callback), compiler phải giả định địa chỉ đó bị giữ, và đối tượng buộc nằm trong bộ nhớ — mất SROA, mất gấp hằng. Đây cũng là lý do nội tuyến mạnh: khi thấy thân hàm nhận con trỏ, compiler có thể chứng minh con trỏ không thật sự thoát và cứu lại tối ưu.
Và đây là chỗ đáng nhớ nhất, vì trong các ngôn ngữ có bộ thu gom rác thì escape analysis là một vũ khí lớn hơn hẳn C. Go dùng nó làm quyết định trung tâm: go build -gcflags=-m sẽ in cho bạn đúng dòng "escapes to heap" hay "does not escape" từng biến — biến không thoát nằm trên stack, né hoàn toàn GC; đây là một trong những mẹo hiệu năng Go được nhắc nhiều nhất. JVM (HotSpot) làm y hệt với scalar replacement: một đối tượng chứng minh không thoát được tách thành các trường trong thanh ghi và không bao giờ cấp phát trên heap — nghĩa là không tạo áp lực GC, không phải dọn sau này. Đây là lý do một vòng Java tạo hàng triệu object nhỏ tạm vẫn có thể chạy nhanh bất ngờ: JIT xóa hẳn phần lớn cấp phát. Ngược lại, chính vì thế mà "cho địa chỉ thoát ra ngoài" (lưu tham chiếu vào field, đưa vào collection, trả ra ngoài) là thứ thật sự tốn — ở đó nó không chỉ tốn vài lệnh mà buộc một lần cấp phát heap và một lần GC tương lai. Mẫu số chung: chi phí của một đối tượng không nằm ở lúc bạn tạo nó, mà ở việc nó có phải sống ngoài phạm vi hiện tại không — giữ đối tượng cục bộ và đừng để tham chiếu rò ra ngoài là cách rẻ nhất, ở mọi ngôn ngữ.
Muốn tự thấy thì viết hai hàm dùng một struct cục bộ ba trường, tính rồi trả về một long. Bản một chỉ dùng struct nội bộ; bản hai truyền &struct cho một hàm extern (compiler không thấy thân). Biên dịch gcc -O2 -S -o - t.c và so: bản một sẽ không có lệnh str/ldr chạm stack cho struct (nó tan vào thanh ghi), còn bản hai có stp/str ghi struct lên stack trước khi gọi. Thử thêm một bản malloc struct rồi free ngay trong hàm mà không cho địa chỉ thoát — bạn sẽ thấy gcc xóa luôn cả malloc/free. Ba mươi giây đó cho bạn thấy ranh giới thật của chi phí trừu tượng: không phải ở việc tạo, mà ở việc để nó thoát.