CPU hiện đại có những lệnh chuyên dụng làm trong một nhịp thứ mà mã C ngây thơ phải làm bằng cả một vòng lặp: đếm số bit 1 (CNT), đếm số bit 0 đầu chuỗi (CLZ), đảo thứ tự byte (REV). Trình biên dịch phơi chúng ra qua các hàm builtin (__builtin_popcount, __builtin_clz, __builtin_bswap), và qua intrinsics cho lệnh SIMD. Lời khuyên thường gặp: "muốn nhanh thì dùng builtin/intrinsics, đừng viết C tay". Tôi vào đo xem lời khuyên đó đúng tới đâu — và thấy nó đúng một nửa, sai một nửa, tùy phép.

Builtin và nhận mẫu

Builtin ánh xạ thẳng sang một lệnh

Một builtin không phải hàm thư viện; nó là một ký hiệu mà compiler thay thẳng bằng lệnh máy tương ứng. Trong container gcc:13 (ARM AArch64), objdump xác nhận:

__builtin_popcountll(x)  ->  cnt   (đếm bit 1, qua thanh ghi NEON)
__builtin_clzll(x)       ->  clz   (đếm bit 0 đầu chuỗi)
__builtin_bswap64(x)     ->  rev   (đảo byte)

Mỗi builtin gói một khả năng phần cứng vào một cái tên dễ đọc, thay cho một đoạn mẹo bit khó hiểu. Câu hỏi thực tế: nếu tôi không dùng builtin mà viết tay logic tương đương, compiler có sinh ra cùng lệnh không? Đây là chỗ đo hớ.

Đo: popcount thắng lớn, bswap thì hòa

Popcount viết tay. Cách kinh điển đếm bit 1 là vòng Kernighan: while(x){ x &= x-1; c++; } — mỗi vòng xóa bit 1 thấp nhất, lặp đúng bằng số bit 1. Đây là một vòng lặp phụ thuộc dữ liệu: với số 64-bit ngẫu nhiên (khoảng 32 bit 1), nó chạy ~32 vòng. So với __builtin_popcountll (một lệnh cnt):

popcount viết tay (Kernighan) : 0,502 ns/số
__builtin_popcountll          : 0,283 ns/số   -> builtin nhanh 1,8 lần

Builtin nhanh gần gấp đôi. Không có gì lạ: một lệnh cnt cố định thắng một vòng lặp phải quay 32 lần và phụ thuộc dữ liệu. Đây là ca builtin đáng dùng — phép mà C không có cách viết tự nhiên nào ánh xạ thẳng sang lệnh.

Byte-swap viết tay. Giờ thử đảo byte bằng shift và mask, cách ai cũng từng viết:

uint32_t bswap(uint32_t x){
  return ((x>>24)&0xff) | ((x>>8)&0xff00) | ((x<<8)&0xff0000) | ((x<<24)&0xff000000);
}

Tôi đoán nó sẽ chậm hơn __builtin_bswap32 (một lệnh rev). Đo ra:

bswap viết tay (shift/mask) : 0,257 ns/số
__builtin_bswap32           : 0,262 ns/số   -> BẰNG nhau

Bằng nhau! Và objdump giải thích vì sao: bản viết tay bằng shift/mask cũng biên dịch thành đúng một lệnh rev. gcc nhận ra cái mẫu — nó thấy chuỗi shift/mask đó chính là một byte-swap và thay bằng lệnh chuyên dụng, y hệt như builtin. Đây gọi là nhận mẫu (idiom recognition): compiler hiện đại có cả một thư viện mẫu mã thông dụng và tự thay chúng bằng lệnh tối ưu.

Vậy đo hớ của tôi được sửa: "phải dùng builtin mới nhanh" chỉ đúng khi phép không có mẫu C tự nhiên để compiler nhận (popcount). Với những phép có mẫu quen (byte-swap, và nhiều phép bit khác), viết C rõ ràng đã cho đúng lệnh tối ưu — builtin không nhanh hơn, chỉ ngắn gọn và rõ ý hơn.

Một builtin còn để đúng, không chỉ để nhanh

