没有对齐的malloc或内存池

3
我正在编写C代码,我有几百万个malloc请求,每个请求需要20-30字节的内存。
结果是GNU C Malloc和Jemalloc的开销达到了40-50%。DL Malloc效果更好,但仍有大约30%的开销。
是否有一种方法可以进行malloc而不需要任何对齐/填充?我理解这会更慢,可能在某些CPU上需要C从不同的字中“重构”数据,但我愿意为内存使用量交换速度。
我可以使用内存池来代替malloc,只要它可以在free()之后重用内存。

你分配的块的大小是否有硬限制?它比平均大小高多少? - Pascal Cuoq
不,但我计划编写一些逻辑,并将超过100字节的所有内容传递给标准malloc函数。 - Nick
你的工作负载是否混合了内存分配和释放?你使用线程吗?你可以为每个所需大小轻松实现一个简单的池化分配器,但是随着许多不同大小的对象被释放,释放操作会变得缓慢。在释放时指定对象大小(free(ptr, size))会有所帮助;这样你只需要扫描与大小兼容的池子,而不是所有池子。如果你一次性释放整个池子而不是单个元素,你可以使用原子内置函数使分配非常快且线程安全。 - Nominal Animal
通常情况下,当软件准备好时,会有解除分配的操作。然而,提到的50%开销是在没有任何解除分配的情况下发生的,唯一的原因是16位填充。例如,17字节的malloc会被舍入为32 + 8元数据= 40字节。 - Nick
2个回答

4

malloc()等函数在C标准中要求为任何数据类型提供足够对齐的指针,因此为了减少分配开销,您需要实现自己的分配器(或使用现有的分配器)。

一种可能的方法是为每个可能的分配大小创建一个池链。在每个池中,您可以使用位图来跟踪哪些项目已分配,哪些空闲。开销仅略高于每个项目的一位,但您可能会拥有许多池链。这往往会使free()变慢,因为它必须搜索正确的池。

我认为更好的方法是为小型分配创建一个池链。在每个池中,小块形成一个链接列表,其中单个unsigned char跟踪长度和分配状态。这会产生未对齐的指针,但开销仅略高于一个字符。

例如:

#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <errno.h>
#include <stdio.h>

#define SMALL_POOL_SIZE 131072
#define SMALL_LIMIT     ((UCHAR_MAX + 1U) / 2U)

struct small_pool {
    struct small_pool *next;
    unsigned int       size;
    unsigned int       used;
    unsigned char      data[];
};

static struct small_pool *list = NULL;

data[]成员内,第一个字符是1到SMALL_LIMIT-1表示该大小的可用块,或者大于SMALL_LIMIT+1表示已用的一块或多块。0和SMALL_LIMIT表示错误。例如,可以实现一个空间紧凑的分配器:

