maomao90's blog

By maomao90, history, 2 months ago, In English

Sidetrack

It has been two years since I wrote an educational blog. I've been inactive in the CP community recently as I've shifted my focus to internships. Recently, I observed some interesting behaviour with std::queue while profiling an application during my internship, which led me to writing this blog.

Most of the images below are taken from the C++ standard https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2023/n4950.pdf

Queues and stacks

Queues and stacks do not actually provide any new implementations... They are just wrappers around a container (which is std::deque) by default.

This means that if you use the default Container template of std::stack and std::queue, it will be as slow as a std::deque. Now, let us see why std::deque is slow.

Deque

Below is a brief implementation of deque. (You may refer to the section below for exact implementations)

template<typename T>
struct SimpleDeque {
  static constexpr size_t BLOCK_BYTES = 512;
  static constexpr size_t BLOCK_SIZE = BLOCK_BYTES / sizeof(T);

  T** map = nullptr;
  size_t map_size = 0;
  size_t n = 0;

  void grow_map() {
    size_t new_size = map_size ? map_size * 2 : 2;

    T** new_map = new T*[new_size]{};

    for (size_t i = 0; i < map_size; i++)
      new_map[i] = map[i];

    delete[] map;
    map = new_map;
    map_size = new_size;
  }
  
  SimpleDeque() : map(new T*[2]{}), map_size(2), n(0) {}

  void push_back(const T& value) {
    size_t block  = n / BLOCK_SIZE;
    size_t offset = n % BLOCK_SIZE;

    if (block >= map_size)
      grow_map();

    if (offset == 0)
      map[block] = new T[BLOCK_SIZE];

    map[block][offset] = value;
    ++n;
  }

  T& operator[](size_t i) {
    return map[i / BLOCK_SIZE]
      [i % BLOCK_SIZE];
  }
};

