memcpymemset 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 vào đo, và cả hai kỳ vọng ban đầu của tôi đều sai.

memcpy và memset builtin

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

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 compiler 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 đo trong container gcc:13 (ARM AArch64): 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?

Một lần tôi đo hớ: aliasing chặn nhận diện

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. 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ả ds 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 đo lường: "compiler sẽ nhận ra vòng của tôi" không phải 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à để đóng 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả thứ hai: khi bạn 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.

Hệ quả thứ ba là bài học đo lường bao trùm: cùng một dòng code có thể sinh mã rất khác nhau tùy compiler chứng minh được gì. Con số mang theo: memcpy/memset là builtin — với kích thước hằng, compiler inline chúng thành lệnh vector ldp/stp (0 lời gọi; -fno-builtin mới đưa về lời gọi thư viện thật); một vòng chép byte tay CHẬM 25 lần (65,24 so 2,57 ns) vì char d và s có thể trùng địa chỉ nên compiler không dám đổi thành memcpy — thêm restrict thì nó vector hóa và bằng memcpy (2,59 ns); còn vòng đặt d[i]=7 được nhận thành memset ngay (2,30 ns) vì không có nguồn để trùng.* Trước khi tự viết một vòng "nhanh", hãy đo — và nhớ rằng builtin cùng aliasing quyết định compiler có giúp bạn hay không.

Thử ba mươi giây

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 độ.