Mỗi lần một hàm được gọi, nó cần một chỗ trên ngăn xếp để lưu địa chỉ trả về, biến địa phương, và những thanh ghi nó phải giữ gìn hộ người gọi — chỗ đó gọi là khung ngăn xếp (stack frame). Trình biên dịch chèn mã dựng khung ở đầu hàm (prologue) và mã dẹp khung ở cuối (epilogue). Bài này đo những đoạn mã đó, và tìm hiểu cờ -fomit-frame-pointer thật sự bỏ được gì — hóa ra không phải cái tôi tưởng từ cái tên của nó.

Khung ngăn xếp

Prologue dựng khung, epilogue dẹp khung

Trên aarch64, một hàm cần khung sẽ mở đầu bằng prologue đại loại:

stp  x29, x30, [sp, -32]!   ; lưu con trỏ khung (x29) và địa chỉ trả về (x30), cấp 32 byte
mov  x29, sp                ; dựng con trỏ khung trỏ vào khung này

và kết thúc bằng epilogue đối xứng: ldp x29, x30, [sp], 32 (khôi phục và trả lại chỗ) rồi ret. Hai thứ được lưu quan trọng nhất là x30 (lr) — địa chỉ để ret nhảy về — và x29 (fp)con trỏ khung, trỏ vào khung hiện tại. Con trỏ khung tạo thành một sợi xích: mỗi khung lưu con trỏ khung của khung cha, nên một công cụ có thể đi ngược sợi xích đó để dựng lại toàn bộ ngăn xếp lời gọi (backtrace).

Nhưng không phải hàm nào cũng có khung. Tôi biên dịch một hàm lá (leaf — không gọi hàm nào khác, không cần chỗ trên stack) int leaf(int a,int b){ return a*b+1; }-O2:

mul  w0, w0, w1
add  w0, w0, 1
ret

Không hề có prologue hay epilogue — không stp, không ldp, không đụng sp. Hàm chỉ làm việc rồi trả về. Khung chỉ được cấp khi thật sự cần: khi hàm gọi hàm khác (phải lưu x30), hay có biến địa phương phải nằm trên stack. Trình biên dịch quyết định điều này theo từng hàm, dựa trên việc phân tích xem hàm có làm gì cần tới stack không — nên hai hàm cùng số dòng nguồn có thể một cái có khung, một cái không. Điều này đã đặt tôi vào tâm thế sai khi tôi thử cờ -fomit-frame-pointer.

Một lần tôi đo hớ: nó bỏ con trỏ, không bỏ khung

Cái tên -fomit-frame-pointer ("bỏ con trỏ khung") tôi đọc lướt thành "bỏ khung ngăn xếp". Tôi đoán: bật cờ này thì hàm sẽ không đụng stack nữa, không còn prologue/epilogue. Để kiểm, tôi lấy một hàm có gọi hàm khác (caller gọi g hai lần, nên buộc phải lưu x30), biên dịch hai lần và so.

Với con trỏ khung (-fno-omit-frame-pointer):

stp  x29, x30, [sp, -32]!    ; lưu cả fp lẫn lr, cấp 32 byte
mov  x29, sp                 ; dựng con trỏ khung

Với -fomit-frame-pointer:

stp  x19, x20, [sp, -32]!    ; vẫn cấp 32 byte (cho các thanh ghi phải giữ)
str  x30, [sp, 16]           ; chỉ lưu địa chỉ trả về

Đo ra hoàn toàn ngược với dự đoán: khung vẫn còn nguyên. Bản -fomit-frame-pointer vẫn cấp 32 byte trên stack ([sp, -32]!), vẫn lưu địa chỉ trả về x30, vẫn lưu các thanh ghi phải giữ. Cái duy nhất biến mất là hai thứ liên quan tới con trỏ khung x29: lệnh mov x29, sp (dựng con trỏ khung) không còn, và x29 không được lưu nữa. Đổi lại, x29 giờ tự do làm một thanh ghi thường.

Bài học đo lường: cái tên cờ nói đúng, chính tôi đọc sai-fomit-frame-pointer bỏ con trỏ khung (frame POINTER, tức thanh ghi x29 và việc dựng nó), chứ không bỏ khung (frame, tức chỗ trên stack cho địa chỉ trả về và biến). Cái nó tiết kiệm rất nhỏ và rất cụ thể: một lệnh mov mỗi hàm, một lần lưu/khôi phục x29, và giải phóng một thanh ghi (x29) để bộ cấp phát dùng — hữu ích trên kiến trúc thiếu thanh ghi. Tôi đã phóng đại "bỏ con trỏ" thành "bỏ cả khung"; đọc prologue thật cho thấy khung vẫn được cấp y nguyên.

