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');
}
Summary
TEXT_DECODER_MIN_LENGTH = 12looks too low on current V8. Between ~12 and ~32 bytes,TextDecoderis measurably slower thanreadUtf8()— up to 2× slower right at thethreshold. 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:
readUtf8(JS)TextDecoderRow 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.
pbfcame out ~2× slower thanprotobufjson the same data, and effectively all of the gap was string decoding:BufferReader→ nativeBuffer.utf8Slice)Reader→ JS loop)TextDecoderfor these lengths)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
Somewhere in 32–48 would be defensible; 48 is where
TextDecoderfirst wins outright in thedata 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'ss += 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