Skip to content
Select theme

Choosing a real function to optimise

Step 4 of 8, and it writes no Rust — it decides where the Rust will go. Step 1 left a search that cost tens of milliseconds per keystroke, and step 3 left the boundary working with a price list attached: numbers cross in registers, strings cross through memory twice. This step adds one file, profile.mjs, runs the profiler on the search, and answers the question the whole track turns on: which function is worth the wasm treatment. The answer comes from a profiler rather than a hunch, and part of it is not what the track was promised — a slice of the cost goes away with two lines of JavaScript, and the track takes that win before reaching for Rust.

Start from the step 3 end state — git checkout wasm-ch03 in your clone of the examples repository — or from that article’s own steps. Everything here runs under Node v22.22.2 on an arm64 Mac; your times will land near, not on, the numbers below.

profile.mjs, new next to Cargo.toml, is step 1’s benchmark in a form a profiler can attach to. It carries two implementations of the same search: original, which is step 1’s code untouched, and lowered, a variant whose right to exist the profile below has to earn:

// profile.mjs — the step 1 benchmark, in a form a profiler can attach to.
//
// node profile.mjs times both search variants
// node --prof profile.mjs original profiles only the step 1 original
//
// "original" is step 1's code, unchanged. "lowered" is the same search with
// the dataset's case folding done once, at build time, instead of on every
// call. The dataset is generated deterministically, so the best hits are the
// same on every machine; the times are not, which is why the script reports
// a median of 7 runs.
const WHICH = process.argv[2] ?? "both";
if (WHICH !== "both" && WHICH !== "original" && WHICH !== "lowered") {
console.error(`profile.mjs: unknown variant "${WHICH}" (use original, lowered, or nothing)`);
process.exit(1);
}
// The dataset: 100,000 catalogue items. Generated deterministically, so every
// reader works through the same list.
const MATERIALS = ["steel", "brass", "carbon", "nylon", "ceramic", "alloy"];
const KINDS = ["bracket", "spacer", "gear", "clamp", "hinge", "pulley", "bearing", "fastener"];
const USES = ["damping", "mounting", "rotation", "sealing", "support", "tension"];
const items = [];
for (let i = 0; i < 100_000; i++) {
const material = MATERIALS[i % 6];
const kind = KINDS[Math.floor(i / 6) % 8];
const use = USES[Math.floor(i / 48) % 6];
items.push({
title: `${material} ${kind} #${i}`,
description: `${material} ${kind} for ${use}, rated ${100 + (i % 40)} kg, batch ${9000 + (i % 900)}`,
});
}
// Fuzzy subsequence score of query against text. -1 means no match.
function fuzzyScore(query, text) {
const q = query.toLowerCase();
const t = text.toLowerCase();
let score = 0;
let qi = 0;
let streak = 0;
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
if (t[ti] === q[qi]) {
qi++;
streak++;
score += 2 + streak; // consecutive matches are worth more
} else {
streak = 0;
}
}
if (qi < q.length) return -1; // ran out of text before running out of query
return score;
}
// Runs once per keystroke in the real app.
function search(items, query) {
const hits = [];
for (const item of items) {
const score = Math.max(fuzzyScore(query, item.title), fuzzyScore(query, item.description));
if (score >= 0) hits.push({ title: item.title, score });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, 20);
}
// The same scan, but the dataset's case folding happened once, above, and the
// query folds once per search instead of twice per call.
const lowered = items.map((item) => ({
title: item.title.toLowerCase(),
description: item.description.toLowerCase(),
}));
function scoreLower(q, text) {
let score = 0;
let qi = 0;
let streak = 0;
for (let ti = 0; ti < text.length && qi < q.length; ti++) {
if (text[ti] === q[qi]) {
qi++;
streak++;
score += 2 + streak;
} else {
streak = 0;
}
}
if (qi < q.length) return -1;
return score;
}
function searchLowered(query) {
const q = query.toLowerCase();
const hits = [];
for (const item of lowered) {
const score = Math.max(scoreLower(q, item.title), scoreLower(q, item.description));
if (score >= 0) hits.push({ title: item.title, score });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, 20);
}
function bench(label, fn) {
fn(); // warm-up: step 6 explains why this matters
const times = [];
let best = null;
for (let i = 0; i < 7; i++) {
const t0 = performance.now();
const hits = fn();
times.push(performance.now() - t0);
best = hits[0];
}
times.sort((a, b) => a - b);
console.log(`${label}: ${times[3].toFixed(1)} ms (median of 7), best "${best.title}"`);
}
const queries = ["bracket", "steel bracket", "nylon pulley for tension"];
if (WHICH === "both" || WHICH === "original") {
for (const q of queries) bench(`original, query "${q}"`, () => search(items, q));
}
if (WHICH === "both" || WHICH === "lowered") {
for (const q of queries) bench(`lowered, query "${q}"`, () => searchLowered(q));
}

The bench function is step 1’s harness — one warm-up, seven timed runs, median — with a label. The variant argument exists for the profiler: a profile of a run that exercises both variants would mix their ticks, so each one can be profiled alone.

Terminal window
node profile.mjs
original, query "bracket": 29.5 ms (median of 7), best "steel bracket #0"
original, query "steel bracket": 30.7 ms (median of 7), best "steel bracket #0"
original, query "nylon pulley for tension": 30.2 ms (median of 7), best "nylon pulley #273"
lowered, query "bracket": 24.0 ms (median of 7), best "steel bracket #0"
lowered, query "steel bracket": 24.6 ms (median of 7), best "steel bracket #0"
lowered, query "nylon pulley for tension": 24.3 ms (median of 7), best "nylon pulley #273"

