FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

src: use simdutf for two-byte strings in UTF-8 writes · nodejs/node@32bb197 · GitHub

/ node Public

Commit 32bb197

Browse files
authored andcommitted
src: use simdutf for two-byte strings in UTF-8 writes
StringBytes::Write() already used simdutf to encode one-byte strings as UTF-8 but sent every two-byte (UTF-16) string through v8::String::WriteUtf8V2(), which is several times slower. That path is behind Buffer.from(string), buf.write(), fs.write*() with string data and every string written to a libuv stream, and JSON.stringify() output is a two-byte string as soon as any value in the payload is outside Latin-1. Encode two-byte strings with simdutf as well whenever their UTF-8 form is guaranteed to fit in the target: well-formed input is converted directly, and input with unpaired surrogates is converted from a copy passed through simdutf::to_well_formed_utf16(), which replaces each unpaired surrogate with U+FFFD exactly like kReplaceInvalidUtf8 (this mirrors what TextEncoder already does). Writes that have to truncate at a character boundary keep using WriteUtf8V2(), so their output is byte-for-byte unchanged, and so do strings of up to 32 code units, for which V8 is already as fast (the same threshold TextEncoder uses). buf.write() of a 2 KiB two-byte string improves ~5x (astral-heavy and lone-surrogate strings ~3.5x and ~5x), Buffer.from() of a 64 KiB JSON string ~2.7x; one-byte strings are unaffected. Signed-off-by: Shelley Vohr <shelley.vohr@gmail.com> PR-URL: #65324 Reviewed-By: Yagiz Nizipli <yagiz@nizipli.com> Reviewed-By: Gürgün Dayıoğlu <hey@gurgun.day> Reviewed-By: Daniel Lemire <daniel@lemire.me> Reviewed-By: James M Snell <jasnell@gmail.com>
1 parent f33dba7 commit 32bb197

3 files changed

Lines changed: 193 additions & 1 deletion

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use strict';
2+
3+
// buf.write(string, 'utf8') for strings whose in-memory representation is
4+
// one-byte (Latin-1) or two-byte (UTF-16), which take different encoder paths.
5+
const common = require('../common.js');
6+
const bench = common.createBenchmark(main, {
7+
chars: ['one-byte', 'two-byte', 'two-byte-astral', 'two-byte-lone-surrogate'],
8+
len: [16, 256, 2048, 65536],
9+
n: [5e5],
10+
});
11+
12+
function makeString(chars, len) {
13+
switch (chars) {
14+
case 'one-byte':
15+
return 'aé'.repeat(len / 2);
16+
case 'two-byte':
17+
return 'aé€日'.repeat(len / 4);
18+
case 'two-byte-astral':
19+
return 'aé€日\u{1F600}'.repeat(len / 6).padEnd(len, 'a');
20+
case 'two-byte-lone-surrogate':
21+
return 'aé€日'.repeat(len / 4 - 1) + 'ab\ud800c';
22+
default:
23+
throw new Error(chars);
24+
}
25+
}
26+
27+
function main({ chars, len, n }) {
28+
const string = makeString(chars, len);
29+
const buf = Buffer.allocUnsafe(Buffer.byteLength(string));
30+
if (len >= 65536) n = Math.floor(n / 32);
31+
bench.start();
32+
for (let i = 0; i < n; ++i) {
33+
buf.write(string, 0, 'utf8');
34+
}
35+
bench.end(n);
36+
}

