Skip to content

Commit dbbcc34

Browse files
committed
perf(ext/node): write UTF-16LE buffers with V8
1 parent 336da42 commit dbbcc34

5 files changed

Lines changed: 221 additions & 1 deletion

File tree

ext/node/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ deno_core::extension!(deno_node,
224224
ops::buffer::op_transcode,
225225
ops::buffer::op_node_buffer_compare,
226226
ops::buffer::op_node_buffer_compare_offset,
227+
ops::buffer::op_node_buffer_write_utf16le,
227228
ops::constant::op_node_fs_constants,
228229
ops::buffer::op_node_encoding_slice,
229230
ops::dns::op_node_getaddrinfo,

ext/node/ops/buffer.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,52 @@ pub fn op_transcode(
4949
}
5050
}
5151

52+
#[op2(fast)]
53+
#[number]
54+
pub fn op_node_buffer_write_utf16le(
55+
scope: &mut v8::PinScope<'_, '_>,
56+
string: v8::Local<v8::String>,
57+
#[buffer] buffer: &mut [u8],
58+
#[number] offset: usize,
59+
#[number] length: usize,
60+
) -> usize {
61+
let Some(buffer) = buffer.get_mut(offset..) else {
62+
return 0;
63+
};
64+
// Write complete UTF-16 code units, not Unicode scalar values: lone
65+
// surrogates and a surrogate pair split by the byte limit must be preserved.
66+
let units = string.length().min(length.min(buffer.len()) / 2);
67+
if units == 0 {
68+
return 0;
69+
}
70+
let buffer = &mut buffer[..units * 2];
71+
72+
#[cfg(target_endian = "little")]
73+
{
74+
// SAFETY: u16 has no invalid bit patterns. align_to_mut checks alignment,
75+
// and this uniquely borrowed slice contains exactly the writable bytes.
76+
let (prefix, words, suffix) = unsafe { buffer.align_to_mut::<u16>() };
77+
if prefix.is_empty() && suffix.is_empty() {
78+
string.write_v2(scope, 0, words, v8::WriteFlags::empty());
79+
return units * 2;
80+
}
81+
}
82+
83+
// Buffer slices and write offsets need not be u16-aligned. Use bounded stack
84+
// storage for these writes (and for byte swapping on big-endian targets).
85+
let mut scratch = [0u16; 1024];
86+
let mut start = 0;
87+
for bytes in buffer.chunks_mut(scratch.len() * 2) {
88+
let words = &mut scratch[..bytes.len() / 2];
89+
string.write_v2(scope, start, words, v8::WriteFlags::empty());
90+
for (word, bytes) in words.iter().zip(bytes.chunks_exact_mut(2)) {
91+
bytes.copy_from_slice(&word.to_le_bytes());
92+
}
93+
start += words.len() as u32;
94+
}
95+
units * 2
96+
}
97+
5298
fn latin1_ascii_to_utf16le(source: &[u8]) -> Uint8Array {
5399
let mut result = Vec::with_capacity(source.len() * 2);
54100
for &byte in source {

ext/node/polyfills/internal/buffer.mjs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ const {
9191
op_mark_as_untransferable,
9292
op_node_buffer_compare,
9393
op_node_buffer_compare_offset,
94+
op_node_buffer_write_utf16le,
9495
op_node_call_is_from_dependency,
9596
op_node_encoding_slice,
9697
op_transcode,
@@ -1329,7 +1330,19 @@ Buffer.prototype.ucs2Slice = function ucs2Slice(offset, length) {
13291330
return decodeUtf16le(this, offset, length);
13301331
};
13311332

1332-
Buffer.prototype.ucs2Write = function ucs2Write(string, offset, length) {
1333+
Buffer.prototype.ucs2Write = function ucs2Write(
1334+
string,
1335+
offset = 0,
1336+
length = this.length - offset,
1337+
) {
1338+
if (
1339+
typeof string === "string" &&
1340+
NumberIsInteger(offset) && offset >= 0 && offset <= this.length &&
1341+
NumberIsInteger(length) && length >= 0
1342+
) {
1343+
return op_node_buffer_write_utf16le(string, this, offset, length);
1344+
}
1345+
// Preserve coercion/error behavior for direct calls with non-standard args.
13331346
return blitBuffer(
13341347
utf16leToBytes(string, this.length - offset),
13351348
this,

tests/bench/buffer_utf16le.js

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// Copyright 2018-2026 the Deno authors. MIT license.
2+
import { Buffer } from "node:buffer";
3+
import { deepStrictEqual } from "node:assert";
4+
5+
// deno bench tests/bench/buffer_utf16le.js
6+
// Run unchanged on baseline and candidate binaries. Both implementations return
7+
// newly allocated bytes; the write cases isolate conversion from allocation.
8+
let sink = 0;
9+
for (
10+
const [corpus, phrase] of [
11+
["ascii", "Hello world! "],
12+
["latin1", "café déjà vu "],
13+
["utf16", "Hello λ 😀 漢字! "],
14+
]
15+
) {
16+
for (const size of [32, 2048, 32768]) {
17+
const text = phrase.repeat(Math.ceil(size / phrase.length)).slice(0, size);
18+
const manual = () => {
19+
const bytes = new Uint8Array(text.length * 2);
20+
const view = new DataView(bytes.buffer);
21+
for (let i = 0; i < text.length; i++) {
22+
view.setUint16(i * 2, text.charCodeAt(i), true);
23+
}
24+
return bytes;
25+
};
26+
const expected = manual();
27+
deepStrictEqual(new Uint8Array(Buffer.from(text, "utf16le")), expected);
28+
const group = `${corpus}/${size * 2} bytes`;
29+
Deno.bench({
30+
name: `DataView ${group}`,
31+
group,
32+
baseline: true,
33+
fn() {
34+
const result = manual();
35+
sink ^= result[sink % result.length];
36+
},
37+
});
38+
Deno.bench({
39+
name: `Buffer.from ${group}`,
40+
group,
41+
fn() {
42+
const result = Buffer.from(text, "utf16le");
43+
sink ^= result[sink % result.length];
44+
},
45+
});
46+
for (const offset of [0, 1]) {
47+
const target = Buffer.alloc(text.length * 2 + offset);
48+
deepStrictEqual(
49+
target.subarray(offset, offset + target.write(text, offset, "utf16le")),
50+
Buffer.from(expected),
51+
);
52+
Deno.bench({
53+
name: `Buffer.write offset=${offset} ${group}`,
54+
group,
55+
fn() {
56+
sink ^= target.write(text, offset, "utf16le");
57+
},
58+
});
59+
}
60+
}
61+
}

tests/unit_node/buffer_test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,105 @@ import { strictEqual } from "node:assert";
1111

1212
const { MAX_STRING_LENGTH } = constants;
1313

14+
Deno.test("[node/buffer] UTF-16LE encoding preserves every code unit", () => {
15+
const aliases = ["utf16le", "utf-16le", "ucs2", "ucs-2"] as const;
16+
for (let start = 0; start < 65536; start += 1024) {
17+
const units = Array.from({ length: 1024 }, (_, i) => start + i);
18+
const text = String.fromCharCode(...units);
19+
const expected = Buffer.alloc(units.length * 2);
20+
for (let i = 0; i < units.length; i++) {
21+
expected[i * 2] = units[i] & 255;
22+
expected[i * 2 + 1] = units[i] >>> 8;
23+
}
24+
for (const encoding of aliases) {
25+
assertEquals(Buffer.from(text, encoding), expected);
26+
}
27+
}
28+
});
29+
30+
Deno.test("[node/buffer] UTF-16LE writes respect byte limits and slice boundaries", () => {
31+
for (
32+
const text of [
33+
"",
34+
"ASCII",
35+
"café",
36+
"漢字",
37+
"A\0B",
38+
"\ud800",
39+
"\udc00",
40+
"😀X",
41+
]
42+
) {
43+
for (let viewOffset = 0; viewOffset <= 2; viewOffset++) {
44+
for (let offset = 0; offset <= 3; offset++) {
45+
for (let length = 0; length <= text.length * 2 + 3; length++) {
46+
const backing = Buffer.alloc(viewOffset + offset + length + 3, 0xaa);
47+
const target = backing.subarray(viewOffset, backing.length - 2);
48+
const expected = Buffer.alloc(backing.length, 0xaa);
49+
const units = Math.min(text.length, Math.floor(length / 2));
50+
for (let i = 0; i < units; i++) {
51+
const code = text.charCodeAt(i);
52+
expected[viewOffset + offset + i * 2] = code & 255;
53+
expected[viewOffset + offset + i * 2 + 1] = code >>> 8;
54+
}
55+
assertEquals(
56+
target.write(text, offset, length, "utf16le"),
57+
units * 2,
58+
);
59+
assertEquals(backing, expected);
60+
}
61+
}
62+
}
63+
}
64+
});
65+
66+
Deno.test("[node/buffer] UTF-16LE handles large and unaligned writes", () => {
67+
const text = "ASCII café 漢字 😀\ud800\0".repeat(4096);
68+
const expected = Buffer.alloc(text.length * 2);
69+
for (let i = 0; i < text.length; i++) {
70+
expected[i * 2] = text.charCodeAt(i) & 255;
71+
expected[i * 2 + 1] = text.charCodeAt(i) >>> 8;
72+
}
73+
assertEquals(Buffer.from(text, "utf16le"), expected);
74+
// Exercise multiple native scratch-buffer iterations and a final short chunk.
75+
for (const offset of [0, 1, 2, 3]) {
76+
const backing = Buffer.alloc(expected.length + offset + 1, 0xaa);
77+
assertEquals(backing.write(text, offset, "utf16le"), expected.length);
78+
assertEquals(backing.subarray(offset, offset + expected.length), expected);
79+
assertEquals(backing.subarray(0, offset), Buffer.alloc(offset, 0xaa));
80+
assertEquals(backing[backing.length - 1], 0xaa);
81+
}
82+
});
83+
84+
Deno.test("[node/buffer] UTF-16LE writes into shared backing stores", () => {
85+
const backing = new SharedArrayBuffer(16);
86+
const all = Buffer.from(backing);
87+
all.fill(0xaa);
88+
const target = Buffer.from(backing, 1, 9);
89+
assertEquals(target.write("A😀\ud800", "utf16le"), 8);
90+
assertEquals(
91+
[...all],
92+
[
93+
0xaa,
94+
0x41,
95+
0,
96+
0x3d,
97+
0xd8,
98+
0,
99+
0xde,
100+
0,
101+
0xd8,
102+
0xaa,
103+
0xaa,
104+
0xaa,
105+
0xaa,
106+
0xaa,
107+
0xaa,
108+
0xaa,
109+
],
110+
);
111+
});
112+
14113
Deno.test({
15114
name: "[node/buffer] alloc fails if size is not a number",
16115
fn() {

0 commit comments

Comments
 (0)