"GDScript 比 C# 慢多少"这个问题在社区里常年有争吵,答案从"差不多"到"慢几十倍"都有人说。两边的数据往往都没错——他们测的是不同的代码形状。这篇把慢的来源拆成结构化的三层,每层给出量级估计,然后你会发现"慢一个量级"是一个可以推导的结果,而不是一个需要实测的玄学。
第一层:解释执行的分发税
前面 dispatch 那篇算过这笔账:每条字节码指令的成本 = 取指(数组下标)+ switch 分发(间接跳转,分支预测器对数据流无能为力)+ 操作数取数 + 执行。单个操作在 C++ 里是编译进寄存器的几条指令,在 GDScript 里是 5~10 条外加两次预测失败。这一层的倍率大致是 10~20 倍:纯算术循环(for i in 1000000: s += i 这种)里 GDScript 和 -O2 编译的 C++ 差的就是这个数。
C# 不交这笔税。Godot 4 的 .NET 宿主跑的是真正 JIT(或 AOT 的 ReadyToRun)出来的机器码,循环体里一次分派都没有。解释器 vs JIT 的差距是语言实现范式的差距,不是 Godot 解释器写得烂——CPython 对 C 也是同样的倍率,这是"树遍历解释器"这个物种的固有税。
第二层:Variant 装箱的内存税
GDScript 栈上的一切都是 Variant——16 字节类型标签加 8 字节数据区(或指向堆数据的指针)。var x: int = 1 里的 x 是个 Variant,每次 x += 1 要:解包 tag → 确认是 int → 取出数值 → 加 → 重新打包。数值本身在寄存器里一纳秒的事, boxing/unboxing 走了一遍带分支的内存协议。
更要命的是类型税传染:C++ 侧写 for (int i = 0; ...) 整个循环在寄存器里跑完,GDScript 侧每次迭代都产生新 Variant 槽位写回栈。一个 100 万次循环,C++ 写栈 0 次(全在寄存器),GDScript 写 100 万次。这一层叠加在第一层上,把纯数值密集代码的差距推到 20~40 倍。
C# 不交的原因:值类型(int、Vector3 这些 blittable 结构)在 IL 层就是原生机器值,JIT 会分配寄存器。注意 C# 的 Variant 等价物——Godot.Variant——只有在和引擎互操作时才出现,纯 C# 计算全程不装箱。
第三层:边界的 marshaling 税
这一层最容易被忽略,也是实际项目里占比常常最大的一层。脚本和引擎的每一次交互——node.position = pos、array.append(x)、调任何引擎 API——都要过互操作边界:
- GDScript → 引擎:Variant 参数数组装箱 →
MethodBind::call解包 → C++ 执行 → 结果再装箱回 Variant。 - C# → 引擎:Interop 层的 P/Invoke 风格调用,blittable 类型直接按内存布局传输,非 blittable(String、GodotObject)走包装对象。
差距在于装箱方向:GDScript 侧每个参数都是 Variant 进出,C# 侧 Vector3 按值直接塞进寄存器传参。高频调引擎 API 的代码(每帧几百次 get_node、属性读写),GDScript 的这一层开销可以单独达到 2~5 倍的差距。而纯脚本内部逻辑(自己算自己的)这层为零——这就是为什么"差不多"派和"慢几十倍"派各自测出各自真理:前者的代码形状是引擎 API 胶水,后者的代码形状是数值循环。
三层叠起来,典型形状的差距:
| 代码形状 | GDScript : C# 差距 | 主导层 |
|---|---|---|
| 引擎 API 胶水(每帧调几十次节点操作) | 1.5~3 倍 | 第三层 |
| 混合业务逻辑(属性访问 + 算术 + 少量调用) | 5~10 倍 | 第一+三层 |
| 数值热循环(向量运算、寻路打分、程序化生成) | 20~40 倍 | 第一+二层 |
决策树:什么时候下沉,沉到哪里
知道了三层税,优化动作就有明确的对号入座关系:
第一层税的对策是类型标注(analyzer 那篇的完整机制):标注把动态指令换成 validated 指令,分发税里"查 method_map、逐参数校验"的部分归零,剩纯取指成本。数值循环标满类型,差距从 40 倍收回 10 倍上下——这是免费午餐,先吃。
第二层税的对策是换算法,不换语言:Variant 栈是语言契约,消灭不了,但可以让循环次数变少。缓存 @onready 查到的节点、把 $O(n^2)$ 的碰撞穷举换成空间哈希,算法层的常数改进对两种语言同样有效,且往往比换语言收益大。
第三层税的对策是减少跨界次数:一次 PhysicsServer3D 批量操作代替一百次节点属性读写;call_group 代替逐个 get_node。跨界次数是设计出来的,不是语言强加的。
三层都榨干还嫌慢,才轮到下沉。选项有两个:C# 重写这个类(互操作便宜、工程成本低、热重载还在)或 GDExtension/C++ 模块(上限最高,可 SIMD、可控制内存布局,成本是构建链和调试体验)。决策依据只有一条:这段代码是"算法密集"还是"引擎交互密集"。算法密集(寻路、几何、噪声生成)沉 C++ 拿寄存器和缓存;引擎交互密集(场景管理、批量节点操作)沉 C# 拿互操作便宜就够了。整个工程沉 C++ 是 3.x 时代的旧病,4.x 的 GDExtension 把"下沉"做成了可以按类粒度的决定。
最后一句话收拢:GDScript 慢一个量级是三层可辨识的税加总,不是模糊的整体迟钝。每一层都有已知的减免路径,而减免的第一步永远是 profiler 告诉你税交在了哪一层——盲沉 C++ 和盲信"差不多"是同一个错误的两个方向。
倍率数据来自本机(M 系 Mac)4.2 导出模板的多次采样,用于给三层税定量级;不同平台、不同代码形状会有显著出入,引用前请自行复测。