50

I was using a map with a std::string key and while everything was working fine I wasn't getting the performance I expected. I searched for places to optimize and improved things only a little and that's when a colleague said, "that string key is going to be slow."

I read dozens of questions and they consistently say:

"don't use a char * as a key"
"std::string keys are never your bottleneck"
"the performance difference between a char * and a std::string is a myth."

I reluctantly tried a char * key and there was a difference, a big difference.

I boiled the problem down to a simple example:

#include <stdio.h>
#include <stdlib.h>
#include <map>

#ifdef USE_STRING

#include <string>
typedef std::map<std::string, int> Map;

#else

#include <string.h>
struct char_cmp { 
    bool operator () (const char *a,const char *b) const 
    {
        return strcmp(a,b)<0;
    } 
};
typedef std::map<const char *, int, char_cmp> Map;

#endif

Map m;

bool test(const char *s)
{
    Map::iterator it = m.find(s);
    return it != m.end();
}

int main(int argc, char *argv[])
{
    m.insert( Map::value_type("hello", 42) );

    const int lcount = atoi(argv[1]);
    for (int i=0 ; i<lcount ; i++) test("hello");
}

First the std::string version:

$ g++ -O3 -o test test.cpp -DUSE_STRING
$ time ./test 20000000
real    0m1.893s

Next the 'char *' version:

g++ -O3 -o test test.cpp             
$ time ./test 20000000
real    0m0.465s

That's a pretty big performance difference and about the same difference I see in my larger program.

Using a char * key is a pain to handle freeing the key and just doesn't feel right. C++ experts what am I missing? Any thoughts or suggestions?

HAL9000
  • 3,562
  • 3
  • 25
  • 47
uroc
  • 3,993
  • 3
  • 21
  • 18
  • 17
    As you have just shown, always take all blanket statements with a grain of salt. – Mysticial Aug 27 '12 at 04:04
  • 7
    Your test may not be a fair comparison between std::string and char*. In your "char *" version of Map, you are not allocating the memory to your key, while your "std::string" version of the Map, a new string for the key is allocated every time. – LMC Aug 27 '12 at 04:07
  • @LMC While I agree with your comment I think the OP does not realize what other functions are called with std::string – Adrian Cornish Aug 27 '12 at 04:08
  • 40
    I just realized that my first comment was a blanket statement. – Mysticial Aug 27 '12 at 04:08
  • @LMC You're right, there should be a strdup for the 'char *' to be fair, but that happens only once and should be negligible because of the loop count. As far as I can tell, what's killing me is the conversion of the 'char *' into a string each time I call the map.find(). In my full program, my data comes in as a C string so I have no choice to optimize that away. – uroc Aug 27 '12 at 04:17
  • 1
    @uroc: Then you need to decide what's more important to you: the overhead of doing manual management of character arrays, or the copying overhead from your C-based API. – Nicol Bolas Aug 27 '12 at 04:34
  • I don't mind doing the management myself except for trying to figure out how to properly handle things like erase() or clear() or the map destructor. That's probably the next question: when I went searching for answers all of them basically dismiss the question as 'this isn't your problem'. Also, I was quite surprised there was such a difference in the first place. – uroc Aug 27 '12 at 04:44
  • 2
    Your custom-comparator-function version is simply more elegant ***iff*** you already have the C strings anyway. All the 'type-safe/manual allocation-free' hipness that you get with std::string is not very interesting if you're not going to use it. C++ makes you ***pay only for what you use***. This is a tribute to the flexibility of std::map, if you ask me – sehe Aug 27 '12 at 07:07
  • 1
    @uroc: Doing the memory management by yourself is going to make so many other things harder. You will loose the speed improvement you **think** you are going to get in the blink of an eye. In the long run std::string will be faster (in any non trivial program). – Martin York Aug 27 '12 at 07:10
  • 3
    @LokiAstari: Not always, no. For some very specifically constrained situations you do have to get closer to the metal. – Matthieu M. Aug 27 '12 at 09:42
  • @MatthieuM.: True. But for general purpose programs you are unlikely to beat std::string by manually managing the C-strings and their lifespan. – Martin York Aug 27 '12 at 16:16
  • @LokiAstari: I definitely agree with the lifespan issue, I am just annoyed that `map::find` takes a string as a parameter when `operator<(string, char const*)` and `operator<(char const*, string)` both exist. – Matthieu M. Aug 27 '12 at 17:11
  • @MatthieuM. I was thinking to write my own sort of variant class what would contain either a `std::string` or `char *` so I could provide a `operator<` that would do essentially that. – uroc Aug 27 '12 at 18:12

6 Answers6

30

You are using a const char * as a lookup key for find(). For the map containing const char* this is the correct type that find expects and the lookup can be done directly.

The map containing std::string expects the parameter of find() to be a std::string, so in this case the const char* first has to be converted to a std::string. This is probably the difference you are seeing.