Có một loại builtin đáng nói riêng, vì nó không chỉ nhanh mà còn đúng: __builtin_add_overflow (và các anh em sub/mul). Ở bài hành vi không xác định ta đã đo cảnh: viết kiểm tra tràn thủ công if(a+100 < a) bị compiler xóa sạch, vì tràn số nguyên có dấu là hành vi không xác định nên compiler giả định nó không xảy ra. Cách kiểm tràn đúng là dùng builtin:

int s;
if(__builtin_add_overflow(a, b, &s)) { /* đã tràn — xử lý an toàn */ }

Builtin này biên dịch thành một phép cộng rồi đọc thẳng cờ tràn của CPU — luôn đúng, không dựa vào hành vi không xác định nào để compiler có cớ xóa. Đây là điểm khác biệt quan trọng: một số builtin tồn tại không phải để đổi một vòng lấy một lệnh cho nhanh, mà để diễn đạt một phép mà C thuần không có cách viết an toàn. Với chúng, builtin là lựa chọn đúng đắn chứ không phải tối ưu vi mô.

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

Hệ quả đầu tiên: dùng builtin khi phép khó nhận, để chắc chắn và rõ ý. __builtin_popcount, __builtin_clz, __builtin_ctz là những phép mà cách viết tay hoặc là vòng lặp chậm, hoặc là mẹo bit khó đọc mà compiler không phải lúc nào cũng nhận. Ở đó, builtin vừa đảm bảo ra một lệnh, vừa nói rõ ý định — __builtin_popcountll(mask) dễ hiểu hơn một vòng while xóa bit. Đây cũng là nơi thao tác bit và bitset hưởng lợi nhiều nhất.

Hệ quả thứ hai: đừng rắc intrinsics thủ công khắp nơi theo phản xạ. Với nhiều phép, compiler tự nhận mẫu và sinh cùng lệnh, nên viết C rõ ràng là đủ. Viết bằng intrinsics NEON tay đổi lại nhiều cái mất: kém di động (mã chỉ chạy trên ARM), khó đọc, và đôi khi còn chặn tối ưu của compiler — một chuỗi intrinsics viết cứng khiến compiler không dám sắp lại hay vector hóa theo cách nó muốn. Intrinsics là dao mổ cho trường hợp compiler thật sự bỏ lỡ, không phải công cụ mặc định. Một quy tắc thực dụng: viết C rõ ràng trước, kiểm objdump xem compiler đã sinh lệnh mong muốn chưa; chỉ khi nó không nhận ra mẫu và bạn đã đo thấy chậm thật, mới chạm tới builtin hay intrinsics — và ngay cả khi đó, ưu tiên builtin (di động qua nhiều kiến trúc) hơn intrinsics gắn cứng vào một tập lệnh.

Hệ quả thứ ba là bài học đo lường: "viết tay thì chậm" là giả định phải kiểm, không phải sự thật hiển nhiên. Nửa số ca tôi đo, viết C thường đã cho đúng lệnh tối ưu nhờ nhận mẫu; chỉ nửa kia (phép không mẫu) mới cần builtin. Con số mang theo: builtin ánh xạ thẳng sang một lệnh chuyên dụng (popcount→cnt, clz→clz, bswap→rev), thắng rõ ở phép không có mẫu C tự nhiên (popcount viết vòng Kernighan chậm 1,8 lần: 0,502 so 0,283 ns); nhưng với phép có mẫu quen, compiler tự nhận mẫu — đảo byte viết tay bằng shift/mask cũng ra một lệnh rev, bằng builtin (0,257 so 0,262 ns) — nên viết C rõ ràng thường đủ, builtin để dành cho phép khó nhận, và intrinsics thủ công dễ kém di động lẫn chặn tối ưu. Đo trước khi cho rằng viết tay là chậm.

Thử ba mươi giây

Viết hai hàm đếm bit 1: một bản vòng Kernighan (while(x){x&=x-1;c++;}) và một bản __builtin_popcountll(x). Biên dịch gcc -O2 -S -o - t.c và so: bản builtin có một lệnh cnt (hoặc chuỗi NEON ngắn), bản viết tay là một vòng lặp có nhãn và nhánh. Rồi viết một byte-swap bằng shift/mask và so với __builtin_bswap32 — lần này cả hai đều rút về một lệnh rev, vì gcc nhận ra mẫu. Ba mươi giây đó cho bạn thấy ranh giới: builtin cần cho phép compiler không tự nhận ra, và thừa cho phép nó đã nhận.