From 3384396e3f94460d5cc2f2c70449cfd7c216767a Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Fri, 30 Oct 2026 09:13:05 +0100 Subject: [PATCH 1/2] buffer: limit UTF-8 fast calls to safe one-byte strings Replace the allocating UTF-8 fast call with a fast binding for sequential one-byte strings. Its slow fallback only returns a Smi sentinel. JavaScript sends rejected strings to a separate regular binding that can flatten strings and handle lazy deoptimization. Both native callbacks used by the fast API avoid JS-heap allocation. Add GC regression and string-representation coverage, including optimized fast acceptance and rejection checks. Fixes: https://github.com/nodejs/node/issues/66870 Refs: https://github.com/nodejs/node/pull/67134 Assisted-by: Codex Signed-off-by: Robert Nagy --- lib/buffer.js | 8 +- src/node_buffer.cc | 83 +++++++------------ test/parallel/test-buffer-bytelength-gc.js | 35 ++++++++ .../test-buffer-bytelength-safe-fast.js | 77 +++++++++++++++++ .../test-buffer-bytelength-strings.js | 51 ++++++++++++ typings/internalBinding/buffer.d.ts | 1 + 6 files changed, 199 insertions(+), 56 deletions(-) create mode 100644 test/parallel/test-buffer-bytelength-gc.js create mode 100644 test/parallel/test-buffer-bytelength-safe-fast.js create mode 100644 test/parallel/test-buffer-bytelength-strings.js diff --git a/lib/buffer.js b/lib/buffer.js index d9c64f27cf3e..ab494b9988ae 100644 --- a/lib/buffer.js +++ b/lib/buffer.js @@ -56,7 +56,8 @@ const { } = primordials; const { - byteLengthUtf8, + byteLengthUtf8: byteLengthUtf8Slow, + byteLengthUtf8Fast, compare: _compare, compareOffset, copy: _copy, @@ -907,6 +908,11 @@ function getEncodingOps(encoding) { } } +function byteLengthUtf8(string) { + const length = byteLengthUtf8Fast(string); + return length === -1 ? byteLengthUtf8Slow(string) : length; +} + function byteLength(string, encoding) { if (typeof string !== 'string') { if (isArrayBufferView(string) || isAnyArrayBuffer(string)) { diff --git a/src/node_buffer.cc b/src/node_buffer.cc index 1d16813b3809..649cf0fa2b1f 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -826,70 +826,41 @@ void SlowByteLengthUtf8(const FunctionCallbackInfo& args) { args.GetReturnValue().Set(static_cast(str->Utf8LengthV2(isolate))); } -uint32_t FastByteLengthUtf8( - Local receiver, - Local sourceValue, - FastApiCallbackOptions& options) { // NOLINT(runtime/references) +// The fast API's slow fallback must not flatten strings either: it uses the +// same unsupported lazy-deoptimization continuation as the fast callback. +// Return a Smi sentinel so JavaScript can make a separate ordinary API call. +void ByteLengthUtf8FastFallback(const FunctionCallbackInfo& args) { + args.GetReturnValue().Set(-1); +} + +int32_t FastByteLengthUtf8(Local receiver, + const FastOneByteString& source) { TRACK_V8_FAST_API_CALL("Buffer::FastByteLengthUtf8"); - auto isolate = options.isolate; - HandleScope handleScope(isolate); - CHECK(sourceValue->IsString()); - Local sourceStr = sourceValue.As(); - - if (!sourceStr->IsExternalOneByte()) { - // Below ~512 units, or for one-byte, V8's Utf8LengthV2 is faster. - if (sourceStr->Length() >= 512 && !sourceStr->IsOneByte()) { - String::ValueView view(isolate, sourceStr); - if (!view.is_one_byte()) { - // with_replacement matches Buffer.from's U+FFFD for lone surrogates. - return simdutf::utf8_length_from_utf16_with_replacement( - reinterpret_cast(view.data16()), - view.length()) - .count; - } - } - return sourceStr->Utf8LengthV2(isolate); + // Even two UTF-8 bytes per Latin-1 code unit fit in a 31-bit Smi. + static_assert(String::kMaxLength <= INT32_MAX / 4); + if (source.length > 128) { + return static_cast( + simdutf::utf8_length_from_latin1(source.data, source.length)); } - auto source = sourceStr->GetExternalOneByteStringResource(); - // For short inputs, the function call overhead to simdutf is maybe - // not worth it, reserve simdutf for long strings. - if (source->length() > 128) { - return simdutf::utf8_length_from_latin1(source->data(), source->length()); - } - - uint32_t length = source->length(); - const auto input = reinterpret_cast(source->data()); - uint32_t answer = length; + const auto input = reinterpret_cast(source.data); + uint32_t answer = source.length; uint32_t i = 0; - - auto pop = [](uint64_t v) { - return static_cast(((v >> 7) & UINT64_C(0x0101010101010101)) * + auto pop = [](uint64_t value) { + return static_cast(((value >> 7) & UINT64_C(0x0101010101010101)) * UINT64_C(0x0101010101010101) >> 56); }; - for (; i + 32 <= length; i += 32) { - uint64_t v; - memcpy(&v, input + i, 8); - answer += pop(v); - memcpy(&v, input + i + 8, 8); - answer += pop(v); - memcpy(&v, input + i + 16, 8); - answer += pop(v); - memcpy(&v, input + i + 24, 8); - answer += pop(v); - } - for (; i + 8 <= length; i += 8) { - uint64_t v; - memcpy(&v, input + i, 8); - answer += pop(v); + for (; i + 8 <= source.length; i += 8) { + uint64_t value; + memcpy(&value, input + i, 8); + answer += pop(value); } - for (; i + 1 <= length; i += 1) { + for (; i < source.length; i++) { answer += input[i] >> 7; } - - return answer; + return static_cast(answer); } static CFunction fast_byte_length_utf8(CFunction::Make(FastByteLengthUtf8)); @@ -2029,10 +2000,11 @@ void Initialize(Local target, SetMethod(context, target, "setBufferPrototype", SetBufferPrototype); + SetMethodNoSideEffect(context, target, "byteLengthUtf8", SlowByteLengthUtf8); SetFastMethodNoSideEffect(context, target, - "byteLengthUtf8", - SlowByteLengthUtf8, + "byteLengthUtf8Fast", + ByteLengthUtf8FastFallback, &fast_byte_length_utf8); SetFastMethod(context, target, "copy", SlowCopy, &fast_copy); SetFastMethodNoSideEffect(context, target, "compare", Compare, &fast_compare); @@ -2123,6 +2095,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SetBufferPrototype); registry->Register(SlowByteLengthUtf8); + registry->Register(ByteLengthUtf8FastFallback); registry->Register(fast_byte_length_utf8); registry->Register(SlowCopy); registry->Register(fast_copy); diff --git a/test/parallel/test-buffer-bytelength-gc.js b/test/parallel/test-buffer-bytelength-gc.js new file mode 100644 index 000000000000..cad54212d3f0 --- /dev/null +++ b/test/parallel/test-buffer-bytelength-gc.js @@ -0,0 +1,35 @@ +// Flags: --allow-natives-syntax --gc-global --gc-interval=500 +'use strict'; + +require('../common'); +const assert = require('assert'); + +function byteLength(object, string) { + return object.x + Buffer.byteLength(string); +} + +function makeDying() { + const object = { x: 1 }; + object.dying = 1; + return object; +} + +const keep = { x: 2, keep: 1 }; + +// The optimized caller holds the dying objects' map weakly. A full GC while +// flattening a cons string can invalidate that map and lazily deoptimize it. +// A value-returning fast API call, including its slow fallback, cannot handle +// that deoptimization and aborts instead. +eval('%PrepareFunctionForOptimization(byteLength)'); +for (let i = 0; i < 10; i++) { + byteLength(makeDying(), 'ab'); + byteLength(keep, 'ab'); +} +eval('%OptimizeFunctionOnNextCall(byteLength)'); +assert.strictEqual(byteLength(keep, 'ab'), 4); + +const big = 'a'.repeat(1 << 20); +for (let i = 0; i < 50; i++) { + const cons = big + String(i) + big; + assert.strictEqual(byteLength(keep, cons), 2 + cons.length); +} diff --git a/test/parallel/test-buffer-bytelength-safe-fast.js b/test/parallel/test-buffer-bytelength-safe-fast.js new file mode 100644 index 000000000000..1665a9e99d25 --- /dev/null +++ b/test/parallel/test-buffer-bytelength-safe-fast.js @@ -0,0 +1,77 @@ +// Flags: --expose-internals --no-warnings --allow-natives-syntax --expose_externalize_string +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const { byteLengthUtf8Fast } = internalBinding('buffer'); + +const { + createExternalizableString, + createExternalizableTwoByteString, + externalizeString, + isOneByteString, +} = globalThis; +common.allowGlobals( + createExternalizableString, + createExternalizableTwoByteString, + externalizeString, + isOneByteString, + globalThis.x, +); + +const flat = ['', 'hello', '\xe9', '\x00\x01\x7f\x80\xff']; +for (const length of [1, 7, 8, 9, 127, 128, 129, 1024]) { + flat.push(Buffer.alloc(length, 0xe9).toString('latin1')); +} +const latin1 = flat[flat.length - 1]; +const externalOneByte = createExternalizableString('external é'.repeat(20)); +const externalTwoByte = createExternalizableTwoByteString('external €'.repeat(20)); +externalizeString(externalOneByte); +externalizeString(externalTwoByte); +const fallback = [ + 'x'.repeat(1000) + latin1, + latin1.slice(1, 1000), + '€', + '\ud83d', + 'a€😀'.repeat(200), + externalOneByte, + externalTwoByte, +]; + +function fastLength(string) { + return byteLengthUtf8Fast(string); +} + +eval('%PrepareFunctionForOptimization(fastLength)'); +for (const string of [...flat, ...fallback]) { + // Before optimization the callback only returns the rejection sentinel. + assert.strictEqual(fastLength(string), -1); +} +eval('%OptimizeFunctionOnNextCall(fastLength)'); +for (const string of flat) { + assert.strictEqual(fastLength(string), Buffer.from(string).length); +} +for (const string of fallback) { + assert.strictEqual(fastLength(string), -1); +} +assert.strictEqual(fastLength('hello'), 5); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual( + getV8FastApiCallCount('Buffer::FastByteLengthUtf8'), flat.length + 1); +} + +const { byteLength } = Buffer; +function test() { + for (const string of [...flat, ...fallback]) { + assert.strictEqual(byteLength(string), Buffer.from(string).length); + assert.strictEqual(byteLength(string, 'UTF-8'), Buffer.from(string).length); + assert.strictEqual(byteLength(string, 'unknown'), Buffer.from(string).length); + } +} +eval('%PrepareFunctionForOptimization(byteLength)'); +test(); +eval('%OptimizeFunctionOnNextCall(byteLength)'); +test(); diff --git a/test/parallel/test-buffer-bytelength-strings.js b/test/parallel/test-buffer-bytelength-strings.js new file mode 100644 index 000000000000..99e4d5b9e01b --- /dev/null +++ b/test/parallel/test-buffer-bytelength-strings.js @@ -0,0 +1,51 @@ +// Flags: --allow-natives-syntax --expose_externalize_string +'use strict'; + +const common = require('../common'); +const assert = require('assert'); + +const { + createExternalizableString, + createExternalizableTwoByteString, + externalizeString, + isOneByteString, +} = globalThis; + +common.allowGlobals( + createExternalizableString, + createExternalizableTwoByteString, + externalizeString, + isOneByteString, + globalThis.x, +); + +const { byteLength } = Buffer; +const latin1 = Buffer.from('aé'.repeat(100), 'latin1').toString('latin1'); +const external = createExternalizableString('external é'.repeat(20)); +externalizeString(external); +assert.strictEqual(isOneByteString(external), true); + +// Check flat, cons, sliced, two-byte and external strings, including lone +// surrogates, before and after optimizing the caller. +const cases = [ + 'hello', + 'é', + latin1, + 'x'.repeat(1000) + latin1, + latin1.slice(1, 150), + '€', + '\ud83d', + 'a€😀'.repeat(200), + external, +]; + +function test() { + for (const string of cases) { + assert.strictEqual(byteLength(string), Buffer.from(string).length); + } +} + +eval('%PrepareFunctionForOptimization(byteLength)'); +test(); +eval('%OptimizeFunctionOnNextCall(byteLength)'); +test(); diff --git a/typings/internalBinding/buffer.d.ts b/typings/internalBinding/buffer.d.ts index 4d5e1f5cde84..9e486c661bd7 100644 --- a/typings/internalBinding/buffer.d.ts +++ b/typings/internalBinding/buffer.d.ts @@ -5,6 +5,7 @@ export interface BufferBinding { setBufferPrototype(proto: object): void; byteLengthUtf8(str: string): number; + byteLengthUtf8Fast(str: string): number; copy(source: ArrayBufferView, target: ArrayBufferView, targetStart: number, sourceStart: number, toCopy: number): number; compare(a: ArrayBufferView, b: ArrayBufferView): number; compareOffset(source: ArrayBufferView, target: ArrayBufferView, targetStart?: number, sourceStart?: number, targetEnd?: number, sourceEnd?: number): number; From 44bd1bfa65fd81f33e1ee78fa9e2f3025f22789a Mon Sep 17 00:00:00 2001 From: Robert Nagy Date: Fri, 30 Oct 2026 09:27:21 +0100 Subject: [PATCH 2/2] buffer: preserve the one-byte counting loop Keep the existing loop unrolling, variable names and scalar tail. Limit the fast-path changes to the safe input and fallback contract. Assisted-by: Codex Signed-off-by: Robert Nagy --- src/node_buffer.cc | 33 +++++++++++++++++++++++++-------- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/src/node_buffer.cc b/src/node_buffer.cc index 649cf0fa2b1f..448ef5b6049e 100644 --- a/src/node_buffer.cc +++ b/src/node_buffer.cc @@ -838,28 +838,45 @@ int32_t FastByteLengthUtf8(Local receiver, TRACK_V8_FAST_API_CALL("Buffer::FastByteLengthUtf8"); // Even two UTF-8 bytes per Latin-1 code unit fit in a 31-bit Smi. static_assert(String::kMaxLength <= INT32_MAX / 4); + // For short inputs, the function call overhead to simdutf is maybe + // not worth it, reserve simdutf for long strings. if (source.length > 128) { return static_cast( simdutf::utf8_length_from_latin1(source.data, source.length)); } + uint32_t length = source.length; const auto input = reinterpret_cast(source.data); - uint32_t answer = source.length; + + uint32_t answer = length; uint32_t i = 0; - auto pop = [](uint64_t value) { - return static_cast(((value >> 7) & UINT64_C(0x0101010101010101)) * + + auto pop = [](uint64_t v) { + return static_cast(((v >> 7) & UINT64_C(0x0101010101010101)) * UINT64_C(0x0101010101010101) >> 56); }; - for (; i + 8 <= source.length; i += 8) { - uint64_t value; - memcpy(&value, input + i, 8); - answer += pop(value); + for (; i + 32 <= length; i += 32) { + uint64_t v; + memcpy(&v, input + i, 8); + answer += pop(v); + memcpy(&v, input + i + 8, 8); + answer += pop(v); + memcpy(&v, input + i + 16, 8); + answer += pop(v); + memcpy(&v, input + i + 24, 8); + answer += pop(v); } - for (; i < source.length; i++) { + for (; i + 8 <= length; i += 8) { + uint64_t v; + memcpy(&v, input + i, 8); + answer += pop(v); + } + for (; i + 1 <= length; i += 1) { answer += input[i] >> 7; } + return static_cast(answer); }