CppCon 2023 学习:Taro: Task graph-based Asynchronous Programming Using C++ Coroutine
概念:TGPS 把程序的函数调用和它们之间的依赖关系,以自顶向下的方式封装为一个任务图(task graph)。任务图节点代表任务(task),边表示先后/数据依赖(precedes)。CodeABCDA/ \B C\ /D核心思想:把并行度和依赖显式化,让调度器(scheduler)根据这个图来并行执行任务,而不是隐式依赖于线程同步与锁。问题识别:现有 TGPS 在异构系统上容易因为把任务当作“
详细解析(按幻灯片顺序、带公式格式说明)
1. 总体议程(Agenda — 目标)
- 理解 Taro 出现的动机(为什么要设计新的系统)
- 学会使用 Taro 的 C++ 编程模型(API/用法示例)
- 深入 Taro 的协程感知(coroutine-aware)调度算法(核心技术)
- 在微基准和真实应用上评估 Taro 的表现
- 总结与结论
2. 什么是 Task Graph-based Programming System(TGPS)
概念:TGPS 把程序的函数调用和它们之间的依赖关系,以自顶向下的方式封装为一个 任务图(task graph)。任务图节点代表任务(task),边表示先后/数据依赖(precedes)。
视觉化(示意):
Code
A
B
C
D
如果 A 是 B、C 的前驱,而 B、C 又是 D 的前驱,图形上就是:
A
/ \
B C
\ /
D
核心思想:把并行度和依赖显式化,让调度器(scheduler)根据这个图来并行执行任务,而不是隐式依赖于线程同步与锁。
3. 任务图的构造(示例代码解释)
幻灯片里给出的伪代码(C++ 风格)示例:
Scheduler sched;
task_a = sched.emplace([](){ /* Code block A */ });
task_b = sched.emplace([](){ /* Code block B */ });
task_c = sched.emplace([](){ /* Code block C */ });
task_d = sched.emplace([](){ /* Code block D */ });
task_a.precede(task_b);
task_a.precede(task_c);
task_b.precede(task_d);
task_c.precede(task_d);
sched.schedule();
sched.wait();
逐行说明:
Scheduler sched;:创建一个调度器对象。sched.emplace(...):在调度器中插入一个任务,返回任务句柄(task_a 等)。task_x.precede(task_y);:表示task_x必须先于task_y完成(建立依赖边)。sched.schedule();:向运行时提交任务图以供执行(开始调度)。sched.wait();:等待整个任务图执行完成(阻塞调用线程直到完成)。
好处:
- 写法直观,易表达任务图。
- 能实现“非规则”的并行(irregular parallelism),适合不规则依赖图。
4. 任务图执行顺序示例(不同可能的拓扑调度)
给定同一个依赖关系,调度器可以产生不同的实际执行顺序,例如:
- 可能的执行序列:
A -> B -> C -> D - 或:
B -> A -> D -> C(取决于任务是否就绪、调度策略、优先级等)
重要点:调度器要保证任何终态都满足依赖约束(即产生一个拓扑序列)。
5. 为什么需要改进:异构计算上的挑战(CPU/GPU 混合)
在异构系统(CPU + GPU)上,现有的 TGPS 面临关键问题:任务的“原子执行”(atomic execution per task)导致 CPU 被动等待 GPU 完成,从而浪费资源。
- 假设一台机器只有 1 个 CPU(或有限的 CPU 线程)和 1 个 GPU。
- 某些任务(例如
B)包含 GPU 调用:CPU 发起 GPU 工作后继续等待,直到 GPU 完成(CPU 线程被阻塞)。 - 如果调度器把每个任务当作“原子不可拆分”,那么当一个 CPU 线程被 GPU 阻塞时,它不能去运行其他就绪任务,系统并不会充分利用 CPU。
时序示意(简化):
时间 →
CPU: A (run) → B (attach GPU, then wait) → idle while GPU runs
GPU: idle → B (running on GPU) → ...
这会带来两类损失:
- CPU 空闲时间(而实际上有其他就绪任务可做)。
- 更差的吞吐或更长的总完成时间。
6. 理论上的并行度度量(引入经典并行算法量)
为分析并行系统性能,常用两个基准量:
- 工作量(work):全部任务在单处理器上执行所需时间,记为 T1T_1T1。
- 临界路径长度(critical path / span):任务图上最长的依赖链所需时间,记为 T∞T_\inftyT∞。
调度在 ppp 个处理器上至少需要的时间有下界(Brent 定理):
Tp≥max(T1p,;T∞) T_p \ge \max\left(\frac{T_1}{p},; T_\infty\right) Tp≥max(pT1,;T∞)
解释:并行加速受限于总工作量分配和最短不可并行化路径。
在异构(CPU+GPU)场景,T1T_1T1 和 T∞T_\inftyT∞ 的估计需要把 CPU/GPU 的不同执行成本考虑进来,且阻塞带来的延迟会放大 T∞T_\inftyT∞ 的实际影响。
7. 现有 TGPS 在异构系统中的典型调度问题(更细致)
- 把 CPU 到 GPU 的调用当作任务内部的同步点(atomic)。结果就是当任务内部发生 GPU 调用时,CPU 线程被阻塞直到 GPU 完成。
- 阻塞导致调度器无法把该 CPU 线程分配去执行其他就绪任务。
- 这在多任务密集、GPU 调用延迟大的场景尤其糟糕。
因此我们需要一个能 在任务内部进行更细粒度的“挂起/恢复” 的调度方法,从而在 CPU 被用于等待时仍能继续做其他工作 — 这就是 Taro 的动机所在。
8. Taro — 协程感知(coroutine-aware)的调度算法:核心思路
Taro 的关键创新在于 把任务体内部的异步点(例如发起 GPU 工作)暴露给调度器,以便可以挂起该任务的执行上下文(coroutine),释放 CPU 去执行其他任务,然后在 GPU 完成时恢复该协程。要点包括:
- 任务以协程(coroutine)形式编写或包装:
- 一个任务可以在内部
await或yield,在等待异步事件(比如 GPU 完成)期间将控制权返回给调度器,而不是阻塞线程。
- 一个任务可以在内部
- 调度器管理协程就绪/等待队列:
- 有两个或更多状态集合,例如
ready(可运行协程队列)与suspended(等待 I/O/GPU 的协程)。 - 一旦 GPU 事件完成,相关协程被放回
ready队列,等待调度执行其后续代码。
- 有两个或更多状态集合,例如
- 避免阻塞的工作窃取(work-stealing)与协程迁移:
- 调度器仍然可以做工作窃取(work-stealing)以实现负载均衡,但要意识到协程的挂起/恢复边界。
- 协程的上下文切换代价通常比线程小,适合用于细粒度异步。
- 把任务原子性拆分成“阶段”:
- 每个任务可以分成若干“阶段”(phase):在一个阶段里发起异步操作,在下一个阶段里处理结果。调度器只在阶段边界切换协程。
示意伪代码(概念):
- 每个任务可以分成若干“阶段”(phase):在一个阶段里发起异步操作,在下一个阶段里处理结果。调度器只在阶段边界切换协程。
task_b = sched.emplace(coroutine([]{
// 阶段 1:CPU 工作
do_cpu_work();
// 发起 GPU 工作并挂起协程,等待 GPU 完成
co_await gpu_launch_and_wait();
// 阶段 2:GPU 完成后的 CPU 工作
process_gpu_results();
}));
在这里 co_await gpu_launch_and_wait(); 并不是把线程阻塞,而是把协程挂起并让出 CPU。
9. 调度器需要处理的机制细节(实现要点)
实现协程感知调度器会涉及这些机制:
- 协程状态机:每个任务协程需要一个小的状态机来保存局部变量与恢复点(这正是 C++ 协程或其他协程库提供的)。
- 事件触发回调:当 GPU 操作完成时,GPU 驱动或运行时需要触发回调,把对应的协程移回
ready队列。 - 挂起时资源管理:不能把临时资源泄露,且要保留协程需要的 context(例如指针、缓冲区)。
- 优先级与亲和性(affinity):某些任务可能强依赖数据位于 GPU 内存或某个 NUMA 节点,调度器需要考虑亲和性以减少迁移成本。
- 阻塞/非阻塞混合策略:当协程不能被挂起(或代价太高)时,调度器仍需支持传统阻塞回退路径。
10. 性能/复杂度的数学直觉(和阻塞的影响)
把“等待时间”明确分解到调度模型中。若任务包含一次 GPU 等待,其总执行时间可以视为:
Ttask=Tcpu-before+Tgpu+Tcpu-after T_{\text{task}} = T_{\text{cpu-before}} + T_{\text{gpu}} + T_{\text{cpu-after}} Ttask=Tcpu-before+Tgpu+Tcpu-after
传统阻塞模型中,T_cpu-before 执行完后,CPU 线程会被堵塞 TgpuT_{gpu}Tgpu 时间;但协程模型可以把这段 TgpuT_{gpu}Tgpu 视为 非占用 CPU 的等待期,从而让其他任务利用这段时间。这会降低系统整体的 TpT_pTp,特别是当 TgpuT_{gpu}Tgpu 相对较大且任务数足够多时。
因此协程感知调度器力求让有效 CPU 工作时间接近 T1T_1T1,并尽量把阻塞引起的空闲时间最小化,从而接近期望的下界 max(T1/p,T∞)\max(T_1/p, T_\infty)max(T1/p,T∞)。
11. 在微基准与真实应用上的评估(如何做实验)
评估 Taro 时通常做两个层面的实验:
- 微基准(microbenchmarks):
- 控制变量(任务粒度、GPU 调用延迟、依赖密度、任务图形态)。
- 对比基线:传统原子任务 TGPS vs Taro(协程感知)。
- 度量:吞吐(throughput)、平均任务延迟、CPU 利用率、总完成时间(makespan)。
- 真实应用(real-world):
- 把一个真实工作负载(例如混合 CPU/GPU 的图像处理流水线、科学计算或机器学习前处理)移植到 Taro。
- 度量端到端运行时间、阶段性延迟、资源利用(如 GPU/CPU 利用率)。
衡量指标常用:
- 总完成时间(makespan)
- 加速比(speedup)
- CPU 空闲时间比率
- GPU 利用率
12. 结论(总结 Taro 的价值)
- 问题识别:现有 TGPS 在异构系统上容易因为把任务当作“原子”而引起不必要的 CPU 阻塞,从而浪费资源。
- 解决方案:Taro 通过把任务包装为协程、并让调度器在任务内部的异步点进行挂起/恢复,从而实现协程感知调度,减少阻塞带来的资源浪费。
- 收益:在许多任务混合的异构场景下,Taro 能提高 CPU 利用率、降低总完成时间、提升吞吐,尤其当 GPU 调用延迟不可忽视时效果显著。
- 实现难点:需要合理管理协程上下文、事件回调、亲和性和负载均衡等细节,且要权衡协程切换开销与阻塞代价。
13. 拓展:把幻灯片代码做更“化”的逐行注释
把上面示例的关键片段做注释版本:
// 创建调度器
Scheduler sched;
// 在调度器中创建四个任务(返回任务句柄)
task_a = sched.emplace([](){ /* 代码块 A */ });
task_b = sched.emplace([](){ /* 代码块 B */ });
task_c = sched.emplace([](){ /* 代码块 C */ });
task_d = sched.emplace([](){ /* 代码块 D */ });
// 建立依赖关系:A 是 B、C 的前驱,B、C 都是 D 的前驱
task_a.precede(task_b);
task_a.precede(task_c);
task_b.precede(task_d);
task_c.precede(task_d);
// 提交并执行任务图,等待完成
sched.schedule();
sched.wait();
如果 task_b 内含 GPU 调用,Taro 推荐的写法是把 task_b 改写为协程式:
task_b = sched.emplace(coroutine([]{
// CPU 前置工作
do_cpu_part();
// 发起 GPU 并挂起协程(非阻塞线程)
co_await launch_gpu_and_suspend();
// GPU 完成后的 CPU 后置工作
finish_cpu_part();
}));
14. 补充:关键术语对照(便于阅读)
- Task / 任务
- Task graph / 任务图(又称任务依赖图)
- Scheduler / 调度器
- Coroutine / 协程(可挂起的函数/执行体)
- Precede / 先行(建立依赖边)
- Atomic task / 原子任务(不可中断的任务执行)
- Work-stealing / 工作窃取(负载均衡策略)
- Critical path / 临界路径(又称 span)
- Makespan / 总完成时间(全部任务完成的时间)
如何解决这个挑战?答案:C++ 协程!
在处理 CPU + GPU 的异构任务图(TGPS)时,任务存在同步问题:
CPU 任务和 GPU 任务不能同时执行,因为 传统 TGPS 一次只能执行一个任务(atomic execution per task)。
因此:
- CPU 发出 GPU kernel 后,会阻塞等待 GPU
- CPU 不能继续执行其他任务
- GPU 空的时候 CPU 忙;CPU 忙的时候 GPU 空 → 资源没有得到正确重叠(overlap)
这就是挑战。
协程如何解决这个异构调度问题?
协程(Coroutine)是一种:
可以自己挂起(suspend),并在未来由调用者恢复(resume)的函数。
普通函数 → 不能中断
协程 → 可以随时停下,并在之后继续执行
换句话说:
普通函数 = “一次性全部执行完的代码”
协程 = “可分段执行的代码”
为什么协程有用?生活类比理解
你下班回家想做两件事:
- 烧水
- 洗澡
如果你按正常逻辑:
- 开火烧水
- 站在那里等水烧开(CPU 阻塞)
- 水开了
- 再去洗澡
整个过程非常慢。
但如果用协程思想: - 开火烧水(开始 GPU 工作)
- 挂起任务(
co_await) - 趁烧水过程去洗澡(重叠 CPU 工作)
- 洗完后水也刚好烧好
→ CPU 任务和 GPU 任务有效重叠!
这就是协程对于异构计算的意义:
协程可以让 CPU 和 GPU 同时工作,不再让 CPU 阻塞等待 GPU。
C++ 协程的核心:suspend(挂起)与 resume(恢复)
协程:
- 能暂停自己(
co_await something) - 暂停后 CPU 可以执行别的任务
- 当等待的事件完成后(例如 GPU kernel 完成)
- 再恢复执行
但——C++ 协程不容易用
协程概念简单,但 C++ 的协程机制非常复杂,需要:
- Coroutine
- Promise
- Awaitable
- coroutine_handle
- compiler transformation rules
- await_suspend/await_resume
- initial_suspend / final_suspend
因此:
实现一个简单的协程 → 需要写大量繁琐的样板代码(boilerplate)
真正的 C++ 协程代码实例
你给出的示例展示了实际 C++ 协程是多么复杂:
▼ GPU 工作协程(简化后逻辑)
Coro gpu_work() {
cudaStream_t stream;
cudaStreamCreate(stream);
gpu_matmul<<<8, 256, 0, stream>>>(matA, matB);
while(cudaStreamQuery(stream) != cudaSuccess) {
co_await std::suspend_always{}; // GPU 未完成 → 挂起协程
}
cudaStreamDestroy(stream);
}
其含义:
- Launch GPU kernel
- GPU busy
- CPU 不阻塞
→ 暂停(suspend)协程 - 每次调度器检查 GPU 状态
→ GPU 完成后恢复协程继续执行
▼ Promise(协程框架核心)
struct promise_type {
std::suspend_always initial_suspend() noexcept { return {}; }
std::suspend_always final_suspend() noexcept { return {}; }
Coro get_return_object();
void return_void() {}
void unhandled_exception() {}
};
Promise 决定:
- 协程开始时是否暂停
- 结束时是否暂停
- 如何处理异常
▼ 编译器生成的核心逻辑
co_await X
=
compiler生成:
if (!X.await_ready()) {
X.await_suspend(handle);
// 协程挂起
}
X.await_resume();
复杂,但这就是 C++ 协程的底层机制。
为什么不能直接用协程做 TGPS?
因为:
- 代码太复杂
- 每个任务都要写 Promise / Awaitable
- scheduler 也要自己写
- 难以读、难以维护
你需要的是:
一个简单易用的 TGPS 框架,内部自动使用协程,但用户不需要知道协程细节。
解决方案:Taro
Taro 的核心思想:
用协程实现 CPU/GPU 的异步重叠
但完全隐藏复杂性
用户只需要写普通的 TGPS 任务
Taro 自动使用 coroutine-aware scheduler 进行调度
实现 CPU/GPU 重叠执行(真正异构调度)
也就是说:
Taro = 用协程实现的一套异构任务图系统(TGPS),对用户完全透明。
最终总结
◾ 异构任务图的挑战
CPU 需要等待 GPU → 无法多任务并行 → GPU/CPU 闲置
◾ 协程的能力
可以暂停、恢复 → CPU 不再阻塞 → 任务可交错执行 → 实现 GPU/CPU overlap
◾ C++ 协程的问题
写法太复杂,需实现 promise/awaitable/handle 等大量细节
◾ Taro 的关键价值
把 C++ 协程隐藏起来,提供易用的 TGPS API,实现异构调度。
2. 背景:同步 vs 异步(Synchronous / Asynchronous)
示例:烧水。
同步(Synchronous)
turn on stove
wait <--- 卡住 CPU
turn off stove
CPU 在等待,不做别的事 ⇒ CPU 闲置。
异步(Asynchronous)
方法:
- Polling:轮询
- Callback:回调
turn on stove
do something else
callback: water ready
turn off stove
CPU 可以去干别的事 ⇒ multitasking。
3. 为什么要 C++ Coroutine?
因为 coroutine 可以:
- 自动挂起(suspend)与恢复(resume)
- 等待 GPU 完成 时 CPU 不阻塞
- 允许多个 CPU/GPU 任务重叠执行
GPU = 热水壶
CPU = 你
Coroutine = “做事 → 等待 → 做事”
4. Taro 的核心理念
Taro 统一异构调度:
- CPU
- GPU(CUDA)
- 自定义加速器(TPU/FPGA/自定义算子)
通过 coroutine 自动支持: - 挂起
- 恢复
- 依赖调度
- 多任务重叠执行
5. Taro 的基本使用方式(Example 1)
#include <taro/taro.hpp>
#include <taro/scheduler/cuda.hpp>
taro::Taro taro{NUM_THREADS};
auto cuda = taro.cuda_scheduler(NUM_STREAMS);
你有:
- 多个 CPU threads
- 多个 CUDA streams
Taro 会负责绑定、调度、依赖、恢复 coroutine。
6. Example 1 —— 四个任务 (A, B, C, D)
图示:
A
├──► B
└──► C
B ──► D
C ──► D
Task A(同步 GPU 调用)
auto task_a = taro.emplace([&]() {
cuda.wait([&](cudaStream_t stream){
kernel_a1<<<...>>>();
}); // synchronize 等 GPU 完成
});
cuda.wait() 是同步的:
- GPU kernel 启动
- CPU 等待该 stream 完成
- CPU 不能做别的任务
Task B(使用 coroutine 的 GPU 异步多任务)
auto task_b = taro.emplace([&]() -> taro::Coro {
cpu_work_b1();
co_await cuda.suspend_callback([&](cudaStream_t stream){
kernel_b1<<<...>>>();
kernel_b2<<<...>>>();
}); // suspend
});
co_await 会做什么?
Coroutine 变换(编译器展开)
当遇到
co_await something
编译器执行:
- 执行
something.await_ready() - 如果不 ready →
await_suspend()
⇒ coroutine 被挂起 - 由 Taro 调度器在未来时间点
resume
效果:
- GPU execute kernel
- CPU 去执行 别的任务
- GPU 完成后 → resume coroutine → 接着执行
Task C(使用 polling 方式 suspend)
auto task_c = taro.emplace([&]() -> taro::Coro {
co_await cuda.suspend_polling([&](cudaStream_t stream){
kernel_c1<<<...>>>();
});
cpu_work_c1();
});
含义:
- 让 GPU 执行 kernel
- coroutine 挂起
- Taro 定期 polling CUDA stream
- 当 stream 完成 → resume coroutine
- 执行
cpu_work_c1()
▶ 为什么不用 cudaStreamSynchronize()?
同步版本:
kernel_c1<<<...>>>();
cudaStreamSynchronize(stream);
cpu_work_c1();
CPU 会 阻塞等待。
Taro coroutine版本:CPU 不会阻塞,不会空转。
Task D(同步 GPU 等待)
auto task_d = taro.emplace([&]() {
cuda.wait([&](stream){
kernel_d1<<<...>>>();
});
});
任务依赖图
task_a.precede(task_b);
task_a.precede(task_c);
task_b.precede(task_d);
task_c.precede(task_d);
7. Example 2 —— 混合更多 coroutine + 同步
Example 2 展示:
- A:GPU suspend + multitask
- B:GPU suspend + CPU 后处理
- C:CPU 前处理 + GPU 同步
- D:GPU suspend + CPU 后处理
展示 Taro 统一管理不同类型的 GPU/CPU 调用方式。
8. Taro 的可扩展性(Custom Accelerator)
你可以接入:
- 自定义硬件(TPU/FPGA/自研芯片)
- 第三方 accelerator
只需写:
#include <taro/scheduler/custom.hpp>
auto custom = taro.custom_scheduler(...);
co_await custom.suspend_callback([&](...){
// 调用你的自定义加速器
});
Taro 的使命:
抽象底层异构细节 + 自动进行 coroutine 调度 + 任务依赖管理
9. 用一句话总结 Taro
Taro 是一个异构任务图调度器,它用 C++ Coroutine 将 CPU/GPU 的同步/异步统一抽象,使得多任务可以自动重叠执行,从而显著减少等待、提高硬件利用率。
Taro 框架:完整解析(含数学排版)
Taro 是一个 CPU + GPU 协同任务调度框架,基于 C++20 协程 (coroutines),支持复杂任务图、异步 GPU 调度、自动挂起/恢复任务,并在多种工作负载上取得显著加速效果。
其核心思想是:
程序员写同步/异步任务逻辑,Taro 自动完成跨 CPU/GPU 的调度、挂起、恢复、抢占和负载均衡。
1⃣ 为什么需要 Taro?(Motivation)
传统 CPU+GPU 混合任务图面临 三个关键痛点:
| 痛点 | 描述 |
|---|---|
| 1. 管理 GPU 异步事件很复杂 | GPU kernel 调度是异步的,需要用 CUDA callback、polling、events 等管理。 |
| 2. CPU 与 GPU 的并发利用率低 | 当 CPU 线程在等待 GPU 回调时会阻塞,导致 CPU 空闲浪费。 |
| 3. 现有框架缺乏对协程的深度支持 | 大部分任务图库不支持挂起/恢复任务的语义。 |
| Taro 的目标是: |
目标三件套
- Simplicity(简单):用户只需写代码,不需要手动处理回调和同步。
- Efficiency(高效):最大化 CPU/GPU 使用率,避免线程阻塞。
- Extendibility(可扩展):支持更多加速器、更多异步模式、更多任务图模式。
2⃣ Taro 编程模型(C++20 协程 + GPU 调度)
用户主要使用两个机制:
✦ 同步任务
适合 CPU-only 工作:
auto task = taro.emplace([&](){
cpu_work();
});
✦ 异步任务(使用协程)
适合 CPU + GPU 混合,例如:
auto task = taro.emplace([&]() -> taro::Coro {
cpu_work_a1();
co_await cuda.suspend_callback([&](cudaStream_t stream){
kernel_a1<<<...>>>();
kernel_a2<<<...>>>();
});
cpu_work_a2();
});
关键点:
co_await cuda.suspend_callback(...)
→ 挂起协程、释放 CPU 线程、让出执行权- GPU 完成后触发 callback
→ Taro 调度器 唤醒协程(按同一 worker 优先恢复) - 协程恢复后继续执行
3⃣ Taro 调度器:协程感知的调度算法
这是 Taro 的核心创新点。
每个 Worker 线程有两个队列
| 队列 | 作用 |
|---|---|
| LPQ (Low-priority Queue) | 存放“新任务” |
| HPQ (High-priority Queue) | 存放“挂起后等待恢复的任务” |
| 恢复任务优先级高,因为: |
让任务回到原来的 worker最能减少竞争与缓存失效。
核心调度动作
| 动作 | 描述 |
|---|---|
| Offload | 任务向 GPU 推送 kernel |
| Suspend | 挂起协程,放到 HPQ |
| Callback | GPU 完成后触发回调 → |
| 恢复任务 | |
| Steal | 空闲 worker 从别的 worker 的 LPQ/HPQ 偷任务 |
| Poll | 通过 polling 查询 GPU 状态 |
| Wait/Block | CPU 等待其它任务完成时阻塞 |
| Enqueue | 任务被恢复后重新进入 HPQ |
▶ 样例调度流程(关键步骤)
现在以示例代码为例,有任务 A、B、C、D 与一个 GPU stream、两个 CPU worker。
整体流程如下(简化版):
- Worker 1 执行 A
- A 启动 GPU kernel → Suspend → 放入 Worker 1 的 HPQ
- Worker 1 进入睡眠
- Worker 2 执行其他任务(C / D)
- GPU 完成 A → callback → A 被放回 Worker 1 的 HPQ
- Worker 1 被唤醒 → 但若被 C block,则无法执行 A
- Worker 2 此时可能 steal A 或 steal B/D
- 剩余任务按 HPQ → LPQ 的优先级执行完毕
你贴出的图完整展示了 多步抢占、恢复、等待、回调、polling 的精细流程。
4⃣ 数学:任务执行时间模型
Taro 的实验包含两类任务:
1. 数据驱动任务(小而多)
一个典型的数据驱动协程:
while(k++ < 300) {
// CPU work
co_await cuda.suspend_polling(...);
}
每次循环:
T=Tcpu+Tgpu+Tsuspend/resume T = T_{\text{cpu}} + T_{\text{gpu}} + T_{\text{suspend/resume}} T=Tcpu+Tgpu+Tsuspend/resume
数据驱动任务特点:
- GPU kernel 非常短
- GPU callback 频繁
- CPU 与 GPU 交替频繁
因此线程阻塞的代价非常大。
Taro 在此场景能达到:
18× 加速比 18\times \text{ 加速比} 18× 加速比
2. 固定运行时任务(长 GPU kernel)
cpu_busy_loop(ctime);
co_await cuda.suspend_callback(... large GPU kernel ...)
此时:
Tgpu≫Tsuspend/resume T_{\text{gpu}} \gg T_{\text{suspend/resume}} Tgpu≫Tsuspend/resume
GPU kernel 足够长,调度开销不敏感。
Taro 与 Fiber 均达到最优。
5⃣ 实验结果总结
多种微基准测试:
- 线性链
- Divide & Conquer
- MapReduce
- Wavefront
- Random DAG
Taro 在大多数 workload 比 Taskflow / Boost fiber 明显更快。
数据驱动任务
Taro 达到:
18.3× 18.3\times 18.3×
相较 Fiber 与 Taskflow。
原因:
- Taro 的调度模型专门优化了频繁 Suspend/Resume 的场景
- 使用 C++20
atomic::wait/notify实现极低开销线程休眠
固定耗时任务(GPU 大核)
Taro ≈ Fiber (最优)
比 Taskflow 快明显。
大规模电路仿真(实际 workload)
与 RTLflow(state-of-the-art)比较:
- Taro 达到:
1.7× 1.7\times 1.7×
加速比 - 内存占用可比
这是论文中意义最大的结果,证明 Taro 并不仅仅是 microbenchmark 优化,而是能在真实工程场景中大幅加速。
6⃣ 结论 + 展望
已经做到:
- 简洁的 C++ 编程模型
- 对 GPU 回调、polling 的完整封装
- 协程感知的任务调度器
- 几乎在所有基准上取得优异性能
- 对复杂电路模拟 workload 显著提速
Future work
- 更多文档
- 更多加速器(例如 TPU、NPU…)
- 更多计算模式(Pipeline、IO async)
- 对称协程传递(symmetric coroutine transfer)
更多推荐


所有评论(0)