ROCm HIP Graph 概念与机制分析-第 3 章 数据结构:辅助资源管理
本章目标:第 2 章讲的是"图长什么样",这一章讲"图跑起来要靠哪些幕后资源"。
GraphExec之所以能在重放时做到近乎零 CPU 开销,靠的是三个预分配的资源管理器——kernel 参数池、HW 信号池、用户对象——以及一套贯穿全子系统的"静态注册表 + 有效性检查"防御模式。理解它们,才能真正读懂后面"实例化时准备好一切、执行时直接复用"的调度章节。
3.1 为什么需要"资源池"
回顾第 1 章的总纲——空间换时间。重放一张图时,每个 kernel 节点都需要两样东西:
- 一块kernel 参数内存(kernarg),把实参打包好给 GPU 读。
- 一个或多个完成信号(HW signal / event),让运行时知道这一步什么时候在 GPU 上做完。
如果每次Run()都临时malloc参数内存、临时createSignal,那 CPU 端开销一点没省——又回到了裸 launch 的老路。所以GraphExec的思路是:在Init()(实例化)时一次性把这些资源池建好,重放时只从池子里"借"、用完"还",永不在热路径上创建。
这三样幕后资源分别由三个类管理:
| 类 | 管理什么 | 基类 | 关键手法 |
|---|---|---|---|
GraphKernelArgManager | kernel 参数内存池 | amd::ReferenceCountedObject+amd::GraphKernelArgManager | 分块(chunk)+ bump 指针分配 |
GraphSignalManager | HW 完成信号集合池 | amd::ReferenceCountedObject | 预建 N 组信号,借/还复用 |
UserObject | 用户对象生命周期 | amd::ReferenceCountedObject | 引用计数 + 析构回调 |
三者都继承amd::ReferenceCountedObject——因为它们可能被多个持有者(GraphExec、子图、多个流)共享,用引用计数来决定何时真正释放。
3.2 GraphKernelArgManager:kernel 参数内存池
先看类定义(节选):
classGraphKernelArgManager:publicamd::ReferenceCountedObject,publicamd::GraphKernelArgManager{public://! 为给定大小在 device 上分配一整块 kernarg 池boolAllocGraphKernargPool(size_t pool_size,amd::Device*device);//! 从当前 chunk 按 size/alignment 切一小块 kernel 参数;//! 当前 chunk 用满就再分配一整块新 chunkaddressAllocKernArg(size_t size,size_t alignment,intdevId)override;//! HDP flush;若 flush 寄存器无效则回退到 readbackvoidReadBackOrFlush();private:structKernelArgPoolGraph{address kernarg_pool_addr_;//! 池基址size_t kernarg_pool_size_;//! 池大小size_t kernarg_pool_offset_;//! 当前分配偏移};booldevice_kernarg_pool_=false;//! 参数池是否在设备内存std::unordered_map<amd::Device*,std::vector<KernelArgPoolGraph>>kernarg_graph_;//! 每设备一组已分配的 kernarg 池};它的核心是一个经典的arena / bump-pointer 分配器:
kernarg_graph_是Device → vector<chunk>:每个设备一串已分配的大块(chunk)。多设备场景下互不干扰。AllocKernArg只是移动偏移量:在当前 chunk 里,把kernarg_pool_offset_按alignment对齐,切出size字节返回,然后把 offset 往后推。这是 O(1) 且无锁竞争的极快分配。- 满了才向底层要新块:只有当前 chunk 装不下时,才调用
AllocGraphKernargPool一次性申请一大块新 chunk。也就是说,底层昂贵的显存/主机内存分配被摊薄到极少数次。 - 析构统一归还:
~GraphKernelArgManager遍历kernarg_graph_,把每个 chunk 用hostFree整块释放——不是逐个小分配释放,而是整块回收,干净利落。
~GraphKernelArgManager(){for(autokernarg:kernarg_graph_){for(auto&element:kernarg.second){kernarg.first->hostFree(element.kernarg_pool_addr_,element.kernarg_pool_size_);}kernarg.second.clear();}}ReadBackOrFlush()与内存一致性:kernarg 可能位于设备可见内存,CPU 写完参数后,需要确保 GPU 能看到最新值。它优先走HDP flush(硬件的一致性刷新路径),当 flush 寄存器不可用时回退到readback(读一下强制同步)。这属于 GPU 内存一致性的细节,知道"写完参数要 flush 一下让 GPU 看见"即可。
一句话:
GraphKernelArgManager把"每个 kernel 一次 malloc"变成了"整张图共享几大块,内部 bump 指针切分",这是参数内存层面的空间换时间。
3.3 GraphSignalManager:HW 信号集合池
第二个资源是完成信号。类定义清清楚楚写明了设计意图:
//! Per-GraphExec pool of HW event sets.classGraphSignalManager:publicamd::ReferenceCountedObject{public://! 在实例化时预建 num_sets 组、每组 count 个信号,//! 这样 launch 永远不必在热路径上创建信号boolPrepopulate(amd::Device*device,intcount,intnum_sets);//! 为一次 launch 借一组就绪信号;从空闲池弹出;//! 只有池意外为空时才兜底新建一组boolAcquireSet(amd::Device*device,intcount,std::vector<void*>&out_set);//! 归还一组用过的信号并重新 arm,放回空闲池。//! 由 launch 完成回调调用——此时保证该 launch 的 GPU 工作已结束voidReleaseSet(amd::Device*device,std::vector<void*>&set);private:std::mutex lock_;//! 每设备一叠空闲信号集std::unordered_map<amd::Device*,std::vector<std::vector<void*>>>free_sets_;};它是一个标准的对象池(Object Pool),工作节奏是"借—用—还"三拍:
关键设计点:
Prepopulate在Init()阶段调用:一次性建好num_sets组信号,每组count个。之后热路径上的 launch 只借不建。AcquireSet优先从空闲池弹出:注释里明确说"only creates a new set as a fallback if the pool is unexpectedly empty"——正常情况下永远命中池子,兜底才新建。这保证了绝大多数重放的信号获取是 O(1) 且零系统调用。ReleaseSet由完成回调触发:注意归还的时机——launch 完成回调。为什么非要等回调?因为只有 GPU 真正执行完这一批,信号才空闲下来、可以安全地"re-arm(重新装填)"再借给下一次 launch。这是一个并发正确性的要点:信号的复用严格由 GPU 完成事件驱动,lock_保护free_sets_的并发存取。- 为什么叫"集合(set)"而非单个信号:一次图 launch 内部可能有多段/多流,需要一组信号同时协作(第 6、7 章的分段与同步计划会用到),所以池子的最小借还单位是"一组信号"。
GraphSignalManager把"每次执行 create/destroy 一堆信号"变成了"实例化时建好、循环里借还复用",是同步原语层面的空间换时间。它和第 7 章的SyncPlan、以及 launch 完成回调OnLaunchComplete是一条线上的伙伴。
3.4 UserObject:引用计数与生命周期
第三个类和"性能"关系不大,管的是用户对象的生命周期——用户通过hipUserObjectCreate往图上挂一个带析构回调的对象(比如一块需要在图销毁时释放的资源),交给运行时托管。
classUserObject:publicamd::ReferenceCountedObject{typedefvoid(*UserCallbackDestructor)(void*data);public://! 哪些 Graph 拥有这个 user objectstd::unordered_set<Graph*>owning_graphs_;UserObject(UserCallbackDestructor callback,void*data,unsignedintflags):ReferenceCountedObject(),callback_(callback),data_(data),flags_(flags){amd::ScopedLocklock(UserObjectLock_);ObjectSet_.insert(this);// 构造即登记}virtual~UserObject(){amd::ScopedLocklock(UserObjectLock_);if(callback_!=nullptr){callback_(data_);// 析构时回调用户释放函数}ObjectSet_.erase(this);// 从注册表移除owning_graphs_.clear();}voidincreaseRefCount(constunsignedintrefCount){for(uint32_ti=0;i<refCount;i++)retain();}voiddecreaseRefCount(constunsignedintrefCount){assert((refCount<=referenceCount())&&"count is bigger than refcount");for(uint32_ti=0;i<refCount;i++)release();}// ...private:UserCallbackDestructor callback_;void*data_;unsignedintflags_;staticstd::unordered_set<UserObject*>ObjectSet_;staticamd::Monitor UserObjectLock_;};几个要点:
- 引用计数即生命周期:
UserObject继承amd::ReferenceCountedObject,用retain()/release()计数。HIP 的 user object API 允许一次增减多个引用,于是有了increaseRefCount/decreaseRefCount这种"批量 retain/release"的封装。当计数归零,对象析构。 - 析构回调:析构时若
callback_非空,就调用它并把data_传回去——这正是用户注册的"东西没人用了就这么释放"的钩子。这让运行时能安全托管任意用户资源。 owning_graphs_记录归属:一个 user object 可以被多张图引用。注释点明了一个易错点——当对象即将被删除(最后一次release())时,必须把指向它的指针从各个 Graph 的 user object 列表里清掉,否则又是悬垂指针。ObjectSet_+isUserObjvalid:和节点一样,构造登记、析构注销,配一个静态有效性检查——这就引出了本章最后、也是最重要的一个横切模式。
3.5 横切模式:静态注册表 + 有效性检查
到这里,你应该已经在四个不同的类里看到了同一套代码骨架:
| 类 | 注册表 | 有效性检查 | 锁 |
|---|---|---|---|
GraphNode | nodeSet_ | isNodeValid | nodeSetLock_ |
Graph | graphSet_ | isGraphValid | (对应 set 锁) |
GraphExec | graphExecSet_ | isGraphExecValid | graphExecSetLock_ |
UserObject | ObjectSet_ | isUserObjvalid | UserObjectLock_ |
它们的模板完全一致:
// 1) 一个静态集合登记所有"活着"的对象staticstd::unordered_set<T*>XxxSet_;staticamd::Monitor XxxLock_;// 或 recursive_mutex / shared_mutex// 2) 构造时登记T(...){amd::ScopedLocklock(XxxLock_);XxxSet_.insert(this);}// 3) 析构时注销~T(){amd::ScopedLocklock(XxxLock_);XxxSet_.erase(this);}// 4) 静态有效性检查:句柄是否还活着staticboolisXxxValid(T*p){amd::ScopedLocklock(XxxLock_);returnp!=nullptr&&XxxSet_.find(p)!=XxxSet_.end();}这个模式解决什么问题?HIP 是 C 风格 API:用户拿到的是不透明句柄hipGraph_t、hipGraphNode_t、hipGraphExec_t、hipUserObject_t,本质就是裸指针。用户完全可能:
- 传一个已经销毁的句柄(use-after-free);
- 传一个根本不是该类型的野指针;
- 在多线程里一边销毁一边使用。
如果运行时直接解引用,就是崩溃或更隐蔽的内存破坏。静态注册表把"这个指针是不是我发出去的、现在还活着吗"变成了一次 O(1) 的哈希查找,把非法句柄挡在 API 边界之外。这是一道纯粹的防御性安全网(对应 OWASP 里"不受信任输入校验"的思路,只不过这里的不受信任输入是 API 句柄)。
代价与权衡:
- 每次对象构造/析构都要抢一次全局锁、动一次全局集合——有一定开销,但相对于图的创建/销毁频率(远低于重放频率)完全可接受。
- 注册表是进程级全局的,所以配套的锁不能少,
GraphExec甚至用到recursive_mutex和shared_mutex(读多写少时用共享锁)来降低竞争。 - 它只能保证"指针曾经是一个有效对象且尚未析构",不能防住"指针被 realloc 复用到同地址"这种极端 ABA,但在实践中足够可靠。
一句话记住这个模式:凡是会以裸指针句柄暴露给用户的类,都用"静态 set 登记 + isXxxValid 查表"来做悬垂/非法句柄防护。第 2 章的
nodeSet_、本章的三个资源类,都是它的实例。
3.6 三类资源在生命周期中的位置
把本章三个资源管理器放回第 1 章的生命周期时间轴上,能看得更清楚它们各自的活跃区间:
- 参数池:
Init()建块 → 每次Run()bump 切分 →Destroy整块归还。 - 信号池:
Init()预建 → 每次Run()借 → GPU 完成回调还 → 循环复用。 - 用户对象:贯穿始终,由引用计数决定寿命,归零时触发析构回调释放用户资源。
这三条线,正是第 5~7 章调度执行"零成本重放"的物质基础——调度算法再巧,也得有现成的参数内存和信号可用。
3.7 本章小结
GraphExec的高性能重放建立在三个预分配资源管理器之上,全部继承amd::ReferenceCountedObject以支持共享与引用计数生命周期。GraphKernelArgManager是bump-pointer arena:按设备维护若干大块 chunk,AllocKernArg只移动偏移量,满了才要新块,析构整块回收;ReadBackOrFlush处理写后一致性。GraphSignalManager是对象池:Prepopulate实例化时预建信号组,AcquireSet借、ReleaseSet(由 GPU 完成回调)还,严格由完成事件驱动复用,lock_保护并发。UserObject用引用计数 + 析构回调托管用户资源,owning_graphs_记录归属,最后一次 release 时须清理各图的引用。- 贯穿全子系统的横切模式是静态注册表 + isXxxValid(
nodeSet_/graphSet_/graphExecSet_/ObjectSet_),把非法/悬垂句柄挡在 API 边界外,是一道 O(1) 的防御性安全网。
下一章进入生命周期:构建与实例化,我们会看两种建图方式(手动 API vs 流捕获)、ihipGraphAddNode如何落地一次"带依赖的加节点",以及GraphExec::Init()究竟在实例化阶段预计算了哪些东西、如何预分配 stream——把本章的资源池真正"点燃"。
