大白话解析LevelDB:Arena内存分配器

Arena

大多数C++相关的开源项目都会实现自己的内存管理,而不直接使用标准库的malloc。 现在流行的malloc实现有jemalloctcmallocptmalloc等,它们都能高效的管理内存,减少内存碎片,提高多线程下内存分配的性能。但这都是对应于general的应用场景,每个应用都有不同的场景,有的分配大内存为主,有的分配小内存为主。为了更进一步的提高内存分配效率与减少内存碎片,LevelDB使用自己的内存管理机制,Arena。

相比于直接使用malloc,Arena的优势在于:

  1. 小内存分配的效率:LevelDB经常需要分配小块内存。对于每个小分配使用malloc可能因为开销而效率不高。“Arena”一次性分配较大的内存块,然后高效地分割出小段。这减少了许多小malloc调用的开销。 这种批处理机制同样应用于jemalloc, tcmalloc等,但他们需要维护复杂的数据结构,不如“Arena”简单高效。

  2. 方便统计内存使用情况:LevelDB需要跟踪内存使用情况,通过Arena而不是直接malloc可以方便的记录内存使用情况。

  3. 控制内存生命周期:“Arena”允许LevelDB轻松控制其内存分配的生命周期。当Arena被销毁时,它分配的所有内存都在一次操作中被释放,这比单独释放每个块更有效。

我们来看下class Arena的结构:

class Arena {
   public:
    Arena();

    // 不允许拷贝
    Arena(const Arena&) = delete;
    Arena& operator=(const Arena&) = delete;

    ~Arena();

    // 等同于malloc(bytes)
    char* Allocate(size_t bytes);

    // 内存对齐版的Allocate
    char* AllocateAligned(size_t bytes);

    // 返回至今为止分配的内存总量
    size_t MemoryUsage() const;
};

Arena提供了两个版本的内存分配函数,一个是Allocate,一个是AllocateAligned。前者不考虑内存对齐,后者会自动进行内存对齐。内存对齐的好处后面再讲。

Arena只提供了内存申请的Allocate接口,却没有内存释放的接口,那怎么才能释放内存呢?当Arena销毁的时候,再集中释放。🤣

现在我们逐个分析各个接口的实现。

Allocate(size_t bytes)的实现

先来认识下Arena里的3个成员:

class Arena {
    // ...

    // Arena每次都从OS申请内存都是申请
    // 一个block,然后放到blocks_里。
    std::vector<char*> blocks_;

    // 当前block里,下一次分配内存返回的地址
    char* alloc_ptr_;
    // 当前block里,剩余可分配的bytes
    size_t alloc_bytes_remaining_;
};

现在再来看Allocate的实现:

inline char* Arena::Allocate(size_t bytes) {
    // Allocate(0)没有意义
    assert(bytes > 0);

    // 如果申请的bytes小于当前block剩余的bytes,
    // 就从block的剩余bytes里分配。
    // 并更新alloc_ptr_和alloc_bytes_remaining_
    if (bytes <= alloc_bytes_remaining_) {
        // 当前block的alloc_ptr_就是需要返回的地址,
        char* result = alloc_ptr_;
        // 更新下一次分配的起始地址
        alloc_ptr_ += bytes;
        // 更新当前block剩余可分配的bytes
        alloc_bytes_remaining_ -= bytes;
        return result;
    }

    // 如果申请的bytes大于当前block剩余的bytes,
    // 使用AllocateFallback(bytes)分配
    return AllocateFallback(bytes);
}

AllocateFallback(bytes)用于当前block剩余内存不足的情况。

这里有个前置知识需要补充一下,在分配内存时,为了避免内存碎片,一般会设两个内存池:大内存池和小内存池。分配大内存时走大内存池,分配小内存时走小内存池。

AllocateFallback的思路是大内存直接找os要,小内存就从自己的block里分配。

这样做的好处是:

  • 申请大内存的几率比较小,不会很频繁,找os要虽然慢但是可以避免内存碎片。
  • 申请小内存的几率大,会比较频繁,从block中分配,效率高并且碎片也少。
