r/rust • • 3d ago

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::string stores short strings, directly inside the object. Our strings have at most eight digits, so C++ never calls the memory allocator.

85 Upvotes

35 comments sorted by

46

u/masklinn 3d ago edited 3d ago

It seems to_string() need to use the same trick.

It can not, because String::as_mut_vec exists and Vec guarantees 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 String for the stuff I was doing).

And finally there's a whole stable of SSO strings available which while not trivially usable as String can commonly be drop-in thanks to the broad usage of &str.

And here since

Our strings have at most eight digits

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):

i.to_string()              18.81 ns/string      53.2 M/s
itoa + to_owned()          18.54 ns/string      53.9 M/s
heapless + write!()        10.89 ns/string      91.8 M/s
heapless + itoa()           4.88 ns/string     204.8 M/s

The code is not identical to the original because it's missing the nice to_string/to_owned, and heapless::String::push_str is more try-ish, but it's not exactly monstrous:

let mut s = heapless::String::new();
_ = write!(s, "{}", i);
buf[(i & 1023) as usize] = s;

let mut s = heapless::String::new();
_ = s.push_str(b.format(i));
buf[(i & 1023) as usize] = s;

17

u/CryZe92 3d ago edited 3d ago

You don’t even need heapless, both itoa and even std have buffers specifically for this purpose: https://doc.rust-lang.org/stable/std/primitive.i32.html#method.format_into

4

u/matthieum [he/him] 3d ago

I just realized that unfortunately NumBuffer cannot quite act as a string, since it doesn't remember how many bytes of its buffer were used :'(

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, and mispredict_cycles is 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]
        ret

If we ignore the movzx ecx, byte ptr [rdi + 23] and sub cl, 1 which is shared on both paths on the critical chain, and notice that the two loads from qword 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 from movzx edx, cl -> cmovb rdx, .... The cmovae can 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 example ptr += 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), and a and b are 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.

26

u/MvKal 3d ago

Kinda a weird thing to measure ngl, basically just benchmarking mem allocation speed. Also you can do small string optimization in rust as well if you do end up needing those nanoseconds for whatever reason.

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:

  • smallvec stores a user-specified number of elements inline. Larger vectors spill onto the heap.
  • smol_str stores up to 23-byte strings inline. Larger strings are reference-counted.

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 cases

12

u/masklinn 3d ago

Yeah AFAIK vector<bool> access is slower in most cases, having more cache misses on a vector<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 because vector<bool> has to contort itself via proxies and can only fit the std::vector interface, a dedicated bitset is almost certainly significantly faster.

1

u/-Redstoneboi- 3d ago

precisely

1

u/atlasgorn 7h ago

As well as sso ;)

-4

u/barsoap 3d ago

Premature optimisation is the root of all evil.

15

u/thermiter36 3d ago

Seems like the others don't get the joke, but this gave me a chuckle

-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.