——“一个人用AI如何写出比PyTorch更快的自研深度学习框架”系列文章之十四
在前一篇文章里,我们沿着 ArchPlan 与编译管线走完了从 BluePrint 到 LayerDescriptor 的变换。接下来 compiler 还要做一件更重要的事:把这些描述编译成可以被 GPU 反复执行的计划。这份计划不是一张“大网”从头跑到尾,而是被切成了许多段——H2D 传输、首层前向、深层前反向、梯度通信、优化器更新、BN 统计量同步、验证推理……每一段都可能因为输入形状不同而需要不同的 CUDA Graph。
同一份模型,为什么需要那么多张图?答案很简单:因为训练不是单一场景。一个 epoch 里普通 batch 和最后一个不完整的 batch 形状不同;渐进式分辨率训练会让前期和后期的输入尺寸不同;训练要跑反向与优化器,验证只需要前向;多卡通信图和单卡计算图的拓扑也不一样。CUDA Graph 要求“捕获一次、重放多次”时张量地址与形状完全一致,只要形状变了就要重新捕获。
Tech-Renaissance 的解法是:把图拓扑和形状/内存布局彻底解耦。
ComputationGraph只保存零形状信息的算子拓扑;GraphAtlas把这份拓扑映射到不同变体、不同子图,并按(cg, gid, shape_id)去重复用;- 运行时通过 O(1) 数组索引直接拿到对应的 CUDA Graph 执行句柄。
这就是本文的主题:一份图纸,多处复用。
一、为什么要把形状从计算图里剥离?
计算图(Computational Graph)描述的是“先算什么、后算什么,谁依赖谁”。你可以把它理解成一道菜的菜谱:菜谱只规定步骤和依赖关系,比如“先炒蛋再炒饭”,它不关心你今天用的是大盘子还是小盘子。
在主流框架里,计算图和形状的关系大致有几种做法。
PyTorch Eager 是最典型的动态图执行模式。每个算子都在 Python 层被触发,经过 dispatcher、Autograd、C++ kernel launch,逐个提交给 GPU。张量对象本身持有 shape、stride、dtype、device 等全部信息,计算图是“隐式”的——它只在反向传播时临时构建。这种模式的优点是灵活、调试方便、支持任意 Python 控制流;代价是 host 调度开销大,全局优化空间受限,也很难直接做 CUDA Graph 全捕获。
TensorFlow 1.x 走了另一条路:先构建静态图,再执行。用户通过 tf.Graph() 和 tf.Session() 定义节点和边,shape 信息在构图时就被注入。这种设计让 XLA 可以做非常激进的算子融合、布局优化和常量折叠。到了 TensorFlow 2.x,虽然默认变成 Eager,但仍然保留了 tf.function 用于图捕获和 XLA 编译。JAX 则更进一步,它鼓励用户用 jax.jit 把 Python 函数编译成 XLA HLO,由 XLA 负责后续优化;但 JAX 的用户通常也要面对 tracing 与 jit 的语义边界。
PyTorch torch.compile 试图兼顾两边:它用 TorchDynamo 在 Python 解释器层面捕获 FX Graph,然后用 AOT Autograd 生成前反向图,再用 Inductor 做算子融合与 kernel 生成,最后尝试 CUDA Graph 捕获。这是一个工程上非常有价值的方向,但它本质上是在动态图之上“事后补静态优化”。动态形状、Python 副作用、数据依赖控制流都可能造成 graph break;每次 graph break 都意味着 host-device 同步和优化机会的损失。CUDA Graph 还要求捕获时的地址在重放时稳定,PyTorch 的缓存分配器需要通过 private pool、CUDA Graph Trees 等机制来保证,复杂度很高。
Tech-Renaissance 从设计之初就选择静态图编译。这不是为了“静态而静态”,而是因为我们要把几件事一次性做对:
- CUDA Graph 全捕获:CUDA Graph 要求捕获时的张量地址、形状、kernel 参数在重放时完全一致。如果图本身带有动态 shape,捕获就无从谈起。
- 静态显存规划:MemoryPlan 需要提前知道每个张量的大小和生命周期,才能做 Region 分区。如果形状在运行期变化,分区就无法静态确定。
- 确定性训练:形状固定后,调度顺序、随机数、通信时序都可以被精确复现。
但静态图不等于“一种形状一套图”。我们可以让同一份拓扑服务多个形状变体,只要每个变体有自己的 MemoryPlan 就行。这正是 ComputationGraph 的设计出发点。
二、ComputationGraph:零形状信息的纯拓扑容器
在 include/renaissance/graph/computation_graph.h 中,ComputationGraph 被定义为一个纯算子拓扑容器。头文件的注释写得很清楚:
核心特征:零形状信息。节点中只存
(GraphNode, OpParams, tensor_id);Shape / DType / Region全部从MemoryPlan的DTensor获取;一份图供多个 shape-only 变体共享。
这种设计的精妙之处在于:图节点不关心自己操作的张量具体长什么样,只关心全局张量 ID。真正的形状、数据类型、内存区域偏移都存放在 MemoryPlan 的 DTensor 描述符里。运行期捕获时,把同一份 ComputationGraph 和不同的 MemoryPlan 配对,就能得到不同的可执行 CUDA Graph。
GraphNode 本身是一个由 kind 驱动的联合体:
struct GraphNode {
enum class Kind : uint8_t { COMPUTE, RANGE };
Kind kind = Kind::COMPUTE;
union {
ComputeOp compute_op; // DTensor 级操作
RangeOp range_op; // Region 级批量操作
};
OpParams params; // 算子参数
std::vector<int32_t> input_ids; // COMPUTE:输入 DTensor 全局 ID
std::vector<int32_t> output_ids; // COMPUTE:输出 DTensor 全局 ID
std::vector<MemRange> input_ranges; // RANGE:预计算 (offset, size)
std::vector<MemRange> output_ranges;
};
COMPUTE 节点对应一个具体的算子,比如卷积、BN、ReLU、矩阵乘;RANGE 节点则对应 Region 级批量操作,比如“把这一整段梯度清零”、“对这一整片权重做 AllReduce”。两种节点都没有任何形状信息,只有“对谁操作”的抽象引用。
ComputationGraph 内部按 GraphId 分桶存储节点:
class ComputationGraph {
public:
void append(GraphId gid, GraphNode node);
const std::vector<GraphNode>& nodes(GraphId gid) const;
private:
std::array<std::vector<GraphNode>,
static_cast<size_t>(GraphId::COUNT)> graphs_;
std::vector<GraphNode> linear_nodes_;
};
这意味着,只要模型结构不变,编译器只需要构建一次 ComputationGraph;之后无论是把 batch_size 从 128 切换到 71,还是把分辨率从 224 切换到 128,都只需要重新推导形状、分配新的 MemoryPlan,而不需要重新拼接图拓扑。
当前 GraphId 枚举共有 33 个标识(GraphId::COUNT == 33),覆盖了训练/验证流程中的各个阶段,例如:
TRANSFER_A / TRANSFER_B:H2D 双缓冲异步传输;FIRST_LAYER_FWD_A / FIRST_LAYER_FWD_B:首层前向(A/B 双缓冲);DEEP_FWD_BWD:深层前向 + 反向融合;FIRST_LAYER_BWD_A / FIRST_LAYER_BWD_B:首层反向;ZERO_GRAD / FIRST_COMM / DEEP_COMM / STATS_COMM:梯度清零与通信;OPTIMIZER / EMA_UPDATE / UPDATE_STATS:优化器与 BN 统计量更新;INF_MAIN_A / INF_MAIN_B / INF_EMA_A / INF_EMA_B:主模型与 EMA 模型推理;- 等等。
在 DeepLearningTask 的图集里,只有那些 train_cg_ 或 infer_cg_ 中确实包含节点的子图才会被填入。例如 SIMPLE_TASK_GRAPH 是留给 SimpleTask 的通用槽位,LARS 相关的三个子图在不启用 LARS 优化器时为空;这些空槽位会在 pre_capture 阶段被直接跳过。在启用全部功能的完整配置下,通常会有 22 张 shape 无关子图、5 张训练 shape 相关子图和 4 张验证 shape 相关子图被填充。
三、GraphAtlas:变体与子图的映射表
如果说 ComputationGraph 是“图纸”,那么 GraphAtlas 就是“图纸索引表 + 去重缓存”。
GraphAtlas 在 include/renaissance/graph/graph_atlas.h 中定义,本质上是一张 6 变体 × 33 子图 的映射表:
class GraphAtlas {
public:
static constexpr size_t kMaxVariants = 6;
static constexpr size_t kMaxGraphIds = static_cast<size_t>(GraphId::COUNT);
struct Slot {
const ComputationGraph* cg = nullptr; // 共享拓扑指针
const MemoryPlan* mp = nullptr; // 变体特有 MemoryPlan
ShapeId shape_id{}; // 去重键
StreamKind stream_kind = StreamKind::COMP_1;
int32_t captured_idx = -1; // 捕获后填入
};
int32_t index(size_t variant, GraphId gid) const noexcept;
private:
std::array<std::array<Slot, kMaxGraphIds>, kMaxVariants> table_;
};
每个 Slot 记录了三样东西:
- cg:指向共享的
ComputationGraph(图纸); - mp:指向该变体自己的
MemoryPlan(布局); - shape_id:用作去重键,决定这张图纸 + 这个布局是否需要单独捕获一次 CUDA Graph。
为什么需要 6 个变体?在 include/renaissance/graph/compile_spec.h 中可以看到定义:
// variant 0: train_base — train_res_begin × standard_batch // variant 1: train_last — train_res_begin × last_batch // variant 2: train_lowres — train_res_end × standard_batch // variant 3: train_lowres_last — train_res_end × last_batch // variant 4: val_base — val_res × standard_batch // variant 5: val_last — val_res × val_last_batch
这 6 个变体覆盖了训练过程中所有可能出现的形状组合:两种训练分辨率 × 两种 batch 大小,再加上验证的两种 batch 大小。
图集的实际填充逻辑在 src/task/deep_learning_task.cpp 的 DeepLearningTask::build_graph_atlas() 中完成。核心逻辑如下:
GraphAtlas DeepLearningTask::build_graph_atlas() {
GraphAtlas atlas;
for (size_t v = 0; v < GraphAtlas::kMaxVariants; ++v) {
const MemoryPlan* mp = variant_memory_plans_[v].get();
bool is_train_var = (v <= 3);
ShapeId variant_shape = variant_compile_specs_[v].get_shape_id();
for (uint8_t gi = 0; gi < static_cast<uint8_t>(GraphId::COUNT); ++gi) {
GraphId gid = static_cast<GraphId>(gi);
// 训练变体不填纯推理图,验证变体不填训练图
if (is_train_var) {
if (is_inference_graph(gid) && !is_shape_invariant_graph(gid)) continue;
} else {
if (is_train_graph(gid) && !is_inference_graph(gid) &&
!is_shape_invariant_graph(gid)) continue;
}
// 选择训练图还是推理图
const ComputationGraph* cg = nullptr;
if (train_cg_ && !train_cg_->nodes(gid).empty()) cg = train_cg_;
else if (infer_cg_ && !infer_cg_->nodes(gid).empty()) cg = infer_cg_;
if (!cg) continue;
auto& sl = atlas.slot(v, gi);
sl.cg = cg;
sl.stream_kind = stream_for(gid);
if (is_shape_invariant_graph(gid)) {
sl.mp = active_memory_plan_;
sl.shape_id = kShapeInvariant; // {0,0,0,0}
} else {
sl.mp = mp;
sl.shape_id = variant_shape;
}
}
}
return atlas;
}
这里有两个关键判断:
is_shape_invariant_graph:对于TRANSFER、COMM、OPTIMIZER、EMA_UPDATE等子图,它们的拓扑不依赖输入形状。这些图在所有 6 个变体间共享同一个kShapeInvariant去重键,Phase B 去重时必然碰撞,最终只捕获一次。is_train_graph / is_inference_graph:训练变体(v0~v3)只填训练图和 shape 无关图;验证变体(v4~v5)只填推理图和 shape 无关图。交叉槽位保持nullptr,运行期直接跳过。
ShapeId 是一个显式的 (N, H, W, C) 四元组,而不是哈希值或指针:
struct ShapeId {
int32_t n = 0; // batch 维度
int32_t h = 0; // 高度
int32_t w = 0; // 宽度
int32_t c = 0; // 通道数
};
constexpr ShapeId kShapeInvariant{0, 0, 0, 0};
用显式四元组的原因是:hash 有碰撞风险,MemoryPlan 指针不同变体不同,而 (N, H, W, C) 是确定性的、可跨变体比较的。只要输入 ShapeId 相同,Compiler 推导出的所有中间 DTensor 形状都相同,就可以复用同一份 CUDA Graph。
四、Phase B 去重:同一张图纸不捕获两次
GraphAtlas 的三阶段设计在头文件注释里写得非常清楚:
- Phase A(build):填入
cg / mp / shape_id; - Phase B(pre_capture):遍历 Slot,按
(cg, gid, shape_id)去重,调用CapturedGraph::capture()填入captured_idx; - Phase C(index):Executor 运行时 O(1) 数组访问。
pre_capture 在 src/graph/captured_graph.cpp 中实现,核心去重逻辑如下:
PreCaptureResult pre_capture(const GraphAtlas& compile_atlas,
const std::vector<DeviceContext*>& contexts) {
PreCaptureResult result;
result.atlas = compile_atlas;
std::unordered_map<CapturedGraph::Key, int32_t, CapturedGraph::KeyHash> seen;
std::unordered_map<CapturedGraph::Key, const MemoryPlan*, CapturedGraph::KeyHash> key_to_mp;
for (size_t vi = 0; vi < GraphAtlas::kMaxVariants; ++vi) {
for (uint8_t gi = 0; gi < static_cast<uint8_t>(GraphId::COUNT); ++gi) {
auto& slot = result.atlas.slot(vi, gi);
if (!slot.cg || !slot.mp) continue;
CapturedGraph::Key key{slot.cg, static_cast<GraphId>(gi), slot.shape_id};
key_to_mp[key] = slot.mp;
auto it = seen.find(key);
if (it != seen.end()) {
slot.captured_idx = it->second; // 复用已有图
++result.reused;
} else {
slot.captured_idx = static_cast<int32_t>(seen.size());
seen[key] = slot.captured_idx;
++result.captured;
}
++result.total_slots;
}
}
// 接下来:cuDNN 预热、识别 NCCL 图、逐 rank 捕获、协调多 rank 捕获 …
// ...
return result;
}
去重之后,pre_capture 还要完成三件对正确性与性能都至关重要的事:
- cuDNN 预热:在真正捕获 CUDA Graph 之前,框架会针对每个唯一图在每个 rank 上把 cuDNN Frontend 的 kernel/plan cache 填充好。这样捕获阶段就只有纯
execute()调用,避免 plan 选择在图内发生,从而保证图的重放是确定且高效的。 - 识别 NCCL 图:包含
DEEP_COMM、FIRST_COMM、STATS_COMM等 NCCL 通信操作的子图需要多 rank 协调捕获。has_nccl_ops()会标记这些 key,随后capture_nccl_graph_coordinated()在所有 rank 上同步进入和退出捕获,确保通信语义正确。 - 并行捕获:非 NCCL 图先在 rank 0 串行捕获,再在 rank 1..N-1 上并行捕获。串行 rank 0 可以发现并缓存所有 cuDNN plan,后续 rank 直接复用,既节省时间又避免多 rank 同时触发 cuDNN 全局锁竞争。
去重键 CapturedGraph::Key 由三个字段组成:
struct Key {
const ComputationGraph* cg = nullptr; // 图纸指针
GraphId gid = GraphId::TRANSFER_A; // 子图标识
ShapeId shape{}; // 输入形状
};
只要 (cg, gid, shape_id) 三元组相同,就只捕获一次。比如 DEEP_FWD_BWD 这张拓扑图,v0 和 v2 的输入分辨率不同,shape_id 不同,所以需要各自捕获;但 v0 的 ZERO_GRAD 和 v2 的 ZERO_GRAD 都是 kShapeInvariant,于是复用同一张图。
我们可以大致估算去重效果。GraphAtlas 共有 6 × 33 = 198 个槽位,但很多槽位是空的(训练变体不填推理图,验证变体不填训练图,未启用的功能子图也为空)。在启用全部功能的完整配置下:
- 约 22 张 shape 无关子图:
TRANSFER、COMM、OPTIMIZER、EMA_UPDATE、各类 cast、metrics 清零等。它们在所有变体间共享同一个kShapeInvariant,全局只捕获一次。 - 训练相关的 shape 敏感子图:首层前向 A/B、深层前反向、首层反向 A/B,共 5 张。4 个训练变体最多产生 4 种不同的
ShapeId(两种分辨率 × 两种 batch 大小);如果训练起始分辨率与结束分辨率相同,则退化为 2 种。 - 验证相关的 shape 敏感子图:主模型推理 A/B、EMA 推理 A/B,共 4 张。2 个验证变体产生 2 种
ShapeId(标准 batch 与末尾 batch)。
所以实际唯一捕获的 CUDA Graph 数量通常在 40 张左右,远小于 198 个槽位。更重要的是,那 22 张 shape 无关图是全局唯一的——无论你是什么任务、什么分辨率、什么 batch size,ZERO_GRAD、OPTIMIZER、STATS_COMM 这些子图永远只需要捕获一次。
五、运行期:run_train_epoch_gpu 如何按图索骥
图集的最终目标是让运行期变得尽可能简单。在 src/task/deep_learning_task.cpp 的 run_train_epoch_gpu() 中,我们可以清楚地看到这一点。
每个 epoch 开始前,先根据当前分辨率选择变体基址:
int current_res = registry.current_resolution_train(); bool at_begin_res = (current_res == registry.train_sample_resolution_begin()); size_t v_base = at_begin_res ? 0 : 2; // 普通 batch 变体 size_t v_last = at_begin_res ? 1 : 3; // 最后一个不完整 batch 变体
这里 v_base 是普通 batch 用的变体索引,v_last 是最后一个不完整 batch 用的变体索引。两者唯一区别就是输入 batch_size 不同,因此 MemoryPlan 不同,但 ComputationGraph 拓扑共享。
接着代码会预解析出每个子图的 CUDA Graph 执行句柄。这个解析发生在 DeepLearningTask::build_exec_table() 中:
auto resolve = [&](GraphId gid, int rank, size_t variant_idx) -> cudaGraphExec_t {
int32_t idx = captured_result_.atlas.index(variant_idx, gid);
if (idx < 0 || static_cast<size_t>(idx) >= captured_result_.graphs.size())
return nullptr;
return static_cast<cudaGraphExec_t>(
captured_result_.graphs[idx].native_exec(rank));
};
resolve 就是 Phase C 的 O(1) 索引:给定变体索引和 GraphId,GraphAtlas::index() 直接返回 captured_idx,然后从 captured_result_.graphs 中取出该 rank 的 cudaGraphExec_t。
运行期的执行表并不直接用 GraphId 索引,而是用一个内部枚举 GraphSlot(定义在 deep_learning_task.cpp 的匿名命名空间中),它把 GraphId 映射到运行时更容易操作的槽位:
enum class GraphSlot : uint8_t {
XFER_A = 0,
XFER_B,
FWD_BWD_DEEP_A,
FWD_BWD_DEEP_B,
FIRST_LAYER_BWD_A,
FIRST_LAYER_BWD_B,
ZERO_GRAD,
DEEP_ALLREDUCE,
FIRST_LAYER_ALLREDUCE,
WEIGHT_UPDATE,
// ... LARS、EMA、CAST、BN 统计量同步、推理图等
COUNT
};
注意 FWD_BWD_DEEP_A 和 FWD_BWD_DEEP_B 都解析自同一个 GraphId::DEEP_FWD_BWD,只是因为绑定到 A/B 双缓冲的输入区而被分成两张 CUDA Graph;而 ZERO_GRAD、DEEP_ALLREDUCE、WEIGHT_UPDATE 等 shape 无关图则不需要 A/B 各一张。
训练循环里的典型一个 batch 如下:
for (int batch = 0; batch < batches - 1; ++batch) {
bool from_a = (batch % 2 == 0);
int next_buf = from_a ? 1 : 0;
auto g_fwd = from_a ? n_fwd_a : n_fwd_b;
auto g_deep = from_a ? n_deep_a : n_deep_b;
auto g_xfer_n = from_a ? n_xfer_b : n_xfer_a;
auto g_first = from_a ? n_bwd_a : n_bwd_b;
if (n_zg) cudaGraphLaunch(n_zg, s_up); // 清零梯度和 loss
if (g_fwd) cudaGraphLaunch(g_fwd, s_c1); // 首层前向
sync_tr();
ts->wait_buffer_readable(next_buf);
if (g_xfer_n) cudaGraphLaunch(g_xfer_n, s_trans); // 下一个 batch 的 H2D 传输
sync_comp(); sync_up();
if (g_deep) cudaGraphLaunch(g_deep, s_c1); // 深层前向 + 反向
sync_comp();
if (!frozen && g_first) cudaGraphLaunch(g_first, s_c1); // 首层反向
if (n_dar) cudaGraphLaunch(n_dar, s_up); // 深层梯度 AllReduce
sync_up(); sync_comp();
if (n_wu) cudaGraphLaunch(n_wu, s_up); // 优化器更新
// ... BN 统计量同步、EMA 更新、学习率同步等
}
最后一个 batch 走 v_last 变体:
const auto& g_l = gpu_exec_.variant_graphs[v_last][rank]; auto l_deep_a = g_l[S(GraphSlot::FWD_BWD_DEEP_A)]; auto l_fwd_a = g_l[S(GraphSlot::FIRST_LAYER_FWD_A)]; auto l_accum_tl = g_l[S(GraphSlot::ACCUM_METRICS_TRAIN_LAST)]; // ...
它的拓扑和普通 batch 完全一样,但因为 batch_size 不同,shape 相关图需要单独捕获。这就是 GraphAtlas 支持最后一个不完整 batch 的关键机制。
需要补充的是,这些执行代码运行在每张 GPU 对应的一个独立线程中。run_train_epoch_gpu() 在 epoch 开始时会为每个 rank 创建一个线程,每个线程拥有独立的 DeviceContext、流句柄和解析后的图执行表。线程之间通过 CUDA 事件与 cudaStreamSynchronize 同步,而不是通过 host 端锁。如果某个 rank 在捕获或执行过程中抛出异常,std::exception_ptr 会把异常传回主线程再统一 rethrow,保证多卡训练不会因为单卡失败而悄无声息地继续。
不同变体之间的切换之所以安全,还得益于 Compiler 在 Phase 2 做的一件重要事情:compute_max_slot_bytes。它会对每个 (layer, tensor) 位置跨所有变体取最大 slot 字节数,然后用这个最大值去构造 6 个 MemoryPlan。用代码注释的话来说:
// Phase 2: 逐 (layer, tensor) 跨变体取 max slot_bytes // Phase 3: 用 max_slot_bytes 构造 6 个 MemoryPlan, // 所有变体同一位置的 DTensor 具有相同 offset。
因此,同一位置的 DTensor 在不同变体下具有相同的 offset——权重、梯度、动量这些非 batch 相关张量的地址不会因为变体切换而漂移。常规 batch 更新完的权重,末尾 batch 直接继续使用同一块显存,无需任何拷贝或重映射。这也为 CUDA Graph 全捕获提供了地址稳定性保证:图在捕获时看到的指针,在重放时仍然指向同一个偏移。
六、与主流框架的对比
PyTorch 的 torch.compile 也试图做静态优化和 CUDA Graph,但它的出发点是在动态图之上“事后捕获”。这带来几个结构性难题:
- Graph break:遇到不支持的操作时 Dynamo 会中断图,中间回退 eager;
- 动态形状:输入 shape 变化可能触发重新编译;
- 地址稳定性:CUDA Graph 要求地址不变,PyTorch 的缓存分配器需要 CUDA Graph Trees 等额外机制;
- 正确性风险:社区长期报告 torch.compile 与 eager 不一致的案例。
Tech-Renaissance 没有这些历史包袱。我们从模型定义阶段就是静态的,所有形状、内存、拓扑、调度关系在编译期确定。ComputationGraph 的零形状设计让“拓扑”成为可复用的基础设施,GraphAtlas 的去重索引让多种变体共享同一份捕获结果。
这不是说 Tech-Renaissance 比 PyTorch “更先进”——它只是在追求极限训练吞吐这条路上做了不同的取舍。如果你的场景需要动态模型结构、数据依赖控制流、Python 调试体验,PyTorch Eager 仍然是更自然的选择。但如果你要的是稳定、确定、可全图捕获的静态训练管线,那么“一份图纸,多处复用”就是一条更干净的路径。
七、小结
ComputationGraph 和 GraphAtlas 共同解决了静态图框架中的一个核心问题:如何在多种运行场景下复用同一份算子拓扑。
ComputationGraph只保存零形状信息的算子拓扑,不持有任何张量或内存;GraphAtlas把这份拓扑映射到 6 个变体、33 个子图,并按(cg, gid, shape_id)去重;- 22 张 shape 无关图(传输、通信、优化器等)在所有变体间全局复用;
- 训练与验证的 shape 相关图按
ShapeId区分,支持普通 batch、最后一个 batch、不同分辨率、训练/推理等多种场景; - 运行期通过
GraphAtlas::index()做 O(1) 索引,直接拿到cudaGraphExec_t并 launch; - 跨变体的
DTensoroffset 一致性,让常规 batch 与末尾 batch、训练与验证之间的切换无需拷贝。
这套机制是后续 CUDA Graph 全捕获、多流并发、MemoryPlan 静态分区等能力的共同底座。它让我们可以把精力集中在拓扑正确性上,而不是被运行时形状变化牵着鼻子走。下一篇文章,我们将进入整个框架最核心的设计之一——MemoryPlan 与显存分区,看看 Tech-Renaissance 是如何把 GPU 显存按语义静态布局,从而彻底消除运行期动态分配的。
