关于 motoko-performance-optimizations
Motoko Performance Optimizations 是面向开发者的 Agent 技能,帮助在不改变行为的前提下提升 Motoko 吞吐量或降低延迟。内容涵盖减少分配、定宽算术、字节处理、文本构建、循环结构及验证方法。
主要功能
- 01减少热点路径中的内存分配
- 02位运算采用定宽算术
- 03分块构建 Text
- 04按整块处理并单独处理尾部
- 05通过测试和基准验证改动
适用场景
- 01优化字节编码器或解码器
- 02改善哈希或二进制解析热点
- 03减少 Blob 遍历时的分配
- 04依据基线检查性能变化
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ research-ag/ motoko-skills --skill motoko-performance-optimizations安装后在 Claude Code 中输入/motoko-performance-optimizations确认已加载。
详细介绍
Motoko 性能优化
这是什么
一份可扩展的指南,帮助你安全、可预测地加快 Motoko 代码。重点介绍机械式且不改变行为的改进:减少分配、定宽算术、分块处理、高效构建 Text,以及清晰的循环结构。在不改变语义的前提下提升吞吐量或降低延迟时,可以使用本指南。
- 基准测试详情和测试框架位于:skills/benchmarks-generation/SKILL.md
- 通常会在性能优化前进行的风格调整和安全重构:skills/code-improvements/SKILL.md
- 可减少冗长代码或开销的点号表示法改进:skills/dot-notation-migration/SKILL.md
快速见效的改进(通用)
-
尽量减少热点路径中的分配
- 避免仅为迭代而物化整个缓冲区(例如,优先直接对
Blob进行索引)。 - 重用长度和容量;将
size()调用的结果缓存到局部变量中。
- 避免仅为迭代而物化整个缓冲区(例如,优先直接对
-
在紧凑循环中优先使用定宽算术
- 使用
Nat32/Nat64中间值进行移位和掩码操作;避免在循环中途扩宽为Nat。
- 使用
-
分块构建
Text- 避免逐字符使用
Text.fromChar并进行大量小规模#拼接;生成 ASCII/UTF‑8 块,每块只追加一次。
- 避免逐字符使用
-
优化循环结构,满足稳定态处理需求
- 在主循环中处理规则大小的块;单独处理较小的尾部。
- 提升循环不变量(大小、常量),让内层循环保持直线执行。
-
每次更改后都进行验证
- 重新构建、运行单元测试/属性检查,然后对有代表性的输入进行基准测试。
- 比较前后结果时,确保
[optimize](及其固定版本的[toolchain] wasm-opt)完全一致——只有当mops.toml中存在[optimize]时,mops bench才会运行wasm-opt,因此,若所谓的基准测试“提升”实际上只是优化器造成的噪声(或该配置节的出现/消失),就会被误认为是代码更改带来的效果。参见 skills/benchmarks-generation/SKILL.md。
技术领域概览
- 字节/位热点路径技术(详见下文)
- 分配与内存管理(即将推出)
- Text/字符串构建模式(即将推出)
- 数值/算术与定宽操作(即将推出)
- 数据结构与算法(即将推出)
- Async/Await 与跨 canister 模式(即将推出)
- Candid/序列化效率(即将推出)
- 缓存与记忆化(即将推出)
相关技能
- 基准测试:skills/benchmarks-generation/SKILL.md
- 代码质量清理:skills/code-improvements/SKILL.md
- 点号表示法迁移:skills/dot-notation-migration/SKILL.md
- 通用风格:skills/motoko-general-style-guidelines/SKILL.md
字节/位热点路径技术
这些技术适用于迭代 Blob 或 [Nat8]、执行位打包/解包,或生成 Text(例如编码器/解码器、校验和、二进制解析器)的代码。它们能够减少分配,避免代价高昂的整数扩宽,并调整循环结构以提升吞吐量,同时保持正确性。
适用场景
- 你发现代码中有针对字节和位操作的热点循环
- 代码逐字符构建
Text,或通过多次小规模拼接来构建 Blob被转换为[Nat8]仅仅是为了迭代或索引
前提条件
- Motoko 编译器(moc)版本较新(推荐 1.3.0+)
- 熟悉定宽整数类型(
Nat8、Nat16、Nat32、Nat64)和环绕算术(+%) - 基准测试指南参见:skills/benchmarks-generation/SKILL.md
快速优化检查清单(启发式规则)
扫描候选代码中的以下模式,并进行相应替换:
-
Blob 处理
- 如果看到
let bytes = Blob.toArray(data)只是为了迭代或索引 → 优先直接索引Blob:data[i]。 - 将
data.size()缓存到局部变量中;选择合适的位宽(通常为Nat64)。
- 如果看到
-
整数转换与位运算
- 避免为了位运算将
Nat8扩宽为Nat(任意精度)。 - 使用固定宽度类型逐步扩宽:在移位和掩码运算前使用
b.toNat16().toNat32()。 - 使用
Nat32/Nat64中间值执行<<、>>、&、|,必要时最后再缩窄类型。 - 当不变量保证不会溢出时,对循环索引使用回绕算术
+%;优先使用较宽的计数器(例如Nat64)。
- 避免为了位运算将
-
字母表 / 查找表
- 避免在性能关键路径中使用
Char/Text表。将字母表存储为[Nat8](UTF-8/ASCII 编码),以便直接输出字节。
- 避免在性能关键路径中使用
-
文本构造
- 避免逐字符调用
Text.fromChar并反复使用#拼接。 - 按块输出文本:构造较小的 ASCII
Blob(例如 4–8 个字节),调用一次Text.decodeUtf8,然后每块追加一次。 - 尽量减少
#操作;每 4–8 个输出字符追加一次,远比多次追加高效。
- 避免逐字符调用
-
循环结构
- 以固定大小的块处理输入(例如 3 个字节 → 4 个字符),然后单独处理较短的尾部(0–2 个字节)。
- 将不变量(大小、常量)提到循环外;保持内层循环为直线代码。
-
正确性保护
- 对理论上不可达的状态(例如 ASCII 解码失败),插入带有清晰消息的
Prim.trap("…")。 - 仔细检查块边界处的差一错误(例如
next_i <= sz)。
- 对理论上不可达的状态(例如 ASCII 解码失败),插入带有清晰消息的
前后对照示例
- 避免 Blob→Array 转换
// Before
let bytes = Blob.toArray(data);
var i = 0;
let b1 = bytes[i];
// After
let sz = Nat64.fromIntWrap(data.size());
var i : Nat64 = 0;
let b1 = data[i.toNat()];
- 优先使用固定宽度整数并逐步扩宽
// Before (goes through arbitrary-precision Nat)
let n = (Nat32.fromNat(Nat8.toNat(b1)) << 16)
| (Nat32.fromNat(Nat8.toNat(b2)) << 8)
| Nat32.fromNat(Nat8.toNat(b3));
// After (fixed-width path)
let n = (b1.toNat16().toNat32() << 16)
| (b2.toNat16().toNat32() << 8)
| b3.toNat16().toNat32();
- 将
Char/Text字母表和逐字符拼接替换为[Nat8]+ 分块解码
// Before
private let alphabet : [Char] = ['A', 'B', /*…*/, '/'];
let c1 = Text.fromChar(alphabet[idx1]);
let c2 = Text.fromChar(alphabet[idx2]);
result #= c1 # c2 # c3 # c4; // many small concats
// After
private let alphabet : [Nat8] = [65, 66, /*…*/, 47]; // ASCII bytes
let bytes = Blob.fromArray([
alphabet[idx1], alphabet[idx2], alphabet[idx3], alphabet[idx4]
]);
switch (Text.decodeUtf8(bytes)) {
case (?t) { result := result # t } // one append per block
case (_) { Prim.trap("Cannot happen: Utf8 decode error …") }
};
- 分块处理并处理尾部
// Example: main loop over full blocks, then a small tail path
var i : Nat64 = 0;
var next_i : Nat64 = block; // e.g., 3, 6, etc.
while (next_i <= sz) {
// read <block> bytes, produce <k> output chars
i := next_i; next_i +%= block;
};
while (i < sz) {
// read remaining bytes (tail), produce padded output as needed
i +%= tailStep;
};
分步流程(Agent 操作指南)
-
确定范围
- 找出性能关键的字节/位处理路径:编码器/解码器、哈希处理、二进制解析器。
- 如有需要,准备一个小型基准测试框架(参见 skills/benchmarks-generation/SKILL.md)。
-
建立基线
- 使用真实场景的输入(连续字节、随机/混合数据)运行基准测试;记录结果。
-
转换(逐步应用;每个提交只聚焦一项)
- 移除仅用于迭代或随机访问的
Blob.toArray;直接索引Blob。 - 缓存大小;将索引切换为
Nat64;在安全时使用+%。 - 将
Char/Text字母表替换为[Nat8]表。 - 使用
Blob.fromArray+Text.decodeUtf8,将逐字符拼接改为分块输出。 - 使用
Nat16/Nat32/Nat64中间值进行移位和掩码运算;避免在性能关键循环中扩宽为Nat。 - 将循环重构为较大的固定块,并用单独的小型路径处理尾部。
- 清除无用的临时变量;优先使用直线代码。
- 为逻辑上不可达的解码错误添加显式
Prim.trap。
- 移除仅用于迭代或随机访问的
-
验证
- 单元测试:较小的 ASCII 示例、填充/边界情况、较长的连续字节;在相关情况下断言输出长度和字母表成员资格。
- 可选属性检查:将随机输入与参考实现进行比较。
- 基准测试:验证不同模式和大小下的性能提升或持平(使用 skills/benchmarks-generation/SKILL.md)。
常见陷阱
-
在紧凑循环中不必要地扩宽到
Nat- 为什么会有影响:任意精度算术较慢,而且会分配内存。
- 解决方法:使用
Nat32/Nat64中间值进行运算;按Nat8 -> Nat16 -> Nat32的顺序转换。
-
逐字符执行
Text操作- 为什么会有影响:
Text.fromChar加上重复使用#会产生许多小型内存分配。 - 解决方法:将 ASCII 字节写入
Blob块,每块调用一次Text.decodeUtf8,然后追加一次。
- 为什么会有影响:
-
将
Blob转换为[Nat8]后再遍历- 为什么会有影响:热路径上会分配并复制整个缓冲区。
- 解决方法:直接对
Blob进行索引,并缓存size()。
-
检查循环计数器是否溢出
- 为什么会有影响:每次迭代都要进行额外检查,而这些检查实际上可以证明没有必要。
- 解决方法:将计数器扩宽到
Nat64,并在明确保证不会溢出的不变量下使用+%。
-
块边界处的差一错误
- 现象:输入接近末尾时发生陷阱或输出错误。
- 解决方法:主循环使用
next_i <= sz;显式处理剩余尾部。
安全性与边界情况
- 回绕算术:仅当不变量能保证所选位宽下不会溢出时才使用
+%;优先扩宽到Nat64。 Text.decodeUtf8:对 ASCII 字节安全(例如编解码器字母表和=填充)。保留防御性的Prim.trap,以便审计。- 边界:明确分开快速块循环和尾部处理;测试边界大小(例如,根据块大小测试 0、1、2、3、5、6、7)。
- 分配情况:确保已移除缓冲区物化(
Blob.toArray),并尽量减少拼接。
验证清单(可复制/粘贴)
- 测试涵盖:空输入、最小输入、填充边界、连续长字节序列,以及适用时的多句 ASCII 文本。
- 输出长度公式成立(按编解码器或算法的定义)。
- 字母表成员检查通过;没有多余字符(适用于编码器/解码器)。
- 基准测试显示性能有所提升或持平;混合模式与零模式均无性能回退(参见 skills/benchmarks-generation/SKILL.md)。
- 块边界处不会发生陷阱;索引不会溢出。
提交消息模板(可选)
尽可能让每个提交只包含一项更改。示例:
- 编码器中跳过从 Blob 到 Array 的转换。
- 避免不必要的 Char→Text 转换;改用
[Nat8]字母表。 - 复用
data.size()并使用Nat64索引。 - 位运算时通过
Nat16将Nat8转换为Nat32。 - 按统一块大小处理输入,并单独处理尾部。
- 在明确的不变量下对循环索引使用
+%;扩宽索引位数。 - 用一次追加解码后的块替代多次小型
#拼接。 - 移除紧凑循环中的临时
c1..c4变量。 - 在不可达的
decodeUtf8错误路径中触发陷阱。
效果
应用这些技巧通常可以实现:
- 避免物化整个缓冲区和逐字符执行
Text操作,从而减少内存分配 - 使用定宽算术加快位打包/解包
- 通过输出更大的
Text块来减少拼接开销 - 更清晰地区分快速路径的块处理与尾部处理
- 通过明确的不变量和陷阱编写更安全、更易审计的代码
分组密码/哈希函数热路径
本节中的技巧专用于MD 类分组哈希函数及类似的分组结构原语(RIPEMD-160、SHA-1/2、MD5、Blake、分组密码等)。这些技巧并非通用建议——大多数代码不应采用这种写法。
适用这些技巧的情形
只有以下条件全部满足时,才适合采用这种优化方式:
- 由转换函数处理固定大小的输入块。 算法规定了一个 N 字节的块(例如 SHA-256/RIPEMD-160 使用 64 字节),并反复将其传入压缩函数。
- 每个块都要执行多轮运算,每轮更新一小组固定的状态字。 通常是对 4–8 个
Nat32/Nat64链接变量执行 64–160 轮运算。每轮路径上的元组/记录分配会成为主要开销。 - 提供公开的流式 API(
write/update+sum/finalize),且必须在调用之间缓冲不完整的数据块。 - 吞吐量很重要。 该函数处于可测量的热点路径中(例如签名、地址推导、大消息哈希)。
如果你的代码只运行一次、处理大小可变的帧,或每个请求只运行寥寥几次,就此打住。请改用简单、易读的代码。参考:src/Ripemd160.mo 经过重写,在 1 KiB 输入下指令数减少约 2 倍,GC 开销降低约 3.6 倍;这些技巧需要编写约 300 行内联轮运算,只有对于使用者高度依赖的基础算法才值得采用。
参考模式(research-ag/sha2 风格)
以下组合模式与 research-ag/sha2 的 SHA-256 使用的模式相同,并已成功应用于 src/Ripemd160.mo。请一并应用这些模式——只采用部分技巧会错失大部分收益。
模式 1 — 使用 [var Nat32](或 [var Nat64])保存可变链接状态
// Persistent chaining state, single allocation reused across all blocks.
private let s : [var Nat32] = VarArray.repeat<Nat32>(0, 5);
链接变量较多时,不要为每个变量单独使用 var 字段——数组槽位会以内联形式存储定宽字。除非测量后确认有收益,否则不要拆分为 Nat16 的一半;对于不超过 8 个状态字,这样做并不值得。
模式 2 — 使用 [var Nat32] 预解码消息调度表(而非 [var Nat8])
// 16 little-endian (or big-endian) words for the current block.
// Bytes are folded in at write time; transform() reads words directly.
private let msg : [var Nat32] = VarArray.repeat<Nat32>(0, 16);
这取代了常见(且缓慢)的模式:
// AVOID for hot-path block hashing
private let buf : [var Nat8] = ...; // 64-byte buffer
transform(buf.toArray(), 0); // allocates a 64-byte [Nat8] PER BLOCK
// transform then re-decodes 4 bytes → Nat32 word internally
[var Nat32] 设计既省去了每个数据块的 toArray() 拷贝,也省去了 transform() 内读取时的解码操作。
模式 3 — 使用不装箱的 Nat16 字节计数器记录数据块内的位置
// 0..63 byte position within the current block.
// Nat16 is unboxed in mutable storage; Nat is heap-allocated per increment.
private var i_msg : Nat16 = 0;
在热点路径上,任何较小的可变计数器都应使用 Nat16(如果数值范围合适,也可使用 Nat8)——每次赋值给 var x : Nat = 0 都会分配一个装箱的大数。Nat64 也不装箱,但当数值确定很小时,会浪费寄存器位宽。
模式 4 — writeByte 将字节合并到字调度表中
private func writeByte(b : Nat8) {
let pos = i_msg;
let wi = Nat16.toNat(pos >> 2);
let lane = pos & 0x3;
let v : Nat32 = Nat32.fromNat16(b.toNat16()) << Nat32.fromNat16(lane << 3);
if (lane == 0) { msg[wi] := v } // first byte: overwrite stale word
else { msg[wi] := msg[wi] | v }; // subsequent bytes: OR in
let next = pos +% 1;
if (next == 64) { transform(); n_blocks +%= 1; i_msg := 0 }
else { i_msg := next };
};
对于大端字调度表(例如 SHA-2),请调整通道移位公式:(3 - lane) << 3。
模式 5 — 在 write 中使用快速路径块解码器
public func write(data : [Nat8]) {
let n = data.size();
if (n == 0) return;
var i = 0;
// (1) Finish any partial block one byte at a time.
while (i_msg != 0 and i < n) { writeByte(data[i]); i += 1 };
// (2) Fast path: decode 16 LE words inline directly from input → msg.
while (i + 64 <= n) {
msg[0] := data[i].toNat16().toNat32()
| (data[i+1].toNat16().toNat32() << 8)
| (data[i+2].toNat16().toNat32() << 16)
| (data[i+3].toNat16().toNat32() << 24);
// ... msg[1] .. msg[15] (15 more identical lines)
transform();
n_blocks +%= 1;
i += 64;
};
// (3) Tail: remaining < 64 bytes go into the partial block.
while (i < n) { writeByte(data[i]); i += 1 };
};
使用三个循环,而非一个。快速路径能让大输入的处理开销低廉。
模式 6 — 将所有轮运算内联到 transform() 中
对于多轮运算的基础算法,这是收益最大的优化。原始 RIPEMD-160 代码如下:
// AVOID: each call allocates a (Nat32, Nat32) tuple — 320 tuples per block
let (a, c) = r11(a, b, c, d, e, msg, 0, 11);
let (e, b) = r11(e, a, b, c, d, msg, 1, 14);
// ... 158 more
将每一轮都内联为一系列扁平的语句,在局部可变变量上进行更新:
var a1 : Nat32 = s[0]; var b1 : Nat32 = s[1]; /* ... */
// Left line round 1: f1(b,c,d) = b ^ c ^ d, K = 0
a1 := rol(a1 +% (b1 ^ c1 ^ d1) +% w0, 11) +% e1; c1 := rol(c1, 10);
e1 := rol(e1 +% (a1 ^ b1 ^ c1) +% w1, 14) +% d1; b1 := rol(b1, 10);
// ... 158 more, with K and rotation amounts varying per round
// Combine back to s without allocating temporaries
let t = s[0];
s[0] := s[1] +% c1 +% d2;
// ...
是的,这会有数百行代码。是的,这些代码是根据规范或原先返回元组的辅助函数机械转写而来的。请为每组轮运算添加注释,注明其 f 和 K。编译器目前不会自动内联这些辅助函数,而且每次调用产生的元组分配确实存在。
模式 7 — 长度编码中实现无装箱的 Nat64 → Nat8 转换
填充时会将位长度写入 8 个小端字节。朴素写法 Nat8.fromNat(Nat64.toNat(x & 0xff)) 会经过任意精度的 Nat 类型来回转换,每个字节都会产生一次分配:
// Stage all narrowing on fixed-width types — no Nat allocation.
private func lowByte64(v : Nat64) : Nat8 {
Nat8.fromNat16(Nat16.fromNat32(Nat32.fromNat64(v & 0xff)));
};
模式 8 — 通过 writeByte 填充,而非分配 [var Nat8]
public func sum() : [Nat8] {
let bitlen : Nat64 = ((n_blocks << 6) +% Nat64.fromNat(Nat16.toNat(i_msg))) << 3;
writeByte(0x80);
while (i_msg != 56) { writeByte(0) };
writeByte(lowByte64(bitlen));
writeByte(lowByte64(bitlen >> 8));
// ... 6 more length bytes; the 8th triggers transform()
// serialize state to 20/32 output bytes
};
这样可以消除常见的 pad : [var Nat8] 和 sizedesc : [var Nat8] 分配,以及它们的 .toArray() 拷贝。
采用此风格时应消除的反模式
- 在逐字节路径中使用
var counter : Nat = 0→ 改用Nat16/Nat64。 - 从逐轮辅助函数返回多值元组
(Nat32, Nat32)。 - 每个数据块都调用
buf.toArray(),再将字节传给transform。 - 在
sum/finalize中分配pad和sizedesc数组。 - 如果可以将字暂存于
[var Nat32]中预先解码,就不要在每轮中对位运算套用Common.readLE32(arr, i)调用。 - 在热路径中进行任何逐字节转换时都经过
Nat。
运算符优先级注意事项
Motoko 中 ^(异或)、&、|、+%、<<、>> 的优先级可能与 C/Rust 的直觉不同。请尽可能多地加上括号,尤其是在 f 函数和轮次表达式中:
a1 := rol(a1 +% ((b1 & c1) | (^b1 & d1)) +% w0 +% 0x5A827999, 11) +% e1;
如果尚未确认,就不要相信编译器会将 ^b1 & d1 分组为 (^b1) & d1。加上括号不会带来任何运行时开销。
验证工作流(必需)
这些优化很容易在转录时出错(仅 RIPEMD-160 就有 160 行轮次代码)。在宣称成功之前:
- 必须通过完整测试套件。 哈希测试向量不可妥协——只要旋转量、K 常量或消息字索引有一处错误,就会破坏所有输出,但结果仍可能看起来稳定。
- 与之前的版本进行基准测试。 如果指令数没有下降 ≥1.5×,且大输入下的 GC 开销没有下降 ≥2×,说明缓冲处理或轮次分配仍有问题。确保两次运行使用相同的
[optimize]/[toolchain] wasm-opt(见上文的 Quick Wins)——否则测到的是优化器,而非你的改动。 - 如果存在外部参照(例如另一个 mops 包),请与之进行基准测试。 这能确认你并非只是将开销转移到了其他地方。
不应采用这些模式的情况
- 一次性或低频代码。 每个请求只调用一次的 5 行哈希代码:保留易读版本即可。
- 具有可变长度数据块的算法(例如压缩、解析)。
msg : [var Nat32]调度表假设字长固定且位置固定。 - 轮次较少的算法(例如
< 16)。内联属于过度优化;元组分配的开销相对于其他工作很小。 - 需要便于对照规范进行审查的代码。 内联轮次比
for r in rounds循环更难阅读。编写新原语时,先写清晰易读的版本并发布,只有基准测试证明值得时才进行内联。 - 通用/参数化实现。 这种风格会将算法硬编码;你无法轻松地在不同哈希变体之间共用
transform()。
结果(参考:本仓库中的 RIPEMD-160)
在 1024 字节输入下测得:
| 指标 | 优化前 | 优化后 | 加速比 |
|---|---|---|---|
| 指令数 | 1,470,125 | 734,849 | 2.0× |
| GC 开销 | 160.28 KiB | 43.88 KiB | 3.65× |
| 堆(稳定状态) | 272 B | 272 B | 1.0× |
公共 API 和可观察行为均未改变;全部 24 个测试文件均通过。