Essentially, the deque stores an array of tiny blocks. In GCC, each tiny block is $$$512$$$ bytes, but it can be different in other compilers (see https://codeforces.me/blog/entry/135332?#comment-1210774). So, if you are storing a deque of int32, each tiny block will store $$$\frac{512}{4} = 128$$$ int32. If you push $$$500$$$ int32 into a deque, map will be an array consisting of $$$\left\lceil \frac{500}{128} \right\rceil = 4$$$ tiny arrays each capable of storing $$$128$$$ int32.

When we push_back, we will add the element to the tiny block. However, when we reach the end of a tiny block, we will need to create a new tiny block (which allocates on the heap!!).

This means that every time you push $$$512$$$ bytes into the deque, it will result in a malloc call!

This does not sound very efficient. Why is deque implemented this way? Let's look at the standard.

The most important part is "An insertion at either end of the deque ... has no effect on the validity of references to elements of the deque". If we implement deque the same way as a vector, where we create a new array that has double the size of the original capacity, this would cause the references to elements of the deque to be invalidated since the new array will be a contiguous chunk of memory at a different location from the original array.

By using this array of tiny blocks structure, the tiny blocks are always the same after the first time its created, so references to elements in the tiny blocks will always remain valid.

Consequences

  1. Deque causes a lot of allocation and deallocation. For performance sensitive code, you should probably implement your own deque that uses vector's doubling behavior.
  2. Deque allocates a chunk of memory (the tiny block) even if the deque only contains one element. So if you have many deques each with very few elements, you will probably be surprised by the amount of wasted memory (especially clang which will use $$$4096$$$ bytes even if there is only one element).
  3. The tiny blocks are not contiguous in memory. Not sure whether this has any performance impact since each tiny block itself is contiguous and larger than a single cache line.

Detailed GCC Analysis

Let us look at GCC's implementation of deque. The implementations jump around the below two files, so you will need to refer to both.

_Tp** _M_node is the array of tiny arrays. Let's look at the += operator of the _Deque_iterator:

_Self& operator+=(difference_type __n)
{
  const difference_type __offset = __n + (_M_cur - _M_first);
  if (__offset >= 0 && __offset < difference_type(_S_buffer_size()))
    _M_cur += __n;
  else
  {
    const difference_type __node_offset =
      __offset > 0 ? __offset / difference_type(_S_buffer_size())
          : -difference_type((-__offset - 1) / _S_buffer_size()) - 1;
    _M_set_node(_M_node + __node_offset);
    _M_cur = _M_first + (__offset - __node_offset
        * difference_type(_S_buffer_size()));
  }
  return *this;
}

See how we need to jump by __node_offset which is just the number of $$$512$$$ byte blocks that we need to shift by.

Then, when we push_front or push_back, if the front / back tiny array still has space, we just add it there. However, once we reach the end of the tiny array, we would need to make a new tiny array block. We can see this in the push_back function:

void push_back(const value_type& __x) {
  if (this->_M_impl._M_finish._M_cur
      != this->_M_impl._M_finish._M_last - 1) {
    this->_M_impl.construct(this->_M_impl._M_finish._M_cur, __x);
    ++this->_M_impl._M_finish._M_cur;
  } else
    _M_push_back_aux(__x);
}

void deque<_Tp, _Alloc>::_M_push_back_aux(const value_type& __t) {
  _M_reserve_map_at_back();
  ...
}

void _M_reserve_map_at_back(size_type __nodes_to_add = 1) {
  if (__nodes_to_add + 1 > this->_M_impl._M_map_size
      - (this->_M_impl._M_finish._M_node - this->_M_impl._M_map))
    _M_reallocate_map(__nodes_to_add, false);
}

template <typename _Tp, typename _Alloc>
void deque<_Tp, _Alloc>::_M_reallocate_map(size_type __nodes_to_add, bool __add_at_front) {
  const size_type __old_num_nodes
    = this->_M_impl._M_finish._M_node - this->_M_impl._M_start._M_node + 1;
  const size_type __new_num_nodes = __old_num_nodes + __nodes_to_add;

  _Map_pointer __new_nstart;
  if (this->_M_impl._M_map_size > 2 * __new_num_nodes) {
    ...
  } else {
    size_type __new_map_size = this->_M_impl._M_map_size
      + std::max(this->_M_impl._M_map_size,
          __nodes_to_add) + 2;

    _Map_pointer __new_map = this->_M_allocate_map(__new_map_size);
    __new_nstart = __new_map + (__new_map_size - __new_num_nodes) / 2
      + (__add_at_front ? __nodes_to_add : 0);
    std::copy(this->_M_impl._M_start._M_node,
        this->_M_impl._M_finish._M_node + 1,
        __new_nstart);
    _M_deallocate_map(this->_M_impl._M_map, this->_M_impl._M_map_size);

    this->_M_impl._M_map = __new_map;
    this->_M_impl._M_map_size = __new_map_size;
  }

  this->_M_impl._M_start._M_set_node(__new_nstart);
  this->_M_impl._M_finish._M_set_node(__new_nstart + __old_num_nodes - 1);
}
  • Vote: I like it
  • +110
  • Vote: I do not like it

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

Auto comment: topic has been updated by maomao90 (previous revision, new revision, compare).

»
2 months ago, hide # |
 
Vote: I like it +12 Vote: I do not like it

This issue is well known in the Chinese OI community: creating 1e6 instances of std::deque/queue/stack<int> can consume more than 512 MB of memory. Many contestants have gotten FSTs because of this.

I have always wondered why std::deque was chosen as the default container for std::stack. Wouldn't std::vector be a much better choice?

  • »
    »
    2 months ago, hide # ^ |
     
    Vote: I like it -9 Vote: I do not like it
    same goes for stack/queue
  • »
    »
    2 months ago, hide # ^ |
     
    Vote: I like it +2 Vote: I do not like it

    I think the choice of std::deque as the default underlying container for std::stack is reasonable, even though std::vector is often more performant in practice now.

    We know that std::vector stores the elements contiguously, which gives it cache locality and lower percontainer memory overhead. On the other hand, we know std::deque store elements in mini fixed-size blocks with a map of pointers, so every deque has some additional metadata. This is probably why std::stack objects can consume more amount of memory, as you said. (though i really think the overhead is not that much? But ive never benchmarked it, maybe you can show a problem example too :D)

    However, std::stack only requires back(), push_back(), and pop_back(). std::deque provides these operations with strict O(1) complexity (in the standard) and never needs to reallocate all of its existing elements as it grows. https://en.cppreference.com/cpp/container/deque : "Insertion or removal of elements at the end or beginning — constant O(1)."

    On the other hand, std::vector needs to allocate a larger contiguous block (iirc sometimes 2x, 1.5x depending on the compiler) and move all elements when capacity is full. Although it gives amortized O(1) push_back(), one insertion can still be O(n), and it invalidates all iterators, pointers, and references. https://en.cppreference.com/cpp/container/vector : "Insertion or removal of elements at the end — amortized constant O(1)."

    I am not sure how the standard ensure constant O(1) for deque, cause in my understanding, when the pointer map is full (which i think is a contiguous memory), it also need to reallocate to larger map, then copy the pointer map to new memory (which is O(#blocks ~ N/512))? But I have not dug into it and found the answer, let’s just trust the standard :D.

    There's also many other tradeoff between std::vector and std::deque, iinw like if std::vector<T> where T does not have noexcept move constructor, in std::vector<T> reallocation it will need to copy each of its element, where std::deque never has to relocate the elements when it grows, only copies the block pointers. (and hey lets not forget about std::vector<bool>...)

    Historically, I think this tradeoff also made more sense. When the STL was designed in the 90s, computer systems had much less RAM, smaller virtual address spaces, and memory fragmentation was a more significant concern. Allocating large contiguous memory was more likely to fail than allocating several smaller blocks (probably?). Modern hardware and allocators have made contiguous memory allocations much cheaper, which is one reason std::vector is often the faster choice today. But sadly, we know C++ has many historical decision that cant change today because maintaining backward compatibility is a priority.

    Also, even today C++ is widely used on embedded & lower end systems too. Where its MMU, virtual mem, and allocator isnt as good, or doesnt even exist, as compared to modern computers/servers.

    That said, we know the STL is designed to be a general purpose library rather than specifically for CP. For a general default, preferring predictable worst case complexity and avoiding large reallocations is a good? design decision. If your workload benefits from vector , you can always write: std::stack<T, std::vector<T>> So I dont think deque is necessarily a bad default, it's just optimized for a different set of tradeoffs than those common in CP, CMIIW.

»
2 months ago, hide # |
 
Vote: I like it 0 Vote: I do not like it

Cool blog. Thanks for explaining why an older code of mine got MLE

»
2 months ago, hide # |
 
Vote: I like it +3 Vote: I do not like it

Here's a fun story (type 2 fun) from IOI: I was getting ridiculous TLEs on a linear-time solution. Deeper review after the contest showed that I was initializing a queue many times inside loops during something like BFS and pushing into it — if the graph was highly disconnected, the constant factor was dominated by a construct+destroy loop. Apparently deque has massive overhead on construction too! In comparison, construct+push_back+destroy loop for vectors is ~2x faster and pure construct+destroy is much faster; it's still a bad design regardless of the choice of DS, but when I'm already close to the time limit because I'm a pleb coder, it can affect my results.