Motoko compiler plugins for fast computation

AI makes it easy to write small plugins/components and test them well, we should use that. It’s much worse at building bigger things. It feels like it’s compiler hack season and as much as I like modding mine with AI, I’d prefer having the people who made Motoko maintain the core with their human brains and let us hack smaller things we can test and understand.

Let all credits for this plugin system go to whoever manages to review it and consider its broader implications. I merely wrote few prompts to an agent to get this PR.

https://github.com/caffeinelabs/motoko/pull/6443


We’ve built an experimental compute-plugin system for native moc. It lets developers distribute optimized functions as separate packages and expose them through Motoko’s Prim module. The first two plugins provide canonical Candid encoding and SHA-256.

The focus is pure, synchronous computation. CandidX generates an encoder specialized for the actual Motoko types, while SHA-256 reads the existing input Blob directly.

With our plugin-enabled compiler, load prebuilt packages dynamically when compiling a canister:

moc --plugin ./packages/candidx \
    --plugin ./packages/sha256 \
    Main.mo -o Main.wasm

Motoko code can then use:

import Prim "mo:⛔";

let bytes = Prim.to_candid_canonical({ id = 42; name = "Alice" });
let digest = Prim.sha256(bytes);

Adding a compatible plugin does not require rebuilding moc. The compiler links the required kernels into one ordinary canister Wasm module, with direct calls. Packages are trusted extensions and must match the compiler/toolchain; incompatible packages produce an “unsupported compute plugin” error.

Representative PocketIC instruction counts, using the same compiler and canister for both implementations:

Workload Motoko library Plugin Library / plugin
SHA-256, 32 bytes 27,968 4,966 5.63×
SHA-256, 1 MiB 323,721,060 78,124,848 4.14×
CandidX, 64 KiB Blob 210,032 66,322 3.17×
CandidX, 1,024 fixed records 37,743,426 351,914 107.25×
CandidX, 1,024 sparse records 88,379,476 5,674,118 15.58×
CandidX, 1,024 distinct variant tags 1,428,890,507 2,190,869 652.20×
CandidX, 1,024 variants repeating one tag 23,692,669 775,646 30.55×

How does this compare with well-optimized Wasm components? Components provide standard interfaces and isolation. An optimized Motoko integration could use the same kernels, generate specialized bindings, and minimize adapter overhead. The components can have transfers down to a single copy between the two memories. Our plugins avoid that boundary transfer by sharing Motoko’s memory.

Native Component Model execution would also need IC support and are not allowed now.

For our functions:

  • SHA-256: a separate component normally receives a copy of the input bytes. Our plugin reads the existing Blob directly. The copy’s cost could still be small relative to hashing.
  • A standalone CandidX encoder component: normally needs the input converted into its representation, then the encoded bytes transferred back. Our implementation reads Motoko values and writes the final Motoko Blob directly.

Motoko plugins: instruction costs and allocation in practice

We benchmarked six experimental Motoko compiler plugins: JSON, CBOR, CandidX,
SHA-256, a persistent map and a separate mutable map. They load during canister
compilation, expose Motoko functions, and specialize operations on typed values
through the compiler’s GC helpers.

Tables show PocketIC instruction counts from three trials. Each library/plugin
pair uses the same compiler and inputs; compiler versions differ across recorded
rounds. The ratio is library instructions / plugin instructions. These are
instruction savings, not wall-clock speed measurements.

Workload Motoko baseline Library instructions Plugin instructions Ratio
JSON encode, 1,024 records json 1.4.0 53,258,267 5,044,780 10.56×
JSON decode, 1,024 records serde 3.5.0 763,222,636 18,210,769 41.91×
CBOR encode, 1,024 records cbor 4.1.0 49,934,053 5,395,241 9.26×
CBOR decode, 1,024 records cbor 4.1.0 152,527,112 7,324,274 20.82×
Canonical CandidX encode, 1,024 sparse records CandidX Motoko library, 94c2868 88,379,476 6,057,260 14.59×
SHA-256, 1 MiB sha2 0.2.5 323,721,060 78,124,848 4.14×

For JSON and CBOR, each row uses the lowest instruction count among the three
tested native libraries. These are end-to-end typed operations, including
conversion to and from each library’s representation. Typed traversal and
avoiding intermediate representations are part of the benefit.

Allocation reductions are substantial too. JSON decoding above drops from
136,203,896 to 172,816 allocated bytes; CandidX encoding drops from
3,578,064 to 22,600 bytes. These counters include results and temporary
garbage. They measure allocation pressure, not peak or retained live memory.

Both map plugins support generic keys and values. The persistent map shares
unchanged branches between versions; the mutable map updates nodes in place.
Neither copies an entire map on an ordinary point update.

The following comparisons use 100,000-entry maps, boxed Nat64 keys and
the same ordinary Motoko comparator in both implementations. Each plugin is
compared with its corresponding core 2.5.0 map. Counts are per-call averages
over batches of 32 operations.

Map Operation Core instructions Plugin instructions Ratio Allocated bytes, Core → plugin
Persistent Lookup, hit 3,324.09 2,524.38 1.32× 0 → 0
Persistent Insert new key 14,112.34 10,148.47 1.39× 2,524.25 → 1,289.25
Persistent Overwrite 12,817.72 6,066.84 2.11× 2,230.50 → 1,143
Mutable Lookup, hit 7,700.16 3,877.41 1.99× 123 → 0
Mutable Insert new key 12,818.28 5,499.44 2.33× 324 → 40
Mutable Overwrite 8,528.06 4,492.91 1.90× 286 → 40
Mutable Delete, hit 14,111.88 7,835.81 1.80× 520.25 → 0

