Widen ASCII to UTF-16 sixteen bytes per step in Buffer.write

Buffer.write(text, "utf16le") zero-extended an ASCII run eight bytes per
step with shifts and masks in 64-bit words. Where the target has vector
unpacks (SSE2 on x86-64, NEON on AArch64) it now widens sixteen bytes
per step: one load, a test of the high bits, and two unpacked stores.
The word loop stays for the remaining bytes and for other targets. A
scanner that fills a Uint16Array of code units this way, as tsc-ts does
once per source file, spends about half as long in the copy.
This commit is contained in:
Chris Tate
2026-10-17 18:59:01 -05:00
parent 7980eb4345
commit 9fd5c8a73f
3 changed files with 91 additions and 3 deletions
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/5892-buffer-write-utf16le-runs.ts"
],
"diags": []
}
+25 -3
View File
@@ -17,6 +17,11 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#if defined(__SSE2__)
#include <emmintrin.h>
#elif defined(__ARM_NEON) && defined(__aarch64__)
#include <arm_neon.h>
#endif
static bool scr_td_label_space(unsigned char c) {
return c == 9 || c == 10 || c == 12 || c == 13 || c == 32;
@@ -3302,13 +3307,30 @@ double scr_bytes_write_str(ScrBytes *b, const ScrStr *s, const ScrStr *enc,
while (i < s->len && budget - written >= width) {
#if defined(__BYTE_ORDER__) && __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__
/* utf16le of an ASCII run: zero-extend each byte to a little-endian
* unit, eight per step with two word stores (the generic loop below
* stores byte by byte, because the source and destination may alias
* as far as the compiler knows). */
* unit, sixteen per step with vector unpacks where the target has them
* and eight per step with two word stores otherwise (the generic loop
* below stores byte by byte, because the source and destination may
* alias as far as the compiler knows). */
if (utf16) {
uint8_t *d = b->data + o + written;
size_t n = 0, room = (budget - written) / 16, left = (s->len - i) / 8;
size_t steps = room < left ? room : left;
#if defined(__SSE2__)
const __m128i zero = _mm_setzero_si128();
for (; n + 2 <= steps; n += 2) {
__m128i v = _mm_loadu_si128((const __m128i *)(s->data + i + 8 * n));
if (_mm_movemask_epi8(v)) break;
_mm_storeu_si128((__m128i *)(d + 16 * n), _mm_unpacklo_epi8(v, zero));
_mm_storeu_si128((__m128i *)(d + 16 * n + 16), _mm_unpackhi_epi8(v, zero));
}
#elif defined(__ARM_NEON) && defined(__aarch64__)
for (; n + 2 <= steps; n += 2) {
uint8x16_t v = vld1q_u8((const uint8_t *)s->data + i + 8 * n);
if (vmaxvq_u8(v) & 0x80) break;
vst1q_u8(d + 16 * n, vreinterpretq_u8_u16(vmovl_u8(vget_low_u8(v))));
vst1q_u8(d + 16 * n + 16, vreinterpretq_u8_u16(vmovl_high_u8(v)));
}
#endif
for (; n < steps; n++) {
uint64_t w;
memcpy(&w, s->data + i + 8 * n, 8);
@@ -0,0 +1,60 @@
// Buffer.write(text, "utf16le") over ASCII runs of every length around the
// sixteen-byte widening steps (4732 covers the eight-byte ones): odd and even
// offsets, length limits that cut a run, and non-ASCII, astral and
// lone-surrogate units at every position of a 41-unit run.
function hex(bytes: Uint8Array): string {
let out = "";
for (let i = 0; i < bytes.length; i++) out += (bytes[i]! < 16 ? "0" : "") + bytes[i]!.toString(16);
return out;
}
function digest(bytes: Uint8Array): number {
let h = 0x811c9dc5 | 0;
for (let i = 0; i < bytes.length; i++) h = Math.imul(h ^ bytes[i]!, 0x01000193);
return h >>> 0;
}
const alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_$";
function run(n: number): string {
let s = "";
for (let i = 0; i < n; i++) s += alphabet[i % alphabet.length];
return s;
}
let summary = 0;
for (let n = 0; n <= 70; n++) {
const text = run(n);
for (const offset of [0, 1, 3, 16]) {
const buf = Buffer.alloc(offset + 2 * n + 5, 0xee);
const written = buf.write(text, offset, "utf16le");
summary = (Math.imul(summary, 31) + written + digest(buf)) | 0;
for (const limit of [0, 1, 15, 16, 17, 31, 32, 33]) {
if (limit > 2 * n + 5) continue;
const cut = Buffer.alloc(offset + 2 * n + 5, 0xee);
const w = cut.write(text, offset, limit, "utf16le");
summary = (Math.imul(summary, 31) + w + digest(cut)) | 0;
}
}
}
console.log("runs", summary);
for (const insert of ["é", "€", "😀", "\ud800", "\udc00", "\u0080", "ÿ"]) {
let local = 0;
for (let at = 0; at <= 40; at++) {
const text = run(at) + insert + run(40 - at);
const buf = Buffer.alloc(2 * text.length + 3, 0xee);
const written = buf.write(text, 1, "utf16le");
local = (Math.imul(local, 31) + written + digest(buf)) | 0;
if (at === 17) console.log(JSON.stringify(insert), written, hex(buf.subarray(33, 41)));
}
console.log("insert", JSON.stringify(insert), local);
}
// A code-unit array the way a scanner builds one, then read back.
const source = run(1000) + "\n" + "const x = 1;\r\n".repeat(50);
const units = new Uint16Array(source.length + 4);
const n = Buffer.from(units.buffer).write(source, "utf16le");
let same = n === 2 * source.length;
for (let i = 0; i < source.length; i++) if (units[i] !== source.charCodeAt(i)) same = false;
console.log("units", n, same, units[source.length], units[source.length + 3]);