‎src/string_bytes.cc‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,37 @@ size_t StringBytes::Write(Isolate* isolate,
310310
input_view.length(),
311311
buf,
312312
buflen);
313-
} else {
313+
} else if (input_view.length() <= 32) {
314+
// V8 is as fast for tiny strings (same threshold TextEncoder uses).
314315
nbytes = str->WriteUtf8V2(
315316
isolate, buf, buflen, String::WriteFlags::kReplaceInvalidUtf8);
317+
} else {
318+
// Use simdutf for two-byte strings as well whenever the UTF-8 form
319+
// is guaranteed to fit; truncating writes (which must stop at a
320+
// character boundary) keep going through V8 so that their output
321+
// stays byte-for-byte identical.
322+
const char16_t* data =
323+
reinterpret_cast<const char16_t*>(input_view.data16());
324+
const size_t length = input_view.length();
325+
MaybeStackBuffer<char16_t, 1024> well_formed;
326+
if (!simdutf::validate_utf16(data, length)) {
327+
// Unpaired surrogates: encode a copy in which each of them has been
328+
// replaced with U+FFFD, which is what kReplaceInvalidUtf8 produces.
329+
well_formed.AllocateSufficientStorage(length);
330+
simdutf::to_well_formed_utf16(data, length, well_formed.out());
331+
data = well_formed.out();
332+
}
333+
// A UTF-16 code unit never expands to more than 3 UTF-8 bytes, so
334+
// 3 * length is what StorageSize() hands most callers; only compute
335+
// the exact length when the buffer is smaller than that.
336+
if (buflen >= 3 * length ||
337+
buflen >= simdutf::utf8_length_from_utf16(data, length)) {
338+
nbytes = simdutf::convert_utf16_to_utf8(data, length, buf);
339+
} else {
340+
// Does not fit: let V8 truncate at a character boundary.
341+
nbytes = str->WriteUtf8V2(
342+
isolate, buf, buflen, String::WriteFlags::kReplaceInvalidUtf8);
343+
}
316344
}
317345
break;
318346

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
'use strict';
2+
// UTF-8 encoding of two-byte (UTF-16) JS strings through the Buffer write
3+
// paths (Buffer.from, Buffer#write, Buffer.byteLength) must:
4+
// - produce standard UTF-8 for well-formed input of any size,
5+
// - replace lone surrogates with U+FFFD (EF BF BD),
6+
// - never write a partial character when the target is too small,
7+
// independent of which internal fast path handles the string.
8+
require('../common');
9+
const assert = require('assert');
10+
11+
// Reference encoder written out longhand so the test does not depend on the
12+
// implementation under test (TextEncoder shares code with it).
13+
function utf8Reference(str) {
14+
const out = [];
15+
for (let i = 0; i < str.length; i++) {
16+
let cp = str.charCodeAt(i);
17+
if (cp >= 0xd800 && cp <= 0xdbff) {
18+
const next = i + 1 < str.length ? str.charCodeAt(i + 1) : 0;
19+
if (next >= 0xdc00 && next <= 0xdfff) {
20+
cp = 0x10000 + ((cp - 0xd800) << 10) + (next - 0xdc00);
21+
i++;
22+
} else {
23+
cp = 0xfffd;
24+
}
25+
} else if (cp >= 0xdc00 && cp <= 0xdfff) {
26+
cp = 0xfffd;
27+
}
28+
if (cp < 0x80) {
29+
out.push(cp);
30+
} else if (cp < 0x800) {
31+
out.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
32+
} else if (cp < 0x10000) {
33+
out.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
34+
} else {
35+
out.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f),
36+
0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
37+
}
38+
}
39+
return Buffer.from(out);
40+
}
41+
42+
function checkFull(str, label) {
43+
const expected = utf8Reference(str);
44+
assert.deepStrictEqual(Buffer.from(str, 'utf8'), expected, `${label}: Buffer.from`);
45+
assert.strictEqual(Buffer.byteLength(str, 'utf8'), expected.length, `${label}: byteLength`);
46+
// Exact-size target.
47+
const exact = Buffer.alloc(expected.length);
48+
assert.strictEqual(exact.write(str, 'utf8'), expected.length, `${label}: write exact`);
49+
assert.deepStrictEqual(exact, expected, `${label}: write exact bytes`);
50+
// Oversized target (3 bytes per code unit is what most internal callers allocate).
51+
const big = Buffer.alloc(str.length * 3 + 7, 0xaa);
52+
assert.strictEqual(big.write(str, 2, 'utf8'), expected.length, `${label}: write big`);
53+
assert.deepStrictEqual(big.subarray(2, 2 + expected.length), expected, `${label}: write big bytes`);
54+
assert.strictEqual(big[0], 0xaa);
55+
assert.strictEqual(big[2 + expected.length], 0xaa, `${label}: no overrun`);
56+
}
57+
58+
// Truncating writes must stop before the first character that does not fit.
59+
function checkTruncation(str, label) {
60+
const expected = utf8Reference(str);
61+
for (let size = 0; size <= Math.min(expected.length, 70); size++) {
62+
const target = Buffer.alloc(size + 1, 0xaa);
63+
const n = target.write(str, 0, size, 'utf8');
64+
assert.ok(n <= size, `${label}: size=${size} wrote ${n}`);
65+
assert.deepStrictEqual(target.subarray(0, n), expected.subarray(0, n), `${label}: prefix size=${size}`);
66+
assert.strictEqual(target[size], 0xaa, `${label}: overrun size=${size}`);
67+
// What was written must be a whole number of characters: the next byte in
68+
// the reference (if any) has to be a lead byte, not a continuation byte.
69+
if (n < expected.length) {
70+
assert.notStrictEqual(expected[n] & 0xc0, 0x80, `${label}: split char at size=${size}`);
71+
// And it stopped only because the next character really did not fit.
72+
let next = n + 1;
73+
while (next < expected.length && (expected[next] & 0xc0) === 0x80) next++;
74+
assert.ok(next > size, `${label}: stopped early at size=${size} (n=${n}, next=${next})`);
75+
}
76+
}
77+
}
78+
79+
// Force a two-byte representation even for ASCII/Latin-1 content by building
80+
// the string from a two-byte seed and slicing (V8 keeps the representation).
81+
function twoByte(str) {
82+
const s = ('\u{1F600}' + str).slice(2);
83+
assert.strictEqual(s, str);
84+
return s;
85+
}
86+
87+
const samples = {
88+
ascii: 'The quick brown fox jumps over the lazy dog 0123456789',
89+
latin1: 'français élan über naïve façade ÿ',
90+
bmp: '日本語テキストとハングル한국어',
91+
astral: 'emoji \u{1F600}\u{1F4A9} math \u{1D49C} han \u{20BB7}',
92+
mixed: 'a é 日 \u{1F600} b ü 本 \u{1F4A9}',
93+
};
94+
95+
for (const [name, base] of Object.entries(samples)) {
96+
for (const repeat of [1, 3, 40, 700, 12000]) {
97+
const str = twoByte(base.repeat(repeat));
98+
checkFull(str, `${name} x${repeat}`);
99+
}
100+
checkTruncation(twoByte(base.repeat(3)), `${name} truncation`);
101+
}
102+
103+
// Lone surrogates in various positions and sizes -> U+FFFD, rest intact.
104+
const high = '\ud83d';
105+
const low = '\ude00';
106+
const surrogateCases = {
107+
'lone high': `ab${high}cd`,
108+
'lone low': `ab${low}cd`,
109+
'reversed pair': `ab${low}${high}cd`,
110+
'high at end': `abcd${high}`,
111+
'low at start': `${low}abcd`,
112+
'high high low': `${high}${high}${low}x`,
113+
'pair then lone': `${high}${low}${high}`,
114+
'only lone': high,
115+
};
116+
for (const [name, base] of Object.entries(surrogateCases)) {
117+
for (const pad of ['', 'x'.repeat(50), 'é'.repeat(300), '日'.repeat(30000)]) {
118+
const str = pad + base + pad;
119+
checkFull(str, `${name} pad=${pad.length}`);
120+
}
121+
checkTruncation(base + 'zz', `${name} truncation`);
122+
}
123+
124+
// Buffer.from of a large two-byte string equals TextEncoder output.
125+
{
126+
const str = twoByte(samples.mixed.repeat(50000));
127+
assert.deepStrictEqual(Buffer.from(str), Buffer.from(new TextEncoder().encode(str)));
128+
}

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL