AsyncRT CPUDevice 运行时剖析:以库式设计为核心的线程池与内存分配抽象(Mojo/MAX 底层基石)
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
M::AsyncRT::CPUDevice是 Modular 平台(MAX 与 Mojo)中面向现代多核 CPU 的底层并发运行时抽象,它把"线程池如何实现"与"堆内存如何分配"这两类策略性问题从业务算法中彻底剥离出来,让上层代码(如 MLIR 编译器、张量计算)可以在完全不了解操作系统线程细节的前提下表达并行计算。本文以 AsyncRT/docs/AsyncRTRuntime.md 为主线,结合仓库中的头文件、实现与 Bazel 配置,讲解其设计哲学、WorkQueue与Allocator两套抽象接口的完整契约,以及MODULAR_ALLOC_LOGGING分配追踪的启用与输出格式,帮助你理解这套可嵌入、可组合、可替换策略的运行时内核。
一、设计哲学:这是一套"库",而不是"虚拟机"
CPUDevice定位为面向现代 CPU 系统的低层并发库(low-level concurrency library),负责管理系统资源。同类竞品包括 Intel TBB(Thread Building Blocks)、Apple GCD(Grand Central Dispatch)等,但 AsyncRT 有三点核心差异(AsyncRT/docs/AsyncRTRuntime.md):
- 严格的库式设计:不存在"针对某个隐式全局线程池的全局 parallel-for-each"这类操作。任何并行操作都显式绑定到某个
CPUDevice实例之上。 - 与应用内其他线程使用者协作:包括多个
CPUDevice实例共存于同一进程,也不假设自己独占整台机器。 - 关键策略完全抽象:线程池怎么实现、内存怎么分配,都与使用它的业务代码解耦。
这一设计的目标场景非常明确:AsyncRT 技术要能嵌入既有应用。文档中举了一个形象例子——高性能主机游戏本质上是一个"自洽的操作系统",既要与加速器通信,又有大量其他并发活动在同时进行,AsyncRT 技术必须能在这种环境下工作(AsyncRT/docs/AsyncRTRuntime.md)。
同时,大量数值算法、编译器算法和并发负载与底层执行模型无关。设计者希望这些算法可以表达为"不过度暴露 OS 细节"的形式——事实上其中许多算法完全可以在裸机(bare-metal)系统上运行。
但请注意反方向的边界:AsyncRT 不是虚拟机。如果CPUDevice的使用者想要以系统特定的方式编写代码,完整的机器能力是开放的,客户不会被阻止做必要时的"花哨与奇特"操作(AsyncRT/docs/AsyncRTRuntime.md)。换句话说:默认给你干净的抽象,需要时也绝不拦着你直接触碰底层。
CPUDevice 在代码库中的组织方式
从源码结构看,AsyncRT 分为Support与Runtime两大部分(AsyncRT/docs/README.md):
- AsyncRT/Support:无依赖的底层并发容器、引用计数(
RCRef<>、ReferenceCounted<>)、原子操作与算法辅助,仅依赖 C++ 标准库。 - AsyncRT/Runtime:更"有主见"的低层并发库,包含线程池与内存分配分区机制,即本篇文章的主体。
CPUDevice头文件自述为"组织 AsyncRT 线程池、内存分配器等的顶层 god object",且刻意保持低依赖,便于其他组件在其上组合(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h)。
二、WorkQueue:线程池的抽象接口
M::AsyncRT::WorkQueue是工作队列的抽象接口,通常由一个线程池实现,负责并行执行提交的工作。该接口刻意保持极简,但承载了几个重要的设计点(AsyncRT/docs/AsyncRTRuntime.md):
- 它是抽象接口,可以为线程抽象层级不同、约束不同的系统提供多种实现。
- 接口最小化:你只能"添加工作",以及"阻塞调用线程直到某些
AsyncValue就绪或全部工作完成"。 - 设计假设工作项永不阻塞(如不得在队列内等待 I/O)。
关于心智模型,文档给出了一条核心经验法则(AsyncRT/docs/AsyncRTRuntime.md):在现代多核系统上高效利用机器的正确方式是每个 CPU 执行上下文(核、超线程)对应一个 OS 线程,持续不中断地干活。你不应该拉起数千个内核线程并在它们之间频繁上下文切换——这对缓存效率有害,且带来其他开销。WorkQueue的接口本身与这一模型解耦,但设计上正是为了在不同场景下高效实现它。
2.1 抽象接口的价值:执行方式的"正确"答案只有客户知道
如果栈底不是恰当的库式设计,那么建立在它之上的任何东西都不可能是。因此WorkQueue抽象对 AsyncRT"作为各种库式设计的根基技术"这一目标至关重要。
实现线程的方式有太多选择(AsyncRT/docs/AsyncRTRuntime.md):pthreads、Windows 线程、纤程(fibers)、无同步的单线程上下文、多核裸机嵌入式系统、Linux 内核内、只钉在移动设备"小核"上、钉在多插槽 NUMA 服务器的某个插槽上、随机器负载动态变化核数的服务器进程……这里没有"唯一正确答案"——只有客户自己知道执行工作的正确方式。
仓库中的实际实现印证了这一点。AsyncRT/lib/Runtime/SingleThreadWorkQueue.cpp 实现的SingleThreadWorkQueue完全不派生额外线程,只用调用者线程执行工作,同时队列本身是线程安全的,addTask与await可从任意线程调用。此外还有ThreadPoolWorkQueue(基于std::thread与信号量)和用于 NUMA 分区场景的DelegateThreadPoolWorkQueue+ 分区队列(见 AsyncRT/include/AsyncRT/Runtime/WorkQueue.h)。
2.2 最小接口:addTask / addLocalTask / await / shutdown
WorkQueue的接口极简,只有少量纯虚方法(AsyncRT/include/AsyncRT/Runtime/WorkQueue.h):
| 方法 | 语义 |
|---|---|
addTask(WorkItem &&work, int taskId = kDefaultTaskId) | 入队一个工作项供稍后执行(可能在其他线程),线程安全;绝不会立即运行,不保证公平性,调用方需用 AsyncValue 等机制防止任务饿死。taskId >= 0表示入队到对应线程的本地环形缓冲,kDefaultTaskId(-1)表示进入所有 worker 共享的全局任务列表。当前实现中只有 Mojo 的async_parallelize会给出非负taskId(AsyncRT/include/AsyncRT/Runtime/WorkQueue.h) |
addLocalTask(WorkItem &&work) | 尽可能在当前线程执行,同样绝不立即运行。适合耗时极短的工作项(上下文切换成本会超过执行本身),例如 AsyncValue 机制用它让等待者及时执行(AsyncRT/include/AsyncRT/Runtime/WorkQueue.h) |
await(ArrayRef<AnyAsyncValueRef> values) | 阻塞直到给定值就绪(置值或出错)。实现可能休眠、可能"捐赠"调用线程去跑工作项,或两者兼有;允许递归调用await,但应尽量避免,优先只用 AsyncValue 同步。注意:await返回不保证所有等待者及其触发的工作都已运行到静默(quiescence),只有shutdown()才能保证在途计算全部完成(AsyncRT/include/AsyncRT/Runtime/WorkQueue.h) |
getParallelismLevel() | 返回线程池规模,内核可用它作为"拆分成多少个工作项"的提示 |
getCpuIds()/getNumaNode() | 队列被分区到的 CPU ID / NUMA 节点(未分区时返回空/kAnyNumaNode) |
shutdown() | 关闭线程池并静默,销毁前必须调用,且必须在任何任务之外调用;mainWillDonate模式下必须由创建线程调用 |
2.3 面向非阻塞工作设计:await 的"客户专用"定位
WorkQueue实现可以假设提交的工作项不会阻塞(例如不阻塞在 I/O 上),这大大简化了实现并提高了机器利用效率——相关议题详见 AsyncRT/docs/WorkQueueNonblocking.md。
但顶层客户通常只想提交工作、不想自己拥抱非阻塞风格。因此存在一个顶层await调用,用于等待一组值计算完成。这个例程仅供客户使用,不得被工作队列内的工作项或其他应当非阻塞的东西调用(AsyncRT/docs/AsyncRTRuntime.md)。
2.4 组合在接口之上的并行算法:Algorithms.h
保持接口最小化,是为了让客户算法(例如"并行 for 循环")与WorkQueue的实现保持正交。这些算法单独实现在 AsyncRT/include/AsyncRT/Runtime/Algorithms.h,包含:
addTask(CPUDevice&, work):向设备的工作队列添加非阻塞工作;当工作返回非 void 类型时,重载返回AsyncValueRef<R>,例如AsyncValueRef<int> r = addTask(cpuDevice, [a, b] { return a + b; });await/awaitOrError:等待值就绪并传播错误。andThenSync/andThenAsync(含 Copying/Moving 变体):多个值全部就绪后触发完成回调,Sync版本在最后一个值被填充的线程上直接执行回调,Async版本将回调加入工作队列。parallelForEachN系列:将[0, N)范围内的元素并行调度。其中parallelForEachNChain返回一个AsyncValueRef<Chain>,全部元素完成后该 Chain 被置为就绪,可用.andThenSync链式接续;parallelForEachNChainWithTaskIds允许用taskIdFn(elementIdx)把每个元素钉到特定 worker(例如面向 GPU 的 NUMA 本地核亲和);parallelForEachN则在前 N-1 个元素后台执行的同时,由调用线程执行最后一个元素再等待(AsyncRT/include/AsyncRT/Runtime/Algorithms.h)。
三、Allocator:堆内存分配的策略抽象
M::AsyncRT::Allocator提供堆分配的接口,与WorkQueue同理:抽象接口让算法代码与客户特定策略相互独立、协同工作(AsyncRT/docs/AsyncRTRuntime.md)。
核心接口类似malloc/free,但有两处细化(AsyncRT/docs/AsyncRTRuntime.md):
- 分配接口带对齐参数:
virtual void *allocateBytes(size_t size, size_t alignment) = 0;,从而可以组合式地分配需要 16 或 32 字节对齐的 SIMD 向量等类型。 - 释放接口也带大小参数:
virtual void deallocateBytes(void *ptr, size_t size = 0) = 0;,这让某些分配器算法高效得多(例如 tcmalloc 的 size-class 回收)。
接口还提供了类型化的便利方法:allocate<T>(n)/deallocate<T>(ptr, n)/construct<T>(args...)/destroy<T>(ptr)/destroyAndDeallocate<T>(ptr, n),以及 NUMA 放置查询getNumaPlacement()(AsyncRT/include/AsyncRT/Runtime/Allocator.h)。
仓库内置的工厂函数(AsyncRT/include/AsyncRT/Runtime/Allocator.h):
| 工厂函数 | 行为 |
|---|---|
createMallocAllocator() | 直接调用malloc/free |
createTCMallocAllocator()/createTCMallocAllocator(int numaPlacement) | 使用 tcmalloc,可绑定 NUMA 节点 |
createLeakCheckAllocator(base) | 包装器:析构时校验所有内存均已释放(泄漏检查) |
createProfilingAllocator(base) | 包装器:析构时打印内存剖析信息,同时做泄漏检查 |
createUseAfterFreeAllocator() | 非 Windows 可用;对每个分配的块做读写保护,用于在无 ASAN 时尽早发现 use-after-free(开销大) |
profiledMemcpy(dst, src, size) | 与std::memcpy相同但受剖析开关控制 |
3.1 抽象带来的关键机会:泄漏追踪 / NUMA / 大页
这一抽象带来若干重要机会(AsyncRT/docs/AsyncRTRuntime.md):
- 泄漏追踪与剖析:AsyncRT 提供泄漏追踪与剖析分配器接口,在把请求转发给另一个分配器的同时记录分配统计。因此单元测试套件默认启用泄漏追踪分配器,对尽早发现 bug 非常有用。调试构建下
CPUDeviceOptions的默认allocatorType就是kLeakChecker,发布构建默认kMalloc(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h)。 - NUMA 亲和:大型服务器系统常有多插槽 NUMA CPU。此时你希望把计算(用
WorkQueue)和数据(用Allocator)都钉在同一个插槽,以避免打满 CPU 之间带宽相对较低的互联总线。createTCMallocAllocator(int numaPlacement)正是为此设计;注意 tcmalloc 最多使用两个分区,NUMA 节点按node % 2映射到交替分区,这是仓库已知的 tcmalloc 局限(AsyncRT/lib/Runtime/TCMallocAllocator.cpp)。 - 大页(huge page)集成:OS 的大页特性"需要繁琐的逻辑才能用好",但在某些场景下可通过减少 TLB 缺失大幅改善延迟。
Allocator抽象为集成此类特性留出了空间。
文档给出的实操建议(AsyncRT/docs/AsyncRTRuntime.md):在构建数据密集型应用时(例如在机器学习应用中分配张量数据),最好用你正在其中执行的 Runtime 的分配器来分配那些数据。
3.2 不要用 Allocator 做微小分配
Allocator接口虽然对大规模分配很重要,但它带有少量开销(一次 vtable 间接调用),不适合融入细粒度 C++ 分配器(AsyncRT/docs/AsyncRTRuntime.md)。这意味着:
- 不要试图把所有
std::string、std::vector的分配都灌进它; - 不要为每个微小的链表节点走它。
请聚焦于能影响工作负载内存带宽的大规模分配。头文件同样明确注释:本接口"供大对象分配(如张量数据)使用,而不是程序执行中每次小分配都走它(不要把std::string路由进来!)"(AsyncRT/include/AsyncRT/Runtime/Allocator.h)。
四、CPUDeviceOptions:把策略集中在一个可配置结构里
CPUDevice构造所需的全部策略选项集中在CPUDeviceOptions结构(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h):
| 字段 | 默认值 | 说明 |
|---|---|---|
allocatorType | Debug 构建kLeakChecker,否则kMalloc | kMalloc/kTCMalloc/kLeakChecker/kProfiler/kUseAfterFree |
workQueueType | kThreadPool | kSingleThread/kThreadPool |
numThreads/maxThreads | 0 / 0 | 0 表示按物理系统自动感知;maxThreads用于在自动感知时封顶 |
mainWillDonate | true | 为 true 时只创建numThreads-1个 worker,假定调用线程会调用await并"捐赠"自己参与干活(适合 REPL/执行工具这类单一主线程驱动场景);为 false 时创建numThreads个 worker,任意线程可addTask/await但不参与干活(适合多请求线程共享同一队列的多线程服务器) |
withAffinity | 默认关闭 | 受环境变量MODULAR_ENABLE_AFFINITY控制,可被--cpu-affinityCLI 参数覆盖;默认关闭是因为多进程场景下有性能问题(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h) |
threadBusyWaitTime | 200(微秒) | 受环境变量MODULAR_THREAD_BUSY_WAIT_US覆盖 |
numaPartitioned | false | 为 true 且workQueueType == kThreadPool时,为每个 NUMA 节点创建一个分区队列并用DelegateThreadPoolWorkQueue包装成统一队列 |
poolName | "🔥 Thread" | 线程池名称 |
profileFilename | 空 | 非空则启用时间剖析,输出 JSON 与文本剖析文件(需启用剖析的构建,见MODULAR_ASYNCRT_MAX_PROFILING_LEVEL) |
runtimeProfilingTypeMask | Trace::kFullyEnabled | 剖析类型过滤器 |
profilerDebuginfo | kNoProfiler | kPerfProfiler(Linux perf)/kSOProfiler(以共享库方式加载内核,兼容所有剖析器) |
CPUDeviceOptions还提供了流畅的链式构建辅助方法(withSingleThreaded、withNumThreads、withLeakCheckedAllocator、withMainWillNotDonate、withCPUAffinity等),以及forDebug()——一键切换为单线程工作队列 + 泄漏检查分配器的调试配置(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h)。CPUDevice::printRuntimeConfig()会向标准输出打印当前使用的分配器、工作队列类型与线程数,方便核对配置。
另外,CPUDevice是引用计数的(继承M::ReferenceCounted,必须通过dropRef()销毁),CPUDeviceRef = RCRef<CPUDevice>。它有三种拓扑类型(AsyncRT/include/AsyncRT/Runtime/CPUDevice.h):kGlobal(无 NUMA 分区的顶层设备)、kGlobalPartitioned(按 NUMA 节点分区的顶层设备)、kNUMAPartition(被kGlobalPartitioned拥有的每个 NUMA 节点分区)。
五、实操:用 MODULAR_ALLOC_LOGGING 追踪每一次分配与释放
TCMallocAllocator与 Mojo 堆分配器(KGEN_CompilerRT_AlignedAlloc/Free)支持可选的DEBUG级逐分配/释放日志。由于这些函数位于热路径上,日志默认在编译期被剔除,必须在构建时显式启用(AsyncRT/docs/AsyncRTRuntime.md)。
5.1 编译期开启
在 Bazel 构建中通过//AsyncRT:alloc_logging标志开启:
./bazelw run //your:target --//AsyncRT:alloc_logging=true该标志在 AsyncRT/BUILD.bazel 中定义为bool_flag,通过config_setting映射为编译宏MODULAR_ALLOC_LOGGING=1注入Runtime库的local_defines(AsyncRT/BUILD.bazel)。源码中则以#if MODULAR_ALLOC_LOGGING包裹MLOG_DEBUG调用(AsyncRT/lib/Runtime/TCMallocAllocator.cpp)。
5.2 运行时打开 DEBUG 日志级别
编译后再在运行时把日志级别调到DEBUG:
MODULAR_LOG_LEVEL=DEBUG ./your_program5.3 输出示例与格式解读
文档给出的典型输出(AsyncRT/docs/AsyncRTRuntime.md):
[13:41:13] [ DBG] tcmalloc alloc: ptr=0x7f1234560000 size=128 alignment=8 [13:41:13] [ DBG] tcmalloc free: ptr=0x7f1234560000 size=128 [13:41:13] [ DBG] mojo alloc: ptr=0x112f3fc00000 size=12582912 alignment=1 [13:41:13] [ DBG] mojo free: ptr=0x112f3fc00000可以看到:tcmalloc行同时打印指针、大小与对齐;NUMA 分区模式下日志还会带上分区号,形如tcmalloc alloc (numa partition {n}): ptr=... size=... alignment=...(AsyncRT/lib/Runtime/TCMallocAllocator.cpp);mojo行则是 Mojo 堆分配器(KGEN_CompilerRT_AlignedAlloc/Free)的记录,典型如 12 MB(12582912 字节)的张量缓冲。
性能保证:编译期开关确保常规构建零开销——没有级别检查、没有分支(AsyncRT/docs/AsyncRTRuntime.md)。日志调用在#if MODULAR_ALLOC_LOGGING之外完全不存在于二进制中。
六、相关文档与代码地图
若要继续深入,以下是仓库中与该主题直接相关的资源:
- AsyncRT/docs/AsyncValue.md:
AsyncValue类型文档——理解await等待的值对象。 - AsyncRT/docs/WorkQueueNonblocking.md:非阻塞工作队列的详细讨论——理解"工作项永不阻塞"假设的成因与代价。
- AsyncRT/docs/README.md:AsyncRT 总览(Support / Runtime 分工)。
- 接口定义:AsyncRT/include/AsyncRT/Runtime/WorkQueue.h、AsyncRT/include/AsyncRT/Runtime/Allocator.h、AsyncRT/include/AsyncRT/Runtime/CPUDevice.h、AsyncRT/include/AsyncRT/Runtime/Algorithms.h。
- 实现与测试:AsyncRT/lib/Runtime/SingleThreadWorkQueue.cpp、AsyncRT/lib/Runtime/TCMallocAllocator.cpp、AsyncRT/lib/Runtime/ThreadPoolWorkQueue.cpp、AsyncRT/lib/Runtime/DebuggingAllocators.cpp,以及单元测试 AsyncRT/unittests/WorkQueueTest.cpp、AsyncRT/unittests/AllocatorTest.cpp、AsyncRT/unittests/CPUDeviceTest.cpp、AsyncRT/unittests/DebuggingAllocatorsTest.cpp。
- 构建配置:AsyncRT/BUILD.bazel(
alloc_logging、worker_stats两个构建标志及consumers包组)。
结语
M::AsyncRT::CPUDevice的价值不在于"又一个线程池",而在于它把执行策略(WorkQueue)与数据放置策略(Allocator)做成可替换的抽象契约,并用CPUDeviceOptions把策略集中到一处、用CPUDevice作为组合二者的引用计数容器。无论是调试阶段的泄漏检查分配器、生产环境的 tcmalloc + NUMA 绑定、面向 REPL 的"主线程捐赠"线程池,还是多请求线程共享的服务端队列,都可以在不改变业务算法代码的前提下完成切换。理解这套抽象,也就理解了 Mojo/MAX 运行时如何在"库式设计"与"全机器开放"之间取得平衡。
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考