From 87a63ef0ef41406a150a7c75d267e83106d26d06 Mon Sep 17 00:00:00 2001 From: dimokol Date: Sat, 19 Sep 2026 11:58:18 +0300 Subject: [PATCH] [embind] Generate value type element writers per type Under DYNAMIC_EXECUTION, build one write function per value_object and value_array so each element's wasm setter gets its own call site. The shared per-element closure made that site megamorphic in modules with many value types, and V8 then boxed every non-integer double on the way in. Number types with an identity conversion are passed straight through. --- ChangeLog.md | 5 + src/lib/libembind.js | 147 +++++++++++++----- ...inimal_runtime_code_size_hello_embind.json | 8 +- ...al_runtime_code_size_hello_embind_val.json | 8 +- .../test_embind_value_object_field_order.cpp | 79 ++++++++++ test/test_other.py | 9 ++ 6 files changed, 207 insertions(+), 49 deletions(-) create mode 100644 test/embind/test_embind_value_object_field_order.cpp diff --git a/ChangeLog.md b/ChangeLog.md index 9d8786a4a20fb..32fce917c8e51 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -36,6 +36,11 @@ See docs/process.md for more on how version tagging works. the per-call garbage on such calls. The registration ABI gained size and triviality parameters, so object files built against an older `bind.h` need to be rebuilt. (#27610) +- Embind `value_object` and `value_array` element writes are now generated + per value type when `DYNAMIC_EXECUTION` is enabled, giving each element's + wasm setter its own call site. On V8 this keeps non-integer number fields + from being boxed on the way in, which was measured as 16 bytes of garbage + per such field in modules with many value types. (#27751) - `emscripten_clear_timeout` now releases the runtime keepalive held by the pending timeout, and both `emscripten_clear_timeout` and `emscripten_clear_immediate` are no-ops for ids that already fired. (#27720) diff --git a/src/lib/libembind.js b/src/lib/libembind.js index d14f950295d05..57a902a9e7db8 100644 --- a/src/lib/libembind.js +++ b/src/lib/libembind.js @@ -330,6 +330,12 @@ var LibraryEmbind = { // https://www.w3.org/TR/wasm-js-api-1/#towebassemblyvalue return value; }, +#if !ASSERTIONS + // With no checks above, toWireType is the identity: the VM's own + // JS-to-Wasm coercion does the work. Value types use this to pass + // element values straight to their wasm setters (see makeValueWriter). + identityToWireType: true, +#endif readValueFromPointer: integerReadValueFromPointer(name, size, minRange !== 0), destructorFunction: null, // This type does not need a destructor }); @@ -411,6 +417,10 @@ var LibraryEmbind = { // https://www.w3.org/TR/wasm-js-api-1/#towebassemblyvalue return value; }, +#if !ASSERTIONS + // See _embind_register_integer. + identityToWireType: true, +#endif readValueFromPointer: floatReadValueFromPointer(name, size), destructorFunction: null, // This type does not need a destructor }); @@ -958,9 +968,81 @@ var LibraryEmbind = { }); }, + // Builds the element write step of a value_array or value_object: a + // function that passes each element of `o` to the wasm setter for its + // slot. `keys` are the indices or field names to read from `o`. + // + // Element types that register no destructor skip the per-write + // destructors array. Stack-allocating types still get one: a null + // destructors argument means an invoker-managed stack frame, which a + // nested write cannot assume. + // + // Number element values must reach the wasm setter without crossing a JS + // call V8 cannot inline: every non-Smi double passing through such a call + // is boxed (16 bytes of garbage per element), and V8 only inlines a + // JS-to-wasm call whose site has a single known target. A per-element + // closure built from one literal serves every element of every value type + // in the module, so its setter call site turns megamorphic once a few + // value types are registered. With dynamic execution the writer is + // generated per value type with one call per element, each site + // monomorphic, and number types with an identity conversion (see + // _embind_register_integer) are passed straight through. + $makeValueWriter__deps: ['$runDestructors'], + $makeValueWriter: (setters, setterContexts, setterArgumentTypes, keys) => { +#if DYNAMIC_EXECUTION + var params = ['runDestructors']; + var args = [runDestructors]; + var body = ''; + for (var i = 0; i < setters.length; ++i) { + var type = setterArgumentTypes[i]; + params.push(`s${i}`, `c${i}`); + args.push(setters[i], setterContexts[i]); + var value = `o[${JSON.stringify(keys[i])}]`; + var needsDestructors = type.destructorFunction !== null || type.argStackAlloc; + if (!type.identityToWireType) { + // Bound and passed in rather than named in the generated source, so + // closure compiler property renaming stays out of the picture. + params.push(`toWire${i}`); + args.push(type.toWireType.bind(type)); + if (needsDestructors) { + body += `var d${i} = [];\n`; + value = `toWire${i}(d${i}, ${value})`; + } else { + value = `toWire${i}(null, ${value})`; + } + } + body += `s${i}(c${i}, ptr, ${value});\n`; + if (needsDestructors) { + body += `runDestructors(d${i});\n`; + } + } + return new Function(...params, `return (ptr, o) => {\n${body}};`)(...args); +#else + var writers = setters.map((setter, i) => { + var context = setterContexts[i]; + var type = setterArgumentTypes[i]; + var key = keys[i]; + if (type.destructorFunction === null && !type.argStackAlloc) { + return (ptr, o) => setter(context, ptr, type.toWireType(null, o[key])); + } + return (ptr, o) => { + var destructors = []; + setter(context, ptr, type.toWireType(destructors, o[key])); + runDestructors(destructors); + }; + }); + return (ptr, o) => { + for (var i = 0; i < writers.length; ++i) { + writers[i](ptr, o); + } + }; +#endif + }, + _embind_finalize_value_array__deps: [ - '$tupleRegistrations', '$runDestructors', - '$readPointer', '$whenDependentTypesAreResolved', '$stackAlloc', '$zeroMemory'], + '$tupleRegistrations', + '$readPointer', '$whenDependentTypesAreResolved', '$stackAlloc', '$zeroMemory', + '$makeValueWriter'], _embind_finalize_value_array: (rawTupleType) => { var reg = tupleRegistrations[rawTupleType]; delete tupleRegistrations[rawTupleType]; @@ -979,24 +1061,13 @@ var LibraryEmbind = { const getterReturnType = elementTypes[i]; const getter = elt.getter; const getterContext = elt.getterContext; - const setterArgumentType = elementTypes[i + elementsLength]; - const setter = elt.setter; - const setterContext = elt.setterContext; elt.read = (ptr) => getterReturnType.fromWireType(getter(getterContext, ptr)); - if (setterArgumentType.destructorFunction === null && !setterArgumentType.argStackAlloc) { - // The element type never registers a destructor, so skip the - // per-write destructors array. (Stack-allocating types still need - // the array here: a null destructors argument means an - // invoker-managed stack frame, which a nested write cannot assume.) - elt.write = (ptr, o) => setter(setterContext, ptr, setterArgumentType.toWireType(null, o)); - } else { - elt.write = (ptr, o) => { - var destructors = []; - setter(setterContext, ptr, setterArgumentType.toWireType(destructors, o)); - runDestructors(destructors); - }; - } } + var writeElements = makeValueWriter( + elements.map((elt) => elt.setter), + elements.map((elt) => elt.setterContext), + elementTypes.slice(elementsLength), + elements.map((elt, i) => i)); return [{ name: reg.name, @@ -1029,9 +1100,7 @@ var LibraryEmbind = { destructors.push(rawDestructor, ptr); } } - for (var i = 0; i < elementsLength; ++i) { - elements[i].write(ptr, o[i]); - } + writeElements(ptr, o); return ptr; }, readValueFromPointer: readPointer, @@ -1091,8 +1160,9 @@ var LibraryEmbind = { }, _embind_finalize_value_object__deps: [ - '$structRegistrations', '$runDestructors', - '$readPointer', '$whenDependentTypesAreResolved', '$stackAlloc', '$zeroMemory'], + '$structRegistrations', + '$readPointer', '$whenDependentTypesAreResolved', '$stackAlloc', '$zeroMemory', + '$makeValueWriter'], _embind_finalize_value_object: (structType) => { var reg = structRegistrations[structType]; delete structRegistrations[structType]; @@ -1110,26 +1180,23 @@ var LibraryEmbind = { const getterReturnType = fieldTypes[i]; const getter = field.getter; const getterContext = field.getterContext; - const setterArgumentType = fieldTypes[i + fieldRecords.length]; - const setter = field.setter; - const setterContext = field.setterContext; - var write; - if (setterArgumentType.destructorFunction === null && !setterArgumentType.argStackAlloc) { - // See the matching element-write logic in _embind_finalize_value_array. - write = (ptr, o) => setter(setterContext, ptr, setterArgumentType.toWireType(null, o)); - } else { - write = (ptr, o) => { - var destructors = []; - setter(setterContext, ptr, setterArgumentType.toWireType(destructors, o)); - runDestructors(destructors); - }; - } fields[field.fieldName] = { read: (ptr) => getterReturnType.fromWireType(getter(getterContext, ptr)), - write, + setter: field.setter, + setterContext: field.setterContext, + setterArgumentType: fieldTypes[i + fieldRecords.length], optional: getterReturnType.optional, }; } + // Write in the enumeration order of `fields`, one write per surviving + // name (a field registered twice keeps its last registration), the same + // order and set the reads use. + var fieldNames = Object.keys(fields); + var writeFields = makeValueWriter( + fieldNames.map((name) => fields[name].setter), + fieldNames.map((name) => fields[name].setterContext), + fieldNames.map((name) => fields[name].setterArgumentType), + fieldNames); return [{ name: reg.name, @@ -1163,9 +1230,7 @@ var LibraryEmbind = { destructors.push(rawDestructor, ptr); } } - for (fieldName in fields) { - fields[fieldName].write(ptr, o[fieldName]); - } + writeFields(ptr, o); return ptr; }, readValueFromPointer: readPointer, diff --git a/test/codesize/test_minimal_runtime_code_size_hello_embind.json b/test/codesize/test_minimal_runtime_code_size_hello_embind.json index 4ee8bc01f4164..17ee81f14a583 100644 --- a/test/codesize/test_minimal_runtime_code_size_hello_embind.json +++ b/test/codesize/test_minimal_runtime_code_size_hello_embind.json @@ -1,10 +1,10 @@ { "a.html": 548, "a.html.gz": 371, - "a.js": 7487, - "a.js.gz": 3437, + "a.js": 7497, + "a.js.gz": 3441, "a.wasm": 7201, "a.wasm.gz": 3303, - "total": 15236, - "total_gz": 7111 + "total": 15246, + "total_gz": 7115 } diff --git a/test/codesize/test_minimal_runtime_code_size_hello_embind_val.json b/test/codesize/test_minimal_runtime_code_size_hello_embind_val.json index 63fcb182ca317..e1d58f31d2f94 100644 --- a/test/codesize/test_minimal_runtime_code_size_hello_embind_val.json +++ b/test/codesize/test_minimal_runtime_code_size_hello_embind_val.json @@ -1,10 +1,10 @@ { "a.html": 548, "a.html.gz": 371, - "a.js": 5349, - "a.js.gz": 2515, + "a.js": 5359, + "a.js.gz": 2520, "a.wasm": 5817, "a.wasm.gz": 2736, - "total": 11714, - "total_gz": 5622 + "total": 11724, + "total_gz": 5627 } diff --git a/test/embind/test_embind_value_object_field_order.cpp b/test/embind/test_embind_value_object_field_order.cpp new file mode 100644 index 0000000000000..958f974300888 --- /dev/null +++ b/test/embind/test_embind_value_object_field_order.cpp @@ -0,0 +1,79 @@ +// Copyright 2026 The Emscripten Authors. All rights reserved. +// Emscripten is available under two separate licenses, the MIT license and the +// University of Illinois/NCSA Open Source License. Both these licenses can be +// found in the LICENSE file. + +// A value_object writes its fields in the enumeration order of its field +// table, integer-like names first, and a name registered twice keeps only +// its last registration. Both must hold on the generated and the closure +// write paths, and match the read order. + +#include +#include + +#include +#include + +struct Rec { + int b; + int two; + int a; + int first; + int last; +}; + +static int lastSetterCalls = 0; +static int firstSetterCalls = 0; + +int getA(const Rec& r) { return r.a; } +void setA(Rec& r, int v) { r.a = v; } +int getB(const Rec& r) { return r.b; } +void setB(Rec& r, int v) { r.b = v; } +int getTwo(const Rec& r) { return r.two; } +void setTwo(Rec& r, int v) { r.two = v; } +int getFirst(const Rec& r) { return r.first; } +void setFirst(Rec& r, int v) { ++firstSetterCalls; r.first = v; } +int getLast(const Rec& r) { return r.last; } +void setLast(Rec& r, int v) { ++lastSetterCalls; r.last = v; } + +int sum(Rec r) { + return r.b + r.two + r.a + r.first + r.last; +} + +int firstCalls() { return firstSetterCalls; } +int lastCalls() { return lastSetterCalls; } + +EMSCRIPTEN_BINDINGS(field_order) { + emscripten::value_object("Rec") + .field("b", &getB, &setB) + .field("2", &getTwo, &setTwo) + .field("a", &getA, &setA) + // "x" registered twice: only the second registration must write. + .field("x", &getFirst, &setFirst) + .field("x", &getLast, &setLast); + + emscripten::function("sum", &sum); + emscripten::function("firstCalls", &firstCalls); + emscripten::function("lastCalls", &lastCalls); +} + +int main() { + EM_ASM({ + var order = []; + var o = {}; + var define = (name, value) => Object.defineProperty(o, name, { + get: () => { order.push(name); return value; }, enumerable: true + }); + define('b', 1); + define('2', 20); + define('a', 300); + define('x', 4000); + if (Module['sum'](o) !== 4321) throw 'sum'; + // Integer-like names enumerate first, then insertion order. + if (order.join() !== '2,b,a,x') throw 'write order: ' + order.join(); + if (Module['firstCalls']() !== 0) throw 'first registration of x wrote'; + if (Module['lastCalls']() !== 1) throw 'last registration of x did not write once'; + }); + printf("done\n"); + return 0; +} diff --git a/test/test_other.py b/test/test_other.py index b755d7b681b5f..1fe75e8d7cc22 100644 --- a/test/test_other.py +++ b/test/test_other.py @@ -3652,6 +3652,15 @@ def test_embind_trivial_value_stack(self, *extra_args): self.require_wasm64() self.do_runf('embind/test_embind_trivial_value_stack.cpp', 'done\n', cflags=['-lembind', *extra_args]) + @parameterized({ + '': (), + 'no_dynamic': ('-sDYNAMIC_EXECUTION=0',), + }) + def test_embind_value_object_field_order(self, *extra_args): + # Field writes follow the field table's enumeration order and a name + # registered twice keeps its last registration, on both write paths. + self.do_runf('embind/test_embind_value_object_field_order.cpp', 'done\n', cflags=['-lembind', *extra_args]) + @requires_node_25 def test_embind_resource_management(self): self.node_args.append('--js-explicit-resource-management')