Optional scalar ordering removes comparator callbacks while keeping values
generic. At this size, getNat64 uses 1,345.19 instructions for the
persistent plugin and 2,755.84 for the mutable plugin. Generic values can
include shared records, closures and large Blobs; payloads retain their identity.

Whole-call costs at 100,000 entries:

Mutable map, 100,000 entries Core instructions Plugin instructions Ratio Allocated bytes, Core → plugin
Build from shuffled entries 1,175,071,733 566,138,242 2.08× 30,051,776 → 5,842,360
Predicate search, no match 170,277,593 40,524,835 4.20× 11,059,840 → 72

Shuffled construction inserts the same entries in a fixed permutation. Predicate
search visits the whole map in key order; Core’s baseline uses an iterator
adapter because it has no matching find function. These comparisons include
algorithm and representation choices, as well as generated code.

There are tradeoffs. The mutable plugin’s 100,000-entry map transform uses
26.7% more instructions than Core, although it allocates less. Its full
138-workload matrix has 135 instruction wins, two ties and one loss; size
and isEmpty are effectively at parity. Scalar ordering also loses to the
callback path in some removal cases. For the persistent plugin, creating a
cursor or snapshot costs more, and shuffled fromIter construction is slightly
slower at 1,000 and 10,000 entries.

Input preparation, correctness checks, reply encoding and message-end GC are
outside timing. Less allocation is encouraging for GC pressure, but these
measurements do not establish a steady-state GC or whole-canister speedup.
The implementations have separate correctness audits, including interpreter/Wasm
agreement, shared references, GC and upgrade tests for the mutable map.

These results support compiler plugins for expensive operations on typed Motoko
data. Developers load only the packages they need, with improvements checked
against real library implementations.

Full benchmark tables, methodology and audit links are in the README.

Interested to see how cryptographic operations like EC stuff perform as plugins. And then there are some things we don’t even in Motoko right now like BLS curves.

Crypto public API benchmarks

155 public callables. Native means pure Motoko from pinned Mops packages; Plugin means the optimized mo:fast implementation. Native/plugin is the measured instruction ratio (larger favors the plugin). Empty native cells mean no correct counterpart for the listed operation in the pinned packages. Marked rows use the closest available code with the contract differences explained below.

Each value is the median of three fresh-canister trials. Inputs and reusable setup are prepared before measurement; only the named operation and result assignment are timed. Exact independent output checks run afterward. Instructions are IC instructions/op, including measured call overhead; allocated bytes are cumulative allocation/op, not live heap. Whole-canister Wasm sizes, all workload sizes, hashes and individual trials are retained in the machine-readable evidence.

Measured with Motoko compiler (source 2.0.0-14-g92b1bdecb), pocket-ic-server 10.0.0, and release RTS SHA256 8456e47e527bbd58… (full hash in evidence).

Pinned packages: sha2@0.1.14, blake3@0.1.2, ecdsa@8.0.1, bitcoin@0.2.1, bls12-381@0.2.0. Byte-oriented curve adapters include parsing, validation and serialization except where marked; reusable curve construction is outside timing. Prepared and streaming rows exclude construction unless construction is the named operation. Every counterpart and any contract differences are recorded per row in the evidence file.

BLAKE3

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
deriveKey [B] isolated deriveKey 1024 bytes 3,875,001 38,805 176,104 56 99.86×
digest unkeyed digest 1024 bytes 3,485,872 35,745 135,536 56 97.52×
finalize isolated finalize 1024 bytes 265,269 41,754 24,304 88 6.35×
finalizeXof short seeked XOF within one output block 1024-byte input; seek 13; output 32 bytes 267,446 41,750 24,312 88 6.41×
keyedHash keyed digest 32-byte key; 1024 bytes 3,492,129 35,935 135,944 88 97.18×
new isolated new empty state 92,375 6,406 14,768 2,840 14.42×
newDeriveKey [B] isolated newDeriveKey empty state 481,567 9,579 55,336 2,840 50.27×
newKeyed isolated newKeyed 32-byte key; empty state 98,592 6,691 15,176 2,872 14.74×
update isolated update 1024 bytes 3,128,677 19,584 96,464 2,872 159.76×
xof short seeked XOF within one output block 1024-byte input; seek 13; output 32 bytes 3,487,986 36,678 135,544 88 95.10×

[B] Native composes published state/flag operations for correct domain-separated derivation. Extra long-XOF workloads compose output-block calls. These bounded cases match official vectors; the package’s high-level derive function ignores context, and its multi-chunk hashing remains incorrect.

SHA512

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
Digest constructor 0 B 9,529 1,430 3,256 432 6.66×
Digest.reset reset 1024 B 1,194 718 0 240 1.66×
Digest.sum sum 1024 B 61,335 9,451 5,736 88 6.49×
Digest.write write 1024 B 154,080 56,112 256 240 2.75×
Digest384 constructor 0 B 9,274 1,430 3,128 432 6.49×
Digest384.reset reset 1024 B 1,194 718 0 240 1.66×
Digest384.sum sum 1024 B 56,677 9,348 5,304 72 6.06×
Digest384.write write 1024 B 154,080 56,114 256 240 2.75×
digest one-shot 1024 B 182,086 61,018 4,544 88 2.98×
digest384 one-shot 1024 B 180,772 60,917 4,240 72 2.97×

RIPEMD160

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
Digest constructor 0 B 2,807 1,322 704 320 2.12×
Digest.reset reset 1024 B 542 610 0 128 0.89×
Digest.sum [M] sum 1024 B 40,253 5,738 232 48 7.02×
Digest.write write 1024 B 527,428 55,496 8,216 128 9.50×
digest one-shot 1024 B 570,076 57,247 9,152 48 9.96×
finalize [M] immutable-state-finalize 1024 B 40,253 5,678 232 48 7.09×
init [M] immutable-state-init 0 B 2,807 577 704 128 4.86×
update [M] immutable-state-update 1024 B 541,428 55,634 8,216 128 9.73×

