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, + }); + } +}