PyPTO-Pro 跨核同步设计指南:set_cross_core/wait_cross_core 的同步点、pipe 与 event_id 规划
【免费下载链接】pypto-gymPyPTO-Gym 是基于 PyPTO 编程框架构建的算子与模型样例仓库项目地址: https://gitcode.com/cann/pypto-gym
本篇技术指南以 PyPTO-Pro 融合算子的跨核(cross-core)同步设计为主题,系统讲解在 Cube 执行域与 Vector 执行域(AIC 与 AIV)之间、以及跨 Block/subblock 场景下,如何判断同步需求、规划共享 TileGroup 槽位、确定 set/wait 同步点与 pipe、分配event_id,并完成双向同步协议与事件配对核查。读者学完后可直接参照本文方法,为自己的融合算子设计可验证、可运行的跨核同步方案,并结合仓库中 matmul_softmax、vec_cube_abs_sqrt_matmul、KDA chunk_h 与 Engram 前向 等真实算子核对同步结构。
跨核同步是什么:事件只传递信号,不搬运数据
在 PyPTO-Pro 中,pl.system.set_cross_core与pl.system.wait_cross_core用于保证生产者和消费者之间的数据读写顺序。最常见的场景是同一个 AI Core block 内,Cube 执行域与 Vector 执行域协同处理一组中间数据;接口也提供跨 Block 或 subblock 的同步模式。
关键语义必须牢记:同步事件只传递"可以继续执行"的信号,不负责搬运数据,也不分配共享缓冲。数据仍由pl.load/pl.store/pl.move/pl.insert等搬运指令负责,事件只是在这些搬运之间建立 happens-before 顺序。设计跨核同步时,需要依次完成四件事:
- 判断是否需要 cross-core 同步;
- 确定中间数据存放在 GM workspace、Mat(L1)还是 Vec(UB);
- 确定生产者和消费者,放置 set/wait 并规划
event_id; - 记录方案并逐项核查配对关系。
接口参数细节可查阅$PYPTO_DEVKIT_DIR/docs/pypto_pro/api/SIMD-API/synchronization/下的set_cross_core.md与wait_cross_core.md(该环境变量指向 CANN 开发套件文档目录,本文不再展开)。
从仓库的工程落地看,跨核同步在 PyPTO-Pro 工作流中处于"手动流水"的核心位置。编排层约束文档 performance-constraints.md 明确区分了两条路径:
- 自动流水:跨核 TileGroup 配置
fwd_ids/bwd_ids,由框架生成set_cross_core/wait_cross_core,event_id∈[0, 16),需复核生成代码; - 手动流水:显式调用
set_cross_core/wait_cross_core,event_id∈[0, 16),auto_mutex不覆盖跨核依赖,事件位置和 pipe 按实际数据操作设计。
本文聚焦手动流水场景——这是设计者必须完全掌控的部分。
判断是否需要 cross-core 同步
消费者读取本次 Kernel 中由另一个执行域或另一个 Block/subblock 写入的数据时,需要设计 cross-core 同步。可以按下面的顺序判断:
与核内同步的边界:mutex_ids、mem_bar 与 unit_flag
数据依赖没有跨 Cube/Vector 执行域,也没有跨 Block/subblock 时,按普通核内依赖处理,此时不应引入 cross-core 事件:
- 带非空
mutex_ids的 TileGroup 由auto_mutex管理跨 Pipe 依赖。例如 KDA 的 chunk_h_kda_impl.py 中,每个 L1/L0/UB TileGroup 都显式声明了mutex_ids=[0..21],auto_mutex自动处理 Section 内部的跨 Pipe 顺序; - 未配置
mutex_ids时,按实际数据路径手工插入核内同步(如pl.system.sync_src/sync_dst); - VF 函数中的局部内存读写顺序使用
vf.mem_bar; - 配置了
matmul phase时,M 与 FIX 之间由unit_flag配对。
这些机制不生成 Cube 与 Vector 之间的事件,mutex_id也不能替代 cross-core 使用的event_id。两者是两套独立的资源体系,数值相同也不会自动建立联系。
需要同步与不需要同步的情形
- 需要 cross-core 同步:Cube 写出中间结果后由 Vector 读取,或者 Vector 准备数据后由 Cube 读取。典型如矩阵乘结果(Acc/L0C)经 GM workspace 交给 Vector 做 softmax,或 Vector 先计算左操作数再交给 Cube 做 matmul;
- 不需要同步:多个逻辑 Block 只写各自独立的输出区域时,不存在跨核读写依赖;
- 跨 Block 共享状态:应根据算法使用原子操作、分阶段 Kernel 或目标产品支持的
INTER_BLOCK同步,而不是简单地在两个 Block 之间插事件。
缓冲复用时必须双向同步
Cube 与 Vector 循环复用同一组缓冲时,必须同时建立正向同步和反向同步:
- 正向同步(READY):保证消费者在数据写完后再读取;
- 反向同步(RELEASE):保证生产者在消费者读完后再覆盖该缓冲。
只建立正向同步无法保护正在被消费的数据,可能导致后续写入覆盖尚未读完的数据。事件的具体放置和配对规则见下文"缓冲复用时的双向同步"。
手动同步:set 在最后一次写之后,wait 在第一次读之前
当前跨核同步统一使用set_cross_core和wait_cross_core,放置规则非常明确:
- 生产者在最后一个写操作之后set;
- 消费者在第一个读操作之前wait;
- 循环复用同一槽位时,还要增加消费者到生产者的反向事件(RELEASE)。
wait 可以先执行并阻塞,直至配对的 set 到达;但反过来,set 之后对应的 wait 必须最终在某条执行路径上被执行,否则会造成永久等待或遗留信号。
明确共享 TileGroup 的槽位
TileGroup 既可以通过next()、current()、previous()访问,也可以用group[i]直接选择槽位。二者语义有重要差别:
group[i]不读取也不推进轮转游标;i可以是运行时整数表达式,但框架不会自动取模——设计时要明确写出槽位表达式,并证明所有运行时取值都在[0, depth)内。
手动同步复用同一个多槽缓冲时,生产者、消费者以及 READY/RELEASE 事件必须指向同一个逻辑槽位。先定义槽位映射,再让两侧按同一映射访问:
slot_idx = task_idx % depth tile = shared_group[slot_idx] READY = READY_IDS[slot_idx] RELEASE = RELEASE_IDS[slot_idx]这是一条手动同步的设计关系,不表示mutex_id与event_id变成了同一种资源。group[slot_idx]选中的 Tile 仍可携带 mutex 元数据,由auto_mutex处理各 Section 内部的跨 Pipe 依赖;READY 和 RELEASE 仍由 cross-core 事件处理 Section 之间的顺序。
继续使用next()也可以,但设计文档必须证明生产者和消费者的访问次数、初始游标和分支路径会选中同一个物理槽位。存在以下情况时,显式下标通常更容易核对:
- 预取(prefetch);
- 尾块分支;
- 两侧循环次数不同;
- 同一轮同时访问多个槽位。
下标访问不会改变游标;与next()混用时,两套状态要分别推导。这一要求在 design-template.md 的 §6"核间同步"模板中也被固化为必填字段:每个共享 TileGroup 要记录depth、访问方式(current()/next()/group[i])、槽位表达式(如task_idx % 2)以及"槽位一致性依据"。
根据数据路径确定同步点:pipe 表示硬件流水
pipe表示执行set或wait的硬件流水,不表示 Section 名称。发送和等待两侧可以使用不同 pipe,取值由同步点紧邻的数据操作决定——这是最常见的出错点之一。
pipe必须与同步点相邻的数据操作一致,仓库中的典型对应关系如下:
| 数据路径 | pipe 配对 | 仓库实例 |
|---|---|---|
| Cube 将 Acc 写入 GM workspace,Vector 再从 GM 加载到 UB | FIX → MTE2 | matmul_softmax_impl.py:Cubeset_cross_core(pipe=FIX, event_id=0),Vectorwait_cross_core(pipe=V, event_id=0) |
| Cube 将 Acc 搬到 Vec 后由 Vector 计算 | FIX → V | KDA chunk_h_kda_impl.py:Cubeset_cross_core(pipe=FIX, event_id=0),Vectorwait_cross_core(pipe=V, event_id=0) |
| Vector 通过 MTE3 写入 Mat(L1),Cube 再搬入 L0 | MTE3 → MTE1 | vec_cube_abs_sqrt_matmul_impl.py:Vectorset_cross_core(pipe=MTE3, event_id=2),Cubewait_cross_core(pipe=MTE1, event_id=2) |
具体接口规定其他 pipe 时,以该接口文档为准。
示例:Cube 整体写完 workspace 后通知 Vector(单向)
以下片段展示 Cube 整体写完 workspace 后通知 Vector 的单向事件位置(Tile 和 Tensor 声明已省略):
with pl.section_cube(): ... pl.store(workspace, acc_tile, [m_off, n_off]) pl.system.set_cross_core( pipe=pl.PipeType.FIX, event_id=0, sync_mode=pl.CrossCoreSyncMode.INTRA_BLOCK, ) with pl.section_vector(): pl.system.wait_cross_core( pipe=pl.PipeType.MTE2, event_id=0, sync_mode=pl.CrossCoreSyncMode.INTRA_BLOCK, ) pl.load(vec_tile, workspace, [m_off, n_off]) ...仓库中 matmul_softmax_impl.py 就是这一模式的完整实现:Cube 侧完成 matmul 后pl.move(mm1_res, tile_c1, acc_to_vec_mode=pl.AccToVecMode.DualModeSplitM),随后set_cross_core(pipe=pl.PipeType.FIX, event_id=0);Vector 侧每个 subblock 先wait_cross_core(pipe=pl.PipeType.V, event_id=0)再对 mm1_res 做 row_max/exp/row_sum 等 softmax 运算。注意该算子在 Cube 内部还用了多组sync_src/sync_dst(如MTE2→MTE1、MTE1→M、M→FIX),这些是核内同步,与跨核事件职责不同。
上例是一条单向依赖。如果 Cube 在循环内逐槽位生产,而 Vector 逐槽位消费,事件也必须按相同粒度建立;把唯一的set放在生产循环之后,只能形成"Cube 全部结束后 Vector 整体开始"的阶段屏障,不能实现逐 Tile 流水——这是性能优化的关键取舍。
反向示例:Vector 准备数据后交给 Cube(Vector → Cube 方向)
vec_cube_abs_sqrt_matmul_impl.py 展示了相反的握手方向:Vector 在 UB 中计算sqrt(|x|),转 NZ 后pl.insert(v1_mat, tile_nz, [off, 0])写入 L1(Mat),随后set_cross_core(pipe=pl.PipeType.MTE3, event_id=2);Cube 侧wait_cross_core(pipe=pl.PipeType.MTE1, event_id=2, sync_mode=pl.CrossCoreSyncMode.INTRA_BLOCK)后再pl.move(v1_left, v1_mat)将 L1 数据搬入 L0A 作为 matmul 左操作数。该样例头部注释明确记录了"cross-core event 2 is signalled by vector MTE3 and awaited by cube MTE1",并在 64×64 FP32 用例上以maxdiff=3.815e-06(容差 1e-3)通过 Gate A 验证。
sync_mode 的四种取值
发送和等待两侧的sync_mode必须一致。按参与者拓扑选择:
| sync_mode | 适用场景 |
|---|---|
INTRA_BLOCK | AIC 与两个 AIV 子核协同:AIC 向 Vector 发送的事件供两个 AIV 子核分别等待;AIC 等待 Vector 事件时需要等待两个 AIV 子核都完成 |
INTER_SUBBLOCK | 两个 AIV 子核之间同步 |
INTER_BLOCK | 跨物理 block 同步(必须同时满足目标产品与算法的适用条件) |
UNICAST_BLOCK | 只与一个 AIV 子核同步(必须同时满足目标产品与算法的适用条件) |
INTER_BLOCK和UNICAST_BLOCK属于受限模式,使用时需确认目标产品支持且算法语义匹配。
规划 event_id
当前接口的event_id范围为[0, 16)。两条硬性规则:
- 一对 set 和 wait 必须使用相同的 ID 与
sync_mode; - 一个 set 发送后,在对应 wait 消费该信号之前,event_id不得分配给可能产生错配的另一条事件。
event_id与 TileGroup 的mutex_id是两套独立机制,应分别规划;二者数值相同不会自动建立联系,也不能相互替代。
单向同步:只有 READY
一次性 workspace 或每轮使用独立地址时,只需建立生产者到消费者的 READY 事件。生产者在完成写操作后发送 READY,消费者在读取数据前等待同一个 READY。wait 可以先执行并阻塞,直至配对的 set 到达。
时间向下 Cube/FIX(生产者) Vector/MTE2(消费者) 写workspace set READY0 ---------------------> wait READY0 读workspaceREADY 保证 Vector 不会在 Cube 写完之前读取 workspace。每轮使用不同地址时,Cube 不再覆盖 Vector 正在读取的数据,因此不需要反向事件。
缓冲复用时的双向同步:READY + RELEASE
生产者和消费者循环复用同一组缓冲时,还需要反方向的 RELEASE 事件:
- READY表示"本轮数据已经写好";
- RELEASE表示"上一轮数据已经读完,这个槽位可以再次写入"。
下图以槽位 0 为例,展示该槽位每次写入、读取和释放的顺序(时间自上而下):
协议要点:
- 循环开始前,Vector 发送初始 RELEASE,Cube 等待该事件后获得槽位 0 的首次写入权限;
- 每次使用该槽位时:Cube 写完后发送 READY(步骤①)→ Vector 等待 READY(步骤②)→ Vector 读取数据(步骤③完成后发送 RELEASE)→ Cube 在下次覆盖前等待 RELEASE(步骤④);
- 槽位 0仍需复用时,步骤④位于下一次写入之前;槽位 0不再复用时,步骤④位于 Cube 侧结束之前,用于确认最后一次读取已经完成;
- ping-pong 缓冲的槽位 1 采用相同过程,并使用另一组
event_id。
循环前的 RELEASE 为生产者提供首次写入权限,循环中的 RELEASE 保护下一次复用。也可以不预发初始 RELEASE:此时生产者第一次写入不等待,从第二次使用该槽位开始等待。两种方案的要点是:初始化、循环主体和循环结束必须采用同一套配对方式,不能中途更换。
事件表按"共享缓冲 + 槽位访问 + 数据方向"记录事件。以下示例把每个物理槽位直接写开;如果源码使用动态下标,表中还应补充统一的slot_idx表达式:
| 事件组 | 共享缓冲及访问 | 方向 | 槽位 | event_id | set位置/pipe | wait位置/pipe | 复用条件 |
|---|---|---|---|---|---|---|---|
QK_READY | qk_group[0] | Cube→Vector | 0 | 0 | FIX写槽位0后 | V读槽位0前 | 对应wait已消费 |
QK_READY | qk_group[1] | Cube→Vector | 1 | 1 | FIX写槽位1后 | V读槽位1前 | 对应wait已消费 |
QK_RELEASE | qk_group[0] | Vector→Cube | 0 | 2 | V读槽位0后 | FIX覆盖槽位0前 | 对应wait已消费 |
QK_RELEASE | qk_group[1] | Vector→Cube | 1 | 3 | V读槽位1后 | FIX覆盖槽位1前 | 对应wait已消费 |
前一个信号由配对 wait 消费后,event_id 才可以复用;前一个信号尚未消费时,新的逻辑通道必须使用其他 ID。动态 event 表达式的所有运行时取值都必须位于[0, 16)。
源码实例:Engram 前向算子的 READY/ACK 双事件
仓库中的 engram_forward_impl.py 是双向同步协议的完整落地。它定义了K_READY_IDS = (0, 1)与ACK_IDS = (2, 3)两组事件 ID,按 head 奇偶h % 2轮转:
- Cube 侧(section_cube):每个 M-tile 的 head 完成
pl.store(key_back, ac, ...)落 GM 后,set_cross_core(pipe=pl.PipeType.FIX, event_id=K_READY_IDS[h % 2], sync_mode=INTRA_BLOCK)通知 Vector 消费;而在消费同槽上一轮数据之前,先wait_cross_core(pipe=pl.PipeType.FIX, event_id=ACK_IDS[h % 2], sync_mode=INTRA_BLOCK)等待 Vector 的背压信号; - Vector 侧(section_vector):先
wait_cross_core(pipe=pl.PipeType.MTE2, event_id=K_READY_IDS[h % 2])等 Cube 写完,消费完毕后set_cross_core(pipe=pl.PipeType.MTE3, event_id=ACK_IDS[h % 2])回发 ACK。
其中K_READY对应正向 READY,ACK对应反向 RELEASE,正好构成"同一逻辑槽位上的双向事件 + 奇偶轮转的两组 ID"。源码注释还点出了一个冷启动细节:mi == core_id and h < 2时(本核第一轮的前两个 head)不执行 ACK wait,否则 core >= 1 的 head 0 会等待一个永远不来的 ACK 导致死锁——这正是文档"初始化、循环主体必须采用同一套配对方式,且各分支路径都必须能配对"的工程实证。
源码实例:KDA chunk_h 的多事件流水
chunk_h_kda_impl.py 展示了一个循环内多个 Phase 交替、多个event_id并行工作的复杂场景:
event_id=3:Vector 侧store(ws_w_f16, ...)后set_cross_core(pipe=MTE3, event_id=3),Cube 侧wait_cross_core(pipe=MTE2, event_id=3)后加载w_l1/s_l1做WS = W @ S;event_id=0:Cube 完成pl.move(cur_ub_ws, cur_ws_acc, acc_to_vec_mode=...)后set_cross_core(pipe=FIX, event_id=0),Vector 侧wait_cross_core(pipe=V, event_id=0)后执行ws = kv - ws;event_id=1:Vector 计算 v_corr 完成后set_cross_core(pipe=MTE3, event_id=1),Cube 侧wait_cross_core(pipe=MTE2, event_id=1)后加载k_l1/v_l1做KV = k_rest^T @ v_corr;event_id=2:Cube 完成 KV 搬入 UB 后set_cross_core(pipe=FIX, event_id=2),Vectorwait_cross_core(pipe=V, event_id=2)后做S = exp(g_total)*S + KV。
四个事件分别对应四条独立的数据通道(w/s 准备、WS 结果、v_corr 结果、KV 结果),各自在"最后一次写之后 set、第一次读之前 wait",互不复用 ID,形成了 Cube/Vector 在单个 chunk 循环内的多阶段软件流水。这验证了"事件按数据方向与槽位逐一对应,指令总数相等也不能代替这项检查"的设计原则。
记录并验证同步方案:六项核查
同步方案应记录共享数据对象、set/wait 位置、pipe、sync_mode、event_id 和事件复用条件。共享对象是多槽 TileGroup 时,还要记录缓冲深度以及生产者、消费者的槽位访问表达式。design-template.md 的 §6 提供了落地的表格框架:cross_core 涉及判定、共享数据与槽位映射表、同步点表(事件组/共享缓冲/方向/set 位置与 pipe/wait 位置与 pipe/sync_mode/event_id/复用条件)、event_id 分配表。完成设计后按以下顺序检查:
检查槽位映射。对同一逻辑任务,确认生产者和消费者最终选中同一个物理槽位,READY/RELEASE 也按该槽位选 ID。动态下标的所有取值必须位于
[0, depth);使用next()时要把初始游标、调用次数和分支影响写清楚。检查事件位置。对照 READY/RELEASE 配对图中的①~④,确认:READY 的 set 位于最后一个生产操作之后,READY 的 wait 位于第一个消费操作之前,RELEASE 的 set 位于最后一个消费操作之后,RELEASE 的 wait 位于下一次覆盖之前。需要确认最后一次消费完成时,对应的 RELEASE wait 位于 Cube 侧结束之前——Section 开头的 wait 只能消费预发信号或上一轮信号,不能代替末尾等待。
检查配对关系。每个 wait 都应明确对应哪个方向、哪个槽位和哪一轮的 set。set 可以晚于 wait 到达,但在所有执行路径上都必须最终执行,整个等待关系不能形成环。
检查执行次数。设某槽位使用 n 次:
- 预发初始 RELEASE 时:初始授权和 n 次消费完成通知共执行
n+1次 set;首次写前、n-1次复用前和末尾确认共执行n+1次 wait; - 不预发初始 RELEASE 时:RELEASE 方向执行 n 次 set 和 n 次 wait——第一次写不等待,后续
n-1次写前等待,末尾再等待最后一次消费完成; - 两种方案的 READY 方向都是 n 次 set 和 n 次 wait。
- 预发初始 RELEASE 时:初始授权和 n 次消费完成通知共执行
检查边界分支。零次、一次、整除和尾块迭代中的每个 wait 都必须能够获得配对信号。任一侧因条件分支少执行一次,都可能造成永久等待或遗留信号;零次使用的槽位还要检查预发信号是否会影响后续 event_id 复用。
检查参与者。Vector 侧有两个 subblock 时,应记录各自访问的地址范围。使用
INTRA_BLOCK时,Cube 等待两个 AIV 子核都完成;使用UNICAST_BLOCK时,只等待参与该事件的 AIV 子核;两个 AIV 子核之间的屏障使用INTER_SUBBLOCK。
上述计数按每个槽位的源码执行次数统计。使用INTRA_BLOCK时,还要分别核对两个 AIV 子核的实际执行情况——例如 Engram 中 Vector 侧按sub_idx切分vm_start段(偶数段归 subblock 0、奇数段归 subblock 1),两侧的 wait/set 必须与这种切分保持一致。
官方指定算子示例:核对完整同步协议
下面这些示例来自 PyPTO Pro 工作流维护的官方指定算子清单,路径均相对于$PYPTO_DEVKIT_DIR。实际设计时以PRO_MATERIAL_INDEX.md§B 中存在的文件为准,并优先选择与目标算子缓冲数量、数据方向和循环拓扑相近的示例。
| 示例 | 适合核对的同步结构 |
|---|---|
pro_ops/lightning_indexer/test_quant_lightning_indexer_vf.py | Cube 与 Vector 之间的双向 READY/RELEASE、ping-pong 槽位和 Cube 侧末尾等待 |
pro_ops/fa/test_fa_tilingkey_attn_mask.py | QK、P、PV 多组跨 Section 数据依赖,以及按槽位分配正反向事件 |
pro_ops/fa/test_fa_perf_tkv_preload_dn_vf_bufid_dynrank.py | 动态循环中的 Cube/Vector 协作、预发反向事件、双缓冲复用和最后一批消费确认 |
pro_ops/fa/test_fa_with_mask.py | mask 分支下的多组事件、双缓冲与三缓冲并存,以及不同 pipe 上的 set/wait 位置 |
这些示例用于核对完整同步协议,不是event_id、pipe 或缓冲深度的固定模板。设计时必须按本算子的生产者、消费者、槽位和循环次数重新推导事件表;单个接口的pipe与sync_mode约束仍以当前 API 文档为准。
设计流程总结
回到 PyPTO-Pro 迭代式方案设计工作流(见 SKILL.md 的 R6 轮次),跨核同步设计遵循一条可复用的流水线:
- 在 R0 模块划分阶段确认是否存在 Cube↔Vector 或跨 Block/subblock 的数据依赖;没有则 §6 填"不涉及 cross_core",不能仅凭 Section 数量判定;
- 在 R4 循环与 Section 结构阶段确定自动流水还是手动流水;手动流水时,在 R6 按本文方法:先确定共享数据与槽位映射 → 再确定每个事件的 set/wait 位置与 pipe → 分配
event_id→ 填写同步点表和 event_id 分配表; - 完成设计后,逐槽位、逐轮次核对 set/wait 一一对应,检查动态下标落在
[0, depth)、各边界分支事件可配对、生产者结束前等待最后一次消费完成。
跨核同步的本质是把"数据就绪"和"缓冲可复用"两个事实,以事件的形式在正确的硬件流水上、以正确的时序传递给正确的参与者。把握住"事件只传信号、不搬数据",以及"READY 保护读、RELEASE 保护覆盖、配对必须按槽位和轮次逐一对应"这三条主线,就能为任何融合算子设计出正确且可验证的同步方案。
【免费下载链接】pypto-gymPyPTO-Gym 是基于 PyPTO 编程框架构建的算子与模型样例仓库项目地址: https://gitcode.com/cann/pypto-gym
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考