[M] Native constructs or mutates an object and consumes it on finalization. Plugin state is an immutable serialized snapshot, and finalization preserves it. Ratios include these different state contracts.

HMAC

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
SHA256 constructor 32-byte key; empty stream 118,338 12,527 13,808 408 9.45×
SHA256.reset reset 32-byte key; 1024-byte message 9,658 273 0 0 35.38×
SHA256.sum sum 32-byte key; 1024-byte message 92,529 11,332 8,328 56 8.17×
SHA256.write write 32-byte key; 1024-byte message 318,024 79,174 0 160 4.02×
SHA512 constructor 32-byte key; empty stream 159,435 17,349 16,288 544 9.19×
SHA512.reset reset 32-byte key; 1024-byte message 18,934 273 0 0 69.36×
SHA512.sum sum 32-byte key; 1024-byte message 116,629 16,384 10,536 88 7.12×
SHA512.write write 32-byte key; 1024-byte message 154,145 56,141 256 296 2.75×
prepareSha256 prepare 32-byte key 98,915 11,948 10,264 280 8.28×
prepareSha256.digest prepared digest 32-byte key; 1024-byte message 402,905 89,322 7,424 56 4.51×
prepareSha256.stream prepared stream 32-byte key; empty stream 19,679 1,045 3,544 200 18.83×
prepareSha256.stream.reset prepared reset 32-byte key; 1024-byte message 9,658 273 0 0 35.38×
prepareSha256.stream.sum prepared sum 32-byte key; 1024-byte message 92,529 11,332 8,328 56 8.17×
prepareSha256.stream.write prepared write 32-byte key; 1024-byte message 318,024 79,174 0 160 4.02×
prepareSha512 prepare 32-byte key 130,659 16,770 12,824 416 7.79×
prepareSha512.digest prepared digest 32-byte key; 1024-byte message 256,072 69,813 9,344 88 3.67×
prepareSha512.stream prepared stream 32-byte key; empty stream 29,032 1,045 3,464 200 27.78×
prepareSha512.stream.reset prepared reset 32-byte key; 1024-byte message 18,934 273 0 0 69.36×
prepareSha512.stream.sum prepared sum 32-byte key; 1024-byte message 116,629 16,384 10,536 88 7.12×
prepareSha512.stream.write prepared write 32-byte key; 1024-byte message 154,145 56,141 256 296 2.75×
sha256 authenticate one message 32-byte key; 1024-byte message 492,476 99,364 18,112 56 4.96×
sha512 authenticate one message 32-byte key; 1024-byte message 350,173 83,009 22,304 88 4.22×

Base58

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
decode Bitcoin Base58 decode 111-character input → 82-byte output 187,866 13,747 1,544 280 13.67×
encode Bitcoin Base58 encode 82-byte input → 111-character output 228,644 16,662 2,864 280 13.72×

Base58Check

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
decode checksummed Bitcoin extended-key sized payload 78-byte payload; 4-byte checksum 287,170 40,525 10,032 1,216 7.09×
encode checksummed Bitcoin extended-key sized payload 78-byte payload; 4-byte checksum 346,092 46,716 12,040 1,240 7.41×

Bech32

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
convertBits [E] 8-bit to padded 5-bit groups 32 input bytes; 52 output groups 23,292 1,656 1,312 112 14.07×
decode Bech32m decode 2-byte HRP; 53 groups 78,834 9,769 3,688 344 8.07×
encode Bech32m encode 2-byte HRP; 53 groups 69,729 5,214 3,088 240 13.37×

[E] Native uses the exact attributed private bitcoin/Segwit.convertBits function, extracted into the benchmark. It assumes valid bit widths; the plugin validates widths and enforces size limits. Both perform the same valid conversion.

Segwit

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
decode Taproot v1 witness program decode 32-byte program; 62-character address 95,596 24,445 4,400 1,560 3.91×
encode Taproot v1 witness program encode 32-byte program; 62-character address 201,151 21,175 8,968 1,408 9.50×

Secp256k1

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
baseMultiply Generator times full-width scalar 32 bytes 124,175,848 6,351,461 5,444,616 96 19.55×
compressPublicKey Valid uncompressed SEC1 input 65 bytes 9,341,246 12,736 470,584 96 733.45×
convertPublicKey Valid compressed SEC1 to uncompressed 33 bytes 39,574,794 575,531 1,664,968 128 68.76×
decompressPublicKey Valid compressed SEC1 input 33 bytes 39,574,794 575,516 1,664,968 128 68.76×
derivePublicKey 32-byte secret; compressed SEC1 output 32 bytes 63,224,878 6,351,398 5,278,944 96 9.95×
normalizeSignature Compact high-S to low-S signature 64 bytes 14,488,926 2,584 666,624 120 5607.17×
pointAdd Compressed and uncompressed distinct points 33 + 65 bytes 28,485,267 1,197,611 1,248,456 96 23.79×
pointMultiply Compressed point and full-width scalar 33 + 32 bytes 148,898,913 6,922,349 6,414,664 96 21.51×
publicKey Full-width secret; uncompressed SEC1 output 32 bytes 70,236,803 6,926,708 5,596,840 224 10.14×
schnorrSign Official BIP340 vector 1, supplied auxiliary bytes 32-byte message 12,762,901 120
schnorrVerify Official BIP340 vector 1, valid signature 32-byte message + 64-byte signature 9,920,568 56
signPrehash [N] RFC6979 deterministic ECDSA of a SHA-256 digest 32-byte secret + 32-byte digest 76,010,180 7,452,214 6,002,504 120 10.20×
signatureFromDer Minimal DER to compact signature 71 bytes 16,972,885 4,530 822,160 120 3746.77×
signatureToDer Compact low-S signature to minimal DER 64 bytes 14,826,684 6,606 677,040 224 2244.43×
tweakPublicKey Compressed point plus full-width scalar times generator 33 + 32 bytes 149,275,314 6,957,310 6,440,800 96 21.46×
tweakXOnlyPublicKey Even-Y lift of X-only key plus full-width tweak 32 + 32 bytes 152,652,841 6,957,132 6,684,456 96 21.94×
tweakXOnlySecretKey Full-width even-Y secret and tweak 32 + 32 bytes 124,045,300 6,349,356 5,446,472 88 19.54×
verifyPrehash Valid compact low-S signature and compressed public key 33 + 32 + 64 bytes 135,628,126 10,093,580 10,763,472 56 13.44×
xOnlyPublicKey Validate compressed SEC1 and extract X 33 bytes 24,765,721 571,759 980,168 88 43.31×