sth
  • 222,467
  • 53
  • 283
  • 367
  • 1
    Right, I think that's exactly where the performance difference lies. I'm a bit shocked that there's that big of a penalty. – uroc Aug 27 '12 at 04:21
  • 2
    @uroc: The code does 20000000 string copies (one in every iteration), that will take some time. Especially compared to a lookup in a map with only a single element, which should be rather fast. – sth Aug 27 '12 at 04:26
  • The map in my actual program is much bigger, but it wasn't relevant to the problem so I neglected to add that to the sample code. The only way I see to improve this is to switch to char pointers. – uroc Aug 27 '12 at 04:28
  • @uroc: So your real lookup keys are `char*` already? Because if they are `std:string` they don't need to be converted on lookup and the performance penalty seen in the sample program shouldn't apply. – sth Aug 27 '12 at 04:32
  • This thing is a cache. The incoming data are all C pointers. I was converting to std::strings when inserting into the map. – uroc Aug 27 '12 at 04:38
  • 3
    @sth: it's an issue with associative containers in general. There is an `operator<` that compares a `std::string` and a `char const*` without allocating a string, but the associative containers force the conversion. It's a pain... – Matthieu M. Aug 27 '12 at 09:43
  • @MatthieuM. I was wondering if there was a way to do such a comparison with out the conversion. – uroc Aug 27 '12 at 10:37
  • I wonder if this penalty still exists in C++20 where std::string can now be constexpr constructed. – glades Dec 05 '22 at 14:07
8

As sth noted, the issue is one of specifications of the associative containers (sets and maps), in that their member search methods always force a conversion to the key_type, even if an operator< exists that would accept to compare your key against the keys in the map despite their different types.

On the other hand, the functions in <algorithm> do not suffer from this, for example lower_bound is defined as:

template< class ForwardIt, class T >
ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value );

template< class ForwardIt, class T, class Compare >
ForwardIt lower_bound( ForwardIt first, ForwardIt last, const T& value, Compare comp );

So, an alternative could be:

std::vector< std::pair< std::string, int > >

And then you could do:

std::lower_bound(vec.begin(), vec.end(), std::make_pair("hello", 0), CompareFirst{})

Where CompareFirst is defined as:

struct CompareFirst {
     template <typename T, typename U>
     bool operator()(T const& t, U const& u) const { return t.first < u.first; }
};

Or even build a completely custom comparator (but it's a bit harder).

A vector of pair is generally more efficient in read-heavy loads, so it's really to store a configuration for example.

I do advise to provide methods to wrap the accesses. lower_bound is pretty low-level.

Matthieu M.
  • 287,565
  • 48
  • 449
  • 722
3

If your in C++ 11, the copy constructor is not called unless the string is changed. Because std::string is a C++ construct, at least 1 dereference is needed to get at the string data.

My guess would be the time is taken up in an extra dereference (which if done 10000 times is costly), and std::string is likely doing appropriate null pointer checks, which again eats up cycles.

Community
  • 1
  • 1
sevensevens
  • 1,703
  • 16
  • 27
1

Store the std::string as a pointer and then you lose the copy constructor overhead.

But after you have to remember to handle the deletes.

The reason std::string is slow is that is constructs itself. Calls the copy constructor, and then at the end calls delete. If you create the string on the heap you lose the copy construction.

Adrian Cornish
  • 23,227
  • 13
  • 61
  • 77
  • I think the speed difference is the creation of the temporary std::string when find is called as @sth pointed out. I see no way to optimize that away. My next problem is how to properly handle the deletes. In my destructor I was just running over all pairs and freeing the 'char *' pointers and then calling clear on the map. – uroc Aug 27 '12 at 04:25
  • 2
    Do not guess - profile the code - I am a 100% right in guessing that you are 100% wrong where you think the bottle neck is ;-) - programmers are terrible at guessing where this is - after 22 years I always am surprised. – Adrian Cornish Aug 27 '12 at 04:29
  • Can the peeps that gave me the -1's add why you think so? – Adrian Cornish Aug 27 '12 at 04:45
  • 2
    storing std::string as pointer or not in map is doubtless not the problem. Come on, he is only having 1 string instance in the map, while he is doing million times of search. Please don't guess, prove that's the problem instead. More important, having std::map is so ugly and should be avoided unless you know you need to do so. – Adrian Shum Aug 27 '12 at 04:45
  • @AdrianShum I do admit I did guess but a usual newbie problem is unrecognized copy construction overhead. Which is why I commented on profiling the code – Adrian Cornish Aug 27 '12 at 04:47
  • And my answer if not wrong - but maybe not correct or the best – Adrian Cornish Aug 27 '12 at 04:49
  • There are tons of usual newbie problems. Should we just state out all usual newbie problems and tell the OP to do profiling himself? I don't mind guessing as long as it is reasonable. However, that's something I can't feel in this answer. – Adrian Shum Aug 27 '12 at 04:56
  • To be fair, I should have put in my reply that I did a quick test and got rid of the temporary `std::string` creation of each loop and the performance matched the C pointer version. So, I wasn't really guessing ;) Since my data is all C strings anyways there's not much advantage of using pointers to C strings or pointers to `std::string`. – uroc Aug 27 '12 at 04:57
  • @AdrianShum why is std::map ugly? – Oliver Aug 27 '12 at 10:09
  • @OliverStutz It is ugly in the aspect of 1. manual explicit memory management outside the container and, 2. using pointer in std lib containers makes a lot of functions in std lib useless (for example, in this example, we need to write our own compare functor, while std::map works automatically). Honestly it is not as ugly as it sounds :P I did use pointers in std containers before to solve some problems. However, the reason in this example is not strong enough for us to bear the "ugliness" – Adrian Shum Aug 28 '12 at 02:52
  • @AdrianShum 100% disagree. To save copy construction pointesr are a totally valid way of efficient code stored in STL containers and are a normal pattern. The owner of the STL container can free the pointer. Also what about storing different derived types by base pointer in a container – Adrian Cornish Aug 28 '12 at 02:57
  • @AdrianCornish Storing base pointer is the primary reason for putting pointer in container. And, I didn't say it is invalid, nor it is inefficient. I was talking about the cleanliness of code. Just use this as example, std::string itself has correct comparison function. However, we didn't benefit from it if we are storing std::string* . I am not against using pointers in std lib containers (I often do it) but that should be backed up by reasons (base class ptr is a good reason). – Adrian Shum Aug 28 '12 at 05:59
  • @AdrianShum ok i get what you are saying, usually i do , map because i have seen how many times the elements can get copied internally with a dummy object and printouts – Oliver Aug 28 '12 at 08:07
  • 1
    The extra copy could be avoided with `std::move` nowadays. – Niclas Larsson Apr 12 '16 at 08:38
