(14) ComputationGraph与GraphAtlas:一份图纸,多处复用

——“一个人用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 从设计之初就选择静态图编译。这不是为了“静态而静态”,而是因为我们要把几件事一次性做对:

  1. CUDA Graph 全捕获:CUDA Graph 要求捕获时的张量地址、形状、kernel 参数在重放时完全一致。如果图本身带有动态 shape,捕获就无从谈起。
  2. 静态显存规划:MemoryPlan 需要提前知道每个张量的大小和生命周期,才能做 Region 分区。如果形状在运行期变化,分区就无法静态确定。
  3. 确定性训练:形状固定后,调度顺序、随机数、通信时序都可以被精确复现。

但静态图不等于“一种形状一套图”。我们可以让同一份拓扑服务多个形状变体,只要每个变体有自己的 MemoryPlan 就行。这正是 ComputationGraph 的设计出发点。

二、ComputationGraph:零形状信息的纯拓扑容器

include/renaissance/graph/computation_graph.h 中,ComputationGraph 被定义为一个纯算子拓扑容器。头文件的注释写得很清楚:

核心特征:零形状信息。节点中只存 (GraphNode, OpParams, tensor_id)Shape / DType / Region 全部从 MemoryPlanDTensor 获取;一份图供多个 shape-only 变体共享。

这种设计的精妙之处在于:图节点不关心自己操作的张量具体长什么样,只关心全局张量 ID。真正的形状、数据类型、内存区域偏移都存放在 MemoryPlanDTensor 描述符里。运行期捕获时,把同一份 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 就是“图纸索引表 + 去重缓存”。

GraphAtlasinclude/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 记录了三样东西:

  1. cg:指向共享的 ComputationGraph(图纸);
  2. mp:指向该变体自己的 MemoryPlan(布局);
  3. 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.cppDeepLearningTask::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:对于 TRANSFERCOMMOPTIMIZEREMA_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_capturesrc/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 还要完成三件对正确性与性能都至关重要的事:

  1. cuDNN 预热:在真正捕获 CUDA Graph 之前,框架会针对每个唯一图在每个 rank 上把 cuDNN Frontend 的 kernel/plan cache 填充好。这样捕获阶段就只有纯 execute() 调用,避免 plan 选择在图内发生,从而保证图的重放是确定且高效的。
  2. 识别 NCCL 图:包含 DEEP_COMMFIRST_COMMSTATS_COMM 等 NCCL 通信操作的子图需要多 rank 协调捕获。has_nccl_ops() 会标记这些 key,随后 capture_nccl_graph_coordinated() 在所有 rank 上同步进入和退出捕获,确保通信语义正确。
  3. 并行捕获:非 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 无关子图TRANSFERCOMMOPTIMIZEREMA_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_GRADOPTIMIZERSTATS_COMM 这些子图永远只需要捕获一次。

五、运行期:run_train_epoch_gpu 如何按图索骥

图集的最终目标是让运行期变得尽可能简单。在 src/task/deep_learning_task.cpprun_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) 索引:给定变体索引和 GraphIdGraphAtlas::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_AFWD_BWD_DEEP_B 都解析自同一个 GraphId::DEEP_FWD_BWD,只是因为绑定到 A/B 双缓冲的输入区而被分成两张 CUDA Graph;而 ZERO_GRADDEEP_ALLREDUCEWEIGHT_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 仍然是更自然的选择。但如果你要的是稳定、确定、可全图捕获的静态训练管线,那么“一份图纸,多处复用”就是一条更干净的路径。

七、小结

ComputationGraphGraphAtlas 共同解决了静态图框架中的一个核心问题:如何在多种运行场景下复用同一份算子拓扑

  • ComputationGraph 只保存零形状信息的算子拓扑,不持有任何张量或内存;
  • GraphAtlas 把这份拓扑映射到 6 个变体、33 个子图,并按 (cg, gid, shape_id) 去重;
  • 22 张 shape 无关图(传输、通信、优化器等)在所有变体间全局复用;
  • 训练与验证的 shape 相关图按 ShapeId 区分,支持普通 batch、最后一个 batch、不同分辨率、训练/推理等多种场景;
  • 运行期通过 GraphAtlas::index() 做 O(1) 索引,直接拿到 cudaGraphExec_t 并 launch;
  • 跨变体的 DTensor offset 一致性,让常规 batch 与末尾 batch、训练与验证之间的切换无需拷贝。

这套机制是后续 CUDA Graph 全捕获、多流并发、MemoryPlan 静态分区等能力的共同底座。它让我们可以把精力集中在拓扑正确性上,而不是被运行时形状变化牵着鼻子走。下一篇文章,我们将进入整个框架最核心的设计之一——MemoryPlan 与显存分区,看看 Tech-Renaissance 是如何把 GPU 显存按语义静态布局,从而彻底消除运行期动态分配的。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

ICP备案号:京ICP备2025133467号-1