[N] Native signs with an independently prepared RFC6979 nonce; plugin derives that nonce inside the measured call. Native secret arithmetic is variable-time. This is a closest-operation comparison, not equal API or side-channel guarantees.

P256

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
convertPublicKey Valid compressed SEC1 to uncompressed 33 bytes 31,098,672 581,934 1,397,304 128 53.44×
derivePublicKey 32-byte secret; compressed SEC1 output 32 bytes 168,233,483 5,207,095 12,489,816 96 32.31×
normalizeSignature Compact high-S to low-S signature 64 bytes 14,437,561 2,138 664,552 120 6752.83×
publicKey Full-width secret; uncompressed SEC1 output 32 bytes 175,332,443 5,207,302 12,810,992 128 33.67×
signPrehash [N] RFC6979 deterministic ECDSA of a SHA-256 digest 32-byte secret + 32-byte digest 182,163,354 13,458,880 13,290,216 120 13.53×
signatureFromDer Minimal DER to compact signature 70 bytes 16,875,526 4,196 817,952 120 4021.81×
signatureToDer Compact low-S signature to minimal DER 64 bytes 14,518,740 6,299 667,264 224 2304.93×
verifyPrehash Valid compact low-S signature and compressed public key 33 + 32 + 64 bytes 341,837,259 12,873,707 25,290,680 32 26.55×

[N] Native signs with an independently prepared RFC6979 nonce; plugin derives that nonce inside the measured call. Native secret arithmetic is variable-time. This is a closest-operation comparison, not equal API or side-channel guarantees.

BLS12381

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
aggregateVerifyEthereum Verify Ethereum aggregate, two distinct messages 2 x 48-byte keys + 2 x 96-byte proofs + 2 x 32-byte messages + 96-byte signature 1,093,955,874 144,576
aggregateVerifyIC Verify IC aggregate, two distinct messages 2 x 96-byte keys + 2 x 32-byte messages + 48-byte signature 468,894,403 66,240
fastAggregateVerifyEthereum Verify Ethereum aggregate, two keys and one message 2 x 48-byte keys + 2 x 96-byte proofs + 32-byte message + 96-byte signature 887,685,951 119,160
g1Add Add two validated points 2 x 48-byte points 328,864,468 28,411,996 12,622,968 104 11.57×
g1Aggregate Sum four validated points 4 points (192 bytes) 654,545,677 53,140,794 25,008,784 104 12.32×
g1Msm 8-term multi-scalar multiplication 8 points (384 bytes) + 8 scalars (256 bytes) 3,105,266,039 251,337,606 118,021,536 1,280 12.35×
g1Mul Multiply point by scalar 48-byte point + 32-byte scalar 393,866,023 54,272,206 15,095,720 104 7.26×
g2Add [U] Add two validated points 2 x 96-byte points 168,180,323 75,738,386 7,255,832 152 2.22×
g2Aggregate [U] Sum four validated points 4 points (384 bytes) 329,778,612 148,143,245 14,141,336 152 2.23×
g2Msm [P] 8-term multi-scalar multiplication, repeated point 8 points (768 bytes) + 8 scalars (256 bytes) 4,617,878,655 805,297,631 186,921,344 2,096 5.73×
g2Mul [P] Multiply point by scalar 96-byte point + 32-byte scalar 572,985,181 168,868,760 23,188,592 152 3.39×
hashToG1 RFC 9380 hash-to-curve 3-byte message + 50-byte DST 15,834,345 104
hashToG2 RFC 9380 hash-to-curve 3-byte message + 50-byte DST 65,993,104 152
mapToG1 Map field element to curve 48-byte field element 87,018,850 11,894,066 3,721,672 104 7.32×
mapToG2 Map extension-field element to curve 96-byte field element (2 x 48 bytes) 504,926,422 49,488,495 19,800,200 152 10.20×
pairingCheck [P] Pairing product, two cancelling terms 2 pairs (288 bytes: 2 x (48 + 96)) 676,099,164 99,939,097 31,422,544 696 6.77×
validateG1 Validate compressed subgroup point 48-byte point 163,459,310 12,247,316 6,204,312 104 13.35×
validateG2 [U] Validate subgroup point 96-byte point 82,316,657 35,782,534 3,492,640 152 2.30×
verifyEthereum Verify Ethereum POP-profile signature 48-byte key + 32-byte message + 96-byte signature 286,386,819 39,280
verifyEthereumProof Verify proof of possession 48-byte key + 96-byte proof 286,386,744 39,280
verifyIC Verify IC NUL signature 96-byte key + 46-byte message + 48-byte signature 236,236,649 39,280

[U] Native decodes and subgroup-validates 256-byte EIP-2537 uncompressed points; plugin accepts 96-byte compressed points and also recovers coordinates. Both encode compressed outputs. [P] Native starts from trusted predecoded points/scalars; arithmetic result encoding is outside timing. Plugin includes byte decoding, validation and encoding. Pairing returns a Boolean. These rows compare the same mathematical inputs and outputs with different API work, so their ratios are not equivalent-contract speedups.

Bitcoin/Hash

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
digest256 Double SHA256 compatibility alias 32-byte message 72,334 9,792 7,704 112 7.39×
hash160 SHA256 followed by RIPEMD160 32-byte message 81,797 8,622 4,880 104 9.49×
sha256d Double SHA256 32-byte message 72,334 9,792 7,704 112 7.39×
taggedHash Compute BIP340 tagged SHA256 8-byte TapTweak tag + 32-byte message 120,329 35,855 8,928 1,024 3.36×
taggedHasher Construct cached-tag hash closure 8-byte TapTweak tag; 32-byte check message outside timing 33,410 5,956 3,752 96 5.61×
taggedHasher.call Invoke cached-tag hash closure 32-byte message; 8-byte tag prepared outside timing 64,028 30,309 3,712 968 2.11×

Bitcoin/Address

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
decode Decode mainnet witness-v0 address and check network 42-character address / 20-byte program 67,238 17,802 3,408 1,296 3.78×
encode Encode mainnet witness-v0 address 20-byte program 139,514 14,624 6,712 1,064 9.54×
hrp Select regtest human-readable address prefix 1 regtest network variant 324 324 0 0 1.00×
p2pkh Validate compressed key and derive mainnet P2PKH address 33-byte compressed SEC1 public key 24,993,652 607,053 995,792 1,224 41.17×
p2sh HASH160 redeem script and encode mainnet P2SH address 34-byte redeem script 204,638 36,021 14,232 1,128 5.68×
p2tr Tweak empty-tree internal key and encode mainnet Taproot address 32-byte internal x-only key 152,356,558 7,021,150 6,677,888 3,120 21.70×
p2trOutput Validate and encode tweaked mainnet Taproot output key 32-byte tweaked x-only key 25,068,336 602,921 990,584 2,000 41.58×
p2wpkh Validate compressed key and derive mainnet P2WPKH address 33-byte compressed SEC1 public key 25,011,677 595,337 993,128 1,336 42.01×
p2wsh SHA256 witness script and encode mainnet P2WSH address 34-byte witness script 236,505 27,390 12,680 1,568 8.63×
scriptPubKey Decode and network-check P2PKH address, build locking script 34-character address / 25-byte output script 292,855 33,655 20,504 1,536 8.70×
signTaprootKeyPath Tweak internal secret and BIP340-sign supplied key-path digest 32-byte secret + 32-byte digest + 32-byte auxiliary randomness 25,505,611 1,808
taprootOutputKey Compute empty-tree Taproot output key and parity 32-byte internal x-only key 152,156,951 6,999,561 6,669,200 1,608 21.74×
taprootSecretKey [S] Derive empty-tree Taproot signing scalar 32-byte full-width internal secret 248,262,603 12,742,701 10,899,648 1,656 19.48×

[S] Native composes published curve/scalar arithmetic and tagged hashing for the same BIP341 secret tweak. Its secret arithmetic is variable-time.

Bitcoin/Bip32

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
deriveChild Derive nonhardened child zero from official BIP32 vector-2 root 78-byte parent payload / 1 child index / depth 1 171,779,710 7,592,952 7,319,408 1,776 22.62×
derivePath Derive two nonhardened children from official BIP32 vector-2 root 78-byte parent payload / indices [0,1] / depth 2 321,348,873 14,612,141 13,764,984 3,488 21.99×
deriveTextPath Parse textual path and derive two nonhardened children 78-byte parent payload + m/0/1 (5 bytes) / depth 2 321,409,635 14,615,878 13,771,120 3,800 21.99×
fingerprint Validate compressed key and compute HASH160 fingerprint 33-byte compressed SEC1 public key / 4-byte result 24,822,041 582,025 980,408 368 42.65×
fromPublicKey Validate and construct depth-zero mainnet extended public key 33-byte compressed key + 32-byte chain code 24,740,381 572,681 975,720 200 43.20×
parse Parse and validate official BIP32 vector-2 root xpub 111-character xpub / 78-byte payload 25,098,210 629,946 992,480 2,336 39.84×
parsePath Parse strict nonhardened textual path m/0/1 (5 UTF-8 bytes) / 2 indices 59,842 2,994 5,944 152 19.99×
serialize Serialize official BIP32 vector-2 root xpub 78-byte node payload / depth zero 25,120,503 639,777 988,384 2,120 39.26×

Bitcoin/Script

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
p2pkh Build P2PKH locking script 20-byte public-key hash / 25-byte script 47,742 6,683 1,952 280 7.14×
p2sh Build P2SH locking script 20-byte script hash / 23-byte script 43,840 6,373 1,848 256 6.88×
p2tr Build witness-v1 Taproot locking script 32-byte output key / 34-byte script 59,396 9,471 2,392 400 6.27×
p2wpkh Build witness-v0 P2WPKH locking script 20-byte key hash / 22-byte script 41,761 6,483 1,824 288 6.44×
p2wsh Build witness-v0 P2WSH locking script 32-byte script hash / 34-byte script 59,351 9,481 2,392 400 6.26×
pushData Build minimally encoded PUSHDATA1 80-byte data / 82-byte script 115,933 21,327 3,936 832 5.44×
tapBranchHash Sort child commitments and compute BIP341 TapBranch hash 2 x 32-byte child hashes 160,077 39,753 10,376 1,256 4.03×
tapLeafHash Compute BIP341 current-version TapLeaf commitment 34-byte tapscript / leaf version 0xc0 137,278 32,200 9,864 1,112 4.26×
witnessProgram Build future witness-v2 locking script 32-byte program / 34-byte script 59,673 9,429 2,424 400 6.33×

