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 vào đo hai giả định đó — 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).
Tôi kiểm trong container gcc:13 (ARM AArch64) 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à đo hớ của tôi. 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 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ì sao điều này quan trọng khi lập trình
Hệ quả đầ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.
Hệ quả 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.
Hệ quả thứ ba mở sang các ngôn ngữ có GC: escape analysis là vũ khí lớn hơn nhiều ngoài C. Trong Go, Java, C#, đối tượng mặc định nằm trên heap và bị bộ thu gom rác (GC) dọn. Escape analysis chứng minh một đối tượng không thoát rồi đặt nó lên stack — né hoàn toàn heap và GC, giảm áp lực bộ nhớ khổng lồ. Ở đó nó không chỉ tiết kiệm vài lệnh mà tránh cả một chuyến cấp phát và một lần dọn rác sau này. Con số mang theo: escape analysis cho phép compiler bỏ vùng nhớ của đối tượng KHÔNG thoát — struct local thành thanh ghi (SROA gộp thành 6*a, chỉ add/lsl/ret), và cả malloc/free của đối tượng không thoát cũng bị xóa sạch, sinh mã y hệt và cùng tốc độ (0,675 so 0,679 ns); nhưng chỉ cần lấy địa chỉ (&p) truyền cho hàm mờ đục là nó thoát, buộc nằm trên stack, chậm 1,76 lần (1,191 ns) — cái đắt là ESCAPE, không phải việc khai biến hay cấp phát. Tạo trừu tượng nhỏ thoải mái; chỉ dè chừng khi bạn trao địa chỉ của nó ra ngoài.
Thử ba mươi giây
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.