Nghĩ về cái ví của bạn. Nó có một ngăn nhỏ ngay trên thân ví để giữ ít tiền lẻ — cần mua ổ bánh mì thì rút ngay, khỏi phải chạy ra ngân hàng. Chỉ khi cần một khoản lớn bạn mới phải tới két, rút ra, rồi cất vào ví một tờ giấy ghi "tiền để ở két số...". Ngăn tiền lẻ tích hợp sẵn ấy khiến phần lớn giao dịch hằng ngày nhanh gọn, không cần đi đâu cả. std::string có đúng một cái ngăn như vậy, và nó tên là SSO.
Có một hiểu lầm phổ biến: "std::string chậm vì mỗi lần tạo là một lần cấp phát bộ nhớ động (malloc)". Nghe hợp lý — chuỗi có độ dài thay đổi, nên phải cấp buffer trên heap. Nhưng thư viện chuẩn C++ có một tối ưu tinh tế biến điều đó thành sai với phần lớn chuỗi thực tế: small-string optimization (SSO, một dạng small-object optimization). Chuỗi ngắn được lưu thẳng bên trong đối tượng string, không chạm heap. Tôi đo ngưỡng và chi phí của nó trong container gcc:13, và con số cho thấy tại sao "string luôn cấp phát" là một lầm tưởng — và tại sao độ dài chuỗi lại là một quyết định hiệu năng.
Buffer inline: chuỗi ngắn không chạm heap
Đối tượng std::string không chỉ chứa một con trỏ tới dữ liệu — nó chiếm 32 byte (đo trên libstdc++), và trong 32 byte đó có sẵn một buffer inline, đúng cái ngăn tiền lẻ. Cơ chế:
- Chuỗi ngắn (≤15 ký tự với libstdc++): lưu trực tiếp trong buffer inline của chính đối tượng. Không
malloc, không con trỏ tới heap. Dữ liệu nằm ngay trong 32 byte đó. - Chuỗi dài (≥16 ký tự): mới cấp một buffer riêng trên heap bằng
mallocvà trỏ tới đó — như phải chạy ra két.
Điều này có hệ quả hiệu năng lớn, vì malloc không rẻ (~5,5 ns/lần như phần 10 đo):
- Tạo chuỗi ngắn = ghi vài byte vào chính đối tượng — không cấp phát. Chuỗi dài = một
mallocmỗi cái. - Copy chuỗi ngắn = copy vài chục byte inline. Chuỗi dài =
mallocmột buffer mới rồi copy.
Và điều quan trọng: phần lớn chuỗi trong đời thực đều ngắn — tên biến, khóa, nhãn, token, mã, đường dẫn ngắn. Chúng hoàn toàn không chạm heap nhờ SSO.
Đo: ngưỡng 15 ký tự, tạo nhanh 3 lần, copy nhanh 5 lần
Tôi tìm ngưỡng SSO (độ dài mà data() bắt đầu trỏ ra ngoài đối tượng — dấu hiệu đã lên heap), rồi đo tạo và copy:
g++ -O2, libstdc++:
sizeof(std::string) = 32 byte (chứa buffer inline)
NGƯỠNG SSO: chuỗi ≤15 ký tự = INLINE (không heap); từ 16 ký tự = HEAP (malloc)
A. TẠO 20 triệu chuỗi:
ngắn (10 ký tự, SSO inline) : 5,6 ns/chuỗi (không malloc)
dài (30 ký tự, heap) : 16,7 ns/chuỗi (1 malloc mỗi cái)
-> ngắn nhanh hơn ~3 lần
C. COPY 20 triệu chuỗi:
copy ngắn (10, inline) : 3,1 ns/copy (copy byte inline)
copy dài (30, heap) : 16,5 ns/copy (malloc + copy)
-> ngắn nhanh hơn ~5,3 lần
Nhìn ngưỡng: chuỗi ≤15 ký tự nằm inline trong đối tượng 32 byte, ≥16 ký tự thì malloc. Đây là con số của libstdc++ (các thư viện khác có ngưỡng khác — libc++ khoảng 22, MSVC khoảng 15). A: tạo một chuỗi 10 ký tự tốn 5,6 ns (chỉ ghi byte inline), còn chuỗi 30 ký tự tốn 16,7 ns (một malloc) — chậm hơn 3 lần, và toàn bộ khác biệt là chi phí cấp phát. C: copy còn chênh hơn — chuỗi ngắn 3,1 ns (copy inline), chuỗi dài 16,5 ns (malloc buffer mới + copy) — 5,3 lần. Với hàng triệu chuỗi, khác biệt "ngắn hay dài" quyết định bạn có gọi hàng triệu lần malloc hay không.
(Môi trường đo: container gcc:13 trên host ARM AArch64, libstdc++, g++ -O2, mỗi số lấy MIN sau warmup chia số chuỗi. Ngưỡng SSO và sizeof là của libstdc++; con số tuyệt đối đổi theo máy, nhưng dáng "ngắn không malloc, dài có malloc" thì phổ quát.)
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin: "std::string chậm vì luôn cấp phát heap, tránh nó khi cần hiệu năng". Con số bác thẳng: nhờ SSO, chuỗi ngắn (≤15 ký tự) lưu inline, không malloc, tạo nhanh 3 lần và copy nhanh 5 lần chuỗi dài. Vì phần lớn chuỗi thực tế ngắn, std::string thường không chạm heap chút nào — lầm tưởng "luôn cấp phát" khiến người ta né std::string một cách không cần thiết.
Nhưng niềm tin ngược lại — "vậy thì string nào cũng như nhau về hiệu năng, khỏi bận tâm độ dài" — cũng sai. Vượt ngưỡng SSO (≥16 ký tự) thì mỗi lần tạo/copy tốn một malloc riêng — chuỗi dài đắt hơn chuỗi ngắn 3–5 lần, và điều đó rất quan trọng nếu bạn xử lý hàng triệu chuỗi. Độ dài chuỗi không phải chi tiết vô hại; nó là ranh giới giữa "ở trong ví" và "phải ra két".
Về đến code hằng ngày
Điều đầu tiên: đừng né std::string cho chuỗi ngắn — SSO làm nó gần như miễn phí. Cho tên, khóa, nhãn, token ngắn, std::string không chạm heap và tra/copy rất nhanh. Cái "cấp phát mỗi lần" mà người ta lo chỉ xảy ra khi chuỗi vượt ngưỡng. Biết điều này giúp bạn dùng std::string tự tin thay vì tối ưu sớm bằng char[] thủ công.
Điều thứ hai: khi xử lý hàng triệu chuỗi, giữ chúng dưới ngưỡng SSO nếu có thể. Nếu bạn có một tập khóa/token và muốn tránh hàng triệu malloc, thiết kế để chúng ≤15 ký tự (libstdc++) — ví dụ rút gọn, mã hóa, hoặc dùng một kiểu chuỗi nhỏ chuyên dụng. Với dữ liệu lớn, mỗi malloc tránh được là ~5,5 ns cộng phân mảnh cộng cache miss khi chạm. Đây là cùng nguyên lý "gộp/tránh cấp phát mỗi phần tử" của phần 10.
Cái ngăn-tiền-lẻ này không riêng gì C++; các runtime cao cấp cũng chơi đúng trò đó, chỉ giấu kỹ hơn. JVM có "compact strings": từ Java 9, một String toàn ký tự Latin-1 được lưu bằng 1 byte mỗi ký tự thay vì 2, một tối ưu chỗ-lưu tương tự ẩn sau API. Nhiều VM (cả HotSpot lẫn V8 của JavaScript) còn intern các chuỗi hằng và literal để chuỗi ngắn lặp lại không cấp phát lại — họ hàng gần với interning ta đã đo. Rust thì phơi bày lựa chọn ra ngoài: String luôn trên heap, nhưng có các crate như smallstr/smartstring cài đúng SSO khi bạn cần. Bài học chung: "kiểu chuỗi chuẩn" hầu như luôn có một đường tắt cho trường hợp nhỏ-và-phổ-biến, và đo là cách duy nhất để thấy nó.
Muốn tự thấy thì in sizeof(std::string) — bạn sẽ thấy nó lớn hơn một con trỏ (32 byte trên libstdc++), vì có buffer inline. Rồi tìm ngưỡng SSO: tạo chuỗi độ dài 0, 1, 2, … và so sánh (char*)s.data() với địa chỉ của chính s — chừng nào data() còn nằm trong đối tượng thì đó là inline (SSO), khi nó nhảy ra ngoài là đã lên heap. Bạn sẽ thấy ngưỡng ~15 ký tự. Cuối cùng, tạo và copy hàng triệu chuỗi ngắn (10 ký tự) so với dài (30 ký tự) và bấm giờ: chuỗi ngắn nhanh hơn vài lần, vì chuỗi dài trả một malloc mỗi thao tác. Vài phút đó cho bạn thấy điều mà "std::string luôn cấp phát" giấu đi: thư viện chuẩn đã tối ưu để chuỗi ngắn — thứ chiếm đa số trong code thực — không hề chạm heap, và độ dài chuỗi vượt một ngưỡng nhỏ mới là lúc chi phí cấp phát xuất hiện.