《GoMLX从入门到精通》第3章:GoMLX的世界观——计算图、后端与执行模型
第3章:GoMLX 的世界观——计算图、后端与执行模型
---
3.1 一个让你困惑的瞬间
在第 2 章,你写了这段代码:
addGraph := func(a, b *graph.Node) *graph.Node {
return graph.Add(a, b)
}
exec := graph.MustNewExec(backend, addGraph)
results := exec.MustExec(1.0, 2.0)
fmt.Println(results[0].Value()) // 3
一切正常。但你不妨试试这个:
counter := 0
weirdGraph := func(x *graph.Node) *graph.Node {
counter++ // 我在图定义函数里修改了外部变量!
return graph.AddScalar(x, float64(counter))
}
exec := graph.MustNewExec(backend, weirdGraph)
r1 := exec.MustExec1(1.0) // counter = 1,返回 1 + 1 = 2 ?
r2 := exec.MustExec1(1.0) // counter = 2,返回 1 + 2 = 3 ?
fmt.Println(r1.Value()) // 实际输出:2
fmt.Println(r2.Value()) // 实际输出:2???为什么还是2?
你可能会愣住——counter++ 明明是写在图定义函数里的,而且 MustNewExec 确实调用了它一次(counter 从 0 变成 1),为什么第二次 MustExec1 时 counter 没再增加?
答案,就是这整章要讲的东西。
---
3.2 命令式编程 vs 符号式编程
Go 程序员(以及几乎所有的命令式语言程序员)的习惯是:写一行代码,它就做一件事。
a := 1
b := 2
c := a + b // 这行代码执行时,a+b 立刻被计算,c 立刻被赋值
a = 5 // 修改 a
// c 还是 3——因为 c = a + b 执行时 a 还是 1
这叫命令式编程(Imperative Programming)。代码的每一行都是一个即时执行的指令。
GoMLX 不是这样工作的。你写的图定义函数不是执行计算,而是描述计算。它属于符号式编程(Symbolic Programming)——你先"画"一张完整的计算图,然后编译器一次性地编译并执行整张图。
3.1 中那个诡异现象的答案就在这里:
counter := 0
weirdGraph := func(x *graph.Node) *graph.Node {
counter++ // 这行只在图构建阶段执行!只执行一次!
return graph.AddScalar(x, float64(counter))
}
exec := graph.MustNewExec(backend, weirdGraph)
// ↑ 此时调用 weirdGraph 一次:counter 变成 1,图上记录了 "x + 1.0"
// 编译完成,图被缓存。
r1 := exec.MustExec1(1.0) // 执行编译好的图:1.0 + 1.0 = 2.0
// counter 没有变——这行只是跑机器码,不跑 Go 代码
r2 := exec.MustExec1(1.0) // 同一张图,同样的机器码:1.0 + 1.0 = 2.0
// counter 还是 1——图早就编译好了,不会再调 weirdGraph
一张表说清区别:
| 命令式编程(Go 默认) | 符号式编程(GoMLX) | |
|---|---|---|
| 你的代码描述的是 | 做什么、怎么做、按什么顺序做 | 数据之间的计算关系 |
| 代码执行时机 | 写一行,执行一行 | 分批:先构建图(一次),再编译+执行(多次) |
| 变量的含义 | 内存中的值,随时可变 | 图中的节点,编译后固化 |
| "加法"的语义 | a + b → 立即读 a 和 b 的值,算和 | graph.Add(a, b) → 在图里画一个加法节点,表明 a 和 b 是这个节点的输入 |
---
3.3 两阶段生命周期:Build → Execute
理解 GoMLX,只需要把它拆成两个阶段来看。
阶段一:图构建(Graph Building)
这是你的 Go 代码真正被执行的阶段。发生在 MustNewExec 第一次调用你的图定义函数时。
在这个阶段:
- 图定义函数被调用了一次(或更准确地说,每种输入形状的组合各调用一次——见 3.5 节)
- 函数内部的 Go 代码(
counter++、if分支、for循环)全部正常执行 - 但是——任何
graph.Add、graph.Square、graph.MulScalar等 GoMLX 运算 API,做的事情不是"算数值",而是往图上添加节点 - 最终产物是一张 DAG(有向无环图),每个节点代表一种运算
阶段二:图执行(Graph Execution)
这是你的图被机器真正执行的阶段。发生在每次 exec.MustExec(args...) 调用时。
在这个阶段:
- 你的 Go 代码不再运行。 运行的只是编译好的机器码(SimpleGo 后端的 Go 原生代码,或 XLA 后端的 LLVM JIT 产物)
- 图里的每个节点,从输入参数开始,按依赖顺序依次被计算
- 有 GPU 时,整张图在显存里跑——数据不离开 GPU
- 结果从设备内存搬回 Go 一侧
核心推论
一旦你接受了"构建→执行"的两阶段模型,很多表层现象立刻就有了解释:
为什么修改外部变量不会影响执行结果? 因为执行阶段只跑机器码,不跑你的 Go 代码。
为什么 MustNewExec 很快但 MustExec 可能慢? 因为图构建可能做一些复杂逻辑(Go 代码层面),而图执行只是纯粹的后端运算(编译后的高效代码)。反过来也可能:简单图的构建很快,但首次 MustExec 要触发 JIT 编译(慢),后续调用直接跑机器码(快)。
为什么 GoMLX 能换后端? 因为图定义(你的 Go 代码)不包含任何后端特定逻辑。它只产出抽象的运算节点。后端拿到这张图后,各自决定怎样把它翻译成自己的可执行格式。XLA 后端翻译成 HLO→LLVM IR→机器码;SimpleGo 后端翻译成 Go 函数调用链。
---
3.4 Node、Exec、Backend
GoMLX 的世界里,有三个角色一直在互动。理解它们的职责边界,你就理解了 GoMLX 的架构。
*graph.Node——图中的节点
// 每次调用 graph.Add, graph.Square 等,都生成一个新的 *Node
xSquared := graph.Square(x) // *Node:代表 x² 这个运算
twoX := graph.MulScalar(x, 2.0) // *Node:代表 2x 这个运算
sum := graph.Add(xSquared, twoX) // *Node:代表 x² + 2x 这个运算
*graph.Node 是什么?
- 它是计算图中的一个节点,代表一个运算的结果
- 它携带的信息包括:做了什么运算(Add/Square/MulScalar...)、输入是哪些节点、输出的形状和数据类型
- 它不包含任何数值——直到图执行阶段
- 一个 Node 可以被多个下游 Node 引用(这就是"共享子表达式"的来源——如果同一个 Node 被引用了三次,底层只会算一次)
*graph.Exec——编译后的可执行图
exec := graph.MustNewExec(backend, graphFn)
*graph.Exec 是什么?
- 它是图的编译结果 + 执行接口
- 内部维护了一个"形状→编译图"的缓存(默认最多 32 个不同形状版本)
- 首次调用
MustExec(args...)时,查看 args 的形状,如果没有对应的编译版本,就触发一次全新的图构建 + 编译——这就是"慢"的来源 - 后续相同形状的调用直接命中缓存——这就是"快"的来源
- 线程安全——可以并发调用
MustExec
backends.Backend——计算后端
backend, _ := backends.New()
backends.Backend 是什么?
- 它是计算设备的抽象。CPU、GPU、TPU,都是 Backend
- 在图定义阶段完全不参与——你不需要知道它在哪
- 在图编译阶段介入——它负责把抽象的图翻译成自己的可执行格式
- 在图执行阶段全权负责——分配设备内存、调度运算、搬运数据
- 不同的 Backend,编译出的机器码完全不同——但你的图定义代码完全一样
> ***graph.Node 是你画的图纸,*graph.Exec 是施工队的翻译和执行总控,backends.Backend 是具体的施工设备(手工具、吊车、挖掘机)。
---
3.5 静态形状限制:设计选择,不是缺陷
你有没有想过:为什么同一个 exec 不能处理不同形状的输入?
exec := graph.MustNewExec(backend, func(x *graph.Node) *graph.Node {
return graph.Square(x)
})
// ✓ 工作正常
r1 := exec.MustExec1([]float64{1.0, 2.0, 3.0}) // shape: [3]
// ✗ 形状变了,需要重建图
r2 := exec.MustExec1([]float64{1.0, 2.0}) // shape: [2] → 新的图构建+编译
这不是 GoMLX 的 bug。这是刻意为之的性能优化策略**。
编译期形状特化(Shape Specialization)
编译器在知道所有张量的确切形状时,可以做一系列"如果不知道就不敢做"的优化:
- 循环展开:一个长度为 128 的向量加法,可以直接展开成 128 条标量加法的流水线
- 内存布局优化:提前计算每个中间张量的内存偏移,消除运行时地址计算
- 算子融合:把
Mul → Add → Relu融合成一个 Kernel(第 5 章会展开)
再加上缓存策略:*graph.Exec 内部缓存了最多 32 个不同形状的编译版本。如果你在一个循环里交替调用 shape=[128] 和 shape=[64] 的输入,第一次各慢一次(编译),之后每次都快(命中缓存)。
这个权衡合理:
exec := graph.MustNewExec(backend, func(x *graph.Node) *graph.Node {
return graph.Square(x)
})
// 批次大小在 [64, 128, 256] 之间轮流出现——每种第一次慢,之后快
for _, batch := range batches {
result := exec.MustExec1(batch) // 第 1 批 64:编译(慢)
// 第 1 批 128:编译(慢)
// 第 2 批 64:命中缓存(快)
// 第 2 批 128:命中缓存(快)
}
实践中怎么处理?
1. 用定点批次大小:固定 batch_size=128。如果你的数据末尾不足 128 条,用 0 补齐(在损失函数里 mask 掉无效位置) 2. 用 2 的幂次:batch_size 用 32, 64, 128, 256——图缓存复用率高 3. 用 padding + mask:这几乎是所有 ML 框架的标准做法——而不是"动态形状"
---
3.6 错误处理:panic 而非 error
在图定义函数里,你不能 return nil, err,因为返回类型是 *graph.Node 而非 (*graph.Node, error)。
GoMLX 的答案是:panic。
func myModel(x *graph.Node) *graph.Node {
// 如果形状断言失败,直接 panic
x.AssertDims(shapes.Make(dtypes.Float32, 128)) // panic 如果形状不对
return layers.Dense(x, 64)
}
但这不会让你的程序真的崩溃。*graph.Exec 在内部捕获(recover)了 panic,转换成 error 返回:
// Must 版本:panic 不被捕获,程序崩溃
exec := graph.MustNewExec(backend, badGraph) // → panic!
// 标准版本:panic 被捕获,转成 error
exec, err := graph.NewExec(backend, badGraph)
if err != nil {
fmt.Printf("graph build failed: %v\n", err)
// 可以优雅降级或重试
}
为什么这么设计?
API 简洁性是首要理由。图定义函数到处都是嵌套的函数调用——如果每个都返回 error,代码会变成不可读的 error 检查地狱。panic 让图定义代码像正常 Go 代码一样干净。
另一个理由是编译期 vs 运行时的区分。图构建阶段是一个"不受信任"的阶段——你的代码可能有 bug。panic 提供了清晰的"这里出错了"的信号,而不是静默返回零值让后续运算全盘错误。
教学和原型代码用 Must* 系列(panic 友好)。生产代码用返回 error 的版本 + recover。
---
3.7 GoMLX 心智模型:一张图收束全章
┌──────────────────────────────────────────────────────────┐
│ 你的 Go 代码(图定义) │
│ func myFn(x *Node) *Node { │
│ a := graph.Square(x) // 不计算,只画图 │
│ b := graph.MulScalar(a, 2.0) │
│ return b │
│ } │
│ │
│ ↓ 进入 graph.MustNewExec │
│ │
│ ┌──────────────────────────────────────────────┐ │
│ │ 计算图(DAG,Abstract) │ │
│ │ [x] → [Square] → [a] → [MulScalar 2] → [b] │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ 后端编译 │
│ ┌──────────────────────┐ ┌──────────────────────────┐ │
│ │ XLA 后端(Linux) │ │ SimpleGo 后端(全平台) │ │
│ │ → HLO → LLVM IR → │ │ → Go 函数调用链 + SIMD │ │
│ │ → CUDA/CPU 机器码 │ │ → WASM(可选) │ │
│ └──────────────────────┘ └──────────────────────────┘ │
│ ↓ MustExec(args...) │
│ ┌──────────────────────────────────────────────┐ │
│ │ 设备执行(Device Execution) │ │
│ │ CPU/GPU/TPU 上真正跑数学运算 │ │
│ └──────────────────────────────────────────────┘ │
│ ↓ 结果 │
│ *tensors.Tensor(搬回 Go 一侧) │
└──────────────────────────────────────────────────────────┘
---
3.8 在进入原理篇之前
你现在从"知道怎么写 GoMLX 代码"到达了"知道 GoMLX 怎么运作的"。这条理解链是:
> 节点(Node)→ 图(DAG)→ 编译(backend)→ 执行(Exec)
第二部分(第 4-7 章)会把这条链上的每一环拆开给你看:
- 第 4 章:把"计算图"从抽象概念变成你用手能画的数据结构
- 第 5 章:XLA 和 JIT 编译——从图到机器码的全过程
- 第 6 章:张量——它的内存模型、本地 vs 设备、为什么 Finalize 不能忘
- 第 7 章:自动微分——梯度到底是怎么精确算出来的
翻到下一页,你拆开它。
---
> 作者:步子哥,资深AI研究员 | 连载中:《GoMLX从入门到精通》 > 上一章:第2章 GoMLX的第一行代码 > 下一章预告:第4章 计算图核心数据结构——把DAG从抽象概念拆成你用手能画的数据结构
🌟 智谱 GLM-5 已上线
我正在智谱大模型开放平台 BigModel.cn 上打造 AI 应用,智谱新一代旗舰模型 GLM-5 已上线,在推理、代码、智能体综合能力达到开源模型 SOTA 水平。
🎁 领取 2000万 Tokens