Essential C++ Performance Optimization: The 15-Byte Critical Point of std::string

1. Introduction

In C++ development, std::string is one of the most commonly used data structures. On the surface, it is merely a “string class,” but its underlying implementation contains hidden intricacies. Understanding these principles not only helps in writing more efficient code but also avoids some subtle performance pitfalls. In this article, we focus on a core mechanism: Small String Optimization (SSO).

2. Core Idea of Small String Optimization (SSO)

The C++ standard does not mandate a specific implementation of std::string, but mainstream libraries (such as GCC’s libstdc++ and LLVM’s libc++) implement Small String Optimization (SSO). The basic idea of SSO is:

  • When the string is short, it is stored directly in the internal buffer of the std::string object, without allocating memory on the heap.

  • Only when it exceeds a certain threshold (commonly 15 bytes) will heap memory be used.

For example, in GCC 11.5 (libstdc++, x86-64), the source code of std::string is as follows:

 84   template<typename _CharT, typename _Traits, typename _Alloc>                                                                                                       85     class basic_string                                                                                                                                               86     {            // ......省略 158       struct _Alloc_hider : allocator_type // TODO check __is_final                                                                                                  159       {                                                                                                                                                              160 #if __cplusplus < 201103L                                                                                                                                            161     _Alloc_hider(pointer __dat, const _Alloc& __a = _Alloc())                                                                                                        162     : allocator_type(__a), _M_p(__dat) { }                                                                                                                           163 #else                                                                                                                                                                164     _Alloc_hider(pointer __dat, const _Alloc& __a)                                                                                                                   165     : allocator_type(__a), _M_p(__dat) { }                                                                                                                           166                                                                                                                                                                      167     _Alloc_hider(pointer __dat, _Alloc&& __a = _Alloc())                                                                                                             168     : allocator_type(std::move(__a)), _M_p(__dat) { }                                                                                                                169 #endif                                                                                                                                                               170                                                                                                                                                                      171     pointer _M_p; // The actual data.                                                                                                                                172       };                                                                                                                                                             173                                                                                                                                                                      174       _Alloc_hider  _M_dataplus;                                                                                                                                     175       size_type     _M_string_length;                                                                                                                                176                                                                                                                                                                      177       enum { _S_local_capacity = 15 / sizeof(_CharT) };                                                                                                              178                                                                                                                                                                      179       union                                                                                                                                                          180       {                                                                                                                                                              181     _CharT           _M_local_buf[_S_local_capacity + 1];                                                                                                            182     size_type        _M_allocated_capacity;                                                                                                                          183       };             // ......省略 3106    };

This explains why the size of std::string is 32 bytes in GCC 11.5, and why the SSO capacity is exactly 15.

In other words:

  • When the string length ≤ 15, the content is stored directly in _M_local_buf;

  • When the length ≥ 16, heap memory is allocated and _M_p points to it.

3. A Small Experiment to Validate SSO

The following code can validate the size of std::string and the allocation differences at 15 and 16 bytes:

// test.cpp// g++ test.cpp -o test#include <string>#include <iostream>
const char* str_15 = "abcdefghijklmno";   // length is 15
const char* str_16 = "abcdefghijklmnoj";  // length is 16
void print_usage(){    std::cerr << "Usage: ./test [15|16]" << std::endl;}
int main(int argc, char** argv) {    if (argc < 2) {        print_usage();        return -1;    }    const char* str = nullptr;    int arg = std::stoi(argv[1]);    if (arg == 15) {        str = str_15;    } else if (arg == 16) {        str = str_16;    } else {        print_usage();        return -1;    }    std::cout << "sizeof(std::string) = " << sizeof(std::string) << std::endl;
    // Infinite loop creating temporary std::string objects    while (true) {                std::string s(str);    }    return 0;}

Next, create perf monitoring events to monitor the process’s malloc calls:

[root@instance-bguv65e0 string_sso]# perf probe /lib64/libc.so.6 mallocAdded new event:  probe_libc:malloc    (on malloc in /lib64/libc.so.6)
You can now use it in all perf tools, such as:
        perf record -e probe_libc:malloc -aR sleep 1

Run the string for both 15-byte and 16-byte scenarios, and use perf to monitor the malloc calls of this process. Example output (GCC 11.5, x86-64):

[root@instance-bguv65e0 string_sso]# ./test 15 &[1] 4162216[root@instance-bguv65e0 string_sso]# sizeof(std::string) = 32
[root@instance-bguv65e0 string_sso]# perf stat -e probe_libc:malloc -t 4162216 -- sleep 5
 Performance counter stats for thread id '4162216':
                 0      probe_libc:malloc                                                      
       5.001849405 seconds time elapsed
[root@instance-bguv65e0 string_sso]# 
[root@instance-bguv65e0 string_sso]# ./test 16 &[1] 4162321[root@instance-bguv65e0 string_sso]# sizeof(std::string) = 32
[root@instance-bguv65e0 string_sso]# perf stat -e probe_libc:malloc -t 4162321 -- sleep 5
 Performance counter stats for thread id '4162321':
         1,604,918      probe_libc:malloc                                                      
       5.001863361 seconds time elapsed
[root@instance-bguv65e0 string_sso]#

From the analysis of the running results, it can be seen that the 15-byte std::string did not trigger heap allocation, while the 16-byte std::string did trigger heap allocation, intuitively confirming that the SSO threshold is indeed 15.

4. The Significant Impact on Performance

Why is this so important for performance optimization?

1. Avoiding dynamic memory allocation Heap allocation (malloc/free) is an expensive operation that can lead to lock contention and cache misses. Short strings (such as log tags, JSON field names, temporary concatenation keys, etc.) are very common, and avoiding heap allocation can significantly improve performance.

2. Improving cache hit rates

Small strings are stored directly within the object, continuously in the stack or array, leading to better locality. This increases CPU cache hit rates and speeds up memory access.

3. Reducing fragmentation Frequent allocation/release of many short strings can cause heap fragmentation. SSO avoids this issue.

5. Conclusion

The Small String Optimization (SSO) of std::string is a seemingly trivial yet extremely important performance feature.It allows us to program without worrying about the performance issues of small strings, as most operations on short strings are virtually zero-cost.When writing high-performance C++, we can confidently use std::string without having to resort to using const char* or manually managing buffers to avoid heap allocation as we did in the past.📬 Feel free to follow for continuous sharing of content related to software performance testing, optimization, programming techniques, and debugging skills, providing valuable and substantial technical insights.

Leave a Comment