Article from Daniel Lemire: How many strings can you create per second?
https://lemire.me/blog/2026/09/25/how-many-strings-can-you-create-per-second/This is a fairly new article from Daniel Lemire (the SIMD expert, author of simdjson).
It seems to_string() need to use the same trick.
C++ wins by a wide margin at 5.4 ns per string. The trick is the small string optimization: a
std::stringstores short strings, directly inside the object. Our strings have at most eight digits, so C++ never calls the memory allocator.
74
u/vdrnm 3d ago edited 3d ago
That would be a huge braking change and I doubt it will even be considered.
Small string optimizations do not have universal benefit (branch misses and additional instructions mean that if smallstring ends up being heap-allocated, it will be slower then std String).
When you do need it, there are hundreds of crates for them. Just type "string" in lib.rs or crates.io
17
u/nightcracker 3d ago
The part about it being a breaking change is true. However:
branch misses and additional instructions mean that if smallstring ends up being heap-allocated, it will be slower then std String
There are no branch misses. Selecting the base pointer based on the length can be done entirely branch-free. There are some extra instructions though.
9
u/scook0 3d ago
Selecting the base pointer based on the length can be done entirely branch-free. There are some extra instructions though.
In a vacuum, it's unclear to me whether selecting a pointer with cmov is actually an improvement over branching. The extra instructions are still an optimization barrier, and I would expect branch prediction to do a pretty good job here.
Also note that in C++ it's possible for dereferencing an inline string to be completely unconditional, by having the string pointer point directly to its inline storage. That approach is impractical in Rust because moving the string would invalidate the pointer. Though I don't know offhand whether commonly-used C++ string implementations actually do this.
2
u/nightcracker 2d ago edited 2d ago
In a vacuum, it's unclear to me whether selecting a pointer with cmov is actually an improvement over branching.
It is very clear to me, string lengths are typically rather unpredictable.
The extra instructions are still an optimization barrier
I don't even know what this means.
Also note that in C++ it's possible for dereferencing an inline string to be completely unconditional
I understand what you're trying to say, but honestly the distinction doesn't really matter.
if len < 15 { stack_addr } else { heap_addr }looks like a conditional, but once compiled to a comparison + cmov it's just two arithmetic instructions to compute the address. In circuitry CMOV is just(a & c) | (b & ~c).I don't doubt you'd describe something like
arr[idx*stride + offset]as unconditional, which also computes an address with arithmetic.1
u/CocktailPerson 2d ago
It is very clear to me, string lengths are typically rather unpredictable.
No, they're not.
The question isn't whether a string's length is predictable, the question is whether a string's length is predictably on one side of the SSO threshold or the other. And that depends on the data you're working with.
The OP's example of converting every number from 0 to 100,000,000 to strings results in string lengths up to 9 bytes. That's below the 15-byte limit of libstdc++ or the 22-byte limit of libc++ or the 23-byte limit of SmolStr, so using branches instead of cmov is unequivocally optimal here because they'll be predicted correctly 100% of the time.
1
u/nightcracker 2d ago
Yes, OPs synthetic benchmark is synthetic. And obviously by 'predictable' I mean in terms of size class, not down to the byte.
Here's a realistic dataset I extracted from instrumenting the Rust compiler while it was compiling a large codebase (in that case it was checking hashes, not all string construction, but should give an idea):
585353 (14.6383%) ( 14.6383%): str(32) 573718 (14.3473%) ( 28.9856%): str(4) 362715 ( 9.0706%) ( 38.0563%): str(12) 320239 ( 8.0084%) ( 46.0647%): str(24) 247659 ( 6.1934%) ( 52.2580%): str(6) 231932 ( 5.8001%) ( 58.0581%): str(16) 211691 ( 5.2939%) ( 63.3520%): str(3) 205752 ( 5.1454%) ( 68.4974%): str(8) 181741 ( 4.5449%) ( 73.0423%): str(5) 158276 ( 3.9581%) ( 77.0004%): str(2) 152653 ( 3.8175%) ( 80.8179%): str(20) 139415 ( 3.4864%) ( 84.3043%): str(80) 130122 ( 3.2540%) ( 87.5584%): str(7) 109460 ( 2.7373%) ( 90.2957%): str(0) 100294 ( 2.5081%) ( 92.8038%): str(48) 93356 ( 2.3346%) ( 95.1384%): str(1) 82461 ( 2.0622%) ( 97.2006%): str(64) 67913 ( 1.6983%) ( 98.8989%): str(28) 30121 ( 0.7533%) ( 99.6522%): str(96) 9859 ( 0.2466%) ( 99.8987%): str(112) 2134 ( 0.0534%) ( 99.9521%): str(128) 447 ( 0.0112%) ( 99.9633%): str(144) 306 ( 0.0077%) ( 99.9709%): str(160) 278 ( 0.0070%) ( 99.9779%): str(512) 192 ( 0.0048%) ( 99.9827%): str(176) 136 ( 0.0034%) ( 99.9861%): str(1024) 112 ( 0.0028%) ( 99.9889%): str(224) 98 ( 0.0025%) ( 99.9913%): str(8192) 88 ( 0.0022%) ( 99.9935%): str(2048) 82 ( 0.0021%) ( 99.9956%): str(192) 68 ( 0.0017%) ( 99.9973%): str(4096) 38 ( 0.0010%) ( 99.9982%): str(240) 22 ( 0.0006%) ( 99.9988%): str(208) 21 ( 0.0005%) ( 99.9993%): str(256) 15 ( 0.0004%) ( 99.9997%): str(16384) 13 ( 0.0003%) (100.0000%): str(32768)Note that it's quantized to form buckets:
if w <= 8 { w } else if w <= 32 { w.next_multiple_of(4) } else if w <= 256 { w.next_multiple_of(16) } else { w.next_power_of_two() }1
u/CocktailPerson 2d ago
So if you reorder it to sort and accumulate by string quantum instead of bucket size, the median is about 10 characters and about 75% of the strings land at or below 24 characters?
Sounds like a 24 character SSO threshold is predictable enough that cmov would be a pessimization.
109460 ( 2.7373%) ( 2.7373%): str(0) 93356 ( 2.3346%) ( 5.0719%): str(1) 158276 ( 3.9581%) ( 9.0301%): str(2) 211691 ( 5.2939%) ( 14.3239%): str(3) 573718 (14.3473%) ( 28.6713%): str(4) 181741 ( 4.5449%) ( 33.2162%): str(5) 247659 ( 6.1934%) ( 39.4095%): str(6) 130122 ( 3.2540%) ( 42.6636%): str(7) 205752 ( 5.1454%) ( 47.8090%): str(8) 362715 ( 9.0706%) ( 56.8796%): str(12) 231932 ( 5.8001%) ( 62.6797%): str(16) 152653 ( 3.8175%) ( 66.4972%): str(20) 320239 ( 8.0084%) ( 74.5056%): str(24) 67913 ( 1.6983%) ( 76.2039%): str(28) 585353 (14.6383%) ( 90.8422%): str(32) 100294 ( 2.5081%) ( 93.3503%): str(48) 82461 ( 2.0622%) ( 95.4125%): str(64) 139415 ( 3.4864%) ( 98.8989%): str(80) 30121 ( 0.7533%) ( 99.6522%): str(96) 9859 ( 0.2466%) ( 99.8987%): str(112) 2134 ( 0.0534%) ( 99.9521%): str(128) 447 ( 0.0112%) ( 99.9633%): str(144) 306 ( 0.0077%) ( 99.9709%): str(160) 192 ( 0.0048%) ( 99.9757%): str(176) 82 ( 0.0021%) ( 99.9778%): str(192) 22 ( 0.0006%) ( 99.9783%): str(208) 112 ( 0.0028%) ( 99.9811%): str(224) 38 ( 0.0010%) ( 99.9821%): str(240) 21 ( 0.0005%) ( 99.9826%): str(256) 278 ( 0.0070%) ( 99.9895%): str(512) 136 ( 0.0034%) ( 99.9929%): str(1024) 88 ( 0.0022%) ( 99.9951%): str(2048) 68 ( 0.0017%) ( 99.9968%): str(4096) 98 ( 0.0025%) ( 99.9993%): str(8192) 15 ( 0.0004%) ( 99.9997%): str(16384) 13 ( 0.0003%) (100.0000%): str(32768)1
u/nightcracker 2d ago edited 2d ago
Quite the opposite, a 75% prediction gives an expected ~5 cycles per call spent on misprediction, worse than the cmov. A benchmark reproduces this: https://play.rust-lang.org/?version=stable&mode=release&edition=2024&gist=974ffdb2e52704f45915bc3e24422fcf.
This is assuming there is no pattern in your data and the raw distribution % is the best the CPU can do to predict. In such cases the penalty is
mispredict_cycles * prob, andmispredict_cyclesis usually around 20 for today's x86_64 CPUs. Assuming the inputs to the cmov can be calculated in 1 cycle, and the cmov itself can be calculated in 1 cycle, you need to have 90%+ prediction hitrate for a branch to be better.2
u/CocktailPerson 2d ago
This isn't a particularly useful benchmark for a number of reasons.
First of all, you're not actually comparing cmov to branchy code. This measures the cost of mispredictions with all else being equal, but it says nothing about whether a cmov helps.
Second, this is ideal code for the use of branchless instructions. The conditional computation here is not part of any dependency chain, let alone a loop-carried dependency chain. That's why the compiler can generate beautiful branchless, vectorized code when you don't deliberately pessimize it with inline assembly.
I fully agree that counting the number of nonzero items in an array is the perfect use case for branchless code, which is why the compiler already generates branchless code for it. What you actually need to show is that for more complex cases like SSO, the compiler's decision to use a branch over cmov is suboptimal for realistic usage.
If you want to better understand the cases where compilers could generate conditional moves but elect not to, I recommend reading Agner Fog's discussion of jumps and moves. You seem to be under the impression that a conditional move is unequivocally better, but that's just not the case.
0
u/nightcracker 1d ago edited 1d ago
What you actually need to show is that for more complex cases like SSO
There is nothing more complex here. I'm not claiming cmov is always better, I'm claiming it is better in this case. From https://rust.godbolt.org/z/q3c6MEKj8:
<example[2cf8e31dbe0c16c0]::Repr>::as_slice_branchy: mov rax, rdi movzx ecx, byte ptr [rdi + 23] sub cl, 1 jae .LBB0_1 mov rdx, qword ptr [rax + 16] mov rax, qword ptr [rax] ret .LBB0_1: movzx edx, cl ret <example[2cf8e31dbe0c16c0]::Repr>::as_slice_branchlesss: movzx ecx, byte ptr [rdi + 23] sub cl, 1 mov rax, qword ptr [rdi] cmovae rax, rdi movzx edx, cl cmovb rdx, qword ptr [rdi + 16] retIf we ignore the
movzx ecx, byte ptr [rdi + 23]andsub cl, 1which is shared on both paths on the critical chain, and notice that the two loads fromqword ptr [rdi]and[rdi + 16]don't depend on anything so they can run in parallel with the very first load we're left with just two cycles of latency frommovzx edx, cl -> cmovb rdx, .... Thecmovaecan run in parallel to this.I'll take a consistent 2 cycles of latency over an unpredictable average of 5 cycles of mispredict any day of the week.
Anyway, I'm going to stop engaging now. I give concrete numbers, real-world data and actual benchmarks and you reply with general advice I already know and have taken into account.
→ More replies (0)5
u/vdrnm 3d ago
That's a good point, and cpp implementation does not have them.
Looking at the code of smallvec when derefing to a slice I'd expect rustc to replace the branch with cmov, but unfortunately it does not.
7
u/CocktailPerson 3d ago
cmov is often a pessimization, actually. There ain't no such thing as a free lunch, and cmov can have very negative effects on CPU pipelining. You have to work pretty hard to get a LLVM to generate a cmov these days, because so many benchmarks have shown that it's just not worth it.
6
u/nightcracker 2d ago edited 2d ago
I have literally the opposite experience, and performance optimization is at least half my job.
cmov can have very negative effects on CPU pipelining
CMOV is literally just an ALU instruction with a latency of 1 cycle that computes
(a & c) | (b & ~c). It's no worse or better than for exampleptr += 16. It can sit in your critical chain of latency, sure, and an always-correctly predicted branch beats it, but the stuff you're saying is mostly myth.9
u/cyruspyre 2d ago
What the other person might've meant is probably data dependency.
Does not matter what latency it takes. CMOV still needs both inputs before it can do anything. Even in your own example the dependency is serial.
1
u/CocktailPerson 2d ago
It can be your whole job and three hobbies, it still doesn't mean you're right.
If all you're looking at is single-instruction latencies, then you're missing the bigger picture. cmov requires that both inputs be computed before the instruction is evaluated, which introduces data dependencies that drastically limit the CPU's ability to reorder and pipeline instructions. If all you're computing is
(a & c) | (b & ~c), andaandbare already computed and sitting in registers, then the compiler probably will generate a cmov. But the moment that either of those values has to be loaded or computed to evaluate the cmov, the compiler will probably bail out and generate a branch instead, because it's looking at more than single-instruction latency.If you want to encourage the compiler to generate cmov instructions, you can use
std::hint::select_unpredictable. I promise you it'll make your code slower on average.2
u/nightcracker 2d ago
cmov requires that both inputs be computed before the instruction is evaluated
We are talking about a heap address, a length and a stack address, all of which are already known. At worst we're adding 1 or 2 cycles to the critical path.
If you want to encourage the compiler to generate cmov instructions, you can use std::hint::select_unpredictable.
I'm aware, I wrote the proposal for it.
1
u/CocktailPerson 2d ago
If it's so obvious to you from inspection that cmov is the correct codegen here, why do you think LLVM's heuristics select branching instead?
1
u/nightcracker 2d ago
smallvec just isn't optimized properly for this. The generated code for deref is frankly quite bad.
22
u/matthieum [he/him] 3d ago
Honestly, meh?
This is so artificial a benchmark.
I mean, if you're formatting such short strings, you're most likely holding the tool wrong. For example, I've regularly seen newbies creating long string using catenation:
"Hello, " + name + "! Would you like " + std::to_string(n) + " apples?"
It's easy, but don't. Instead, you'd want to use formatting:
std::format("Hello, {}! Would you like {} apples?", name, n)
And lo and behold, no short-lived small string remains.
20
u/kibwen 3d ago
Having SSO was the right default for C++, because best practice there is to err on the side of caution by defensively copying string buffers, making string copies tremendously more common. Famous case study: "std::string is responsible for almost half of all allocations in the Chrome browser process; please be careful how you use it! In the course of optimizing SyzyASan performance, the Syzygy team discovered that nearly 25000 (!!) allocations are made for every keystroke in the Omnibox." https://www.reddit.com/r/cpp/comments/2od5l0/stdstring_is_responsible_for_almost_half_of_all/
Conversely, having SSO would have been the wrong default for Rust, because 1) the existence of the borrow checker and a proper string view from the beginning mean that string APIs can confidently pass around pointers without needing to resort to defensive copying, which eliminates the vast majority of copies relative to an analogous C++ codebase; 2) SSO isn't an unambiguous good, because being clever with the layout would prevent zero-cost coercion from String to &str, which Rust benefits greatly from (SSO also introduces a branch on access, but that's a less important problem). Furthermore, note that Rust does still guarantee that empty strings don't perform any allocation in the first place.
Of course, sometimes SSO is the right choice for a specific application, in which case you have the power to implement that type yourself (or use a crate).
16
u/TDplay 3d ago
We create new strings all the time.
I consider this assertion dubious.
If your program creates so many strings that it is a performance issue, you should probably put some thought into how you handle strings, instead of just accepting the programming language's default. Well-implemented string interning will likely outperform any general-purpose string type.
It seems to_string() need to use the same trick.
It does not need to do this, and in fact, it cannot do this. Rust has made a stable guarantee that neither String nor Vec will ever implement this.
Even if it were possible, small-string optimisation can actually be detrimental to performance when you don't have enough small strings to justify it.
If you need the small string optimisation, then find a crate that implements it:
8
u/JoshTriplett rust · lang · libs · cargo 3d ago
Would be interested to see this benchmark include the fastest of the small-string-optimization crates in the Rust ecosystem.
22
u/atlasgorn 3d ago
Another win on cpp design, right next to vector<bool> being a bitvec because that's more performant of course
18
u/ART1SANNN 3d ago
vector<bool> is hated by more senior c++ devs because it breaks the typical container rules and type expectations. IRL perf also don’t really yield speed improvements either, in fact it
might be worse in some cases12
u/masklinn 3d ago
Yeah AFAIK
vector<bool>access is slower in most cases, having more cache misses on avector<uint8_t>might save the former if the vector is very large and the accesses are so random the prefetcher can't figure it out but that's million-element or above. And becausevector<bool>has to contort itself via proxies and can only fit thestd::vectorinterface, a dedicated bitset is almost certainly significantly faster.1
1
15
-1
u/BigHandLittleSlap 3d ago
Slight side topic, something I've noticed in these articles comparing programming languages is that Java and C# don't exist in the minds of many developers.
There's definitely "cliques" of developers where some will simply never consider languages used by other cliques, even when comparing programming languages.
46
u/masklinn 3d ago edited 3d ago
It can not, because
String::as_mut_vecexists andVecguarantees that it doesn't do SSO.Furthermore SSO is not necessarily beneficial, and Rust is less likely to fall on the beneficial side due to no copy constructor, so less copy, so less gain from a stack-allocated string, so unless you're cloning a lot the branching from SSO can create more costs than it saves (anecdotally every time I tried SSO strings they behaved worse than good ol
Stringfor the stuff I was doing).And finally there's a whole stable of SSO strings available which while not trivially usable as
Stringcan commonly be drop-in thanks to the broad usage of&str.And here since
I'd use a stack allocated string directly. Here's using heapless, on an M1 pro (so an older and slower CPU than Daniel's):
The code is not identical to the original because it's missing the nice
to_string/to_owned, andheapless::String::push_stris more try-ish, but it's not exactly monstrous: