diff --git a/bench/postMessage/postMessage-object.mjs b/bench/postMessage/postMessage-object.mjs new file mode 100644 index 0000000000..4c88afe065 --- /dev/null +++ b/bench/postMessage/postMessage-object.mjs @@ -0,0 +1,116 @@ +// Benchmark for object fast path optimization in postMessage with Workers + +import { bench, run } from "mitata"; +import { Worker } from "node:worker_threads"; + +const extraProperties = { + a: "a!", + b: "b!", + "second": "c!", + bool: true, + nully: null, + undef: undefined, + int: 0, + double: 1.234, + falsy: false, +}; + +const objects = { + small: { property: "Hello world", ...extraProperties }, + medium: { + property: Buffer.alloc("Hello World!!!".length * 1024, "Hello World!!!").toString(), + ...extraProperties, + }, + large: { + property: Buffer.alloc("Hello World!!!".length * 1024 * 256, "Hello World!!!").toString(), + ...extraProperties, + }, +}; + +let worker; +let receivedCount = new Int32Array(new SharedArrayBuffer(4)); +let sentCount = 0; + +function createWorker() { + const workerCode = ` + import { parentPort, workerData } from "node:worker_threads"; + + let int = workerData; + + parentPort?.on("message", data => { + switch (data.property.length) { + case ${objects.small.property.length}: + case ${objects.medium.property.length}: + case ${objects.large.property.length}: { + if ( + data.a === "a!" && + data.b === "b!" && + data.second === "c!" && + data.bool === true && + data.nully === null && + data.undef === undefined && + data.int === 0 && + data.double === 1.234 && + data.falsy === false) { + Atomics.add(int, 0, 1); + break; + } + } + default: { + throw new Error("Invalid data object: " + JSON.stringify(data)); + } + } + + }); + `; + + worker = new Worker(workerCode, { eval: true, workerData: receivedCount }); + + worker.on("message", confirmationId => {}); + + worker.on("error", error => { + console.error("Worker error:", error); + }); +} + +// Initialize worker before running benchmarks +createWorker(); + +function fmt(int) { + if (int < 1000) { + return `${int} chars`; + } + + if (int < 100000) { + return `${(int / 1024) | 0} KB`; + } + + return `${(int / 1024 / 1024) | 0} MB`; +} + +// Benchmark postMessage with pure strings (uses fast path) +bench("postMessage({ prop: " + fmt(objects.small.property.length) + " string, ...9 more props })", async () => { + sentCount++; + worker.postMessage(objects.small); +}); + +bench("postMessage({ prop: " + fmt(objects.medium.property.length) + " string, ...9 more props })", async () => { + sentCount++; + worker.postMessage(objects.medium); +}); + +bench("postMessage({ prop: " + fmt(objects.large.property.length) + " string, ...9 more props })", async () => { + sentCount++; + worker.postMessage(objects.large); +}); + +await run(); + +await new Promise(resolve => setTimeout(resolve, 5000)); + +if (receivedCount[0] !== sentCount) { + throw new Error("Expected " + receivedCount[0] + " to equal " + sentCount); +} + +// Cleanup worker +worker?.terminate(); diff --git a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp index f5bcd71990..1b16e1bee7 100644 --- a/src/bun.js/bindings/webcore/SerializedScriptValue.cpp +++ b/src/bun.js/bindings/webcore/SerializedScriptValue.cpp @@ -114,6 +114,8 @@ #include "JSPrivateKeyObject.h" #include "CryptoKeyType.h" #include "JSNodePerformanceHooksHistogram.h" +#include +#include #if USE(CG) #include @@ -5565,9 +5567,16 @@ SerializedScriptValue::SerializedScriptValue(Vector&& buffer, std::uniq m_memoryCost = computeMemoryCost(); } +SerializedScriptValue::SerializedScriptValue(WTF::FixedVector&& object) + : m_simpleInMemoryPropertyTable(WTFMove(object)) + , m_fastPath(FastPath::SimpleObject) +{ + m_memoryCost = computeMemoryCost(); +} + SerializedScriptValue::SerializedScriptValue(const String& fastPathString) : m_fastPathString(fastPathString) - , m_isStringFastPath(true) + , m_fastPath(FastPath::String) { m_memoryCost = computeMemoryCost(); } @@ -5623,8 +5632,30 @@ size_t SerializedScriptValue::computeMemoryCost() const // cost += handle.url().string().sizeInBytes(); // Account for fast path string memory usage - if (m_isStringFastPath) + switch (m_fastPath) { + case FastPath::String: + ASSERT(m_simpleInMemoryPropertyTable.isEmpty()); cost += m_fastPathString.sizeInBytes(); + break; + case FastPath::SimpleObject: + ASSERT(m_fastPathString.isEmpty()); + cost += m_simpleInMemoryPropertyTable.byteSize(); + // Add the memory cost of strings in the simple property table + for (const auto& entry : m_simpleInMemoryPropertyTable) { + // Add property name string cost + cost += entry.propertyName.sizeInBytes(); + + // Add value string cost if it's a string + if (std::holds_alternative(entry.value)) { + const auto& str = std::get(entry.value); + cost += str.sizeInBytes(); + } + } + + break; + case FastPath::None: + break; + } return cost; } @@ -5699,6 +5730,43 @@ static Exception exceptionForSerializationFailure(SerializationReturnCode code) return Exception { TypeError }; } +// This is based on `checkStrucureForClone` +static bool isObjectFastPathCandidate(Structure* structure) +{ + static constexpr bool verbose = false; + + if (structure->typeInfo().type() != FinalObjectType) { + dataLogLnIf(verbose, "target is not final object"); + return false; + } + + if (!structure->canAccessPropertiesQuicklyForEnumeration()) { + dataLogLnIf(verbose, "target cannot access properties quickly for enumeration"); + return false; + } + + if (hasIndexedProperties(structure->indexingType())) { + dataLogLnIf(verbose, "target has indexing mode"); + return false; + } + + if (structure->isBrandedStructure()) { + dataLogLnIf(verbose, "target has isBrandedStructure"); + return false; + } + + if (structure->hasAnyKindOfGetterSetterProperties()) { + dataLogLnIf(verbose, "target has any kind of getter setter properties"); + return false; + } + + if (structure->hasNonConfigurableProperties() || structure->hasNonEnumerableProperties()) { + dataLogLnIf(verbose, "target has non-configurable or non-enumerable properties"); + return false; + } + + return true; +} // static bool containsDuplicates(const Vector>& imageBitmaps) // { // HashSet visited; @@ -5766,17 +5834,86 @@ ExceptionOr> SerializedScriptValue::create(JSGlobalOb auto scope = DECLARE_THROW_SCOPE(vm); // Fast path optimization: for postMessage/structuredClone with pure strings and no transfers - if ((context == SerializationContext::WorkerPostMessage || context == SerializationContext::WindowPostMessage || context == SerializationContext::Default) + const bool canUseFastPath = (context == SerializationContext::WorkerPostMessage || context == SerializationContext::WindowPostMessage || context == SerializationContext::Default) && forStorage == SerializationForStorage::No && forTransfer == SerializationForCrossProcessTransfer::No && transferList.isEmpty() - && messagePorts.isEmpty() - && value.isString()) { + && messagePorts.isEmpty(); - JSC::JSString* jsString = asString(value); - String stringValue = jsString->value(&lexicalGlobalObject); - RETURN_IF_EXCEPTION(scope, Exception { TypeError }); - return SerializedScriptValue::createStringFastPath(stringValue); + if (canUseFastPath) { + bool canUseStringFastPath = false; + bool canUseObjectFastPath = false; + JSObject* object = nullptr; + Structure* structure = nullptr; + if (value.isCell()) { + auto* cell = value.asCell(); + if (cell->isString()) { + canUseStringFastPath = true; + } else if (cell->isObject()) { + object = cell->getObject(); + structure = object->structure(); + + if (isObjectFastPathCandidate(structure)) { + canUseObjectFastPath = true; + } + } + } + + if (canUseStringFastPath) { + JSC::JSString* jsString = asString(value); + String stringValue = jsString->value(&lexicalGlobalObject); + RETURN_IF_EXCEPTION(scope, Exception { ExistingExceptionError }); + return SerializedScriptValue::createStringFastPath(stringValue); + } + + if (canUseObjectFastPath) { + ASSERT(object != nullptr); + + WTF::Vector properties; + + structure->forEachProperty(vm, [&](const PropertyTableEntry& entry) -> bool { + // Only enumerable, data properties + if (entry.attributes() & PropertyAttribute::DontEnum) [[unlikely]] { + ASSERT_NOT_REACHED_WITH_MESSAGE("isObjectFastPathCandidate should not allow non-enumerable, data properties"); + canUseObjectFastPath = false; + return false; + } + + if (entry.attributes() & PropertyAttribute::Accessor) [[unlikely]] { + ASSERT_NOT_REACHED_WITH_MESSAGE("isObjectFastPathCandidate should not allow accessor properties"); + canUseObjectFastPath = false; + return false; + } + + JSValue value = object->getDirect(entry.offset()); + + if (value.isCell()) { + // We only support strings, numbers and primitives. Nothing else. + if (!value.isString()) { + canUseObjectFastPath = false; + return false; + } + + auto* string = asString(value); + String stringValue = string->value(&lexicalGlobalObject); + if (scope.exception()) { + canUseObjectFastPath = false; + return false; + } + properties.append({ entry.key()->isolatedCopy(), Bun::toCrossThreadShareable(stringValue) }); + } else { + // Primitive values are safe to share across threads. + properties.append({ entry.key()->isolatedCopy(), value }); + } + + return true; + }); + RETURN_IF_EXCEPTION(scope, Exception { ExistingExceptionError }); + + if (canUseObjectFastPath) { + return SerializedScriptValue::createObjectFastPath(WTF::FixedVector(WTFMove(properties))); + } + } } Vector> arrayBuffers; @@ -6000,6 +6137,11 @@ Ref SerializedScriptValue::createStringFastPath(const Str return adoptRef(*new SerializedScriptValue(Bun::toCrossThreadShareable(string))); } +Ref SerializedScriptValue::createObjectFastPath(WTF::FixedVector&& object) +{ + return adoptRef(*new SerializedScriptValue(WTFMove(object))); +} + RefPtr SerializedScriptValue::create(JSContextRef originContext, JSValueRef apiValue, JSValueRef* exception) { JSGlobalObject* lexicalGlobalObject = toJS(originContext); @@ -6117,11 +6259,36 @@ JSValue SerializedScriptValue::deserialize(JSGlobalObject& lexicalGlobalObject, { VM& vm = lexicalGlobalObject.vm(); auto scope = DECLARE_THROW_SCOPE(vm); - // Fast path for string-only values - avoid deserialization overhead - if (m_isStringFastPath) { + switch (m_fastPath) { + case FastPath::String: if (didFail) *didFail = false; return jsString(vm, m_fastPathString); + case FastPath::SimpleObject: { + JSObject* object = constructEmptyObject(globalObject, globalObject->objectPrototype(), std::min(static_cast(m_simpleInMemoryPropertyTable.size()), JSFinalObject::maxInlineCapacity)); + if (scope.exception()) [[unlikely]] { + if (didFail) + *didFail = true; + return {}; + } + + for (const auto& property : m_simpleInMemoryPropertyTable) { + // We **must** clone this so that the atomic flag doesn't get set to true. + JSC::Identifier identifier = JSC::Identifier::fromString(vm, property.propertyName.isolatedCopy()); + JSValue value = WTF::switchOn( + property.value, [](JSValue value) -> JSValue { return value; }, + [&](const String& string) -> JSValue { return jsString(vm, string); }); + object->putDirect(vm, identifier, value); + } + + if (didFail) + *didFail = false; + + return object; + } + case FastPath::None: { + break; + } } DeserializationResult result = CloneDeserializer::deserialize(&lexicalGlobalObject, globalObject, messagePorts diff --git a/src/bun.js/bindings/webcore/SerializedScriptValue.h b/src/bun.js/bindings/webcore/SerializedScriptValue.h index bf2c0c9688..ba434e9c12 100644 --- a/src/bun.js/bindings/webcore/SerializedScriptValue.h +++ b/src/bun.js/bindings/webcore/SerializedScriptValue.h @@ -33,6 +33,8 @@ #include #include #include +#include +#include #include #include #include @@ -58,6 +60,26 @@ class MemoryHandle; namespace WebCore { +class SimpleInMemoryPropertyTableEntry { +public: + // Only: + // - String + // - Number + // - Boolean + // - Null + // - Undefined + using Value = std::variant; + + WTF::String propertyName; + Value value; +}; + +enum class FastPath : uint8_t { + None, + String, + SimpleObject, +}; + #if ENABLE(OFFSCREEN_CANVAS_IN_WORKERS) class DetachedOffscreenCanvas; #endif @@ -104,6 +126,9 @@ public: // Fast path for postMessage with pure strings static Ref createStringFastPath(const String& string); + // Fast path for postMessage with simple objects + static Ref createObjectFastPath(WTF::FixedVector&& object); + static Ref nullValue(); WEBCORE_EXPORT JSC::JSValue deserialize(JSC::JSGlobalObject&, JSC::JSGlobalObject*, SerializationErrorMode = SerializationErrorMode::Throwing, bool* didFail = nullptr); @@ -205,6 +230,7 @@ private: // Constructor for string fast path explicit SerializedScriptValue(const String& fastPathString); + explicit SerializedScriptValue(WTF::FixedVector&& object); size_t computeMemoryCost() const; @@ -230,9 +256,10 @@ private: // Fast path for postMessage with pure strings - avoids serialization overhead String m_fastPathString; - bool m_isStringFastPath { false }; - + FastPath m_fastPath { FastPath::None }; size_t m_memoryCost { 0 }; + + FixedVector m_simpleInMemoryPropertyTable {}; }; template diff --git a/test/js/web/structured-clone-fastpath.test.ts b/test/js/web/structured-clone-fastpath.test.ts index 147fd221b6..099a4710de 100644 --- a/test/js/web/structured-clone-fastpath.test.ts +++ b/test/js/web/structured-clone-fastpath.test.ts @@ -1,18 +1,107 @@ import { describe, expect, test } from "bun:test"; describe("Structured Clone Fast Path", () => { + test("structuredClone should work with empty object", () => { + const object = {}; + const cloned = structuredClone(object); + expect(cloned).toStrictEqual({}); + }); + + test("structuredClone should work with empty string", () => { + const string = ""; + const cloned = structuredClone(string); + expect(cloned).toStrictEqual(""); + }); + + const deOptimizations = [ + { + get accessor() { + return 1; + }, + }, + Object.create(Object.prototype, { + data: { + value: 1, + writable: false, + configurable: false, + }, + }), + Object.create(Object.prototype, { + data: { + value: 1, + writable: true, + configurable: false, + }, + }), + Object.create(Object.prototype, { + data: { + get: () => 1, + configurable: true, + }, + }), + Object.create(Object.prototype, { + data: { + set: () => {}, + enumerable: true, + configurable: true, + }, + }), + ]; + + for (const deOptimization of deOptimizations) { + test("structuredCloneDeOptimization", () => { + structuredClone(deOptimization); + }); + } + test("structuredClone should use a constant amount of memory for string inputs", () => { - // Create a 100KB string to test fast path - const largeString = Buffer.alloc(512 * 1024).toString(); + const clones: Array = []; + // Create a 512KB string to test fast path + const largeString = Buffer.alloc(512 * 1024, "a").toString(); for (let i = 0; i < 100; i++) { - structuredClone(largeString); + clones.push(structuredClone(largeString)); } + Bun.gc(true); const rss = process.memoryUsage.rss(); for (let i = 0; i < 10000; i++) { - structuredClone(largeString); + clones.push(structuredClone(largeString)); } + Bun.gc(true); + const rss2 = process.memoryUsage.rss(); + const delta = rss2 - rss; + expect(delta).toBeLessThan(1024 * 1024 * 8); + expect(clones.length).toBe(10000 + 100); + }); + + test("structuredClone should use a constant amount of memory for simple object inputs", () => { + // Create a 512KB string to test fast path + const largeValue = { property: Buffer.alloc(512 * 1024, "a").toString() }; + for (let i = 0; i < 100; i++) { + structuredClone(largeValue); + } + Bun.gc(true); + const rss = process.memoryUsage.rss(); + for (let i = 0; i < 10000; i++) { + structuredClone(largeValue); + } + Bun.gc(true); const rss2 = process.memoryUsage.rss(); const delta = rss2 - rss; expect(delta).toBeLessThan(1024 * 1024); }); + + test("structuredClone on object with simple properties can exceed JSFinalObject::maxInlineCapacity", () => { + let largeValue = {}; + for (let i = 0; i < 100; i++) { + largeValue["property" + i] = i; + } + + for (let i = 0; i < 100; i++) { + expect(structuredClone(largeValue)).toStrictEqual(largeValue); + } + Bun.gc(true); + for (let i = 0; i < 100; i++) { + expect(structuredClone(largeValue)).toStrictEqual(largeValue); + } + }); });