Zweistein
Zweistein

Reputation: 343

C++ STL - Containers implementation

I am currently learning a lot about Intrusive Containers. So I often compare them to the standard containers.

Let's take the std::list for example. I read that this container is usually implemented as a doubly-linked list. But I didn't read anything of how the nodes are implemented in detail. I assume there is a 'previous' and 'next' pointer, but what about the object that belongs to such a node. Is it a Pointer to an object, or the object itself, that is constructed in the nodes allocated memory?

In Boost.Intrusive it is stated that their containers have a better locality (see here: https://www.boost.org/doc/libs/1_72_0/doc/html/intrusive/usage_when.html, or here: https://www.boost.org/doc/libs/1_72_0/doc/html/intrusive/performance.html). I am not sure why that is the case. When the node in the std::list holds an object and the intrusive container holds a node in their object, how does that lead to better locality?

Upvotes: 0

Views: 420

Answers (1)

Andrey Semashev
Andrey Semashev

Reputation: 10606

Is it a Pointer to an object, or the object itself, that is constructed in the nodes allocated memory?

In Boost.Intrusive, the element type of the container is the node. In order to make it compatible with the container, you have to modify your element type so that it includes data members required by the container - either by inheriting from a base class (e.g. list_base_hook, see here) or by adding a special data member (e.g. list_member_hook). This is why they are called intrusive containers. In comparison, the standard containers do not require you to modify your classes and instead wrap them in container nodes, if necessary.

When the node in the std::list holds an object and the intrusive container holds a node in their object, how does that lead to better locality?

In std::list each container node (which contains your element) is allocated separately, in its own dynamic memory allocation. The node contains pointers to the previous and the next elements in the list. Since each node is allocated separately, their locality depends on the memory allocator used, but generally you can assume that different nodes are located at arbitrary locations in memory, possibly distant and not in order. Additionally, traversing the list requires to dereference a pointer to the next element on each iteration, which typically hampers memory caching algorithms in the CPU.

In boost::intrusive::list, the container does not impose any memory allocation strategy on the user. It is possible to have a single memory region for multiple or all elements of the intrusive container, which makes them more closely packed and possibly ordered in memory. This requires more work from the user, of course. List iteration still requires pointer dereferencing and hurts prefetcher in the CPU, but if container elements are closely packed, chances are high that each next node will be in a cache line that was already fetched from memory for the previous element(s).

Another thing to note is that intrusive containers are much more useful when you need to store element in multiple containers at once. With standard containers, you have to use pointers to refer to the element from each container. For example:

// Element type
class Foo {};

std::list< std::shared_ptr< Foo > > list;
std::map< int, std::shared_ptr< Foo > > map;

In this example, you have at least one allocation for a Foo object, one allocation for the list node and one allocation for the map node. Each of these allocations is arbitrarily located in memory. Accessing the element either via list or via map requires an additional level of indirection.

With intrusive containers, you can reduce this to just one allocation and no extra indirection:

// List hook
typedef boost::intrusive::list_base_hook<> FooListHook;
// Map/set hook
typedef boost::intrusive::set_base_hook<
    boost::intrusive::optimize_size< true >
> FooSetHook;

// Node type
class Foo :
    public FooListHook,
    public FooSetHook
{
    ...
};

boost::intrusive::list< Foo, boost::intrusive::base_hook< FooListHook > > list;
boost::intrusive::set< Foo, boost::intrusive::base_hook< FooSetHook >, ... > set;

In this case, neither list nor set does memory allocation of their own, all the necessary data is already within the Foo node, which you allocate yourself. Iterating through either of the containers automatically fetches both hooks and Foo contents (at least partially) into cache, which makes memory accesses faster. There are other benefits to this approach as well, like the ability to switch between the two containers' iterators without expensive element lookup.

Upvotes: 1

Related Questions