void *small_alloc(const size_t size)
{
    struct small_pool *pool;

    if (size < 1 || size >= SMALL_LIMIT) {
        errno = EINVAL;
        return NULL;
    }

    pool = list;

    while (pool != NULL) {

        /* Unused space in the pool? */
        if (pool->used + size < pool->size) {
            unsigned char *const ptr = pool->data + pool->used;

            /* Grab it. */
            pool->used += size + 1U;

            /* Create new slot. */
            (*ptr) = size + SMALL_LIMIT;

            /* Done. */
            return ptr + 1U;
        }

        /* Check the free slots in the pool. */
        {
            unsigned char *const end = pool->data + pool->used;
            unsigned char       *ptr = pool->data;
            unsigned char    big_len = SMALL_LIMIT;
            unsigned char   *big_ptr = NULL;

            while (ptr < end)
                if (*ptr == 0U || *ptr == SMALL_LIMIT) {
                    /* Invalid pointer */
                    errno = EDOM;
                    return NULL;
                } else
                if (*ptr > SMALL_LIMIT) {
                    /* Used slot, skip */
                    ptr += (*ptr) - SMALL_LIMIT + 1U;
                    continue;
                } else {
                if (*ptr < size) {
                    /* Slot is too small, skip it */
                    ptr += (*ptr) + 1U;
                    continue;
                } else
                if (*ptr == size) {
                    /* Perfect slot; grab it. */
                    (*ptr) = size + SMALL_LIMIT;
                    return ptr + 1U;
                } else
                    /* Remember smallest of the large enough slots */
                    if (*ptr < big_len) {
                        big_len = *ptr;
                        big_ptr = ptr;
                    }
                    ptr += (*ptr) + 1U;
                }

            if (big_ptr != NULL) {
                (*big_ptr) = big_len + SMALL_LIMIT;
                return big_ptr + 1;
            }
        }

        /* Check the next pool. */
        pool = pool->next;
    }

    /* Need a new pool. */
    pool = malloc(SMALL_POOL_SIZE);
    if (pool == NULL) {
        errno = ENOMEM;
        return NULL;
    }

    /* Initialize pool; use the initial slot for the new allocation. */
    pool->used = size + 1;
    pool->size = SMALL_POOL_SIZE - sizeof (struct small_pool);
    pool->data[0] = size + SMALL_LIMIT;

    /* Prepend this pool to the pool chain. */
    pool->next = list;
    list = pool;

    /* Return the slot we used. */
    return pool->data + 1;
}

它有一个简单的策略:如果池中存在未使用的尾随空间,则使用它。否则,扫描池以找到未使用的插槽。如果有一个完美大小的插槽,则使用它;否则,使用最小的足够大的未使用插槽。
有许多改进可行。例如,您可以将完整的池保留在单独的列表中,以避免扫描它们。还可能将找到自由插槽的池移动到池列表的开头,这是一个好主意。
解除分配更加复杂。如果我们在分配中相对较少地解除分配,并且不担心释放整个池,则解除分配可能只需简单地实现。
int small_free(void *const item)
{
    if (item == NULL)
        return 0;
    else {
        struct small_pool *pool = list;

        while (pool != NULL && !((unsigned char *)item > pool->data && (unsigned char *)item < pool->data + pool->used))
            pool = pool->next;

        if (pool != NULL) {
            unsigned char *const ptr = (unsigned char *)item - 1;

            if (*ptr > SMALL_LIMIT)
                (*ptr) -= SMALL_LIMIT;

            return 0;
        }

        return ENOENT;    
    }
}

假设您需要函数在分配不是小内存块的情况下返回ENOENT。如果验证要释放的指针是否有效很重要(例如进行调试),则:
int small_free(void *const item)
{
    if (item == NULL)
        return 0;
    else {
        struct small_pool *pool = list;

        while (pool != NULL && !((unsigned char *)item > pool->data && (unsigned char *)item < pool->data + pool->used))
            pool = pool->next;

        if (pool != NULL) {
            unsigned char *const end = pool->data + pool->used;
            unsigned char       *ptr = pool->data;

            while (ptr < end)
                if (*ptr == 0U || *ptr == SMALL_LIMIT)
                    return EDOM;
                else
                if (*ptr < SMALL_LIMIT) {
                    size_t len = (*ptr) + 1U;

                    /* Coalesce consecutive slots, if possible. */
                    while (len + ptr[len] < SMALL_LIMIT) {
                        (*ptr) = len + ptr[len];
                        len = (*ptr) + 1U;
                    }

                    ptr += len;

                } else {
                    const size_t len = (*ptr) + 1U - SMALL_LIMIT;

                    /* Within the current slot.. probably should just
                     * compare item to ptr+1 instead. */
                    if ((unsigned char *)item > ptr && (unsigned char *)item < ptr + len) {
                        *ptr = len - 1U;
                        return 0;
                    }

                    ptr += len;
                }
        }

        return ENOENT;    
    }
}

