复用张量释放后的空间,生成推理内存布局
ONNX Runtime 记录一次执行中非字符串内部张量的分配与释放,用 best-fit 选择可用偏移,生成后续执行可以复用的内存布局。
工程问题#
中间张量往往只存活于部分执行阶段。每个张量独立分配会增加分配开销,而简单把所有张量放到不重叠的位置,又浪费已经结束生命周期的空间。
实际实现#
执行器先按设备对齐要求与 kAllocAlignment 中较大的值计算张量存储大小,非字符串内部张量才进入分配跟踪。非 program-counter 路径用按偏移排序的 blocks_ 链表表示当前仍存活的块。分配时扫描块间与缓冲区尾部的空隙,挑选足够大且剩余空间最小的一个;没有合适空隙才扩展高水位。释放只从存活链表移除,历史分配仍保存在 allocs_ 中,最终生成每个 OrtValue 的偏移和大小。
处理流程#
- 按设备和运行时的对齐要求计算大小,跟踪非字符串内部张量分配,按设备交给 MemPatternPlanner
- 扫描当前存活块之间的空隙,选择足够大的最小剩余空间
- 记录偏移;释放时移除存活记录,使相同空间可被后续张量复用
- 生成 MemoryPattern,后续命中且大小相符时从预分配缓冲区加偏移取内存
源码片段#
固定版本 · 原文摘录938void ExecutionFrame::TraceAllocate(int ort_value_idx, size_t size) {939 if (planner_.has_value()) {940 // don't trace the output tensors or external outputs.941 auto& allocation_plan = GetAllocationPlan(ort_value_idx);942 if (allocation_plan.alloc_kind == AllocKind::kAllocateOutput ||943 allocation_plan.alloc_kind == AllocKind::kAllocatedExternally) {944 return;945 }946 auto status = planner_->TraceAllocation(ort_value_idx, size);947 if (!status.IsOK()) {948 LOGS(session_state_.Logger(), WARNING) << "TraceAllocation for ort_value_idx=" << ort_value_idx949 << " size=" << size << " failed: " << status.ErrorMessage();950 }951 }952}这里接收调用者已按对齐要求计算的大小;调用点排除了字符串张量,本函数再排除输出和外部输出。OrtValuePatternPlanner 随后按设备位置转发。
148 for (auto it = blocks_.begin(); it != blocks_.end(); it++) {149 if (allocs_[*it].block_.offset_ >= current) {150 auto gap = allocs_[*it].block_.offset_ - current;151 if (gap >= size && (gap - size) < waste_bytes) {152 waste_bytes = gap - size;153 best_offset = current;154 best_offset_found = true;155 }156 }157 current = std::max(current, allocs_[*it].block_.offset_ + allocs_[*it].block_.size_);158 }159160 ORT_ENFORCE(current <= buffer_size_);161162 if (current < buffer_size_) {163 auto gap = buffer_size_ - current;164 if ((gap >= size) && ((gap - size) < waste_bytes)) {165 best_offset = current;166 best_offset_found = true;167 }168 }按偏移扫描存活块,比较足够大的间隙剩余量,末尾已分配缓冲区内的空隙也参与选择。
170 if (!best_offset_found) {171 best_offset = current;172 }173174 // we only need to bounds check the addition of size to best_offset as that is the only time we extend175 // the maximum size of the buffer.176 buffer_size_ = std::max(buffer_size_, SafeInt<size_t>(best_offset) + size);177 allocs_.emplace_back(ml_value_idx, MemoryBlock(best_offset, size));178 std::list<int>::iterator best_fit_it = blocks_.end();179 for (auto it = blocks_.begin(); it != blocks_.end(); it++) {180 if (allocs_[*it].block_.offset_ < best_offset)181 continue;182183 if ((allocs_[*it].block_.offset_ > best_offset) || (allocs_[*it].block_.size_ >= size)) {184 best_fit_it = it;185 break;186 }187 }188189 blocks_.insert(best_fit_it, (static_cast<int>(allocs_.size()) - 1));无合适空隙时从 current 开始放置,更新高水位并按偏移把新块插入存活链表。
192 void TraceFree(int ml_value_index) {193 std::lock_guard<std::mutex> lock(lock_);194195 for (auto it = blocks_.begin(); it != blocks_.end(); it++) {196 if (allocs_[*it].index_ == ml_value_index) {197 blocks_.erase(it);198 break;199 }200 }201 }释放从 blocks_ 删除存活项,不清除 allocs_ 的历史布局;后续分配因此能够看到新出现的空隙。
236 MemoryPattern pattern;237 pattern.peak_size_ = buffer_size_;238 pattern.patterns_.reserve(allocs_.size());239 for (auto& alloc : allocs_) {240 pattern.patterns_.insert_or_assign(alloc.index_, alloc.block_);241 }242243 return pattern;244 }把高水位与每个值的块位置转成布局,历史上不同值的块可以因生命周期不重叠而共享偏移。
575 if (mem_patterns_ && per_alloc_plan.alloc_kind != AllocKind::kAllocateOutput &&576 per_alloc_plan.alloc_kind != AllocKind::kAllocatedExternally) {577 auto pattern = mem_patterns_->GetPatterns(location);578 if (pattern) {579 auto block = pattern->GetBlock(ort_value_index);580 // if block not found, fall back to default behavior581 if (block) {582 auto it = buffers_.find(location);583 if (it != buffers_.end()) {584 // if the block is not correct, log message then fall back to default behavior585 if (block->size_ == size) {586 void* buffer = it->second.get();587 auto status = AllocateTensorWithPreAllocateBufferHelper(588 ort_value, static_cast<void*>(static_cast<char*>(buffer) + block->offset_), element_type, location,589 shape);590 return status;591 } else {查到布局且块大小与实际请求一致时,以缓冲区基址加 offset 构造张量;缺失或不匹配会走后面的回退路径。
片段保持固定提交的连续原文;完整源码与许可证保存在本地。每段说明交代所在分支,省略部分可通过出处查看。
收益与取舍#
工程分析带来的收益
- 复用的是不同时刻的同一段空间,避免把所有历史张量大小直接相加
- 一次记录可服务后续匹配的执行,减少重复分配操作
付出的代价
- best-fit 是局部贪心,不保证全局最小峰值;每次分配扫描和插入按存活块数量线性增长,释放查找也是线性
- 字符串张量、输出和外部分配的值不走此跟踪复用路径;块缺失、缓冲区分配失败或实际大小不符时回退普通分配
- 布局依赖实际生命周期与输入形状等缓存条件,不能用于任意并行执行时序;训练的 program-counter 分支另有重叠判断
可比较的选择
每个张量独立分配生命周期简单,但分配器调用和峰值内存可能较高。
first-fit 空隙选择可更早停止扫描,但可能留下不同的碎片布局;两者都不保证全局最优。
版本与适用范围#
对应固定提交的非 program-counter MemPatternPlanner,为非字符串内部张量按实际执行生命周期规划布局。输入大小已按设备对齐要求与 kAllocAlignment 中较大的值计算,不是直接拼接原始元素字节数。它是执行器上的内存规划层,不等同底层 BFCArena 分配器;优化开关、执行计划和输入条件在 ExecutionFrame 中检查。
参考来源#
源码、官方文档与原始论文onnxruntime/core/framework/execution_frame.cc · ExecutionFrame::TraceAllocate
onnxruntime/core/framework/mem_pattern_planner.h · MemPatternPlanner::TraceAllocation:选空隙
onnxruntime/core/framework/ort_value_pattern_planner.cc · 按设备分派 TraceAllocation / TraceFree
相关实现与比较#
4 个相关案例伙伴分配器把空闲物理页组织为不同 order 的连续块,并用空闲链表管理同阶块。
limit_req 把漏桶计算、红黑树查状态与队列回收组合起来,按配置的 key 管理请求速率。
QEMU 的 QEMUTimerList 用有序单链表维护定时器,链表头就是下一到期项。
缓存块命中后可能需要从队列中间移除,双向链表让这一步保持常数时间。