From 0087afc0a739cdb7469ed2647381a0782262f22e Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:37:55 -0700 Subject: [PATCH] node-api: use fast properties in node_api_create_object_with_properties node_api_create_object_with_properties() creates every object with v8::Object::New(), which stores the properties in a dictionary. Reads of those objects are about 7x slower than reads of an object literal, and about 70x slower with a null prototype, because then each object also gets a map of its own. Keep a cache of v8::DictionaryTemplate per env, keyed by a hash of the property names. A list of names passed once is only recorded by its hash. When the same list is passed again, a template is created for it, and from then on its objects are instances of the template, with fast properties and one shared map, like object literals. The first 256 lists that are passed again get a template and keep it for the life of the env; later lists keep using v8::Object::New(). A prototype other than Object.prototype costs a SetPrototype() call per object. Names that a template cannot hold keep using v8::Object::New(): symbols, strings that are not one-byte, array indices, duplicate names, and lists of more than 127 names. Add a read operation to the benchmark, which only measured creation. Fixes: https://github.com/nodejs/node/issues/66441 Assisted-by: Claude Code Signed-off-by: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> --- .../create_object_with_properties/binding.cc | 35 +++++ .../create_object_with_properties/index.js | 21 ++- src/js_native_api_v8.cc | 127 +++++++++++++++++- src/js_native_api_v8.h | 44 ++++++ test/js-native-api/test_object/test_object.c | 36 +++++ .../test_object_with_properties.js | 118 ++++++++++++++++ 6 files changed, 372 insertions(+), 9 deletions(-) create mode 100644 test/js-native-api/test_object/test_object_with_properties.js diff --git a/benchmark/napi/create_object_with_properties/binding.cc b/benchmark/napi/create_object_with_properties/binding.cc index fda692395b74..8a0beb94cc39 100644 --- a/benchmark/napi/create_object_with_properties/binding.cc +++ b/benchmark/napi/create_object_with_properties/binding.cc @@ -91,6 +91,40 @@ static napi_value CreateObjectWithPropertiesOld(napi_env env, return nullptr; } +// Returns an array of objects created like the ones above, for reading. +static napi_value CreateObjects(napi_env env, napi_callback_info info) { + size_t argc = 2; + napi_value args[2]; + napi_get_cb_info(env, info, &argc, args, nullptr, nullptr); + bool use_new; + uint32_t count; + napi_get_value_bool(env, args[0], &use_new); + napi_get_value_uint32(env, args[1], &count); + + InitializeTestProperties(env); + + napi_value null_prototype; + napi_get_null(env, &null_prototype); + + napi_value result; + napi_create_array_with_length(env, count, &result); + for (uint32_t i = 0; i < count; i++) { + napi_value obj; + if (use_new) { + node_api_create_object_with_properties( + env, null_prototype, global_names, global_values, 20, &obj); + } else { + napi_create_object(env, &obj); + for (int j = 0; j < 20; j++) { + napi_set_property(env, obj, global_names[j], global_values[j]); + } + } + napi_set_element(env, result, i, obj); + } + + return result; +} + NAPI_MODULE_INIT() { napi_property_descriptor desc[] = { {"createObjectWithPropertiesNew", @@ -109,6 +143,7 @@ NAPI_MODULE_INIT() { 0, napi_default, 0}, + {"createObjects", 0, CreateObjects, 0, 0, 0, napi_default, 0}, }; napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc); diff --git a/benchmark/napi/create_object_with_properties/index.js b/benchmark/napi/create_object_with_properties/index.js index b099697dd018..79f45199e203 100644 --- a/benchmark/napi/create_object_with_properties/index.js +++ b/benchmark/napi/create_object_with_properties/index.js @@ -1,5 +1,6 @@ 'use strict'; +const assert = require('assert'); const common = require('../../common.js'); let binding; @@ -13,12 +14,28 @@ try { const bench = common.createBenchmark(main, { n: [1e2, 1e3, 1e4, 1e5, 1e6], method: ['new', 'old'], + operation: ['create', 'read'], }); -function main({ n, method }) { - if (method === 'new') { +function main({ n, method, operation }) { + if (operation === 'read') { + read(n, method); + } else if (method === 'new') { binding.createObjectWithPropertiesNew(n, bench, bench.start, bench.end); } else { binding.createObjectWithPropertiesOld(n, bench, bench.start, bench.end); } } + +// Reads three properties of one of 1000 objects, n times. +function read(n, method) { + const objects = binding.createObjects(method === 'new', 1000); + let length = 0; + bench.start(); + for (let i = 0; i < n; i++) { + const object = objects[i % objects.length]; + length += object.foo0.length + object.foo9.length + object.foo19.length; + } + bench.end(n); + assert.ok(length > 0); +} diff --git a/src/js_native_api_v8.cc b/src/js_native_api_v8.cc index 6ae976df2971..d46452da9afa 100644 --- a/src/js_native_api_v8.cc +++ b/src/js_native_api_v8.cc @@ -597,8 +597,114 @@ inline bool CanBeHeldWeakly(v8::Local value) { return value->IsObject() || value->IsSymbol(); } +// "0" to "4294967294" in canonical form, which V8 stores as an element. +bool IsArrayIndex(std::string_view name) { + if (name.empty() || name.size() > 10 || (name.size() > 1 && name[0] == '0')) { + return false; + } + uint64_t index = 0; + for (char c : name) { + if (c < '0' || c > '9') return false; + index = index * 10 + (c - '0'); + } + return index <= 4294967294u; +} + +// v8::DictionaryTemplate::New() takes the names as one-byte strings and +// aborts on an array index, and a duplicate name is only handled by +// v8::Object::New(), so there is no template for those names. +v8::Local NewDictionaryTemplate( + v8::Isolate* isolate, v8::Local* names, size_t count) { + std::vector strings(count); + for (size_t i = 0; i < count; i++) { + if (!names[i]->IsString()) return {}; + v8::Local name = names[i].As(); + if (!name->ContainsOnlyOneByte()) return {}; + strings[i].resize(name->Length()); + name->WriteOneByteV2(isolate, + 0, + name->Length(), + reinterpret_cast(strings[i].data())); + if (IsArrayIndex(strings[i])) return {}; + for (size_t j = 0; j < i; j++) { + if (strings[j] == strings[i]) return {}; + } + } + std::vector views(strings.begin(), strings.end()); + return v8::DictionaryTemplate::New( + isolate, + v8::MemorySpan(views.data(), views.size())); +} + } // end of anonymous namespace +v8::MaybeLocal ObjectShapeCache::New( + v8::Isolate* isolate, + v8::Local context, + v8::Local prototype_or_null, + v8::Local* names, + v8::Local* values, + size_t count) { + auto object_new = [&]() -> v8::MaybeLocal { + return v8::Object::New(isolate, prototype_or_null, names, values, count); + }; + // v8::Object::New() reports a prototype that is neither null nor an object. + if (count == 0 || count > kMaxProperties || + !(prototype_or_null->IsNull() || prototype_or_null->IsObject())) { + return object_new(); + } + + // The identity hash of a string is the hash of its contents, so equal names + // created separately hash the same. + uint32_t hash = static_cast(count); + for (size_t i = 0; i < count; i++) { + hash = hash * 31 + static_cast(names[i]->GetIdentityHash()); + } + auto it = shapes_.find(hash); + if (it == shapes_.end()) { + uint32_t& seen = seen_[(hash ^ (hash >> 16)) % kSeenEntries]; + if (seen != hash || shapes_.size() == kMaxShapes) { + seen = hash; + return object_new(); + } + // The second time these names are passed. + it = shapes_.emplace(hash, Shape()).first; + v8::Local tmpl = + NewDictionaryTemplate(isolate, names, count); + if (!tmpl.IsEmpty()) { + it->second.tmpl.Reset(isolate, tmpl); + it->second.names.reserve(count); + for (size_t i = 0; i < count; i++) { + it->second.names.emplace_back(isolate, names[i]); + } + } + } + + const Shape& shape = it->second; + if (shape.tmpl.IsEmpty() || shape.names.size() != count) return object_new(); + for (size_t i = 0; i < count; i++) { + v8::Local name = shape.names[i].Get(isolate); + if (name != names[i] && !name->StrictEquals(names[i])) return object_new(); + } + + std::array, kMaxProperties> property_values; + for (size_t i = 0; i < count; i++) { + // NewInstance() would leave the property out, so keep what + // v8::Object::New() does with an empty value. + if (values[i].IsEmpty()) return object_new(); + property_values[i] = values[i]; + } + v8::Local obj = shape.tmpl.Get(isolate)->NewInstance( + context, + v8::MemorySpan>(property_values.data(), count)); + // The template's map has the context's Object.prototype. + if (obj->GetPrototypeV2() != prototype_or_null && + obj->SetPrototypeV2(context, prototype_or_null).IsNothing()) { + return {}; + } + return obj; +} + void Finalizer::ResetEnv() { env_ = nullptr; } @@ -1651,13 +1757,20 @@ node_api_create_object_with_properties(napi_env env, v8_values[i] = v8impl::V8LocalValueFromJsValue(property_values[i]); } - v8::Local obj = v8::Object::New(env->isolate, - v8_prototype_or_null, - v8_names.data(), - v8_values.data(), - property_count); - - RETURN_STATUS_IF_FALSE(env, !obj.IsEmpty(), napi_generic_failure); + if (env->object_shape_cache == nullptr) { + env->object_shape_cache = std::make_unique(); + } + v8::Local obj; + RETURN_STATUS_IF_FALSE(env, + env->object_shape_cache + ->New(env->isolate, + env->context(), + v8_prototype_or_null, + v8_names.data(), + v8_values.data(), + property_count) + .ToLocal(&obj), + napi_generic_failure); *result = v8impl::JsValueFromV8LocalValue(obj); return napi_clear_last_error(env); } diff --git a/src/js_native_api_v8.h b/src/js_native_api_v8.h index 262916c09b5b..34136eade6eb 100644 --- a/src/js_native_api_v8.h +++ b/src/js_native_api_v8.h @@ -1,6 +1,11 @@ #ifndef SRC_JS_NATIVE_API_V8_H_ #define SRC_JS_NATIVE_API_V8_H_ +#include +#include +#include +#include + #include "js_native_api_types.h" #include "js_native_api_v8_internals.h" @@ -48,6 +53,43 @@ class RefTracker { RefList* prev_ = nullptr; }; +// Creates the objects of node_api_create_object_with_properties(). +// v8::Object::New() puts the properties of every object it creates in a +// dictionary, so those objects are slow to read, and with a null prototype +// each of them also gets a map of its own. When the same property names are +// passed again, this cache creates a v8::DictionaryTemplate for them and +// instantiates it instead, so the objects get fast properties and share a +// map, like objects created from an object literal. +class ObjectShapeCache { + public: + v8::MaybeLocal New(v8::Isolate* isolate, + v8::Local context, + v8::Local prototype_or_null, + v8::Local* names, + v8::Local* values, + size_t count); + + private: + // The first kMaxShapes lists of names that are passed a second time get a + // template, and keep it for the life of the env. A list passed once is only + // recorded by its hash, in a direct-mapped table of kSeenEntries. + static constexpr size_t kMaxShapes = 256; + static constexpr size_t kSeenEntries = 1024; + // From 128 properties V8 creates the objects in dictionary mode anyway, as + // it does for object literals. + static constexpr size_t kMaxProperties = 127; + + struct Shape { + std::vector> names; + // Empty when V8 cannot create a template for the names. + v8::Global tmpl; + }; + + // Keyed by a hash of the names. + std::unordered_map shapes_; + std::array seen_{}; +}; + } // end of namespace v8impl struct napi_env__ { @@ -161,6 +203,8 @@ struct napi_env__ { void* instance_data = nullptr; int32_t module_api_version = NODE_API_DEFAULT_MODULE_API_VERSION; bool in_gc_finalizer = false; + // Created by the first node_api_create_object_with_properties() call. + std::unique_ptr object_shape_cache; protected: // Should not be deleted directly. Delete with `napi_env__::DeleteMe()` diff --git a/test/js-native-api/test_object/test_object.c b/test/js-native-api/test_object/test_object.c index c4901c5adf13..c87489a51310 100644 --- a/test/js-native-api/test_object/test_object.c +++ b/test/js-native-api/test_object/test_object.c @@ -783,6 +783,40 @@ static napi_value TestCreateObjectWithCustomPrototype(napi_env env, return result; } +// CreateObjectWithProperties(names, values, prototype) passes the arrays to +// node_api_create_object_with_properties(), and an undefined prototype as +// NULL. +static napi_value CreateObjectWithProperties(napi_env env, + napi_callback_info info) { + size_t argc = 3; + napi_value args[3]; + napi_value names[200]; + napi_value values[200]; + uint32_t count; + napi_valuetype prototype_type; + napi_value result; + + NODE_API_CALL(env, napi_get_cb_info(env, info, &argc, args, NULL, NULL)); + NODE_API_CALL(env, napi_get_array_length(env, args[0], &count)); + NODE_API_ASSERT(env, count <= 200, "Too many properties"); + for (uint32_t i = 0; i < count; i++) { + NODE_API_CALL(env, napi_get_element(env, args[0], i, &names[i])); + NODE_API_CALL(env, napi_get_element(env, args[1], i, &values[i])); + } + NODE_API_CALL(env, napi_typeof(env, args[2], &prototype_type)); + + NODE_API_CALL(env, + node_api_create_object_with_properties( + env, + prototype_type == napi_undefined ? NULL : args[2], + names, + values, + count, + &result)); + + return result; +} + EXTERN_C_START napi_value Init(napi_env env, napi_value exports) { napi_property_descriptor descriptors[] = { @@ -822,6 +856,8 @@ napi_value Init(napi_env env, napi_value exports) { TestCreateObjectWithPropertiesEmpty), DECLARE_NODE_API_PROPERTY("TestCreateObjectWithCustomPrototype", TestCreateObjectWithCustomPrototype), + DECLARE_NODE_API_PROPERTY("CreateObjectWithProperties", + CreateObjectWithProperties), }; init_test_null(env, exports); diff --git a/test/js-native-api/test_object/test_object_with_properties.js b/test/js-native-api/test_object/test_object_with_properties.js new file mode 100644 index 000000000000..a9cd2c0c8809 --- /dev/null +++ b/test/js-native-api/test_object/test_object_with_properties.js @@ -0,0 +1,118 @@ +// Flags: --allow-natives-syntax +'use strict'; +const common = require('../../common'); +const assert = require('assert'); + +// node_api_create_object_with_properties() returns objects with fast +// properties and a shared map once it has seen the same names before. + +const { CreateObjectWithProperties: create } = + require(`./build/${common.buildType}/test_object`); + +function assertSharedShape(objects) { + // eslint-disable-next-line no-unused-vars + for (const object of objects) { + assert(eval('%HasFastProperties(object)')); + assert(eval('%HaveSameMap(object, objects[0])')); + } +} + +function dataProperty(value) { + return { value, writable: true, enumerable: true, configurable: true }; +} + +{ + const names = ['id', 'name', 'score']; + const prototype = { greet() { return 'hi'; } }; + for (const [argument, expected] of [ + [undefined, null], + [null, null], + [Object.prototype, Object.prototype], + [prototype, prototype], + ]) { + const objects = []; + for (let i = 0; i < 5; i++) { + objects.push(create(names, [i, `row ${i}`, i * 1.5], argument)); + } + for (const [i, object] of objects.entries()) { + assert.strictEqual(Object.getPrototypeOf(object), expected); + assert.deepStrictEqual(Object.getOwnPropertyDescriptors(object), { + id: dataProperty(i), + name: dataProperty(`row ${i}`), + score: dataProperty(i * 1.5), + }); + } + assertSharedShape(objects.slice(1)); + } +} + +// Names are matched by content, not identity. +{ + const objects = []; + for (let i = 0; i < 3; i++) { + const names = ['x', 'y'].map((name) => name.split('').join('')); + objects.push(create(names, [i, i], undefined)); + } + assertSharedShape(objects.slice(1)); +} + +// One-byte names outside ASCII. +{ + const objects = []; + for (let i = 0; i < 3; i++) { + objects.push(create(['café', 'ü'], [i, i], undefined)); + } + assert.deepStrictEqual(Object.keys(objects[2]), ['café', 'ü']); + assertSharedShape(objects.slice(1)); +} + +// Up to 127 properties. +{ + const names = Array.from({ length: 127 }, (_, i) => `p${i}`); + const objects = []; + for (let i = 0; i < 3; i++) objects.push(create(names, names, undefined)); + assert.deepStrictEqual(Object.values(objects[2]), names); + assertSharedShape(objects.slice(1)); +} + +// Names that V8 cannot take in a template keep their behavior: a symbol, an +// array index, a two-byte string, a duplicate (the last value wins), and +// more than 127 names. +{ + const symbol = Symbol('s'); + const many = Array.from({ length: 128 }, (_, i) => `p${i}`); + for (const [names, values, expected] of [ + [[symbol, 'a'], [1, 2], { [symbol]: 1, a: 2 }], + [['0', 'a'], [1, 2], { 0: 1, a: 2 }], + [['名前', 'a'], [1, 2], { '名前': 1, 'a': 2 }], + [['a', 'b', 'a'], [1, 2, 3], { a: 3, b: 2 }], + [many, many, Object.fromEntries(many.map((name) => [name, name]))], + ]) { + for (let i = 0; i < 3; i++) { + const object = create(names, values, undefined); + assert.deepStrictEqual(object, { __proto__: null, ...expected }); + assert.deepStrictEqual(Reflect.ownKeys(object), Reflect.ownKeys(expected)); + } + } +} + +// Every list of names passed again gets fast objects, not only some of them. +for (let i = 0; i < 100; i++) { + const objects = []; + for (let j = 0; j < 3; j++) { + objects.push(create([`c${i}`, `d${i}`], [i, j], undefined)); + } + assertSharedShape(objects.slice(1)); +} + +// More lists of names than the cache has entries. +for (let round = 0; round < 3; round++) { + for (let i = 0; i < 1000; i++) { + const object = create([`a${i}`, `b${i}`], [i, round], undefined); + assert.deepStrictEqual(object, { + __proto__: null, + [`a${i}`]: i, + [`b${i}`]: round, + }); + } +}