char* Arena::AllocateFallback(size_t bytes) {
    if (bytes > kBlockSize / 4) {
        // 当分配的内存大于块的1/4时,直接找os要,不从block中分配,
        // 以此保证将每个block的内存碎片限制在1/4以内。
        // 找os要一个 bytes 大小的block,这个
        // block不再用于后续分配内存,用户单独享用。
        // 
        // 其实这里是可以直接写成 return malloc(bytes)的,
        // 只是为了记录内存分配情况,所以要走一遍AllocateNewBlock。
        char* result = AllocateNewBlock(bytes);
        return result;
    }

    // 只有bytes小于当前block剩余的bytes时才会走到AllocateFallback,
    // 所以此时肯定要找os要一个新的block。
    alloc_ptr_ = AllocateNewBlock(kBlockSize);
    alloc_bytes_remaining_ = kBlockSize;

    // 更新 alloc_ptr_ 与 alloc_bytes_remaining_ 
    char* result = alloc_ptr_;
    alloc_ptr_ += bytes;
    alloc_bytes_remaining_ -= bytes;
    return result;
}

继续看AllocateNewBlock的实现,代码写的很清晰了,不需要解释。

char* Arena::AllocateNewBlock(size_t block_bytes) {
    // 找os拿一块block_bytes大小的内存,
    char* result = new char[block_bytes];

    // 将该block放到blocks_中,
    // Arena销毁的时候一起释放。
    blocks_.push_back(result);

    // 记录分配的内存量。
    memory_usage_.fetch_add(block_bytes + sizeof(char*),
                            std::memory_order_relaxed);
    return result;
}

最后值得一提的是,为什么AllocateFallback中的AllocateNewBlock(kBlockSize),找os申请新block时,默认要申请kBlockSize = 4K的大小呢?

kBlockSize设为4K,有以下几点好处:

  • 减少内存碎片:linux的内存管理里,每次内存申请都以页为单位,页的大小是4KB。比如说从os拿5KB的内存,操作系统实际上会分配2页的内存,也就是8KB,这就会产生3KB的内存碎片。而如果每次申请的内存都是4KB的整数倍,os就会刚好分配1页,不会产生内存碎片。

  • 减少Page Fault的开销:4KB意味着这段内存空间位于一张页面上,只需做1次Page Fault。若将4KB改为4100B,访问最后10B的时候,由于这10B不在一张页面上,需要产生2次Page Fault

  • 更好利用CPU缓存: 1个cache-line的大小是64B,4KB刚好是64B的整数倍,连续的数据块更有可能完全位于单个cache-line内。

  • 降低Cache False-Sharing的概率:关于Cache False-Sharing详见这里

AllocateAligned(size_t bytes)的实现

AllocateAlignedAllocate相比,保证分配的内存是对齐的。

比如当前alloc_ptr_指向的是0x1010,也就是10,而平台的字长是8Byte,那如果从0x1010开始分配8个字节,范围是10 ~ 17,这样就不是对齐的了。CPU需要取两次内存,一次取10 ~ 15,一次取15 ~ 17,这样就会降低效率。

AllocateAligned会先将alloc_ptr_移动到16, 然后再分配8个字节,这样就是对齐的了。

char* Arena::AllocateAligned(size_t bytes) {
    // 计算对齐的长度,最小对齐长度为8。
    // 如果当前平台的字长大于8,则对齐长度为字长。
    const int align = (sizeof(void*) > 8) ? sizeof(void*) : 8;
    
    // x & (x-1) 是个位运算的技巧,用于快速的将x的最低一位1置为0。
    // 比如x = 0b1011, 则x & (x-1) = 0b1010。
    // 此处用(align & (align - 1)) == 0)快速判断align是否为2的幂。
    // 因为2的幂的二进制表示总是只有一位为1,所以x & (x-1) == 0。
    static_assert((align & (align - 1)) == 0,
                  "Pointer size should be a power of 2");

    // 位运算技巧,等同于 current_mod = alloc_ptr_ % align
    size_t current_mod = reinterpret_cast<uintptr_t>(alloc_ptr_) & (align - 1);

    // 为了对齐,多分配slop个字节。
    size_t slop = (current_mod == 0 ? 0 : align - current_mod);
    size_t needed = bytes + slop;

    char* result;
    if (needed <= alloc_bytes_remaining_) {
        // 向后移动alloc_ptr_, 
        // 将alloc_ptr_对齐到align的整数倍。
        result = alloc_ptr_ + slop;
        alloc_ptr_ += needed;
        alloc_bytes_remaining_ -= needed;
    } else {
        // AllocateFallback本身就是对齐的,所以直接调用即可。
        // 因为AllocateFallback要么从os直接分配,
        // 要么new一个4KB的block,返回block的首地址
        result = AllocateFallback(bytes);
    }
    assert((reinterpret_cast<uintptr_t>(result) & (align - 1)) == 0);
    return result;
}