The original prints about 30 ms where step 1 published about 40 — same machine, same Node, same code. That drift is the case for measuring instead of remembering, and it is why every number in this track is a median rather than a single run. The lowered variant is consistently faster by roughly five milliseconds. The best hit agrees on every line, and the full top-20 lists are identical for all three queries — the fold moved, the scores did not. Where those five milliseconds come from is the profile’s job to say.

Terminal window
rm -f isolate-*.log
node --prof profile.mjs original > /dev/null
node --prof-process isolate-*.log

--prof runs the program under V8’s sampling profiler and writes a log into the current directory; --prof-process turns the log into a report. (A warning about VM Modules may appear on stderr depending on your Node install — it is not part of the report.) The report runs for pages, but the story is in the two sections near the top:

Statistical profiling result from isolate-0x758480c000-20766-v8.log, (589 ticks, 116 unaccounted, 0 excluded).
[JavaScript]:
ticks total nonlib name
386 65.5% 65.5% JS: *search file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:56:16
58 9.8% 9.8% Builtin: StringToLowerCaseIntl
8 1.4% 1.4% Builtin: ArrayIteratorPrototypeNext
5 0.8% 0.8% Builtin: StringAdd_CheckNone
3 0.5% 0.5% Builtin: ArrayTimSort
2 0.3% 0.3% JS: *<anonymous> file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:62:13
2 0.3% 0.3% Builtin: LoadIC
2 0.3% 0.3% Builtin: KeyedLoadIC
1 0.2% 0.2% JS: *fuzzyScore file:///Users/rango/www/hackingwithrust/code/react-wasm/profile.mjs:36:20
1 0.2% 0.2% Builtin: ToString
1 0.2% 0.2% Builtin: StrictEqual_Baseline
1 0.2% 0.2% Builtin: SortCompareUserFn
1 0.2% 0.2% Builtin: LessThan_Baseline
1 0.2% 0.2% Builtin: FastNewClosure
1 0.2% 0.2% Builtin: DivideSmi_Baseline
[Summary]:
ticks total nonlib name
473 80.3% 80.3% JavaScript
0 0.0% 0.0% C++
23 3.9% 3.9% GC
0 0.0% Shared libraries
116 19.7% Unaccounted

The isolate number and process id in the first line are the profiler’s own; yours will differ, as will the file path in the entries — that is this machine’s checkout. Read the entries like this:

  • search holds 65.5% of all ticks, and fuzzyScore gets 0.2%. Not because the score loop is cheap — because V8 inlined the small function into its caller, so its loop is billed to search. A flat profile says “it is all search” and tells the truth; it does not say which part of search.
  • Builtin: StringToLowerCaseIntl at 9.8% is the lowercasing from inside fuzzyScore, visible as its own line on this run. Run the same script again and that line may vanish into search — V8’s inliner changes its mind between runs. The work is there either way.
  • GC at 3.9% is allocation pressure. The arithmetic behind it is next.
  • Unaccounted — nearly a fifth of the samples landed in frames the profiler could not name. A regular sight in sampling profiles on macOS; treat it as blur, not as hidden cost.

The same data has a second road: node --cpu-prof --cpu-prof-name original.cpuprofile profile.mjs original writes the profile as a .cpuprofile file — 21,727 bytes here — which is the format Chrome DevTools’ Performance panel loads. That pays off when the search lives in a browser tab, which is step 7’s address.

Every keystroke runs 200,000 fuzzyScore calls — two per item, title and description. Each call lowercases both of its arguments before scanning:

const q = query.toLowerCase();
const t = text.toLowerCase();

That is 400,000 short-lived strings per keystroke. The expensive half is t: 200,000 fresh copies of fields that never change — 7,022,212 characters folded and discarded on every keystroke. StringToLowerCaseIntl’s 9.8% is that folding; GC’s 3.9% is burying the results.

The lowered variant does the folding once, at build time:

// The same scan, but the dataset's case folding happened once, above, and the
// query folds once per search instead of twice per call.
const lowered = items.map((item) => ({
title: item.title.toLowerCase(),
description: item.description.toLowerCase(),
}));

scoreLower is fuzzyScore without the fold, and searchLowered folds the query once per search instead of twice per call. Nothing else moves — the scan walks the same characters in the same order, which is why the top-20 lists come back identical.

Five of the thirty milliseconds were never a wasm problem, and now they are gone — claimed by two lines of JavaScript that the profile, not intuition, pointed at. The remaining 24 ms is the scan itself: seven million characters, one comparison at a time, proportional to the corpus. No refactor of the JavaScript removes that work, and that scan — not search, not the sort, not the microseconds of glue — is the real function to optimise.

Step 3 already priced the wasm version of it: strings cross through memory twice per call, so scanning from wasm the way step 3 does it means 200,000 crossings per keystroke. That makes the target sharper than “rewrite the loop in Rust”: the win is in changing what crosses the boundary. Step 5, Passing large data without copying, moves the dataset into wasm memory so a keystroke crosses it once; step 6 measures the result in a way that survives scrutiny.

This step’s end state is tagged wasm-ch04 in the examples repository. profile.mjs is new; nothing else changes — Cargo.toml, src/lib.rs and check.mjs are exactly as step 3 left them. Checking out the tag puts your tree where this article ends:

Terminal window
git clone https://github.com/hackingwithrust/examples
cd examples/react-wasm
git checkout wasm-ch04
node profile.mjs

Step 5 moves the 100,000 items into wasm memory and hands JavaScript a view instead of a copy — the change the profile in this step argued for. The track overview keeps the full list current as articles land.