Bitcoin/Transaction

Public function Workload Size Native instructions/op Plugin instructions/op Native allocated B/op Plugin allocated B/op Native/plugin
SegwitV0Hasher.signatureHash Compute first cold BIP143 digest from prepared hasher 10 inputs / 2 outputs / input index 0 / ALL / 25-byte scriptCode 245,557 9,056
TaprootHasher.signatureHash [C] Compute first cold BIP341 digest from prepared hasher 10 inputs / 2 outputs / input index 0 / DEFAULT / 34-byte spent scripts 1,323,009 400,300 60,408 14,624 3.31×
parse Parse complete legacy transaction 494 wire bytes / 10 inputs / 2 outputs 261,824 131,979 14,728 6,984 1.98×
prepareSegwitV0 Validate transaction and construct lazy BIP143 hasher 10 inputs / 2 outputs; check digest outside timing 21,213 1,968
prepareTaproot Validate transaction and prevouts, construct lazy BIP341 hasher 10 inputs / 2 outputs / 10 spent outputs; check digest outside timing 30,389 2,928
serialize Serialize complete legacy transaction 10 inputs / 2 outputs / 494 wire bytes 552,769 144,609 30,880 6,760 3.82×
signatureHashLegacy Compute one complete legacy SIGHASH_ALL digest 10 inputs / 2 outputs / input index 0 / 25-byte scriptCode 1,076,205 200,447 50,592 7,136 5.37×
signatureHashSegwitV0 Compute one complete BIP143 SIGHASH_ALL digest 10 inputs / 2 outputs / input index 0 / 25-byte scriptCode 266,530 11,024
signatureHashTaproot Compute one complete BIP341 key-path DEFAULT digest 10 inputs / 2 outputs / input index 0 / equal 34-byte spent scripts 1,323,009 430,460 60,408 17,552 3.07×
txid Serialize without witness and compute display-order transaction ID 10 inputs / 2 outputs / 494 wire bytes 770,490 196,503 38,904 7,248 3.92×
wtxid Serialize with witness option and compute display-order transaction ID 10 inputs / 2 outputs / empty witness stacks / 494 wire bytes 776,045 197,444 39,568 7,248 3.93×

[C] Native computes an uncached one-shot Taproot hash; plugin measures the first cold call on a prepared hasher. Preparation is excluded, lazy common hashes are included. Native returns an array with oracle conversion outside timing; plugin returns a Blob.

Pure Motoko secret-key arithmetic can be variable-time; ratios compare mathematical outputs, not side-channel guarantees. The pinned packages lack BIP340, complete RFC 9380/BLS signature profiles, BIP143, and retained Taproot-hasher construction counterparts; those cells remain empty. Per-row explanations and all adapter sources are in the evidence file.

We released https://mops.one/bls12-381 last week as part of a motoko-agent! :smiley:

A bit more human readable and after another round of optimizations. This info will be interesting to Rust-icians as well, the plugins are written in Rust.
Keep in mind, they are made for the purpose of building the Plugin SDK and checking what the performance benefits will be, not claiming they are bug-free.
ML-DSA-44 and BLAKE3 are quantum resistant.

Plugin Input Sign instructions Verify instructions Allocated B, sign / verify
Ed25519 32-byte message 1,802,738 2,306,648 120 / 32
Secp256k1 ECDSA 32-byte SHA-256 digest 3,618,817 2,735,020 120 / 32
Secp256k1 BIP340 32-byte message 5,099,055 2,519,140 120 / 32
P256 ECDSA 32-byte SHA-256 digest 7,225,034 12,873,707 120 / 32
RSA-3072 PSS 32-byte SHA-256 digest; 32-byte signing salt 550,857,988 9,822,506 440 / 32
ML-DSA-44 1-byte message; 59-byte context 14,753,165 3,806,359 2,480 / 32
BLAKE3 (shared-secret) 1,024-byte message; 32-byte secret key 35,935 40,819 88 / 88

IC certified-data relay verification

An untrusted client can carry A’s certificate and Merkle witness to B. Caching
A’s authenticated certified root lets every client proving a record against
that root skip repeated BLS verification.

Verification path Instructions, 1,000 entries Instructions, 100,000 entries Allocated B, 1,000 / 100,000 entries
Cold: root delegation and subnet signatures 423,376,817 423,716,169 102,320 / 117,552
Cached verified delegation and prepared subnet key 168,737,010 169,076,362 65,408 / 80,640
Cached authenticated A root; application proof only 565,546 904,898 26,176 / 41,408

Fast Rust inside Motoko canisters, without giving up Motoko: native kernels in moxzi alpha.10

Thanks to @infu for this thread, for PR #6443 and for the plugins themselves (now at Neutrinomic/motoko_plugins): the idea of calling native code from Motoko and every baseline measurement below come from @infu. It made us measure something we had been hand-waving about, and then build it. Here is what came out, written so it makes sense even if you have never compiled a canister by hand.

The problem, in a few numbers. Every message a canister handles has an instruction budget (40 billion on an ordinary subnet), and every instruction costs cycles. Cryptography is where Motoko hurts: verifying an Internet Computer certificate (one or two BLS signature checks) costs about 2 billion instructions in Motoko, SHA-256 of 1 MB about 405 million, a secp256k1 signature about 66 million. All perfectly writable in Motoko, and all several times to dozens of times more expensive than the same work done by a mature Rust crate, because crypto lives on low-level tricks (64-bit limb arithmetic, precomputed tables, hand-tuned field multiplication) that Motoko doesn’t give you.

The idea from this thread. Let a Motoko program call fast code written in another language. The plugins @infu showed did that by building the Rust into the compiler. We took the same idea and made it a library feature instead:

  • A kernel is a tiny WebAssembly library built from an existing, well-known Rust crate (RustCrypto, dalek, zkcrypto, libcrux…). It ships inside an ordinary mops package.
  • The package’s Motoko function keeps its Motoko body. Under stock moc, or if you don’t opt in, that body runs and your program behaves exactly as before. With moxzi and the opt-in, the compiler calls the kernel instead and links it only into canisters that actually use it.
  • You opt in from your own mops.toml, pinning the exact bytes:

toml [kernels] moxzi-hash = "sha256:d9e07eab…"

A dependency can never add native code behind your back, and if the bytes on disk don’t match your pin, the build stops instead of falling back silently.

Same source, two compilers, same answers: the Motoko body doubles as the test oracle, and every kernel is checked against it (and against official test vectors) on every build of moxzi.

What it buys. Instructions per call, compared with the plugins @infu benchmarked earlier in this thread (the “Plugin” column is @infu’s published numbers; we measured our kernels the same way; lower is better):

Operation Plugin (this thread) moxzi kernel
secp256k1 ECDSA sign 3.6 M 1.5 M
secp256k1 BIP340 Schnorr sign 5.1 M 2.1 M
P-256 verify 12.9 M 8.4 M
Ed25519 verify 2.3 M 2.2 M
RSA-3072 verify 9.8 M 5.0 M
ML-DSA-44 sign (post-quantum) 14.8 M 9.0 M
BLS12-381 pairing check 100 M 64 M

And the one that started it: mo:ic-agent verifies a mainnet certificate in about 0.37 billion instructions with its kernel, against about 2 billion with the Motoko body alone (0.61 vs 3.8 billion with a subnet delegation). SHA-256 of 1 MB drops from 405 million to 76 million. Hashing (SHA-256/512, HMAC, RIPEMD-160, BLAKE3), Base58, Bech32 and Bitcoin script/transaction helpers are there too. Ten kernel packages in all, plus the BLS kernel inside mo:ic-agent.

A side quest from this thread: we also made moxzi’s built-in to_candid encoder faster. On @infu’s CandidX benchmark (1,024 records; the plugin does it in about 352 K instructions), the built-in encoder went from 832 K to 165 K instructions, with output byte-identical to moc’s — no plugin needed.

“Native code in my canister” should make you nervous. Here’s what we did about it.

A kernel shares your canister’s memory: a buggy or malicious one could read or write anything in it. So:

  1. You choose, by hash. Kernels we build and publish are recognised by their exact bytes; any other kernel stops the build the first time and shows you its source and what it can do until you explicitly accept that exact version.
  2. Anyone can rebuild them. Each kernel is built from pinned, locked sources with a pinned toolchain, and rebuilds to identical bytes on macOS and Linux (one Docker command checks all of them). The canister’s public metadata lists every kernel it contains, with its hash and source, so a verifier can tell what’s inside a deployed module.
  3. We attacked our own code. Constant-time code is the classic crypto pitfall: if signing takes longer for some keys than others, the key leaks. On the IC the observable is the instruction count, so we test that every secret-key operation costs exactly the same for every key. That test found four real leaks in our own code, now fixed. Then we went further and scanned the compiled WebAssembly for branches on secret data — and found that the compiler had quietly turned constant-time Rust into branches in two places, invisible to the instruction count but real on machines that run the same code natively. Fixed, and the scan now runs on every build.
  4. Adversarial review. Independent reviewers went through the RSA, secp256k1, P-256, Ed25519 and BLS code looking for wrong results and leaks, with a failing test required for every claim.

What we have not done yet is an independent expert audit of the hand-written parts (RSA’s arithmetic, the curve tables). Until that happens, please treat those kernels as fast and well-tested, but not audited: fine for experiments, prototypes and verification work, not yet for guarding real value.

Try it. Install moxzi alpha.10, add one of the moxzi-* packages, add the [kernels] pin, and build. The docs page “Native kernels” walks through it with a SHA-256 example that shows the instruction count with and without the kernel. Writing your own kernel from a Rust crate is moxzi kernel build.

If moc ever adopts compute plugins: our design deliberately keeps programs moc-compatible (the Motoko body is always there) and names kernels by package and function, so the two approaches could meet. We’d be glad to line the names and the calling convention up with whatever the Motoko team prefers.

The kernels live in their own public repository, icdevsorg/moxzi-kernels (MIT OR Apache-2.0), maintained by ICDevs.org (a 501c3 non-profit); if they’re useful to you, please consider supporting that work at https://icdevs.org/donation.html. moxzi itself is built by RIVVIR Tech.

Questions, kernel requests and “you’re wrong about X” all welcome.

