Skip to content

TEXT_DECODER_MIN_LENGTH=12 is below the measured crossover; TextDecoder is ~2x slower at that size #150

Description

@ScottMansfield

Summary

TEXT_DECODER_MIN_LENGTH = 12 looks too low on current V8. Between ~12 and ~32 bytes,
TextDecoder is measurably slower than readUtf8() — up to 2× slower right at the
threshold. The measured crossover on Node 26 is 32–48 bytes.

This matches the number in the original discussion: in #106, @dcervelli benchmarked it and
noted "The crossover point is in the neighborhood of 32 for my machine" — but #109 shipped
with 12.

Measurements

Node v26.7.0. Constant total work per row (~24 MB decoded), strings are mostly-ASCII with one
2-byte character, median of 5 runs:

len count readUtf8 (JS) TextDecoder winner ratio
4 6,291,456 136 ms 1020 ms readUtf8 7.47×
8 3,145,728 165 ms 561 ms readUtf8 3.40×
12 2,097,152 182 ms 368 ms readUtf8 2.02×
16 1,572,864 170 ms 279 ms readUtf8 1.64×
24 1,048,576 166 ms 185 ms readUtf8 1.11×
32 786,432 160 ms 183 ms readUtf8 1.14×
48 524,288 143 ms 125 ms TextDecoder 1.14×
64 393,216 136 ms 96 ms TextDecoder 1.41×
96 262,144 130 ms 66 ms TextDecoder 1.98×
128 196,608 124 ms 52 ms TextDecoder 2.39×
256 98,304 118 ms 24 ms TextDecoder 5.00×
512 49,152 116 ms 13 ms TextDecoder 8.90×
1024 24,576 113 ms 8 ms TextDecoder 13.65×

Row 12 is the current threshold: everything from there to ~32 bytes is taking the slower
branch. TextDecoder's advantage is real and large, but it only starts around 48 bytes.

Benchmark script is at the bottom so you can re-run it on your own targets.

How I ran into this

Decoding a SentencePiece vocabulary (~256k tokens, average ~14 bytes each) — a workload
that lands almost entirely in the 12–32 byte band. pbf came out ~2× slower than
protobufjs on the same data, and effectively all of the gap was string decoding:

median
protobufjs (BufferReader → native Buffer.utf8Slice) 59 ms
protobufjs (generic Reader → JS loop) 111 ms
pbf (as-is, TextDecoder for these lengths) 98 ms

For what it's worth, pbf's reader core compares well — with the string path controlled for,
it edges out protobufjs's generic reader. It's specifically the threshold that costs it here.

Suggested change

-const TEXT_DECODER_MIN_LENGTH = 12;
+const TEXT_DECODER_MIN_LENGTH = 48;

Somewhere in 32–48 would be defensible; 48 is where TextDecoder first wins outright in the
data above, and the curve is shallow on either side of it so the exact value isn't critical.

The memory caveat (#106) — worth checking, probably fine

I'm conscious that #109 wasn't only about speed: readUtf8's s += String.fromCharCode(...)
accumulation triggers V8 issue 4786,
where cons-string ropes retain far more memory than the flattened string.

That concern shouldn't return at 48 bytes — the repro in #106 builds a 64 KB+ string, and rope
overhead is bounded by the number of concatenations, so a ≤48-byte string is a handful of
nodes at worst. But I've only measured throughput here, not retention, so it's worth a memory
check before changing the constant if that's still a live concern for your workloads.

If keeping the memory guarantee matters more than the throughput, an alternative would be to
keep a low threshold only when the decoded result is likely to be retained long-term — though
that's not something the reader can know, so simply raising the constant seems better.

Benchmark script

const TD = new TextDecoder();
function readUtf8(buf, pos, end) {
  let s = '', i = pos;
  while (i < end) { const c = buf[i];
    if (c < 0x80) { s += String.fromCharCode(c); i++; }
    else if (c < 0xe0) { s += String.fromCharCode(((c&0x1f)<<6)|(buf[i+1]&0x3f)); i += 2; }
    else if (c < 0xf0) { s += String.fromCharCode(((c&0x0f)<<12)|((buf[i+1]&0x3f)<<6)|(buf[i+2]&0x3f)); i += 3; }
    else { const cp = (((c&0x07)<<18)|((buf[i+1]&0x3f)<<12)|((buf[i+2]&0x3f)<<6)|(buf[i+3]&0x3f)) - 0x10000;
           s += String.fromCharCode(0xd800+(cp>>10), 0xdc00+(cp&0x3ff)); i += 4; } }
  return s;
}
const TOTAL = 24 * 1024 * 1024;
for (const len of [4,8,12,16,24,32,48,64,96,128,256,512,1024]) {
  const count = Math.max(2000, Math.floor(TOTAL / len));
  const one = Buffer.from('a'.repeat(len - 2) + '\u00e9', 'utf8');
  const u8 = new Uint8Array(Buffer.concat(Array.from({length: count}, () => one)));
  const step = one.length;
  const bench = f => { f(); const a = []; for (let r = 0; r < 5; r++) { const t = performance.now(); f(); a.push(performance.now() - t); } return a.sort((x,y)=>x-y)[2]; };
  const tJs = bench(() => { let x; for (let i = 0; i + step <= u8.length; i += step) x = readUtf8(u8, i, i + step); return x; });
  const tTd = bench(() => { let x; for (let i = 0; i + step <= u8.length; i += step) x = TD.decode(u8.subarray(i, i + step)); return x; });
  console.log(len, tJs.toFixed(0) + 'ms', tTd.toFixed(0) + 'ms', tJs < tTd ? 'readUtf8' : 'TextDecoder');
}

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions