Hình dung bạn thuê một đội chuyển nhà và bảo "chép hết đồ từ phòng A sang phòng B". Nếu chắc chắn A và B là hai phòng riêng, họ chất cả xe tải một chuyến, nhanh gọn. Nhưng nếu bạn không nói rõ — biết đâu A và B thật ra là cùng một phòng, đồ chồng lên nhau — đội chuyển đành làm cách an toàn nhất: bê từng thùng một, kiểm tra từng bước để không đè cái nọ lên cái kia. Cùng một yêu cầu, nhưng chỉ vì thiếu một lời cam đoan mà tốc độ chênh nhau cả chục lần. memcpy của compiler đúng là đội chuyển nhà đó, và cái lời cam đoan bị quên chính là chỗ tôi vấp.

memcpy và memset trông như hai hàm thư viện bình thường — bạn #include <string.h> rồi gọi. Nhưng với compiler, chúng không phải hàm lạ: chúng là builtin, những cái tên compiler hiểu nghĩa và được phép thay bằng mã tối ưu tại chỗ. Câu hỏi thực tế: một memcpy có thật sự là một lời gọi hàm không, và nếu tôi tự viết một vòng chép byte thì có nhanh bằng không? Tôi đo trong container gcc:13 (ARM), và cả hai kỳ vọng ban đầu của tôi đều sai.

Builtin: compiler hiểu nghĩa, thay bằng mã tại chỗ

Vì memcpy/memset là builtin, compiler biết chính xác chúng làm gì và có thể không phát ra một lời gọi hàm nào. Với kích thước là hằng số đủ nhỏ, nó chèn thẳng vài lệnh load/store vào chỗ gọi — trên AArch64 là các cặp ldp/stp với thanh ghi vector q (128-bit). Chỉ khi kích thước lớn hoặc động (không biết lúc biên dịch) thì nó mới gọi routine memcpy của libc — vốn cũng đã được tối ưu tay rất kỹ, thường nhanh hơn bất kỳ vòng byte nào bạn viết.

Compiler còn đi xa hơn: nó nhận dạng idiom. Một vòng for(i) d[i]=c (đặt cùng một giá trị) có thể được nhận ra là một memset và thay bằng mã memset nhanh; một vòng for(i) d[i]=s[i] (chép) có thể được nhận ra là memcpy. Nghĩa là ngay cả khi bạn không gọi hàm, compiler vẫn có thể biến vòng lặp thủ công của bạn thành builtin. Nếu nó thấy làm vậy an toàn — và cái chữ "nếu" đó là chỗ tôi vấp.

Đo: memcpy 2,57 ns, vòng byte tay 65 ns

Tôi chép một khối 256 byte, kích thước hằng số, lặp hàng chục triệu lần (có đọc mảng đích để chặn khử mã chết xóa mất phép chép).

chép memcpy        : 2,57 ns/lần   (0,010 ns/byte)
chép vòng byte tay : 65,24 ns/lần  (0,255 ns/byte)  -> chậm 25 lần
đặt memset         : 2,30 ns/lần
đặt vòng byte tay  : 2,30 ns/lần   (bằng nhau)

objdump giải thích ngay. Hàm gọi memcpy(d,s,256) không có một lời gọi nào — nó là tám cặp ldp q0,q1 / stp q0,q1, chép 32 byte mỗi cặp, hoàn toàn inline và vector hóa. Đây đúng là nội tuyến bỏ lời gọi cộng vector hóa hợp lại. Còn vòng byte tay for(i) d[i]=s[i] lại là ldrb/strb — chép từng byte một, scalar, không vector, không được đổi thành memcpy. Đó là 25 lần chậm hơn.

Nhưng chú ý hàng cuối: vòng byte tay để đặt (d[i]=7) lại nhanh y hệt memset — cả hai là stp q0,q0. Vậy tại sao idiom đặt được nhận ra mà idiom chép thì không? Đúng lúc này tôi phải dừng lại nghĩ.

Thủ phạm là aliasing — nỗi sợ hai con trỏ trùng nhau

Tôi vào đo với hai niềm tin, và cả hai đều lệch. Niềm tin thứ nhất: "memcpy luôn là một lời gọi hàm thư viện" — sai, nó được inline thành lệnh vector, 0 lời gọi. Niềm tin thứ hai: "vòng chép byte tự viết của tôi nhanh ngang memcpy vì compiler sẽ nhận ra nó" — cũng sai, nó chậm 25 lần.