Thanks to @infu for this thread, for [PR #6443](https://github.com/caffeinelabs/motoko/pull/6443) and for the plugins themselves (now at [Neutrinomic/motoko_plugins](https://github.com/Neutrinomic/motoko_plugins)): the idea of calling native code from Motoko and every baseline measurement below come from @infu. It made us measure something we had been hand-waving about, and then build it. Here is what came out, written so it makes sense even if you have never compiled a canister by hand.

**The problem, in a few numbers.** Every message a canister handles has an instruction budget (40 billion on an ordinary subnet), and every instruction costs cycles. Cryptography is where Motoko hurts: verifying an Internet Computer certificate (one or two BLS signature checks) costs about 2 billion instructions in Motoko, SHA-256 of 1 MB about 405 million, a secp256k1 signature about 66 million. All perfectly writable in Motoko, and all several times to dozens of times more expensive than the same work done by a mature Rust crate, because crypto lives on low-level tricks (64-bit limb arithmetic, precomputed tables, hand-tuned field multiplication) that Motoko doesn’t give you.

**The idea from this thread.** Let a Motoko program call fast code written in another language. The plugins @infu showed did that by building the Rust into the compiler. We took the same idea and made it a library feature instead:

- A **kernel** is a tiny WebAssembly library built from an existing, well-known Rust crate (RustCrypto, dalek, zkcrypto, libcrux…). It ships inside an ordinary mops package.

- The package’s Motoko function **keeps its Motoko body**. Under stock `moc`, or if you don’t opt in, that body runs and your program behaves exactly as before. With moxzi and the opt-in, the compiler calls the kernel instead and links it only into canisters that actually use it.

- You opt in from **your own** `mops.toml`, pinning the exact bytes:

```toml [kernels] moxzi-hash = “sha256:d9e07eab…” ```

A dependency can never add native code behind your back, and if the bytes on disk don’t match your pin, the build stops instead of falling back silently.

Same source, two compilers, same answers: the Motoko body doubles as the test oracle, and every kernel is checked against it (and against official test vectors) on every build of moxzi.

**What it buys.** Instructions per call, compared with the plugins @infu benchmarked earlier in this thread (the “Plugin” column is @infu’s published numbers; we measured our kernels the same way; lower is better):

| Operation | Plugin (this thread) | moxzi kernel |

|—|—:|—:expressionless:

| secp256k1 ECDSA sign | 3.6 M | 1.5 M |

| secp256k1 BIP340 Schnorr sign | 5.1 M | 2.1 M |

| P-256 verify | 12.9 M | 8.4 M |

| Ed25519 verify | 2.3 M | 2.2 M |

| RSA-3072 verify | 9.8 M | 5.0 M |

| ML-DSA-44 sign (post-quantum) | 14.8 M | 9.0 M |

| BLS12-381 pairing check | 100 M | 64 M |

And the one that started it: `mo:ic-agent` verifies a mainnet certificate in **about 0.37 billion instructions** with its kernel, against about 2 billion with the Motoko body alone (0.61 vs 3.8 billion with a subnet delegation). SHA-256 of 1 MB drops from 405 million to 76 million. Hashing (SHA-256/512, HMAC, RIPEMD-160, BLAKE3), Base58, Bech32 and Bitcoin script/transaction helpers are there too. Ten kernel packages in all, plus the BLS kernel inside `mo:ic-agent`.

A side quest from this thread: we also made moxzi’s *built-in* `to_candid` encoder faster. On @infu’s CandidX benchmark (1,024 records; the plugin does it in about 352 K instructions), the built-in encoder went from 832 K to 165 K instructions, with output byte-identical to moc’s — no plugin needed.

**“Native code in my canister” should make you nervous. Here’s what we did about it.**

A kernel shares your canister’s memory: a buggy or malicious one could read or write anything in it. So:

1. **You choose, by hash.** Kernels we build and publish are recognised by their exact bytes; any other kernel stops the build the first time and shows you its source and what it can do until you explicitly accept that exact version.

2. **Anyone can rebuild them.** Each kernel is built from pinned, locked sources with a pinned toolchain, and rebuilds to identical bytes on macOS and Linux (one Docker command checks all of them). The canister’s public metadata lists every kernel it contains, with its hash and source, so a verifier can tell what’s inside a deployed module.

3. **We attacked our own code.** Constant-time code is the classic crypto pitfall: if signing takes longer for some keys than others, the key leaks. On the IC the observable is the instruction count, so we test that every secret-key operation costs exactly the same for every key. That test found four real leaks in our own code, now fixed. Then we went further and scanned the *compiled* WebAssembly for branches on secret data — and found that the compiler had quietly turned constant-time Rust into branches in two places, invisible to the instruction count but real on machines that run the same code natively. Fixed, and the scan now runs on every build.

4. **Adversarial review.** Independent reviewers went through the RSA, secp256k1, P-256, Ed25519 and BLS code looking for wrong results and leaks, with a failing test required for every claim.

What we have *not* done yet is an independent expert audit of the hand-written parts (RSA’s arithmetic, the curve tables). Until that happens, please treat those kernels as fast and well-tested, but not audited: fine for experiments, prototypes and verification work, not yet for guarding real value.

**Try it.** Install moxzi alpha.10, add one of the `moxzi-*` packages, add the `[kernels]` pin, and build. The docs page “Native kernels” walks through it with a SHA-256 example that shows the instruction count with and without the kernel. Writing your own kernel from a Rust crate is `moxzi kernel build`.

**If moc ever adopts compute plugins:** our design deliberately keeps programs moc-compatible (the Motoko body is always there) and names kernels by package and function, so the two approaches could meet. We’d be glad to line the names and the calling convention up with whatever the Motoko team prefers.

The kernels live in their own public repository, [icdevsorg/moxzi-kernels](https://github.com/icdevsorg/moxzi-kernels) (MIT OR Apache-2.0), maintained by ICDevs.org (a 501c3 non-profit); if they’re useful to you, please consider supporting that work at https://icdevs.org/donation.html. moxzi itself is built by RIVVIR Tech.

Questions, kernel requests and “you’re wrong about X” all welcome.

More here: https://forum.dfinity.org/t/moxzi-0-1-0-alpha-10-fast-rust-inside-motoko-faster-candid-and-builds-you-can-check/76238