跳转至

编排代码生成(Orchestration Codegen)

设计原则:严格的 1-to-1 映射

编排代码生成遵循与 PTO 代码生成相同的原则:从 IR 到生成 C++ 代码的严格 1-to-1 转换。代码生成不应执行优化、分析或间接转换——此类工作属于前置 Pass。

例如,返回值到参数的追踪(将被调用者返回值映射回 Out 参数)是分析工作,应由代码生成之前的 Pass 解决。NormalizeReturnOrder pass 现在会在代码生成之前完成此规范化,使编排代码生成可以直接将 return[i] 映射到 out_indices[i],无需追踪 tile.store/yield 链。

同样,判断一个 ForStmt iter_arg 是否需要物化 carry 变量,过去要在循环体上跑别名等价不动点。ClassifyIterArgCarry pass 现在把该判定(以及 TaskId fence 数组的 extent)打在 ForStmt::attrs_ 上,codegen 直接读 iter_arg_rebind_<i> / iter_arg_array_size_<i>,不再自行推导。

概述

编排代码生成器(Orchestration Codegen)生成 PTO2 运行时 C++ 代码,用于管理昇腾硬件上的任务图执行。PTO 代码生成产生 InCore 核函数代码(Tile 级计算),而编排代码生成器产生主机侧代码,负责:

  • 将设备内存指针(通过 ChipStorageTaskArgs)封装为 Tensor 对象
  • 构建 Arg 对象,调用 add_input/add_output/add_inout/add_scalar 对参数分类(manual scope 的依赖边通过一个 set_dependencies 栈数组单独发出——见 Manual Scope 与 TaskId 降级
  • 通过 rt_submit_*_task 向 AIC(CUBE)或 AIV(VECTOR)核心提交任务
  • 处理控制流(循环、条件分支),使用 PTO2_SCOPE

流水线: IR(Orchestration 函数)→ OrchestrationCodegen → C++(PTO2 运行时 API)

源码位置: src/codegen/orchestration/orchestration_codegen.cpp

架构

组件结构

组件 职责 位置
OrchestrationInfoCollector IR 访问器,收集元数据(元组映射、张量赋值) orchestration_codegen.cpp
OrchestrationStmtCodegen 语句级 C++ 代码生成器(继承 CodegenBase) orchestration_codegen.cpp
OrchestrationOpRegistry 张量操作代码生成处理器的单例注册表 orchestration_op_registry.h
GenerateOrchestration() 主入口函数,组合所有生成阶段 orchestration_codegen.cpp
VarLineageCollector 通过 VarPtr 身份追踪函数体变量到函数参数的来源 orchestration_codegen.cpp
GetSSABaseName() 剥离 SSA/流水线后缀用于 C++ 名称生成(非身份判定) orchestration_codegen.cpp

OrchestrationInfoCollector

IR 访问器,预扫描函数体以收集:

  • 元组元素映射 — 追踪哪些变量来自元组解构
  • 调用-元组键 — 唯一键(_tc_N)防止跨调用冲突
  • 输出张量赋值 — 将变量名映射到其赋值语句

OrchestrationStmtCodegen

主代码生成器。访问每条 IR 语句并生成对应的 C++:

  • AssignStmt → 张量操作、函数调用或别名生成
  • ForStmtfor 循环及迭代参数初始化和 yield 更新
  • IfStmt → 每个分支带 PTO2_SCOPE 的条件块及返回变量处理
  • YieldStmt → 循环携带值的变量重赋值

操作注册表

张量操作通过 REGISTER_ORCHESTRATION_OP 宏注册:

REGISTER_ORCHESTRATION_OP("tensor.create", TensorCreateHandler);
REGISTER_ORCHESTRATION_OP("tensor.read", TensorReadHandler);
REGISTER_ORCHESTRATION_OP("tensor.slice", TensorSliceHandler);

这允许在不修改核心访问器的情况下扩展操作代码生成。

代码生成流程

GenerateOrchestration() 分 9 个阶段生成 C++:

阶段 1:模板代码

#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include "pto_orchestration_api.h"

阶段 2–3:入口点

// 阶段 2:配置函数 — 返回期望的参数数量
PTO2OrchestrationConfig aicpu_orchestration_config(const ChipStorageTaskArgs& orch_args) {
    (void)orch_args;
    return PTO2OrchestrationConfig{ .expected_arg_count = 3 };
}

// 阶段 3:入口函数签名
void aicpu_orchestration_entry(const ChipStorageTaskArgs& orch_args) {

阶段 4–5:张量设置

// 阶段 4:外部张量 — 所有布局统一调用 from_tensor_arg()
Tensor ext_a = from_tensor_arg(orch_args.tensor(0));
Tensor ext_b = from_tensor_arg(orch_args.tensor(1));
Tensor ext_dn = from_tensor_arg(orch_args.tensor(2));

// 阶段 5:内部张量(来自 pl.create_tensor — 仅中间变量)
// 同一 scope 中的所有 tensor.create 批量合并为一条 alloc_tensors 调用
uint32_t tmp_ci_shapes[2] = {16, 16};
TensorCreateInfo tmp_ci(tmp_ci_shapes, 2, DataType::FLOAT32);
TaskOutputTensors alloc_0 = alloc_tensors(tmp_ci);
const Tensor& tmp = alloc_0.get_ref(0);

阶段 6–8:任务提交与控制流

所有任务提交包裹在顶层 PTO2_SCOPE() 中。codegen 不再依据 for / if 结构 决定 scope 位置:MaterializeRuntimeScopes pass 会向 IR 中插入显式的 AUTO RuntimeScopeStmt 节点(函数体以及每个 for / if 体),codegen 从这些节点 1:1 地 emit PTO2_SCOPE(manual scope 降级为 PTO2_SCOPE(PTO2ScopeMode::MANUAL)):

PTO2_SCOPE() {
    Arg params_t0;
    params_t0.add_input(ext_a);
    params_t0.add_input(ext_b);
    params_t0.add_output(tmp);               // 预分配张量使用 add_output(const Tensor&)
    rt_submit_aiv_task(0, params_t0);

    // ForStmt 示例 — 普通 for 循环,不嵌套独立的 PTO2_SCOPE
    for (int64_t i = start; i < stop; i += step) {
        // 任务提交
    }
}

核心概念

外部张量 vs 内部张量

类型 来源 C++ 构造方式 命名
外部(ND/DN) 函数参数(In/Out/InOut from_tensor_arg(orch_args.tensor(N)) ext_<name>
内部 函数体中的 pl.create_tensor(...) TensorCreateInfo var_ci(...) + scope 入口处 alloc_tensors(...) <name>(无前缀)

外部张量封装从主机通过 ChipStorageTaskArgs 传入的设备内存指针。内部张量在 scope 入口处通过 alloc_tensors() 预分配——同一 scope(函数体、for 循环体、if 分支体)中的所有 tensor.create 被批量合并为一条 alloc_tensors 调用。预分配的张量随后通过 add_output(const Tensor&) (OUTPUT_EXISTING 重载) 传递给核函数。

参数方向

每个函数参数的 ParamDirection 决定其在任务提交中的表现:

方向 Python 注解 C++ 任务参数 语义
In pl.Tensor[...](默认) params.add_input(var) 只读
Out(外部) pl.Out[pl.Tensor[...]](参数) params.add_output(ext_x) 只写预分配缓冲区
Out(内部) pl.Out[pl.Tensor[...]](tensor.create) params.add_output(x) 通过 alloc_tensors 预分配,使用 OUTPUT_EXISTING 重载
InOut pl.InOut[pl.Tensor[...]] params.add_inout(ext_x) 读写
Scalar pl.Scalar[...] params.add_scalar(value) 标量常量(独立 scalar 槽位)

来自 tensor.create 的内部张量在 scope 入口通过 alloc_tensors() 预分配。传递给核函数时,使用 add_output(const Tensor&) 触发 OUTPUT_EXISTING 重载——运行时复用预分配的缓冲区,而非分配新的。

标量参数编码

标量参数占用 ChipStorageTaskArgs 的 scalar 槽位(从 0 开始独立索引,与张量槽位分离)。 浮点标量使用 to_u64(f) 进行位转换,其他整数/bool 标量强制转换为 (uint64_t)。 接收端使用联合体(union)进行类型双关,将 uint64_t 重新解释为目标 C 类型:

union { uint64_t u64; float val; } scale_conv;
scale_conv.u64 = orch_args.scalar(0);
float scale = scale_conv.val;

输出别名(emit 名重映射)

kernel/submit 的输出就是它原地写入的 Out/InOut 参数——即同一物理张量。因此当 结果 Var 的名字与该参数不同时,代码生成器再生成 const Tensor& result = ext_output; 这样的重命名,而是把结果 Var 的 emit 名重映射到源,下游所有引用都直接 解析到源名。(这正是 tensor.assemble 采用的策略,现统一应用。)

# Python IR
result = self.kernel_add(a, b, output)  # result ≠ output
consumer = self.kernel_use(result)
// 生成的 C++ —— result 被重映射到 ext_output,消费者直接读取它
Arg params_t0;
params_t0.add_output(ext_output);
rt_submit_aiv_task(0, params_t0);

Arg params_t1;
params_t1.add_input(ext_output);  // result -> ext_output(无别名声明)

结果别名到哪个 Out/InOut 参数是查表而非启发式——也不是分析。 ReturnParamsExplicit 属性 (NormalizeReturnOrder)保证: 每个"写回参数"的张量返回值就是该参数本身(指针同一性)。因此 codegen 直接 从被调用者的 ReturnStmt 上读取"返回位置 → 参数下标"映射 (ir::return_lineage::ExplicitReturnedParamIndices):无需 SSA 遍历、无需递归 进入被调用者、无需 Program。跨函数的血缘追踪器(ReturnedParamIndices)留在 IR 层,只服务于在该属性建立之前运行的那些 pass。

因此该属性是 codegen 的前置条件。当某个返回位置解析不到参数时,仅在被调用者恰好 只有一个 Out/InOut 时单返回值才回退到该唯一参数——多输出的被调用者若其 ReturnStmt 未直接引用参数,则是内部错误,绝不猜测。

不参与重映射的情形:phi/循环 carry 的重赋值(它重新绑定外层 if/循环所拥有的左值) 保留 <name> = <src>; 形式;源在读取者的 C++ 作用域中无效的张量(manual scope 局部 的源——见下文跨作用域张量与 manual_scope)保留声明路径;绑定到 task_<n>_outs.get_ref(k) 的运行时分配输出同样保留其 const Tensor& 绑定。

核心类型推断

代码生成器根据被调用函数的 MemorySpace 决定提交到 AIC(CUBE)还是 AIV(VECTOR):

MemorySpace 核心类型 提交函数
LeftRightAccMat CUBE (AIC) rt_submit_aic_task
Vec(默认) VECTOR (AIV) rt_submit_aiv_task

元组处理

元组返回的调用使用唯一键(_tc_N)追踪元素:

# Python IR
pij, mij, lij = self.kernel_softmax(sij, scale, pij, mij, lij)
// 生成的 C++ — 先张量后标量
Arg params_t0;
params_t0.add_input(ext_sij);
params_t0.add_inout(ext_pij);
params_t0.add_inout(ext_mij);
params_t0.add_inout(ext_lij);
params_t0.add_scalar(to_u64(scale));  // 标量在所有张量之后
rt_submit_aiv_task(0, params_t0);

Group 函数(混合核)

当核函数同时使用 AIC 和 AIV 核心(混合核)时,代码生成器生成 MixedKernels 提交:

// Group: mixed_kernel (AIC + AIV)
Arg params_t0;
// ... add_input / add_inout / add_scalar 调用 ...
MixedKernels mixed_0 = {aic_id, aiv_id, INVALID_KERNEL_ID};
rt_submit_task(mixed_0, params_t0);

操作映射

IR 操作 C++ 代码生成 描述
tensor.create TensorCreateInfo var_ci(...) + alloc_tensors(...) scope 级批量分配;const Tensor& var = alloc_N.get_ref(i)
tensor.read *reinterpret_cast<T*>(arg_ptr + offset) 从主机张量读取标量
tensor.slice make_tensor_external(ptr + byte_offset, ...) 创建现有张量的视图
tensor.transpose Tensor xt = ext_x.transpose(axis1, axis2) 零拷贝交换两个维度的元数据(lower 到运行时 Tensor::transpose
tensor.dim(静态) int64_t d0 = 16 编译时常量维度值
tensor.dim(动态) int64_t d0 = (int64_t)orch_args.tensor(N).ref().shapes[axis] 从 ChipStorageTaskArgs 获取运行时维度。在编排(Orchestration)函数体内,解析器会将其折叠为已声明的 extent —— 见下文

动态维度符号(Dynamic-dim symbols)

pl.dynamic("M") 符号命名的是「声明它的那个张量实参」的运行时 extent。在 kernel 中它 只是类型层面的占位符;但编排函数体可以把它当作使用 —— 循环上界、pl.create_tensor 的 extent,或折叠后的 pl.tensor.dim。因此函数体引用到的每个符号都会在入口处定义一次, 从「首个声明该符号的参数」的描述符中读取:

    // Dynamic-dim symbols (extent of the declaring argument)
    int64_t M = (int64_t)orch_args.tensor(0).ref().shapes[0];

只有生成代码中真正出现的符号才会被定义;仅出现在参数类型中的符号不会产生定义 (外部张量的 shape 从不会被打印)。

由于符号就是该 extent,解析器会把编排函数体内的 pl.tensor.dim(x, i) 折叠为 x 的类型已经命名的那个 extent —— 一个运行时 extent 只对应一个 IR 名字。若重新读取,会为 同一个量再造出一个标量,而分析器无法证明它与该符号相等;由该副本构造出的所有 shape 都会在结构上与由符号构造的 shape 不一致。该折叠适用于编排函数:Inline/InCore 被调用方可能被传入形状不同的实参,因此在那里 tensor.dim 仍是真正的运行时读取。

完整示例

输入:PyPTO 编排函数

@pl.function(type=pl.FunctionType.Orchestration)
def orch_basic(
    self,
    a: pl.Tensor[[16, 16], pl.FP32],
    b: pl.Tensor[[16, 16], pl.FP32],
    d: pl.Out[pl.Tensor[[16, 16], pl.FP32]],
) -> pl.Tensor[[16, 16], pl.FP32]:
    c: pl.Tensor[[16, 16], pl.FP32] = pl.create_tensor([16, 16], dtype=pl.FP32)
    c = self.kernel_add(a, b, c)       # c 是内部张量(中间变量)
    d = self.kernel_add(c, b, d)       # d 是外部张量(Out 参数)
    return d

输出:生成的 C++

// Orchestration Function: orch_basic
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include "pto_orchestration_api.h"

extern "C" {

PTO2OrchestrationConfig aicpu_orchestration_config(const ChipStorageTaskArgs& orch_args) {
    (void)orch_args;
    return PTO2OrchestrationConfig{ .expected_arg_count = 3 };
}

void aicpu_orchestration_entry(const ChipStorageTaskArgs& orch_args) {
    // 外部张量(来自 ChipStorageTaskArgs)
    Tensor ext_a = from_tensor_arg(orch_args.tensor(0));
    Tensor ext_b = from_tensor_arg(orch_args.tensor(1));
    Tensor ext_d = from_tensor_arg(orch_args.tensor(2));

    PTO2_SCOPE() {
        // 内部张量 — 在 scope 入口通过 alloc_tensors 预分配
        uint32_t c_ci_shapes[2] = {16, 16};
        TensorCreateInfo c_ci(c_ci_shapes, 2, DataType::FLOAT32);
        TaskOutputTensors alloc_0 = alloc_tensors(c_ci);
        const Tensor& c = alloc_0.get_ref(0);

        // 任务 0: kernel_add (a + b → c)
        Arg params_t0;
        params_t0.add_input(ext_a);
        params_t0.add_input(ext_b);
        params_t0.add_output(c);
        rt_submit_aiv_task(0, params_t0);

        // 任务 1: kernel_add (c + b → d)
        Arg params_t1;
        params_t1.add_input(c);
        params_t1.add_input(ext_b);
        params_t1.add_output(ext_d);
        rt_submit_aiv_task(1, params_t1);
    }
}

}  // extern "C"

变量命名

基于 VarPtr 的变量身份追踪

变量身份判定(该变量是否为参数?两个变量是否为同一张量?)使用基于 VarPtr 指针的身份识别,而非字符串匹配。VarLineageCollector 在代码生成 前遍历函数体,通过 ForStmt iter_arg/return_var 链和简单的 Var-to-Var 赋值, 将每个函数体 Var* 追踪回其源函数参数 Var*。这避免了后缀剥离导致的名称 冲突问题(例如 out_0out 合并了不同变量)。

GetSSABaseName() 仍用于 C++ 代码生成(生成输出中的清晰变量名), 但不再用于身份判定。

命名约定

实体 模式 示例
外部张量 ext_<name> ext_a
内部张量 <name>(无前缀) c
内部 TensorCreateInfo <name>_ci c_ci
任务参数 params_t<N> params_t0
分配结果 alloc_<N> alloc_0
张量参数索引 orch_args.tensor(N) orch_args.tensor(0)
标量参数索引 orch_args.scalar(N) orch_args.scalar(0)

控制流生成

ForStmt

# Python IR
for i in pl.range(0, 4):
    acc = self.kernel_add(a, acc, acc)
// 生成的 C++(位于顶层 PTO2_SCOPE 内部)
Tensor acc = ext_acc;  // 迭代参数初始化
for (int64_t i = 0; i < 4; i += 1) {
    Arg params_t0;
    // ... add_input / add_inout 调用 ...
    rt_submit_aiv_task(0, params_t0);
}

迭代参数在循环前初始化。YieldStmt 更新在每次迭代末尾发出。

IfStmt

# Python IR
if condition:
    c = self.kernel_a(a, b, c)
else:
    c = self.kernel_b(a, b, c)
// 生成的 C++
if (condition) {
    PTO2_SCOPE() {
        Arg params_t0;
        // ... add_input / add_inout 调用 ...
        rt_submit_aiv_task(0, params_t0);
    }
} else {
    PTO2_SCOPE() {
        Arg params_t1;
        // ... add_input / add_inout 调用 ...
        rt_submit_aiv_task(1, params_t1);
    }
}

Python API

from pypto import codegen, backend

backend.set_backend_type(backend.BackendType.Ascend910B)
result = codegen.generate_orchestration(MyProgram, orch_func)
code = result.code

# 访问生成的编排代码
orch_code = files["orchestration/orch_func_name.cpp"]

编排文件在生成的文件映射中命名为 orchestration/<func_name>.cpp

Manual Scope 与 TaskId 降级

with pl.manual_scope(): 区域被降级为 PTO2_SCOPE(PTO2ScopeMode::MANUAL) 代码块,区域内 runtime 的 auto OverlapMap 关闭。每个 task 的 params 始终 声明为普通的 Arg <task_var>;。orchestration codegen 把所需的依赖边 物化为一个定长栈数组加一次 set_dependencies 调用:

Arg params_t1;
params_t1.add_input(...);
// ...
PTO2TaskId params_t1_deps[K];          // K = 精确的 dep 边数
uint32_t params_t1_deps_count = 0;
params_t1_deps[params_t1_deps_count++] = tid;                          // 新鲜生产者——不加守卫
if (carry.is_valid()) params_t1_deps[params_t1_deps_count++] = carry;  // 循环 carry——可能无效
params_t1.set_dependencies(params_t1_deps, params_t1_deps_count);

只有当 TaskId 可能合法地持有 PTO2TaskId::invalid() 哨兵时,dep 槽位才被 if (task_id.is_valid()) 包裹,因为 invalid id 绝不能进入 set_dependencies;而新鲜的直接生产者 TaskId 静态上恒为有效,因此其插入 不加守卫(issue #1966)。完整的分类见 TaskId 的来源

不再有 params.add_dep(...) 调用,也没有 16 条依赖上限——runtime 的 Arg::set_dependencies 原语没有上限,栈数组按精确数量定长。用户依赖来自 parser:parser 把用户的 pl.submit(..., deps=[tid1, tid2]) kwarg 写入类型化的 Submit::deps_ 字段;codegen 通过临时的 SubmitToCallView 读取它们——该 view 把 deps_ 合成为 attrs["manual_dep_edges"]。普通 Call 携带 manual_dep_edges 的形态已不存在——ManualDepsOnSubmitOnly 结构性属性会校验 任何跨函数 Call 都不携带它;只有 system.task_dummy barrier op 作为 fanin 契约保留该 attr。编译器推导的依赖边来自 AutoDeriveTaskDependencies, 保存在 Call.attrs["compiler_manual_dep_edges"](独立的 key,允许出现在普通 call 上)。该 pass 从不分析用户写的 MANUAL scope——在 pl.manual_scope() 内, 显式的 deps=[...] 仍是唯一的依赖边来源。它只分析 AUTO 区域,且仅当编译期开关 analyze_auto_scopes_for_deps 打开时才生效。默认模式(auto_scope=True)区域 在被完整覆盖时会成为编译器自有的 MANUAL scope,否则保持 AUTO,其可表示的边 叠加在 runtime 自动追踪之上发出;手工放置的 pl.scope() 始终保持 manual=false,一旦分析回退则会剥离其部分推导边。Codegen 会按这个顺序合并两组 列表,并按 Var identity 去重后再发出栈数组,同时为每个条目标注其来源 (DepEdge::user_written)。

当某条依赖边无法解析到活跃的 TaskId 绑定时,处理方式取决于该来源——参见 无法解析的依赖边

TaskId 的来源

SubmitToCallView 合成的 manual_dep_edges 或普通 call 上的 attrs["compiler_manual_dep_edges"] 中的每个显式依赖条目都是 TaskId VarPtrScalar[TASK_ID]Array[N, TASK_ID])。每个条目在 codegen 时通过 manual_task_id_map_ 解析为以下来源之一:

Producer 种类 codegen 发出的 C++
pl.submit 的 producer TaskId(增广 Call 的 TaskId tuple 元素) PTO2TaskId <tid_name> = task_<n>_outs.task_id();,其中 task_<n>_outs 是 submit 捕获的 TaskOutputTensors
None 种子(deps=[None] 条目中的字面量,或 TaskId iter_arg init) PTO2TaskId::invalid()
循环 carry iter_arg(穿行循环的 TaskId 配套) for 循环中穿行的命名变量——标量或数组,见下
数组槽读取(prev = tids[k]——对 Array[TASK_ID]array.get_element PTO2TaskId <name> = <arr>[k];——一个标量快照局部变量;dep 引用该局部变量而非重新读取槽位,因此之后的 tids[k] = ... 覆写不会改变它

pl.submit call 的 kernel-result tuple 元素与普通多输出 kernel call 一样, 直接 alias kernel 的 Out/InOut 参数。

当 id 可能持有 PTO2TaskId::invalid() 哨兵时,dep 数组填充条目才会被 if (<task_id>.is_valid()) 包裹(首轮迭代的 iter_arg carry、未写入的数组槽、 数组槽读取,或 None 种子)。而新鲜的 pl.submit producer TaskId 静态上 恒为有效,因此 EmitManualDeps 对其插入不加守卫(issue #1966);其余所有标量 (string 形式)TaskId 仍保留守卫。array-carry iter_arg 则按元素逐槽生成带守卫的填充。

词法作用域生命周期。 TaskId 绑定命名的是在其产生所在的 PTO2_SCOPE { ... } 块内声明的 C++ 局部变量(PTO2TaskId tid = ...)。每个 PTO2_SCOPE(AUTO 或 MANUAL)在进入时快照 manual_task_id_map_array_carry_vars_、退出时恢复, 因此在某作用域内产生的绑定不会泄漏到外层作用域(否则其标识符会超出 C++ 作用域)。 循环 / 分支的 carry 在其 body 的 PTO2_SCOPE 之前声明,因此能正确地在块结束后存活。

无法解析的依赖边

上述生命周期规则带来一个结果:若某条依赖边引用的 TaskId 产生于一个已经关闭的 作用域,则它无法解析——该绑定在作用域退出时已被恢复掉。Codegen 的处理方式取决于 这条边的来源:

边的来源 无法解析时的行为
编译器推导(compiler_manual_dep_edges 静默跳过。这类边是尽力而为的 hazard 补丁;PrepareCrossScopeTaskIdHoists 已对能处理的部分做了 LCA 提升,丢弃其余是安全的,因为该 pass 只会增加定序
用户书写(deps=[...] 硬报错——CHECK_SPAN 抛出 pypto::ValueError,并指出该 TaskId 与对应的 DSL 源码行

来源由 attr key 判定。注意 attrs["dummy_task"] 不是作者身份标记:parser 会把 它打在用户书写的 pl.system.task_dummy(deps=[...]) 上,与 ExpandManualPhaseFence 给自己合成的 barrier 打的完全相同,因此所有 manual_dep_edges 载体一律强制校验。合成的 barrier 只会引用其所改写的 manual scope 内仍然活跃的 TaskId,故其 fanin 必然可解析。

丢弃用户边会让消费者与其生产者之间失去定序,在运行时表现为静默的陈旧数据读取, 因此 codegen 宁可拒绝生成,也不生成错误的代码。ResolveDepEdgeBinding 是唯一的 解析并校验入口,CountManualDeps(数组定长)与 EmitManualDeps(数组填充)都经由 它,因此两者对"哪些边存活"的判断绝不会不一致。

被关闭的作用域不一定由用户书写。MaterializeRuntimeScopes 会把每个 ForStmt body 和每个 IfStmt 分支 body 各自包进一个 AUTO 作用域,因此在完全没有出现 pl.scope() / pl.manual_scope() 的普通编排代码中也会触发——例如在 pl.range body 内捕获 TaskId、却在循环之后依赖它。修复方式是把消费者放到生产者所在的 作用域内,或把生产者外提。

MANUAL 作用域的 array_carry_vars_ 恢复有一个例外:在该作用域 内部 注册、 但其底层数组声明于 外层 作用域的 array carry 必须在恢复后存活。这就是将 manual_scope 产生的 TaskId loop-carry 进 Array[TASK_ID] 的场景(issue #1811)—— 例如从外层 pl.range 循环的底层存储穿入的 pl.parallel array carry,每次迭代写入 一个槽位(carry[n] = prod_tid)。外层循环的 YieldStmtPTO2_SCOPE(MANUAL)之后 发出并引用该 carry;若将其抹除会丢失被 loop-carry 的 TaskId,并触发 scalar yield to array carryINTERNAL_CHECK。因此退出时 codegen 会保留底层存储 为 enclosing-scope-valid(由不在该作用域 local 集合中的标识符命名)的 array carry, 仅回退作用域内局部的那些。

跨作用域张量与 manual_scope manual_scope 是一个调度区域,而非存储/取值 作用域:它所触及的张量会透明地流向 PTO2_SCOPE(MANUAL) { ... }之后的 task。 因此块后读取者所命名的任何标识符都不能是 manual scope 内的局部 C++ 标识符——否则它会 在右花括号处失效,读取者的 add_input(...) 将引用一个超出作用域的名字(.cpp 随即 无法通过 C++ 编译,issue #1697)。两种机制保证这一点,二者都以名字是否在外层作用域 有效(在块之前保留,或为已提升的块内缓冲——即非作用域局部)为判据:

  • 输出重映射(remap)。 一个由调用方分配、且别名为外层作用域源的 kernel/submit 输出,单独生成 const Tensor& 声明——其 emit 名被重映射到源,于是所有引用 (块内与块后)都直接解析到外层名字。这正是 tensor.assemble 已采用的策略;由于该 输出与其源是同一物理张量(原地写),共享名字恰好正确。phi/循环 carry 的重赋值被排除 ——它重新绑定的是外层 if/循环所拥有的左值。

  • 分配提升(allocation hoisting)。 在块内部创建的缓冲(pl.create_tensoralloc_tensors)只是存储预留、没有调度依赖,因此其声明被提升到外层作用域。codegen 会缓冲每个 PTO2_SCOPE(MANUAL) 块体,并把提升后的 alloc_tensors 声明刷写到块头 之前。该批次按构造即在外层作用域有效(形状引用了作用域局部值的 create 会被排除并保持 原位)。

二者结合后,无论张量在块之前还是块内部创建、并在块后被读取,都会解析到外层作用域中唯一的 const Tensor& buf = ...;——块后 task 只需 add_input(buf),不再产生任何按 SSA 版本 的别名。

pl.parallel TaskId iter_arg 的 array carry

承载 TaskId 配套的 pl.parallel(N) ForStmt 被降级为大小为 N 的数组 carry, 而不是"last-write wins"的标量。pass 在 IR 中保留 iter_arg 为 Scalar[TASK_ID];codegen 识别这一形态(Parallel + TaskId iter_arg)后:

  1. 在 iter_arg 声明处分配定长数组:PTO2TaskId arr[N];,初始化时 广播标量 init(init 为标量时)或按槽位拷贝(init 本身是数组时—— 例如 case1 内层 pl.parallel 的 init 来自外层 pl.range 的数组 carry)。
  2. 每个 parallel iter 体内把新产生的 task id 写入一个槽位: arr[(loop_var - start) / step] = <task_id>;。当 start == 0 && step == 1 (常见形式)时,槽位表达式被 peephole 化简为 arr[loop_var]
  3. 对每个 deps_ 引用该 iter_arg 的下游消费者 Submit,把 N 个带守卫 的槽位填入该 task 的 dep 栈数组,每个槽位一条:
for each k in [0..N):
    if (arr[k].is_valid()) { params_deps[params_deps_count++] = arr[k]; }

pl.range(Sequential)循环,若其 yield 是内层 pl.parallel array carry 的 rv,会继承同一个 N:它自身的 iter_arg 也成为大小 N 的数组 carry, 外层 yield 时按槽位拷贝。这种结构上的传播就是 case1(外层 SEQ × 内层 PARALLEL) 等拓扑中"多 iter fence 语义"的来源。

Phase-fence dummy barrier

DeriveCallDirections 之后,ExpandManualPhaseFence pass 可能压缩有收益且稳定的完整数组 manual dependency:它把选中的 consumer Submitdeps_=[tids] 改写为 deps_=[barrier_tid]。该 pass 会插入一个带标记的 system.task_dummy call;这个 dummy call 自己的 manual_dep_edges attr 仍然引用原始 TaskId 数组 (这是该 attr 唯一被允许的 op-call 载体——普通跨函数 Call 永不携带它)。Orchestration codegen 会把带标记的 call 降低为 rt_submit_dummy_task(...),随后对被改写的 consumer 继续使用普通标量 dependency lowering。

空 deps 与带 deps 的 dummy 区别。 带 deps 的 dummy 会在 if (deps_count > 0) 运行时守卫下提交:它的每条 dep 都在 per-edge is_valid() 守卫下追加,运行时可能 全部解析为 invalid sentinel(即什么都不 fence)。而静态空 deps 的 dummy——只能来自 用户手写的 pl.system.task_dummy(deps=[]),因为 ExpandManualPhaseFence 从不插入 空 barrier——则无条件提交。没有前驱的 barrier 依然是一个立即就绪的真实 task, 其有效 id 必须加入每个 consumer 的 fanin;没有前驱既不影响它的提交,也不影响它到 后继的边,因此若用 deps_count > 0 守卫它就会被静态消除,从而悄悄丢掉这些边。

这会保留 phase boundary,同时避免重复 all-to-all fanout:

tids[N] -> dummy barrier -> consumers[M]

如果形状、安全性或收益不够明确,则继续走原有直接 Submit::deps_ lowering 路径。 尤其在 manual_scope 中,用户显式写出的 deps 是权威约束:如果 pl.parallel body 读取 deps=[tids],随后又更新 tids[branch],这表示 same-carrier dependency chain,而不是可在 loop 前压缩的 snapshot source。若用户需要 layer-parallel snapshot 语义,应写入单独的 tids_next carrier,并通过 loop-carried init_values / pl.yield_ 在 parallel body 之后传回。这里不写成 普通的 tids = tids_next,因为当前 codegen 路径暂不支持 ArrayType 的普通 AssignStmt

codegen 入口检查的约束(带用户友好 CHECK 消息):

  • pl.parallel 的 trip count 必须是 Python 字面量(编译期常量)。 动态 trip count 在 codegen 时被拒绝,提示 "statically-known trip count"。

dep 栈数组按精确依赖数定长(对数组 carry 为 N 个槽),不再对 trip count 超过 16 设上限——runtime 原语 Arg::set_dependencies(ptr, count) 同样 没有上限。

示例

源 DSL(case1 形态):

with pl.manual_scope():
    prev_tid = None
    for phase in pl.range(N_PHASES):
        for branch in pl.parallel(N_BRANCHES):
            row = (phase * N_BRANCHES + branch) * TILE_M
            out, prev_tid = pl.submit(self.kernel_stripe, data, row, 1.0, out, deps=[prev_tid])

生成 C++(骨架):

PTO2_SCOPE(PTO2ScopeMode::MANUAL) {
    PTO2TaskId out__rv_v2__tid[N_BRANCHES];                    // 外层 rv = 数组
    for (int64_t i = 0; i < N_BRANCHES; ++i)
        out__rv_v2__tid[i] = PTO2TaskId::invalid();            // 广播 None 种子
    for (int64_t phase = 0; phase < N_PHASES; phase += 1) {
        PTO2TaskId out__rv_v4__tid[N_BRANCHES];                // 内层 rv = 数组
        for (int64_t i = 0; i < N_BRANCHES; ++i)
            out__rv_v4__tid[i] = out__rv_v2__tid[i];           // 按槽位拷贝
        for (int64_t branch = 0; branch < N_BRANCHES; branch += 1) {
            int64_t row = ...;
            Arg params_t0; /* ... */
            PTO2TaskId params_t0_deps[N_BRANCHES];             // 按数组 carry N 定长
            uint32_t params_t0_deps_count = 0;
            for (int64_t k = 0; k < N_BRANCHES; ++k) {         // 多依赖 fanout
                if (out__rv_v2__tid[k].is_valid())
                    params_t0_deps[params_t0_deps_count++] = out__rv_v2__tid[k];
            }
            params_t0.set_dependencies(params_t0_deps, params_t0_deps_count);
            TaskOutputTensors task_0_outs = rt_submit_aiv_task(0, params_t0);
            PTO2TaskId out__ssa_v5__tid = task_0_outs.task_id();
            out__rv_v4__tid[branch] = out__ssa_v5__tid;        // 槽位 yield
        }
        for (int64_t i = 0; i < N_BRANCHES; ++i)
            out__rv_v2__tid[i] = out__rv_v4__tid[i];           // 外层 yield (拷贝)
    }
}

phase N+1 中的每个 task 都会等待 phase N全部 N_BRANCHES 个 task。

参见