MemoryUsage()的实现

MemoryUsage()很简单,直接返回memory_usage_即可。

size_t MemoryUsage() const {
    return memory_usage_.load(std::memory_order_relaxed);
}

AllocateNewBlock中,每次分配内存都会更新memory_usage_

char* Arena::AllocateNewBlock(size_t block_bytes) {
    // ...

    // 记录分配的内存量。
    memory_usage_.fetch_add(block_bytes + sizeof(char*),
                            std::memory_order_relaxed);
    // ...
}

为什么更新memory_usage_和读取memory_usage_使用的都是std::memory_order_relaxed呢?

因为memory_usage_的上下文里没有需要读取memory_usage_的地方,不需要对指令重排做约束。

至此,Arena的实现就分析完了。

Arena的内存释放

Arena对象销毁时,会集中销毁blocks_里的block,释放内存。

Arena::~Arena() {
    for (size_t i = 0; i < blocks_.size(); i++) {
        delete[] blocks_[i];
    }
}

AllocateAligned && Allocate 的适用场景

LevelDB中,用到Arena的只有两个地方:

  • MemTable::Add里使用Arena::Allocate分配代插入记录的内存
  • SkipList::NewNode里使用Arena::AllocateAligned分配SkipList::Node的内存
void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key,
                   const Slice& value) {

    // ...

    // 通过Allocate分配record(key+value)的内存
    char* buf = arena_.Allocate(encoded_len);

    // ...
}
template <typename Key, class Comparator>
typename SkipList<Key, Comparator>::Node* SkipList<Key, Comparator>::NewNode(
    const Key& key, int height) {
    // 通过AllocateAlgined分配node的内存
    char* const node_memory = arena_->AllocateAligned(
        sizeof(Node) + sizeof(std::atomic<Node*>) * (height - 1));
    // 这里是 placement new 的写法,在现有的内存上进行 new object
    return new (node_memory) Node(key);
}

为什么前者使用Allocate,后者使用AllocateAligned呢?

MemTable::Add用于往MemTable中插入记录,这条记录的内存即使没对齐也没关系,因为不会对这块不会像遍历数组那样挨个访问,只是开辟一块内存把东西写进去,然后基本就不会访问这块内存了。若强行使用AllocateAligned只会徒增内存碎片。

SkipList::Node就不一样了,SkipList::Node里有个next_[]数组,next_[]会被频繁读取。如果next_[]里某个元素不是对齐的,每次取这个元素的时候CPU都需要取两次内存,并且会增加Cache False-Sharing的概率,关于Cache False-Sharing详见这里。所以SkipList::Node的内存需要使用AllocateAligned分配。

  • 17
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
XGBoost(eXtreme Gradient Boosting)是一种非常流行的机学习算法,它是一种梯度提升树模型。它的设计目标是提高其前身GBDT(Gradient Boosting Decision Tree)算法的性能和鲁棒性。 XGBoost使用的是一种特殊的决策树模型,称为CART(Classification and Regression Trees)。与传统的决策树不同,CART决策树在每个节点上进行分裂时,会使用一种称为泰勒展开的方法,来近似地找到最优分裂点。通过这种方法,XGBoost能够更精确地构建决策树模型,并提高预测的准确性。 XGBoost还通过引入正则化技术,如L1和L2正则化,来避免模型过拟合。正则化可以限制模型的复杂性,提高模型的泛化能力,并使得模型对噪音数据不敏感。 在训练过程中,XGBoost使用梯度提升算法,该算法通过迭代地训练多个决策树,并使用梯度下降法来优化模型的损失函数。在每一轮迭代中,XGBoost会根据之前模型的预测结果和真实标签之间的误差,调整每个样本的权重,并生成一个新的决策树。通过这种迭代优化的方式,XGBoost能够逐步提升模型的准确性。 此外,XGBoost还具备优化性能的功能。它使用一种称为并行化的技术,通过同时在多个处理上训练多个决策树,来加快训练速度。另外,XGBoost还支持特征重要性评估,可以通过计算每个特征对模型的贡献度来帮助我们理解数据的特征重要性。 总之,XGBoost是一种非常强大的机学习算法,它通过使用特殊的决策树模型、正则化技术、梯度提升算法和优化性能等方法,提高了模型的预测准确性和鲁棒性。它在很多数据竞赛和实际应用中都取得了出色的结果。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值