Và cái giá của việc bỏ con trỏ khung không phải zero: con trỏ khung chính là sợi xích để các công cụ lần vết ngăn xếp (perf, gdb backtrace, trình phân tích hiệu năng) đi ngược từ hàm hiện tại về hàm gọi. Bỏ nó đi thì những công cụ đó phải dựa vào thông tin gỡ rối DWARF (.eh_frame) mới lần được ngăn xếp — phức tạp hơn và không phải lúc nào cũng có. Đây là lý do bản gỡ lỗi thường giữ con trỏ khung (để backtrace dễ), còn bản phát hành tối ưu mới bỏ để lấy thêm một thanh ghi và bớt một lệnh.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên là hiểu đúng chi phí của một lời gọi hàm. Một hàm lá đơn giản gần như miễn phí (không khung). Một hàm có gọi hàm khác hoặc có biến địa phương lớn phải dựng khung — vài lệnh stp/ldp và điều chỉnh sp. Đây là một phần lý do nội tuyến (bài nội tuyến) nhanh: nó xóa luôn cả prologue/epilogue của hàm bị nội tuyến. Khi tối ưu mã nóng, biết hàm nào có khung, hàm nào không, giúp bạn hiểu chi phí thật.

Hệ quả thứ hai là chọn giữ hay bỏ con trỏ khung theo mục đích build. Bản gỡ lỗi và bản dùng để phân tích hiệu năng (profiling) nên giữ con trỏ khung (-fno-omit-frame-pointer) để backtrace và perf hoạt động chính xác — nhiều hệ thống production lớn cố ý giữ nó dù mất một chút hiệu năng, vì khả năng chẩn đoán đáng giá hơn. Bản phát hành cực tối ưu, đặc biệt trên kiến trúc ít thanh ghi như x86-32, mới bỏ để lấy lại thanh ghi. Đây là một đánh đổi chẩn đoán đổi lấy hiệu năng, không phải một tối ưu miễn phí. Trên aarch64 với 31 thanh ghi số nguyên, mất thêm một thanh ghi hiếm khi đáng kể, nên nhiều bản build hiện đại trên nền này giữ con trỏ khung mặc định; còn trên x86-32 xưa chỉ có bảy thanh ghi dùng chung, giải phóng một cái là khác biệt lớn — đó là lý do lịch sử của cờ này.

Hệ quả thứ ba là bài học đọc-tên-cẩn-thận, đúng tinh thần sê-ri: tên một cờ hay một tối ưu mô tả chính xác thứ nó làm, nhưng dễ bị đọc phóng đại. -fomit-frame-pointer không phải -fomit-frame; -funroll-loops (bài mở vòng lặp) hóa ra là chuyện song song hóa chứ không chỉ là nhánh. Con số mang theo: mỗi hàm cần khung có prologue (stp lưu fp+lr, mov dựng fp) và epilogue (ldp, ret); hàm lá không cần khung; và -fomit-frame-pointer chỉ bỏ con trỏ khung x29 — tiết kiệm một lệnh và một thanh ghi — chứ KHÔNG bỏ khung, đổi lại làm khó việc lần vết ngăn xếp. Muốn biết một cờ thật sự đổi gì, đừng đoán từ cái tên — đọc prologue nó sinh ra.

Thử ba mươi giây

Viết một hàm int f(int x){ int a[4]={x,x,x,x}; return a[x&3]; } (có mảng địa phương nên cần khung), dịch gcc -O2 -S file.c -o o1.s rồi gcc -O2 -fomit-frame-pointer -S file.c -o o2.s, và so hai file ở đầu hàm. Bạn sẽ thấy cả hai đều có sub sp hoặc stp ...[sp,-N]! cấp khung — khung vẫn còn — nhưng bản đầu có thêm mov x29, sp (hoặc mov rbp, rsp trên x86) mà bản sau không có. Đó chính là con trỏ khung bị bỏ, không phải cả khung. Chỉ hai lần đọc .s là bạn thấy đúng cái cờ đó làm gì — và vì sao đọc tên không thay được đọc mã.