Vì sao vòng chép không được đổi thành memcpy, trong khi vòng đặt được đổi thành memset? Câu trả lời là aliasing — đúng nỗi sợ "hai phòng có thể là một" của đội chuyển nhà. Hàm copy_byteloop(char* d, const char* s) nhận hai con trỏ char*, mà char* trong C được phép trỏ tới bất cứ đâu — kể cả d và s có thể trùng hoặc chồng lấn. Nếu chúng chồng lấn, memcpy cho kết quả sai (memcpy đòi vùng nguồn và đích không giao nhau). Compiler không thể chứng minh chúng tách rời, nên nó không dám đổi vòng thành memcpy — phải giữ vòng chép byte tuần tự an toàn. Ngược lại, vòng đặt d[i]=7 không có nguồn để trùng, không có nghi ngờ chồng lấn nào, nên compiler tự do nhận ra nó là memset.

Bằng chứng dứt điểm: thêm restrict vào hai con trỏ — copy_restrict(char* restrict d, const char* restrict s) — là lời hứa "hai vùng này không chồng lấn". Đo lại:

vòng byte (char* thường)  : 65,29 ns/lần
vòng byte + restrict      : 2,59 ns/lần   -> nhanh 25 lần, bằng memcpy

Với restrict, compiler lập tức vector hóa vòng thành ldp/stp q — đúng như restrict và không chồng lấn con trỏ đã đo ở một ngữ cảnh khác. Cùng một vòng lặp, chỉ khác một lời hứa về aliasing, mà nhanh gấp 25 lần. Bài học đọng lại: "compiler sẽ nhận ra vòng của tôi" không phải một lời hứa vô điều kiện — nó phụ thuộc compiler có chứng minh được thao tác an toàn hay không, và aliasing là rào cản số một.

Và để khép vòng: nếu tôi biên dịch với -fno-builtin, memcpy(d,s,256) không còn được inline nữa — nó thành một lời gọi thật b memcpy@plt sang libc. Cờ đó tắt toàn bộ nhận diện builtin, đưa memcpy trở về đúng cái "lời gọi thư viện" mà tôi tưởng ban đầu. Nói cách khác, cái tôi tưởng là mặc định (một lời gọi) thực ra chỉ xảy ra khi bạn tắt tối ưu builtin.

Về đến code hằng ngày

Điều đầu tiên: đừng tự viết vòng chép/đặt byte để "tránh chi phí gọi hàm" — thường bạn thua. memcpy/memset là builtin được inline thành lệnh vector cho kích thước hằng, và là routine libc tối ưu tay cho kích thước lớn. Một vòng byte tay hoặc bằng chúng (khi được nhận diện), hoặc chậm hàng chục lần (khi không). Viết thẳng memcpy/memset là cách rõ ràng nhất và thường nhanh nhất.

Điều thứ hai: khi buộc phải viết vòng sao chép/biến đổi mảng, hãy giúp compiler bằng restrict. Nếu bạn biết nguồn và đích không chồng lấn, nói cho compiler biết — nó sẽ vector hóa hoặc chuyển thành builtin. Không nói, nó phải phòng trường hợp xấu nhất (chồng lấn) và giữ mã scalar chậm. Đây là một trong những chỗ mà một từ khóa đổi hiệu năng gấp chục lần.

Cái thế lưỡng nan "chép nhanh nhưng phải chắc không chồng lấn" này hóa ra ám ảnh mọi ngôn ngữ, chỉ khác cách giải. Java giấu hẳn nó sau System.arraycopy — một hàm intrinsic mà JIT thay bằng mã máy chép khối, và runtime tự xử lý ca nguồn-đích trùng (như ArrayList.remove dịch mảng tại chỗ) nên bạn không phải lo. Go có copy(dst, src) builtin, và Go xử lý chồng lấn đúng đắn luôn. Nhưng chỗ triệt để nhất là Rust: cả vấn đề aliasing mà restrict phải hứa bằng lời trong C thì Rust bắt buộc bằng kiểu — quy tắc mượn (borrow) không cho bạn có hai tham chiếu khả biến chồng lấn, nên trình biên dịch được tự do tối ưu mà không cần bạn cam đoan gì. Mẫu số chung: chép khối là một thao tác nền cực nóng, và câu hỏi "nguồn với đích có trùng không" luôn ở đó — ngôn ngữ nào cũng phải trả lời nó, hoặc bằng thư viện lo hộ, hoặc bằng lời hứa của bạn, hoặc bằng kiểu.

Muốn tự thấy thì viết void f(char*d,const char*s){ for(int i=0;i<256;i++) d[i]=s[i]; } và xem gcc -O2 -S -o - t.c — bạn sẽ thấy một vòng ldrb/strb chép từng byte, không có memcpy, không vector. Giờ thêm restrict: void f(char*restrict d,const char*restrict s){...} và xem lại — vòng biến thành ldp/stp vector, chép 32 byte mỗi nhịp. Rồi thử void g(char*d){ for(int i=0;i<256;i++) d[i]=0; } — nó thành memset (hay stp vector) ngay cả khi không restrict, vì không có nguồn để trùng. Ba mươi giây đó cho bạn thấy compiler nhận diện builtin ra sao, và vì sao một lời hứa aliasing lại đáng giá gấp chục lần tốc độ.