Intersec TechTalk

malloc() not the one-size-fits-all allocator

malloc() is extremely convenient because it is generic. It does not make any assumptions about the context of the allocation and the deallocation. Such allocators may just follow each other, or be separated by a whole job execution. They may take place in the same thread, or not… Since it is generic, each allocation is different from each other, meaning that long term allocations share the same pool as short term ones.

Consequently, the implementation of malloc() is complex Since memory can be shared by several threads, the pool must be shared and locking is required. Since modern hardware has more and more physical threads, locking the pool at every single allocation would have disastrous impacts on performance. Therefore, modern malloc() implementations have thread-local caches and will lock the main pool only if the caches get too small or too large. A side effect is that some memory gets stuck in thread-local caches and is not easily accessible from other threads.

Since chunks of memory can get stuck at different locations (within thread-local caches, in the global pool, or just simply allocated by the process), the heap gets fragmented. It becomes hard to release unused memory to the kernel, and it becomes highly probable that two successive allocations will return chunk of memories that are far from each other, generating random accesses to the heap. As we have seen in the previous article, random access is far from being the optimal solution for accessing memory.

As a consequence, it is sometimes necessary to have specialized allocators with predictable behavior. At Intersec, we have several of them to use in various situations. In some specific use cases we increase performance by several orders of magnitude.

Benchmarks

In order to provide some comparison points, we ran a small synthetic benchmark. This benchmark tests the performance of malloc() and free() in two scenarios. The first scenario is simple: we allocate a 100 million pointers and then we free them all. The allocator’s raw performance is tested in a single-threaded environment for small allocations.

#include <stdlib.h>
#include <stdio.h>
#include <stdint.h>
#include <sys/time.h>

struct list {
    struct list *next;
};

static int64_t timeval_diffmsec(const struct timeval *tv2,
                                const struct timeval *tv1)
{
    int64_t delta = tv2->tv_sec - tv1->tv_sec;

    return delta * 1000 + (tv2->tv_usec - tv1->tv_usec) / 1000;
}

int main(int argc, char *argv[])
{
    for (int k = 0; k < 3; k++) {
        struct timeval start;
        struct timeval end;
        struct list *head = NULL;
        struct list *tail = NULL;

        /* Allocation */
        gettimeofday(&start, NULL);
        head = tail = malloc(sizeof(struct list));
        for (int i = 0; i < 100000000; i++) {
            tail->next = malloc(sizeof(struct list));
            tail = tail->next;
        }
        tail->next = NULL;
        gettimeofday(&end, NULL);

        printf("100,000,000 allocations in %ldms (%ld/s)\n",
               timeval_diffmsec(&end, &start),
               100000000UL * 1000 / timeval_diffmsec(&end, &start));

        /* Deallocation */
        gettimeofday(&start, NULL);
        while (head) {
            struct list *cur = head;

            head = head->next;
            free(cur);
        }
        gettimeofday(&end, NULL);
        printf("100,000,000 deallocations in %ldms (%ld/s)\n",
               timeval_diffmsec(&end, &start),
               100000000UL * 1000 / timeval_diffmsec(&end, &start));
    }

    return 0;
}

The second scenario adds multithreading: after allocating all our pointers, we start freeing them in another thread while allocating new batches of pointers in the main thread. As a consequence, allocation and deallocation are run concurrently in two different threads, creating contention on the allocation pool.

The benchmark was run three times: once with the ptmalloc (glibc‘s implementation), another with tcmalloc (Google’s implementation), and finally with jemalloc (Linux port of FreeBSD implementation).

Scenario Allocation Deallocation Memory Time
Uncontended ptmalloc 1512ms (66M/s) 1261ms (79M/s) 2.9GiB 9.98s
tcmalloc 1712ms (58M/s) 2229ms (44M/s) 769MiB 12.10s
jemalloc 3312ms (30M/s) 4191ms (23M/s) 784MiB 22.55s
Contended ptmalloc 16154ms (6.2M/s) 15309ms (6.3M/s) 2.9GiB 39.18s
tcmalloc 2860ms (34M/s) 6707ms (14M/s) 1.7GiB 14.62s
jemalloc 3845ms (26M/s) 11672ms (8.5M/s) 2.3GiB 23.55s

Indeed, the results depend vastly on the implementation of malloc(). While, on a non-contended environment, the ptmalloc shows slightly better performances than tcmalloc (at the cost of a much larger memory footprint), tcmalloc behaves much better in a multithreaded environment.

One allocation batch contains 100M 8-byte pointers, this means it allocates 800MB (762MiB). As a consequence, in the single threaded case, the payload is 762MiB. As we can see tcmalloc is near-optimal in terms of memory consumption. However, one odd thing about tcmalloc is that deallocation is slower than allocation: the deallocation thread is not able to free memory as fast as it is allocated, causing an ever growing memory footprint if we increase the number of threads run in the benchmark.

The benchmark is synthetic and stresses only the small-chunk allocator in an extremely specific use-case. Thus, it should not be considered as an absolute proof that tcmalloc is faster in multithreaded environment while ptmalloc is faster in single threaded ones. However, the benchmark shows that there is no perfect malloc() implementation and that choosing the right implementation for your use case may have huge impacts on overall performance.

Last, but not least, this benchmark shows that you can perform only a few millions of allocations/deallocations per second. This may seem quite large, but as soon as you want to process several hundreds of thousands of events per second, and if each event triggers one or more allocations, then malloc() will start being a bottleneck.

Stack allocator

The first (and certainly the most used) custom allocator at Intersec is the stack allocator. This is a LIFO allocator, meaning that allocations are deallocated in the reverse order of their allocation. It mimics the behavior of the program’s stack since allocations are grouped in frames, and frames are deallocated at once.

Internals

The stack allocator is an arena-based allocator. It allocates huge blocks and then splits them into smaller chunks.

It keeps track of two key pieces of information about each block:

  • the bottom of the stack
  • the delimitation of the frames

When an allocation is performed, the bottom of the stack is incremented by the size of the requested memory (plus the alignment requirements and the size of the canaries). If the requested size cannot fit in the current block, a new one is allocated: the allocator will not try to fill the gap left in the previous block.

When a frame is created, a mark is pushed at the bottom of the stack with the position of the beginning of the previous frame. The allocator always knows the position of the beginning of the current frame. That way, removing a frame is extremely fast: the allocator sets the bottom of the stack back to the current frame’s position, then it reloads the previous frame’s position and makes it its current. Additionally, the allocator will list the blocks that were totally freed by the operation and deallocate them.

">↩]

Read the original on techtalk.intersec.com ↗