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.

Small-object optimization (SSO)

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. 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 malloc và trỏ tới đó.

Đ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 malloc mỗi cái.
  • Copy chuỗi ngắn = copy vài chục byte inline. Chuỗi dài = malloc mộ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ột lần tôi đo hớ: "string luôn cấp phát" và "string nào cũng như nhau"

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". Đo phá tan: 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. Còn niềm tin ngược "string nào cũng như nhau về hiệu năng" 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.

Bài học đo lường: std::string có SSO — chuỗi NGẮN (≤15 ký tự với libstdc++) lưu INLINE trong đối tượng 32 byte, KHÔNG malloc, nên tạo (5,6 ns) và copy (3,1 ns) nhanh hơn nhiều chuỗi DÀI (≥16 ký tự, mỗi cái 1 malloc: tạo 16,7 ns = 3x, copy 16,5 ns = 5,3x); phần lớn chuỗi thực tế ngắn nên không chạm heap — "std::string luôn cấp phát" là lầm tưởng, nhưng vượt ngưỡng SSO thì mỗi thao tác tốn 1 malloc. Nếu tôi tin "luôn cấp phát" tôi né std::string vô ích; nếu tôi tin "string nào cũng như nhau" tôi bỏ lỡ chuyện chuỗi dài đắt gấp mấy lần và tạo ra hàng triệu malloc ẩn.

Vì sao điều này quan trọng khi lập trình

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

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

Hệ quả thứ ba là tinh thần đo lường: kiểu dữ liệu chuẩn thường có tối ưu ẩn mà "trực giác chi phí" bỏ qua — đo mới thấy. Con số mang theo: std::string (32 byte) có SSO: chuỗi ≤15 ký tự (libstdc++) lưu INLINE, không malloc — tạo 5,6 ns / copy 3,1 ns; chuỗi ≥16 ký tự lên heap, 1 malloc mỗi cái — tạo 16,7 ns (3x) / copy 16,5 ns (5,3x). Phần lớn chuỗi thực tế ngắn nên std::string thường không chạm heap; nhưng chuỗi dài đắt gấp mấy lần. Ngưỡng SSO khác theo thư viện (libstdc++ 15, libc++ 22). "std::string luôn chậm vì cấp phát" là một lầm tưởng mà một phép đo sizeof và một vòng bấm giờ đủ để sửa.

Thử ba mươi giây

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. Ba mươi giây đó 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.