Reducing Template Size

Ootle / Reference / Reducing Template Size

Reducing Template Size

A template’s compiled WASM is stored permanently on the network and shipped to every validator in the committee. Size is therefore priced twice: once when you publish, and again — for your users — on every transaction that calls the template.

Everything on this page was measured against the same ~85 line state template using the current fee table, which is the same on every network. Your numbers will differ, but the ratios hold.


Why size matters

There is a hard cap. ENGINE_LIMITS.max_template_binary_size_bytes is 1 MiB. A larger binary is rejected at publish time, and a transaction may publish at most one template. The cap is set by what a publish costs: every validator Cranelift-compiles the binary, that compile is charged against MAX_NATIVE_POINTS_PER_TRANSACTION, and 1 MiB is the largest binary whose compile still leaves a transaction room to source its fee.

Publishing is priced on two axes. The storage charge is a flat 250,000 µT, plus the first 96 KiB at the per-byte storage rate, plus 100 µT × units² for every whole KiB beyond that allowance — so it grows quadratically. On top of that, the compile charge pays for the Cranelift compile every validator runs: 140M points plus 2,100 per binary byte, billed at 1 µT per 1,000 points. The compile term is linear where the storage premium is quadratic, so it roughly doubles the cost of a small publish and is negligible against storage past ~128 KiB.

Binary sizeStorage chargeCompile chargeTotal publish chargeTemplateLoad charged to each caller
72 KiB~0.32 TARI~0.29 TARI~0.61 TARI240 µT
96 KiB~0.35 TARI~0.35 TARI~0.70 TARI320 µT
128 KiB~0.45 TARI~0.42 TARI~0.87 TARI430 µT
256 KiB~2.9 TARI~0.69 TARI~3.6 TARI870 µT
512 KiB~17.7 TARI~1.24 TARI~19 TARI1,740 µT
1 MiB~87 TARI~2.34 TARI~89 TARI3,500 µT

The 96 KiB allowance is deliberately set just above the floor for a minimal component template, so the premium prices your code rather than the fixed tari_template_lib machinery. A template that stays under it pays the same as ordinary storage.

Your users pay for size on every call. TemplateLoad is charged at 10 µT per 3 KB the first time each template is loaded in a transaction — a cost carried by whoever calls your template, not by you. Halving your binary halves that line item forever.

Note

See Fees for the full fee model, including the storage and execution charges that component state and logic incur separately from binary size.


Start with the release profile

The single highest-leverage change is the [profile.release] section of your template’s Cargo.toml. The default cargo build --release profile is tuned for native speed, not for WASM size.

[profile.release]opt-level = 's'     # Optimize for size ('z' is more aggressive — measure both)lto = true          # Link-time optimization: drops unreachable library codecodegen-units = 1   # One unit lets LTO see the whole crate graphpanic = 'abort'     # Unwinding tables are dead weight on wasm32strip = true        # Drop the `name`, `producers` and `target_features` sections

Measured on the state template (sizes after the same wasm-opt pass the walletd applies):

BuildRaw .wasmAfter wasm-opt
Default --release138 KiB95 KiB
Profile above88 KiB78 KiB
Profile above + no_std75 KiB67 KiB
Profile above + immediate-abort panics68 KiB60 KiB
All three together63 KiB55 KiB

A few things worth knowing about those knobs:

  • lto and codegen-units = 1 do the heavy lifting. They are also what makes opt-level pay off: on its own, opt-level = 's' or 'z' actually produced a larger binary than the default opt-level = 3. Only in combination with LTO did the size profile win.
  • 's' is not always beaten by 'z'. In the measurement above 's' came out ~1.5 KiB smaller than 'z' after wasm-opt. Try both; it costs one rebuild.
  • panic = 'abort' is near-free either way. wasm32-unknown-unknown has no unwinder, so this changed the binary by under 100 bytes. Keep it for correctness of intent, not for the savings.
  • strip = true is about more than bytes. The engine rejects a published template carrying any custom section other than tari_tdef, and the toolchain emits name, producers and target_features by default. strip = true removes exactly those three and leaves tari_tdef intact.

Tip

Keep the [profile.dev.package.*] entries from the guessing game guide as well — they speed up template tests and have no effect on the published release binary.


wasm-opt — the walletd already runs it

