memcpy là một trong những hàm bạn gọi nhiều nhất mà gần như không bao giờ nghĩ tới. Trực giác ngây thơ bảo nó chỉ là for (i) dst[i] = src[i] — chép là chép, viết tay cũng vậy thôi. Sai, và sai xa.
Hãy hình dung chuyện chuyển nhà. Cái vòng for chép từng byte giống như bạn tự tay bê từng cuốn sách một, đi đi về về cả trăm lượt. Còn memcpy của thư viện chuẩn là cả một đội chuyển nhà chuyên nghiệp với xe tải: mỗi chuyến nó khuân nguyên một lố 16 byte bằng lệnh SIMD, và nó còn khôn tới mức chọn cách khuân theo khối lượng — vài món thì xách tay cho nhanh, cả kho thì điều xe lớn có tính cả đường prefetch. Đây là code viết tay bằng assembly riêng cho từng kiến trúc CPU. Nên câu hỏi không phải "memcpy có nhanh hơn không", mà là nhanh hơn bao nhiêu, và tốc độ đó có ổn định không. Tôi đo trên máy ARM.
memcpy nhanh 15 lần, và tốc độ nhảy 40 lần theo kích thước
Đầu tiên là cú so găng trực tiếp. Chép một khối 16 MB:
memcpythư viện: 61 GB/s- Vòng
forchép từng byte: 3,9 GB/s — memcpy nhanh 15 lần
Có một chi tiết trung thực đáng nói: ngay cả ở -O2, cái vòng byte kia vẫn lết, trình biên dịch không tự biến nó thành memcpy. Lý do là hai con trỏ char* về lý thuyết có thể trỏ trùng vùng nhớ, nên compiler không dám dùng lệnh khuân-cả-lố. Gọi thẳng memcpy là cách đảm bảo bạn được đường tối ưu; một vòng viết tay thì hên xui.
Bất ngờ thứ hai là tốc độ memcpy không phải một con số cố định, mà là một đường cong theo kích thước khối:
| Kích thước khối | Tốc độ | Vì sao |
|---|---|---|
| 8 B | 2,8 GB/s | quá nhỏ, chi phí lời gọi nuốt hết |
| 128 B | 63,3 GB/s | đang tăng |
| 4 KB | 117,6 GB/s | đỉnh — vừa L1, SIMD chạy hết công suất |
| 64 KB | 80,8 GB/s | rơi xuống L2 |
| 16 MB | 59,3 GB/s | vượt cache, chạm trần băng thông RAM |
Từ khối 8 byte tới khối 4 KB, tốc độ đổi khoảng 40 lần — vẫn là cùng một hàm memcpy. Khối tí hon thì chi phí cố định của lời gọi và việc chọn chiến lược lấn át hết mấy byte dữ liệu. Khối vừa nằm gọn trong L1 thì đạt đỉnh. Khối lớn vượt cache thì bị trần băng thông RAM chặn lại — memcpy phải vừa đọc nguồn vừa ghi đích, không cỗ máy nào phá được giới hạn vật lý đó.
Còn chuyện căn lề mà x86 hay dọa? Trên ARM gần như không đáng kể. memcpy 1 MB căn lề đạt 63,7 GB/s, còn lệch một vài byte thì 60,0 GB/s — chậm vỏn vẹn 6%. ARM xử lý truy cập lệch nhẹ nhàng hơn nhiều so với folklore x86.
Hai định kiến trái ngược
Tôi bước vào với cái đầu coi nhẹ memcpy: chép là chép, viết vòng for từng byte thì khác gì gọi hàm thư viện. Sai — chậm gấp 15 lần, và compiler còn chẳng buồn cứu bạn khi con trỏ có thể trùng. Dùng memcpy hay std::copy là cách chắc chắn được đường nhanh.
Nhưng đừng vội tin điều ngược lại, rằng "memcpy tối ưu rồi thì lúc nào cũng chạy hết tốc". Tốc độ của nó là một đường cong. Nếu bạn gọi memcpy hàng triệu lần cho những khối tí hon vài byte, nút cổ chai không còn là băng thông mà là chi phí mỗi lời gọi — lúc đó gộp nhiều khối lại thành một lần chép lớn, hay tìm cách khỏi chép, mới là tối ưu thật; bản thân memcpy không cứu được.
Ngoài đời thì sao
Quy tắc gọn: cần chép bộ nhớ thì dùng memcpy, std::copy, memmove, đừng tự viết vòng byte. Nếu bắt buộc phải viết vòng, thêm __restrict để báo với compiler rằng hai con trỏ không trùng, cho nó dám dùng lệnh rộng.
Chuyện này không riêng gì C. Java có System.arraycopy, một intrinsic được JIT dịch xuống đúng loại lệnh SIMD này, nhanh hơn hẳn vòng for bạn tự gõ; ByteBuffer.put cũng vậy. Go thì có builtin copy() gọi thẳng xuống assembly tối ưu. Cùng một bài học: chép dữ liệu là việc mà runtime/thư viện đã tối ưu tới tận răng, tự làm lại gần như luôn thua. Và với khối lớn, khi bạn đã chạm trần băng thông RAM, cách nhanh nhất không phải chép nhanh hơn mà là chép ít đi — tránh những bản sao thừa, truyền tham chiếu hay dùng view/span thay vì copy.
Muốn tự thấy thì cấp hai buffer vài chục MB, đo memcpy rồi đo một vòng for chép byte: vòng byte chậm hơn hẳn mấy lần. Đo memcpy ở nhiều cỡ khối từ 8 byte tới 16 MB, bạn sẽ thấy đường cong đi lên đỉnh ở cỡ vừa cache rồi tụt về trần RAM. Thử lệch nguồn/đích một byte trên ARM: gần như không chậm đi, khác hẳn lời dọa x86. Một hàm nghe tầm thường mà giấu cả một đường cong hiệu năng — đo mới lộ ra.