memcpy là một trong những hàm bạn gọi nhiều nhất mà ít khi nghĩ tới. Trực giác ngây thơ bảo nó chỉ là một vòng lặp for (i) dst[i] = src[i] — "chép là chép, viết tay cũng vậy". Sai. memcpy của thư viện chuẩn là một cỗ máy chép được tối ưu tận xương: nó dùng lệnh SIMD/nạp-ghi rộng (16 byte mỗi lệnh trên NEON), rẽ nhánh theo kích thước (khối nhỏ dùng vài lệnh khéo léo, khối lớn dùng vòng vector), và xử lý căn lề. Câu hỏi đo lường: memcpy nhanh hơn một vòng byte bao nhiêu, và tốc độ của nó thay đổi thế nào theo kích thước và căn lề? Tôi đo trong container gcc:13 trên host ARM.
memcpy không phải một vòng byte — và tốc độ tùy kích thước
Khi bạn gọi memcpy(dst, src, n), thư viện không chạy một vòng chép từng byte. Nó chọn chiến lược theo n: với n rất nhỏ, vài lệnh nạp-ghi khéo (thậm chí một lệnh cho 8/16 byte); với n vừa, các lệnh vector SIMD chuyển 16–32 byte mỗi nhịp; với n lớn, vòng lặp vector kèm tối ưu prefetch. Đây là code viết tay bằng assembly cho từng kiến trúc, tận dụng đúng phần cứng.
Hệ quả là tốc độ memcpy không phải một hằng số — nó là một đường cong theo kích thước. Với khối rất nhỏ, chi phí cố định của lời gọi và rẽ nhánh lấn át vài byte dữ liệu, nên GB/s hiệu dụng thấp. Với khối vừa (nằm trong cache L1), memcpy đạt đỉnh băng thông. Với khối lớn (vượt cache), nó bị chặn bởi băng thông RAM — trần vật lý, không thể nhanh hơn. Tôi đo cả ba, cộng với căn lề và so với vòng byte.
Đo: memcpy nhanh 15 lần vòng byte, tốc độ đổi 40 lần theo kích thước
Host ARM, g++ -O2, memcpy giữa hai buffer:
(a) Tốc độ theo KÍCH THƯỚC:
kích thước | GB/s | ghi chú
-----------|--------|-------------------------------
8 B | 2,8 | quá nhỏ, chi phí lời gọi lấn át
128 B | 63,3 | tăng dần
4 KB | 117,6 | ĐỈNH (vừa L1, SIMD rộng)
64 KB | 80,8 | L2
16 MB | 59,3 | vượt cache -> trần băng thông RAM
(b) 16 MB: memcpy vs vòng chép TỪNG BYTE:
memcpy thư viện : 61,0 GB/s
vòng for byte : 3,9 GB/s -> memcpy nhanh 15x
(c) Căn lề (memcpy 1 MB):
căn lề (B+0 <- A+0) : 63,7 GB/s
lệch (B+1 <- A+3) : 60,0 GB/s -> chỉ 1,06x
Đọc ra ba điều. Một (đường cong theo kích thước): memcpy 8 byte chỉ đạt 2,8 GB/s — vì chép 8 byte thì chi phí lời gọi và rẽ nhánh (chọn chiến lược) lấn át hoàn toàn vài byte dữ liệu. Tăng kích thước, GB/s leo nhanh: 63 GB/s ở 128 B, đỉnh 117,6 GB/s ở 4 KB (dữ liệu nằm gọn L1, SIMD chạy hết công suất). Rồi giảm xuống 80,8 GB/s (64 KB, L2) và 59,3 GB/s (16 MB, vượt cache — chạm trần băng thông RAM, vì memcpy phải đọc nguồn và ghi đích). Từ 8 B tới 4 KB, tốc độ đổi ~40 lần — cùng một hàm memcpy.
Hai (memcpy vs vòng byte): trên 16 MB, memcpy đạt 61 GB/s còn một vòng for (i) dst[i]=src[i] chép từng byte chỉ 3,9 GB/s — memcpy nhanh 15 lần. Vì memcpy chuyển 16 byte mỗi lệnh SIMD còn vòng byte chuyển một byte mỗi lần. (Điều đáng chú ý trung thực: ngay cả ở -O2, vòng byte ở đây vẫn chậm — trình biên dịch không biến nó thành memcpy hay vector hóa, có lẽ vì hai con trỏ char* có thể trùng nhau nên nó không dám dùng lệnh rộng. Gọi memcpy bảo đảm đường tối ưu; một vòng viết tay thì hên xui.)
Ba (căn lề): memcpy 1 MB với nguồn/đích căn lề đạt 63,7 GB/s, còn lệch (offset 1 và 3) đạt 60,0 GB/s — chỉ chậm 1,06 lần (6%). Trên host ARM này, căn lề gần như không ảnh hưởng tới memcpy — khớp với phần cache line và phần căn lề: ARM xử lý truy cập lệch nhẹ nhàng, khác folklore x86.
Một lần tôi đo hớ: "chép byte cũng như memcpy" và "memcpy luôn nhanh tối đa"
Tôi vào đo với niềm tin coi nhẹ memcpy: "chép là chép, viết một vòng for từng byte thì cũng như gọi memcpy thôi, khỏi cần hàm thư viện". Đo phá tan: memcpy nhanh 15 lần vòng chép byte trên 16 MB (61 vs 3,9 GB/s), vì nó dùng SIMD/nạp-ghi rộng còn vòng byte bò từng byte. Tệ hơn, trình biên dịch không tự cứu vòng byte thành memcpy khi hai con trỏ có thể trùng — nên "viết tay cũng vậy" thành ra chậm cả chục lần. Gọi memcpy/std::copy là cách bảo đảm bạn được đường tối ưu của thư viện.
Nhưng đo cũng chỉnh niềm tin ngược: "memcpy được tối ưu rồi thì lúc nào cũng chạy nhanh tối đa, bất kể kích thước hay căn lề". Sai — tốc độ memcpy là một đường cong. Khối rất nhỏ (8 byte) chỉ 2,8 GB/s vì chi phí lời gọi và rẽ nhánh lấn át; đỉnh 117 GB/s ở cỡ vừa L1; khối lớn về 59 GB/s vì bị chặn bởi trần băng thông RAM — không cỗ máy chép nào vượt được giới hạn vật lý đó. Chênh ~40 lần theo kích thước. Nếu bạn gọi memcpy hàng triệu lần cho những khối tí hon, chi phí mỗi lời gọi mới là nút cổ chai, không phải băng thông — lúc đó gộp nhiều khối nhỏ hay tránh copy mới là tối ưu, chứ memcpy tự nó không cứu được.
Bài học đo lường: memcpy KHÔNG phải vòng chép byte — nó dùng SIMD/nạp-ghi rộng + rẽ nhánh theo kích thước. Đo: memcpy 61 GB/s vs vòng byte 3,9 GB/s = 15x (16 MB). Tốc độ là ĐƯỜNG CONG theo kích thước: 8 B chỉ 2,8 GB/s (overhead lời gọi lấn át) -> đỉnh 117 GB/s ở 4 KB (L1) -> 59 GB/s ở 16 MB (trần băng thông RAM) = ~40x range. Căn lề trên ARM chỉ 1,06x (ít nhạy). 'Chép byte cũng như memcpy' SAI (memcpy 15x, và compiler không luôn cứu vòng byte); 'memcpy luôn nhanh tối đa bất kể kích thước' SAI (nhỏ overhead-bound, lớn bandwidth-bound). Nếu tin "chép byte cũng vậy" tôi tự viết vòng chậm 15x; nếu tin "memcpy luôn tối đa" tôi ngạc nhiên khi copy khối nhỏ thành nút cổ chai.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: luôn dùng memcpy/std::copy/memmove để chép bộ nhớ, đừng tự viết vòng byte. Chúng là code viết tay tối ưu cho từng kiến trúc, dùng SIMD và chọn chiến lược theo kích thước — nhanh gấp nhiều lần vòng thủ công, và bảo đảm bạn được đường tối ưu kể cả khi trình biên dịch không dám vector hóa vòng của bạn (vì aliasing). Nếu buộc phải viết vòng, thêm __restrict để báo con trỏ không trùng, giúp trình biên dịch dùng lệnh rộng.
Hệ quả thứ hai: với khối nhỏ, chi phí mỗi lời gọi mới là vấn đề — hãy gộp hoặc tránh copy. Nếu bạn memcpy hàng triệu khối tí hon (vài byte), băng thông không phải nút cổ chai; chi phí cố định của mỗi lời gọi mới là. Lúc đó gộp nhiều khối thành một memcpy lớn, hoặc thiết kế để tránh copy (dùng tham chiếu, move, view/span). Với khối lớn, ngược lại — bạn đã chạm trần băng thông RAM, nên cách nhanh nhất là copy ít hơn (tránh bản sao thừa) chứ không phải copy nhanh hơn.
Hệ quả thứ ba là tinh thần đo lường: memcpy là phần cứng-được-tối-ưu, và tốc độ của nó là một đường cong theo kích thước, không phải hằng số. Con số mang theo: memcpy dùng SIMD, nhanh ~15x vòng byte; tốc độ theo kích thước: nhỏ overhead-bound (~vài GB/s), đỉnh ở cỡ L1 (~117 GB/s), lớn bandwidth-bound (~trần RAM 59 GB/s); căn lề trên ARM gần như miễn phí. Dùng memcpy; gộp khối nhỏ; tránh copy thừa với khối lớn. Một hàm tưởng tầm thường lại giấu cả một đường cong hiệu năng — đo mới thấy.
Thử ba mươi giây
Cấp hai buffer lớn (vài chục MB) và đo memcpy giữa chúng: chia tổng byte cho thời gian để ra GB/s. Rồi làm lại với một vòng for (size_t i=0;i<n;i++) dst[i]=src[i] viết tay và đo: rất có thể vòng byte chậm hơn nhiều lần — trên máy tôi là 15 lần — vì memcpy dùng lệnh SIMD rộng còn vòng bò từng byte (và trình biên dịch không phải lúc nào cũng cứu nó thành memcpy). Giờ đo memcpy ở nhiều kích thước: 8 B, 128 B, 4 KB, 64 KB, 16 MB, tính GB/s mỗi cỡ. Bạn sẽ thấy một đường cong: rất thấp ở khối tí hon (chi phí lời gọi lấn át), lên đỉnh ở cỡ vừa cache, rồi tụt về trần băng thông RAM ở khối lớn. Cuối cùng, thử memcpy với nguồn/đích lệch một byte: trên ARM bạn sẽ thấy gần như không chậm đi, khác với điều folklore x86 hay dọa. Ba mươi giây đó cho bạn thấy điều mà "chép là chép" giấu đi: memcpy là một cỗ máy phần cứng được tối ưu tận xương, tốc độ của nó vẽ thành một đường cong theo kích thước — nên hãy luôn dùng nó, và biết rằng nhanh chậm còn tùy khối to hay nhỏ.