Effortless Performance Improvements in C++: std:vector
julien.jorge.st
julien.jorge.st
If you can keep the original source string around, consider using std::vector<std::string_view> with each string_view pointing to part of the original text.
An even better approach is to avoid using an intermediary vector altogether if all you need is to process the tokens one-by-one and store them in a map. You could have `std::string_view parse_next_token(std::string_view *text);` which advances the source text and returns the next token.
for( auto value : from_csv( str ) ) { … }
{
std::vector<std::string> tokens = tokenize(line);
// do work with tokens
}
which you could change to std::vector<std::string> tokens;
{
tokens.clear();
tokenize(line, &tokens);
// do work with tokens
}
Combined with your suggestion to use std::string_view, this would mean only O(1) allocations across the program for this part of the code.not quite, std::string can store <=22 character strings without needing to allocate (in 64 bit mode at least) (look up short string optimization), 22 characters is actually quite a lot in the context of tokenization, so its not a given that switching to string views would be an improvement here
implementation dependent - the c++ standard says nothing on this
* libstdc++ has an internal reference to its own address for the SSO. If the moved-from string was referencing its SSO buffer, the moved-to string needs to use its own address. The branch is differentiating the SSO state from a heap-allocated state.
* libc++ string move can be implemented this way, but the branch ends up happening on access to the string. It still needs to discard the old heap allocated buffer, if need-be as well.
if I was actually tasked with hyper-optimizing a tokenizer I would probably skip past string view and do a pair of U16 indexes instead assuming the input file is less than 65k characters [with a "slow path" that uses U32 instead]. I just think that its probably not actually going to be a whole order of magnitude faster than just using string (unless there's long tokens)
https://en.cppreference.com/w/cpp/container/vector/reserve
> Correctly using reserve() can prevent unnecessary reallocations, but inappropriate uses of reserve() (for instance, calling it before every push_back() call) may actually increase the number of reallocations (by causing the capacity to grow linearly rather than exponentially) and result in increased computational complexity and decreased performance.
The cost of vector dynamic reallocation has gone down dramatically since C++11 introduced move constructors -- the example of a vector<string> actually uses std::move (which is comparable to copying 3 pointers, as opposed to the pointed-to allocation of the underlying string). Of course, that specific case relies on std::string's move constructor being defined as noexcept. So, when used properly, reserve will speed up your program, just often not as much as you might expect.
1. It's gone down, but it's still very high.
2. It hasn't gone down for types types like std::string_view, for which moving and copying take about the same amount of effort.
Just be warned that if you do it wrong, you can literally make your program exponentially slower. And, that it's less imperative to do so now than it was 15 years ago.
(I'm personally much less concerned about the cost of copying 16B from a string_view, than I am about copying 24B + arbitrary amounts of underlying storage from a string.)
* Yes, I know, elements at the beginning of the vector will be moved/copied more often than elements at the end.
The whole point of move construction is that moving a string is almost as cheap as moving a string_view. The moved-to string now owns the external storage and the moved-from string does not.
push_back and emplace_back already give you geometric growth; reserve is supposed to circumvent this.
Side note: vector::operator[] is UB for out-of-bounds access; if your goal is for something to break loudly, you should be using vector::at().
for (const auto& value : some_container) my_vector.push_back(value);
quadratic in the number of elements. To get linear time, when a reallocation occurs extra memory has to be allocated; we might double the allocated storage each time, or use Fibonacci numbers and get about a 1.6 ratio.
An early implementation of Microsoft Foundation Classes (pre-STL) had this bug: repeatedly appending one character at a time to a string had quadratic cost, because they got this wrong. I used to include a version of this as an interview question: how do you grow a dynamically allocated buffer, and what happens if you do it wrong.
It's true that if you know exactly how many elements you need you can reserve that space in one call. But doing this wrong (always allocating exactly what you need, no spare capacity) can sometimes increase the number of allocations you need.
-3,6 +3,8
std::vector<std::string> tokenize(const std::string& s)
{
std::vector<std::string> result;
+ // Expect four fields or less in our input.
+ result.reserve(4);
std::string::size_type f = 0;
std::string::size_type p = s.find(':');
I wonder why not: -3,6 +3,7
std::vector<std::string> tokenize(const std::string& s)
{
- std::vector<std::string> result;
+ // Expect four fields or less in our input.
+ std::vector<std::string> result(4);
std::string::size_type f = 0;
std::string::size_type p = s.find(':');
?It's not a big difference, but vectors have a constructor that takes an initial reservation size in order to facilitate pre-allocation.
Edit: No it doesn't. I've not written C++ in anger in... actually quite a bit longer than I thought. And it shows. Doh. Thanks, wirelessgigabit.
#include <string>
#include <iostream>
#include <vector>
template < typename T >
std::ostream & operator<< (std::ostream & s, const std::vector < T > &v)
{
s.put ('[');
char comma[3] = { '\0', ' ', '\0' };
for (const auto & e:v)
{
s << comma << e;
comma[0] = ',';
}
return s << ']';
}
int main ()
{
std::vector < std::string > with_reservation;
with_reservation.reserve (4);
std::cout << "with_reservation: " << with_reservation << std::endl;;
std::vector < std::string > via_ctor (4);
std::cout << "via_ctor: " << via_ctor << std::endl;;
return 0;
}
Yields with_reservation: []
via_ctor: [, , , ]
https://onlinegdb.com/GUlVHoqC5zIn many scenarios you need token spans / token strings only for diagnostics, in other words you almost never need them. When any individual data item is rarely looked up, being economical about memory footprint will in fact improve your overall cache hit rate. You only want to store enough information to retrieve the tokens just in case, so storing file offsets is exactly the right call.
Joining them has nothing to do with linear scanning. Binary search or anything else will still be cache friendlier by having them be adjacent rather than spread apart.
There are also additional advantages I didn't mention. e.g., you can combine strings from multiple files (and throw away the files) without needing to track or care where they came from. They're not performance-related though.
And no, I'm not saying you should do this everywhere. Use what makes sense for your situation.
> The whole discussion was about string containers, how fast lookups are, etc
No, it was about token storage.
> Your "you're wrong" comment was that the program can end up slower when you collect the strings but don't use them
No, it was assuming common access patterns of token data in parsers, which aren't "you don't use them" but "you rarely access them, likely only once when parsing but on rare occasion you have to refer back to specific tokens later".
Apart from that -- a string view is typically understood as being implemented using a pointer to the string buffer, which is something else -- a pointer is 64 bits instead of 32 bit (on most systems, of course). And it requires the source file to be cached in memory at a stable location, can't be serialized and deserialized, etc. etc.
In short, it's the typical fashionable modern C++ approach that is also wrong.
string_view is a useful general purpose tool that implements the std::string API for e.g. taking strings of various types in general purpose function signatures. Obviously it's not meant to be serialized and deserialized. It works well for its intended purpose.
The two are not mutually exclusive: you could turn your 32-bit offset/length pair into a string_view to pass to functions that operate in the context of a single string.
> It works well for its intended purpose.
The intended purpose is quite general. Which admits other, better solutions in specific applications.
Of course, as that post suggests, use reserve() to encourage having the vector itself as optimal as possible. (In my strsplit call I pass it in as optional so each caller can optimize it).
[0] https://github.com/mediakind-video/effortless-performance-im...
https://nee.lv/2021/02/28/How-I-cut-GTA-Online-loading-times...