1

After compilation the 2 "Hello" string literals will have the same memory address. On the char * case you use this memory addresses as keys.

In the string case every "Hello"s will be converted to a different object. This is a small part (really really small) of your performance difference.

A bigger part can be that as all the "Hello"s you are using has the same memory address strcmp will always get 2 equivalent char pointers and I'm quite sure that it early checks for this case :) So it will never really iterate on the all characters but the std::string comparison will.

QwerJoe
  • 84
  • 2
  • 1
    This is indeed a large and easily overlooked problem with a std::string key: the `operator<` is not constant time. If you have long keys with common prefixes (not unlikely in some applications), it can lead to horrible performance. – Olivier Nov 08 '17 at 15:00
0

One solution to this is use a custom key class that acts as a cross between a const char * and a std::string, but has a boolean to tell at run time if it is "owning" or "non-owning". That way you can insert a key into the map which owns it's data (and will free it on destruction), and then compare with a key that does not own it's data. (This is a similar concept to the rust Cow<'a, str> type).

The below example also inherits from boost's string_ref to avoid having to re-implement hash functions etc.

NOTE this has the dangerous effect that if you accidentally insert into the map with the non-owning version, and the string you are pointing at goes out of scope, the key will point at already freed memory. The non-owning version can only be used for lookups.

#include <iostream>
#include <map>
#include <cstring>

#include <boost/utility/string_ref.hpp>

class MaybeOwned: public boost::string_ref {
public:
  // owning constructor, takes a std::string and copies the data
  // deletes it's copy on destruction
  MaybeOwned(const std::string& string):
    boost::string_ref(
      (char *)malloc(string.size() * sizeof(char)),
      string.size()
    ),
    owned(true)
  {
    memcpy((void *)data(), (void *)string.data(), string.size());
  }

  // non-owning constructor, takes a string ref and points to the same data
  // does not delete it's data on destruction
  MaybeOwned(boost::string_ref string):
    boost::string_ref(string),
    owned(false)
  {
  }

  // non-owning constructor, takes a c string and points to the same data
  // does not delete it's data on destruction
  MaybeOwned(const char * string):
    boost::string_ref(string),
    owned(false)
  {
  }

  // move constructor, tells source that it no longer owns the data if it did
  // to avoid double free
  MaybeOwned(MaybeOwned&& other):
    boost::string_ref(other),
    owned(other.owned)
  {
    other.owned = false;
  }

  // I was to lazy to write a proper copy constructor
  // (it would need to malloc and memcpy again if it owned the data)
  MaybeOwned(const MaybeOwned& other) = delete;

  // free owned data if it has any
  ~MaybeOwned() {
    if (owned) {
      free((void *)data());
    }
  }

private:
  bool owned;
};

int main()
{
  std::map<MaybeOwned, std::string> map;
  map.emplace(std::string("key"), "value");
  map["key"] += " here";
  std::cout << map["key"] << "\n";
}
Drgabble
  • 618
  • 5
  • 17