Binaryen’s wasm-opt rewrites an existing binary to be smaller. You do not need to run it before publishing: the wallet daemon optimizes every binary it receives, on both the fee-estimate path and the publish path, before the transaction is built. That applies to the wallet web UI, tari publish, and any direct JSON-RPC call.

The walletd’s pass is equivalent to:

Terminal window
wasm-opt -Os   --enable-bulk-memory --enable-reference-types   --strip-debug --strip-producers --strip-target-features   template.wasm -o template.optimized.wasm

On an unoptimized default-profile build that pass alone recovered ~31% (138 KiB → 95 KiB). On a binary already built with the size profile it still recovered ~12%.

Running it yourself is still useful when you want to know the published size before you publish — to check it against the 96 KiB allowance in CI, for example. Install it with cargo install wasm-opt or from the Binaryen releases.

Caution

Do not add extra wasm-opt passes that rewrite custom sections or enable SIMD. The engine rejects binaries carrying custom sections other than tari_tdef, and the walletd’s pass explicitly disables SIMD and relaxed-SIMD.


Going no_std

tari_template_lib supports no_std. Dropping std removed a further ~11 KiB (~14%) from the optimized state binary, most of it Rust’s standard-library startup, panic-unwinding and allocator machinery.

[dependencies]tari_template_lib = { version = "*", default-features = false, features = ["macro", "alloc"] }
# A small allocator. `talc` is what the engine's own no_std test template uses.talc = { version = "5.0.3", default-features = false }
#![no_std]
extern crate alloc;
#[cfg(target_arch = "wasm32")]#[global_allocator]static TALC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocator();
use tari_template_lib::prelude::*;
#[template]mod counter {    use alloc::{format, string::String};
    use super::*;    // ...}

What changes in practice:

  • You must supply a global allocator. Templates allocate — every String, Vec and CBOR decode does. talc is the recommended choice. Avoid wee_alloc, which is unmaintained and leaks.
  • std and alloc are mutually exclusive. Enabling both (or neither) on wasm32 is a compile error from tari_template_lib, so you will find out immediately.
  • Imports move to alloc. use alloc::{string::String, vec::Vec, format, collections::BTreeMap} inside your template module.
  • Panic messages still work. tari_template_lib installs a #[panic_handler] for no_std WASM that forwards the message, line and column to the engine, so a failed template still produces a readable reject reason.

no_std is the right call for a template you expect to publish once and have called often. For a template you are still iterating on, the release profile plus wasm-opt gets you most of the way with none of the friction.


Dropping panic messages entirely

Most of what remains in a small binary is panic and formatting machinery: the code that builds a message, and the message text itself. Rust’s immediate-abort panic strategy replaces every panic site with a bare trap, and the optimizer then deletes the messages and the formatting code that fed them. On the measured template it removed a further 23% (78 KiB → 60 KiB), shrinking the data section from ~8.8 KiB to ~2.8 KiB.

It needs a nightly toolchain with the rust-src component, because the standard library has to be rebuilt with the strategy:

Terminal window
rustup toolchain install nightlyrustup component add rust-src --toolchain nightly
RUSTFLAGS="-Zunstable-options -Cpanic=immediate-abort"   cargo +nightly build --target wasm32-unknown-unknown --release   -Z build-std=std,panic_abort

For a no_std template, build core instead: -Z build-std=core,alloc,panic_abort.

Caution

You lose every panic message. Normally a failed template produces Template error: in the reject reason, with the source line and column logged by the validator — tari_template_lib forwards them through the engine’s on_panic host call. Under immediate-abort that call never happens, and the failure surfaces as a bare WASM trap with no message, no line and no column. Every expect, assert! and arithmetic-overflow message becomes indistinguishable from every other.

Because of that, prefer the RUSTFLAGS form above over baking it into [profile.release]: it is opt-in per build, so your cargo test runs and your debugging builds keep their messages and only the binary you publish is stripped of them. Confirm the template behaves identically before and after — semantics do not change (a trap still aborts execution and rejects the transaction), but your ability to diagnose a failure in production does.

Note

This is unstable and the interface has changed: on older nightlies the equivalent is -Z build-std-features=panic_immediate_abort, and it can also be enabled as panic = "immediate-abort" in [profile.release] together with cargo-features = ["panic-immediate-abort"] at the top of Cargo.toml.


Code-level habits that cost the most

Strings and formatting

Every string literal lands verbatim in the binary’s data section, and every format!, debug!, panic! or assert! message drags in core::fmt code for the types it formats. In a minimal template the data section is already ~9 KiB, most of it CBOR and core error text you have no control over — what you write is added on top of that.

  • A single debug!("Changing value from {:?} to {}", self, value) cost 1.7 KiB in the measured build. Formatting is not free; treat log lines as a debugging tool you remove before publishing, or gate them.
  • Prefer short, fixed messages: expect("no vault") over unwrap_or_else(|| panic!("no vault for resource {resource} in component {addr}")).
  • Reuse one message rather than writing five near-identical ones — each distinct literal is separate bytes.
  • Prefer &'static str over String for constants, and return &str where you can.
  • Logs are also charged a runtime fee per 64 bytes, so short messages save twice.

Debug and Display

A derived Debug impl is only dead weight if something actually formats it — with LTO enabled, removing #[derive(Debug)] after removing the last {:?} use changed the binary by zero bytes. The rule is therefore about the call site, not the derive:

  • Remove the formatting, and the derive costs nothing.
  • Never derive Debug on a type just to {:?} it in a log line you plan to keep.
  • Hand-writing Display to emit a fixed &'static str is smaller than deriving Debug on a large enum you format.

Dependencies

A single carelessly chosen crate can dwarf everything else on this page.

  • Audit with cargo tree --target wasm32-unknown-unknown. Anything you do not recognise is a candidate for removal.
  • Avoid the usual heavyweights: serde_json, regex, chrono, num-bigint. Most have no meaning on-chain anyway — a clock, for instance, is Consensus::current_epoch().
  • An RNG crate such as rand is not an option at all: it cannot work inside a template, and adding one only costs you bytes. Use tari_template_lib::rand and read Randomness in Templates first.
  • Depend on crates that are no_std-capable and default-features-off wherever possible.
  • Do not enable tari_template_lib features you are not using — extra-maps pulls in indexmap and xxhash-rust, and precision, extra-arith, serde, borsh and ts all add code. The last three exist for host-side tooling, not for templates.
  • dev-dependencies (such as tari_template_test_tooling) and #[cfg(test)] code are never compiled into the cdylib, so tests cost you nothing on-chain.

Generics and duplication

Monomorphisation duplicates a function’s body for every type it is instantiated with. When a generic function is large, keep the generic part thin:

pub fn transfer>(&mut self, amount: A) {  self.transfer_inner(amount.into()); // one instantiation of the big body}
  fn transfer_inner(&mut self, amount: Amount) { /* ... */}

The same applies to macro-generated code and to copy-pasted method bodies — factor the shared part into one non-generic function.

Collections

Prefer Vec and BTreeMap over HashMap in component state: the standard hasher brings machinery you are paying for in bytes, and ordered collections give you a deterministic encoding for free. Reach for extra-maps’ IndexMap only when you genuinely need insertion order plus lookup.

Share code across templates instead of duplicating it

Templates can call other templates. If several of your templates share a body of logic, publishing it once and calling it with TemplateManager::get(address).call(...) beats compiling it into each binary — you pay the publish premium once instead of N times.


Measuring what is actually big

Guessing is a waste of time; both of these take seconds.

Per-function sizes, from Binaryen:

Terminal window
wasm-opt --func-metrics target/wasm32-unknown-unknown/release/my_template.wasm -o /dev/null

A ranked breakdown, from twiggy:

Terminal window
cargo install twiggytwiggy top -n 20 target/wasm32-unknown-unknown/release/my_template.wasm

Note

Both tools report readable function names only if the name section is present — build with strip = "debuginfo" (or no strip at all) while profiling, then switch back to strip = true for the build you publish.


Conclusion

Applied together, these steps took the measured template from the 95 KiB (just using cargo build --release) down to 55 KiB — a 42% smaller binary running the same logic.


Checklist

  1. Add the [profile.release] block above; try opt-level = 's' and 'z' and keep the winner.
  2. Build with cargo build --target wasm32-unknown-unknown --release.
  3. Check the published size with wasm-opt -Os ... — the walletd will apply the same pass.
  4. If you are over the 96 KiB allowance, run twiggy top before changing any code.
  5. Remove or shorten log and panic formatting; drop dependencies you do not need.
  6. If it still matters, switch to no_std + alloc with a talc global allocator.
  7. As a last resort, build the published binary with immediate-abort panics — and accept that a production failure will no longer tell you what went wrong.

See also