Bạn có thể đã thấy macro likely() / unlikely() trong mã nhân Linux hay thư viện hiệu năng cao: if(unlikely(err)). Chúng bọc __builtin_expect(cond, val), một cách báo cho trình biên dịch biết nhánh nào thường đúng. Danh tiếng của nó là "tăng tốc nhánh". Tôi vào đo xem cái lợi đó lớn tới đâu, và tìm ra nó nhỏ tới mức đáng ngạc nhiên — trong khi cái hại khi dùng sai lại lớn hơn nhiều.
Hint tĩnh khác đoán động thế nào
__builtin_expect(cond, 1) nói "cond thường đúng"; = 0 nói "thường sai". Compiler dùng thông tin đó để sắp bố cục mã: đặt đường nóng (hay chạy) vào dòng chảy thẳng liền mạch, đẩy đường lạnh (hiếm chạy) ra xa. Đây là dự đoán nhánh tĩnh — quyết định lúc biên dịch, in cứng vào bố cục lệnh.
Nó khác hai thứ ta đã gặp. Khác dự đoán nhánh động của CPU (bộ đoán nhánh học lúc chạy dựa trên lịch sử thực), và khác việc chuyển nhánh thành csel (bỏ nhánh hẳn). __builtin_expect không đổi việc CPU đoán đúng hay sai — nó chỉ đổi cách mã được xếp trong bộ nhớ.
Tôi kiểm trong container gcc:13 (ARM AArch64): một vòng có nhánh xử lý lỗi hiếm. objdump xác nhận unlikely có đổi bố cục — bản không hint để lời gọi handle_error nằm ngay trong thân vòng (dùng tbz để nhảy qua khi không lỗi), còn bản unlikely dùng tbnz nhảy tới một khối đặt sau lệnh ret, giữ thân vòng nóng thẳng không xen lời gọi lạnh. Bố cục thật sự đổi. Câu hỏi: nó có làm nhanh hơn không?
Đo: hint đúng lợi gần 0, hint sai hại 10%
Tôi đo vòng 100 triệu phần tử với nhánh lỗi hiếm (~0,1% phần tử âm), ba phiên bản:
không hint : 0,276 ns/phần tử
unlikely (đúng) : 0,275 ns/phần tử -> lợi gần 0
likely (SAI) : 0,30 ns/phần tử -> chậm ~10%
Hai dòng đầu gần như bằng nhau. Thêm unlikely đúng chỗ không làm vòng nhanh hơn đo được. Có hai lý do. Thứ nhất, bộ dự đoán nhánh động của CPU đã đoán hướng runtime hoàn hảo: với nhánh 0,1% (gần như luôn không lỗi), phần cứng học ngay và gần như không bao giờ đoán sai — cái mà __builtin_expect cố cải thiện thì phần cứng đã lo xong. Thứ hai, gcc đã có heuristic mặc định coi nhánh kiểm lỗi (if(err)) là hiếm, nên bố cục vốn đã hợp lý mà không cần hint. Bố cục có đổi (objdump cho thấy) nhưng đồng hồ không nhúc nhích, vì lợi ích bố cục i-cache chỉ đáng kể khi đường lạnh lớn — ở đây nó chỉ là một lời gọi.
Dòng thứ ba mới là điều đáng nhớ. Khi tôi cố tình hint sai — dùng likely cho nhánh thực tế hiếm — vòng chậm đi ~10%. Hint sai khiến compiler xếp đường lỗi (thực ra hiếm) thành đường thẳng fall-through, và đẩy đường không-lỗi (thực ra là 99,9% số lần) thành nhánh phải nhảy mỗi vòng. Bố cục bị pessimize: mã nóng thật sự giờ tốn một nhánh mỗi lần lặp.
Đây là đo hớ của tôi. Tôi tưởng likely/unlikely là công cụ tăng tốc; đo ra nó là một cược bất đối xứng xấu: đoán đúng gần như không được gì, đoán sai mất 10%. Với một công cụ mà mặt lợi ~0 và mặt hại thật, rắc nó khắp nơi theo cảm tính là lỗ ròng.
Vì sao hint tĩnh gần như thừa hôm nay
Đáng dừng lại ở lý do sâu xa khiến unlikely đúng không giúp gì. Dự đoán nhánh tĩnh — đoán một lần, in cứng vào mã — từng quan trọng ở thời CPU chưa có bộ đoán nhánh động tốt: khi ấy hướng bạn khai vào mã là tất cả những gì phần cứng biết. Nhưng CPU hiện đại có một bộ đoán nhánh động tinh vi: nó ghi lịch sử mỗi nhánh, nhận ra cả những mẫu lặp phức tạp, và với một nhánh lệch mạnh như 0,1% nó đạt độ chính xác gần như tuyệt đối chỉ sau vài lần lặp đầu. Khi phần cứng đã đoán đúng hướng gần 100%, việc bạn khai hướng đó vào mã tĩnh chẳng thêm được gì — thông tin đó đã có, và có chính xác hơn, ngay trong silicon.
Cái __builtin_expect còn đóng góp chỉ là bố cục (đặt khối nào trước, khối nào sau), không phải hướng đoán. Và bố cục chỉ thành tiền khi nó thay đổi đáng kể lượng i-cache mà đường nóng chiếm — điều chỉ xảy ra khi đường lạnh đủ lớn. Với một if nhỏ, cả hai bố cục nằm gọn trong cùng vài dòng cache, nên đổi qua đổi lại không đổi gì. Đó là vì sao đồng hồ của tôi phẳng dù objdump cho thấy mã đã sắp khác.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: đừng rắc likely/unlikely theo cảm tính. Với nhánh lệch mạnh, bộ dự đoán động của CPU đã xử lý hướng runtime tốt hơn bất kỳ phỏng đoán tĩnh nào của bạn, và gcc đã tự đoán các nhánh lỗi thông thường. Bạn thêm hint đúng thì lời gần bằng không; thêm hint sai thì lỗ. Xác suất bạn đoán sai xu hướng thật của một nhánh (nhất là khi mã tiến hóa và phân bố dữ liệu đổi) là đủ cao để nước cờ này thường là tiêu cực.
Hệ quả thứ hai: để PGO lo việc này. Tối ưu theo hồ sơ chạy đo thực tế nhánh nào nóng rồi sắp bố cục chính xác — nó làm đúng cái __builtin_expect cố làm, nhưng dựa trên số liệu thật thay vì phỏng đoán, và tự cập nhật khi bạn thu hồ sơ lại. Một hint tay viết năm ngoái có thể đã lỗi thời khi dữ liệu đổi; một hồ sơ PGO thì luôn phản ánh hiện tại.
Hệ quả thứ ba là nơi __builtin_expect thật sự đáng: khi đường lạnh lớn và bạn chắc chắn nó hiếm. Trong mã nhân hệ điều hành hay thư viện lõi, một nhánh xử lý lỗi có thể kéo theo cả trăm dòng mã; đẩy khối đó ra khỏi đường nóng giữ i-cache của đường nóng gọn, và ở quy mô đó lợi ích bố cục là thật. Đó là lý do unlikely() đầy rẫy trong nhân Linux — mã hệ thống với đường lỗi nặng — chứ không phải vì nó tăng tốc mọi if. Con số mang theo: __builtin_expect (likely/unlikely) đổi bố cục mã (đẩy đường lạnh ra xa) nhưng hint đúng cho lợi gần 0 trên CPU hiện đại (0,275 so 0,276 ns — bộ dự đoán nhánh động đã lo hướng, gcc đã tự đoán nhánh lỗi), trong khi hint SAI làm chậm ~10% (0,30 so 0,275) — một cược bất đối xứng xấu; để PGO đo hồ sơ thật lo bố cục nhánh, chỉ tự tay hint ở đường lạnh lớn trong mã nhân/hạt nhân. Đừng đoán xu hướng nhánh; đo nó, hoặc để phần cứng và PGO lo.
Thử ba mươi giây
Viết một vòng có nhánh hiếm (if(a[i]<0) handle(a[i]); với ~0,1% phần tử âm), đo min qua nhiều lần. Rồi bọc điều kiện bằng __builtin_expect((a[i]<0), 0) và đo lại: bạn sẽ thấy thời gian gần như không đổi — bộ dự đoán động đã lo phần đó. Giờ thử điều ngược lại: bọc bằng __builtin_expect((a[i]<0), 1) (báo sai rằng nhánh lỗi hay đúng) và đo: lần này vòng chậm đi vài phần trăm, vì bạn vừa bảo compiler xếp bố cục ngược. Ba mươi giây đó cho bạn thấy likely/unlikely không phải nút tăng tốc, mà là một lời khẳng định có thể phản đòn khi sai.