即使是后一种版本,当池中的最后一个块被释放时,也不会修剪-> used,也不会完全释放未使用的池。换句话说,上述回收只是一个粗略的示例。
就速度而言,在我的机器上,上述方法似乎至少比GLIBC的malloc()/free()慢一个数量级。这里有一个简单的测试,用于检查线性分配 - 半随机回收模式:
/* Make sure this is prime wrt. 727 */
#define POINTERS 1000000

int main(void)
{
    void **array;
    size_t i;

    fprintf(stderr, "Allocating an array of %lu pointers: ", (unsigned long)POINTERS);
    fflush(stderr);
    array = malloc((size_t)POINTERS * sizeof array[0]);
    if (array == NULL) {
        fprintf(stderr, "Failed.\n");
        return EXIT_FAILURE;
    }
    fprintf(stderr, "Done.\n\n");
    fprintf(stderr, "Allocating pointers in varying sizes.. ");
    fflush(stderr);
    for (i = 0; i < POINTERS; i++) {
        const size_t n = 1 + ((i * 727) % (SMALL_LIMIT - 1));
        if (!(array[i] = small_alloc(n))) {
            if (errno == EDOM)
                fprintf(stderr, "Failed at %lu; corrupted list.\n", (unsigned long)i + 1UL);
            else
                fprintf(stderr, "Failed at %lu: %s.\n", (unsigned long)i + 1UL, strerror(errno));
            return EXIT_FAILURE;
        }
    }

    fprintf(stderr, "Done.\n\n");
    fprintf(stderr, "Deallocating pointers in a mixed order.. ");
    fflush(stderr);

    for (i = 0; i < POINTERS; i++) {
        const size_t p = (i * 727) % POINTERS;
        if (small_free(array[p])) {
            if (errno == EDOM)
                fprintf(stderr, "Failed at %lu: corrupted list.\n", (unsigned long)i + 1UL);
            else
                fprintf(stderr, "Failed at %lu: %s.\n", (unsigned long)i + 1UL, strerror(errno));
            return EXIT_FAILURE;
        }
    }

    fprintf(stderr, "Done.\n\n");
    fprintf(stderr, "Deallocating the pointer array.. ");
    fflush(stderr);

    free(array);

    fprintf(stderr, "Done.\n\n");
    fflush(stderr);

    return EXIT_SUCCESS;
}

在我看来,基于池的内存分配器的真正优势在于可以一次性释放整个池。考虑到这一点,也许您的工作负载在构建结构时可以使用专门的内存分配器,并在最后至少运行一个压缩阶段(能够调整指针),如果已经进行了足够数量的删除,则还可以在构建过程中执行。这种方法将允许您将临时需要的内存释放回操作系统。(如果没有压缩,则大多数池可能仍然有至少一次分配,这使得无法释放该分配。)
我认为这并不是一个很好的答案,但更好的答案需要更多详细信息,特别是存储的数据结构以及实际发生的分配/释放模式。在缺乏这些信息的情况下,希望这至少能给您一些思路。

1

它不一定更慢。 如果块的大小是固定的(或有限范围内的大小),或者您实际上按逻辑顺序分配和取消分配(FIFO / FILO),则可以通过处理“池”来改善性能和内存管理。

有一个Boost库实现了这个功能,可能适合您的需求。

http://www.boost.org/doc/libs/1_57_0/libs/pool/doc/html/boost_pool/pool/interfaces.html

或者您可以自己动手 - 分配大块内存并自行切割。

请注意,这种方法可能不仅速度较慢,而且可能会失败。许多对齐的机器在被要求加载地址不对齐的数据时会出现问题。例如,实际上加载的是舍入的地址,其中包含正确值之前的随机数据。编译器绝对没有义务“重构”值,我认为它们不这样做比这样做更常见。

因此,为了可移植性,您可能需要使用 memcpy() 将不对齐的数据移动到对齐位置后再使用。这并不一定会有您想象中的所有开销,因为一些编译器会将 memcpy() 内联。

因此,池管理分配通常可以更快(潜在地快得多),但 memcpy() 可能会更慢(虽然可能不会慢太多)。

这是个权衡。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接