mcpField
M
motoko-performance-optimizations

motoko-performance-optimizations

github.com/research-ag开发056 次安装10 周前更新
代码仓库

关于 motoko-performance-optimizations

Motoko Performance Optimizations 是面向开发者的 Agent 技能,帮助在不改变行为的前提下提升 Motoko 吞吐量或降低延迟。内容涵盖减少分配、定宽算术、字节处理、文本构建、循环结构及验证方法。

主要功能

  1. 01减少热点路径中的内存分配
  2. 02位运算采用定宽算术
  3. 03分块构建 Text
  4. 04按整块处理并单独处理尾部
  5. 05通过测试和基准验证改动

适用场景

  1. 01优化字节编码器或解码器
  2. 02改善哈希或二进制解析热点
  3. 03减少 Blob 遍历时的分配
  4. 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)。
前后对照示例
  1. 避免 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()];
  1. 优先使用固定宽度整数并逐步扩宽
// 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();
  1. 将 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 …") }
};
  1. 分块处理并处理尾部
// 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 操作指南)
  1. 确定范围

    • 找出性能关键的字节/位处理路径:编码器/解码器、哈希处理、二进制解析器。
    • 如有需要,准备一个小型基准测试框架(参见 skills/benchmarks-generation/SKILL.md)。
  2. 建立基线

    • 使用真实场景的输入(连续字节、随机/混合数据)运行基准测试;记录结果。
  3. 转换(逐步应用;每个提交只聚焦一项)

    • 移除仅用于迭代或随机访问的 Blob.toArray;直接索引 Blob。
    • 缓存大小;将索引切换为 Nat64;在安全时使用 +%。
    • 将 Char/Text 字母表替换为 [Nat8] 表。
    • 使用 Blob.fromArray + Text.decodeUtf8,将逐字符拼接改为分块输出。
    • 使用 Nat16/Nat32/Nat64 中间值进行移位和掩码运算;避免在性能关键循环中扩宽为 Nat。
    • 将循环重构为较大的固定块,并用单独的小型路径处理尾部。
    • 清除无用的临时变量;优先使用直线代码。
    • 为逻辑上不可达的解码错误添加显式 Prim.trap。
  4. 验证

    • 单元测试:较小的 ASCII 示例、填充/边界情况、较长的连续字节;在相关情况下断言输出长度和字母表成员资格。
    • 可选属性检查:将随机输入与参考实现进行比较。
    • 基准测试:验证不同模式和大小下的性能提升或持平(使用 skills/benchmarks-generation/SKILL.md)。
常见陷阱
  1. 在紧凑循环中不必要地扩宽到 Nat

    • 为什么会有影响:任意精度算术较慢,而且会分配内存。
    • 解决方法:使用 Nat32/Nat64 中间值进行运算;按 Nat8 -> Nat16 -> Nat32 的顺序转换。
  2. 逐字符执行 Text 操作

    • 为什么会有影响:Text.fromChar 加上重复使用 # 会产生许多小型内存分配。
    • 解决方法:将 ASCII 字节写入 Blob 块,每块调用一次 Text.decodeUtf8,然后追加一次。
  3. 将 Blob 转换为 [Nat8] 后再遍历

    • 为什么会有影响:热路径上会分配并复制整个缓冲区。
    • 解决方法:直接对 Blob 进行索引,并缓存 size()。
  4. 检查循环计数器是否溢出

    • 为什么会有影响:每次迭代都要进行额外检查,而这些检查实际上可以证明没有必要。
    • 解决方法:将计数器扩宽到 Nat64,并在明确保证不会溢出的不变量下使用 +%。
  5. 块边界处的差一错误

    • 现象:输入接近末尾时发生陷阱或输出错误。
    • 解决方法:主循环使用 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、分组密码等)。这些技巧并非通用建议——大多数代码不应采用这种写法。

适用这些技巧的情形

只有以下条件全部满足时,才适合采用这种优化方式:

  1. 由转换函数处理固定大小的输入块。 算法规定了一个 N 字节的块(例如 SHA-256/RIPEMD-160 使用 64 字节),并反复将其传入压缩函数。
  2. 每个块都要执行多轮运算,每轮更新一小组固定的状态字。 通常是对 4–8 个 Nat32/Nat64 链接变量执行 64–160 轮运算。每轮路径上的元组/记录分配会成为主要开销。
  3. 提供公开的流式 API(write/update + sum/finalize),且必须在调用之间缓冲不完整的数据块。
  4. 吞吐量很重要。 该函数处于可测量的热点路径中(例如签名、地址推导、大消息哈希)。

如果你的代码只运行一次、处理大小可变的帧,或每个请求只运行寥寥几次,就此打住。请改用简单、易读的代码。参考: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 行轮次代码)。在宣称成功之前:

  1. 必须通过完整测试套件。 哈希测试向量不可妥协——只要旋转量、K 常量或消息字索引有一处错误,就会破坏所有输出,但结果仍可能看起来稳定。
  2. 与之前的版本进行基准测试。 如果指令数没有下降 ≥1.5×,且大输入下的 GC 开销没有下降 ≥2×,说明缓冲处理或轮次分配仍有问题。确保两次运行使用相同的 [optimize]/[toolchain] wasm-opt(见上文的 Quick Wins)——否则测到的是优化器,而非你的改动。
  3. 如果存在外部参照(例如另一个 mops 包),请与之进行基准测试。 这能确认你并非只是将开销转移到了其他地方。
不应采用这些模式的情况
  • 一次性或低频代码。 每个请求只调用一次的 5 行哈希代码:保留易读版本即可。
  • 具有可变长度数据块的算法(例如压缩、解析)。msg : [var Nat32] 调度表假设字长固定且位置固定。
  • 轮次较少的算法(例如 < 16)。内联属于过度优化;元组分配的开销相对于其他工作很小。
  • 需要便于对照规范进行审查的代码。 内联轮次比 for r in rounds 循环更难阅读。编写新原语时,先写清晰易读的版本并发布,只有基准测试证明值得时才进行内联。
  • 通用/参数化实现。 这种风格会将算法硬编码;你无法轻松地在不同哈希变体之间共用 transform()。
结果(参考:本仓库中的 RIPEMD-160)

在 1024 字节输入下测得:

指标优化前优化后加速比
指令数1,470,125734,8492.0×
GC 开销160.28 KiB43.88 KiB3.65×
堆(稳定状态)272 B272 B1.0×

公共 API 和可观察行为均未改变;全部 24 个测试文件均通过。

评论

支持 Markdown