Skip to content

Stack-allocate trivial value_object/value_array argument temporaries - #27610

Merged
brendandahl merged 5 commits into
emscripten-core:mainfrom
dimokol:embind-trivial-value-stack
Sep 15, 2026
Merged

brendandahl merged 5 commits into
emscripten-core:mainfrom
dimokol:embind-trivial-value-stack

Conversation

@dimokol

@dimokol dimokol commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Addresses the argument-marshaling side of #27553.

When a value type is trivially constructible and destructible and does not require over-alignment (alignof(T) <= STACK_ALIGN), its argument temporaries no longer round-trip through new T() plus destructor bookkeeping. The registration passes sizeof(T) and a triviality flag; toWireType places the temporary on the wasm stack when the invoker brackets the call in stackSave/stackRestore (a null destructors argument is that contract), zero-filled so unregistered fields and padding match the heap path's value-initialization. The bracket restores the frame in a finally, so a throwing argument conversion or callee cannot leak stack. Over-aligned types keep the heap path, as do callers that defer destruction (emval returns, property setters), Asyncify builds, and JSPI-async invokers, which outlive the frame. Field/element writes skip their per-write destructors array when the element type registers no destructor, which turns out to be the dominant source of per-call garbage in large modules (V8 sinks those arrays in small benchmarks but not at scale). The AOT generator mirrors the type shape so invoker signatures stay in sync, and libsigs.js is regenerated for the new registration parameters.

Measured on box3d.js (a real embind physics binding) rebuilt with this branch, forced-GC heapUsed deltas, median over 9 rounds of 20k calls:

call before after
getter taking one 3-field value_object id 96 B/call 0
getter with id + value_array vec3 argument 208 16
raycast (id, two vec3, filter in; result object out) 179 37
world step (id + two scalars) 125 61

A stock rebuild with an unpatched toolchain reproduces the before column exactly. The remaining nonzeros are value-type returns, which still materialize fresh objects by design and are out of scope here.

other.test_embind, other.test_embind_aot_js, and other.test_embind_no_dynamic pass locally.

Draft because two things are still missing and I'd like direction before writing them: dedicated tests for the new behavior (a trivial POD value type exercising the stack path, plus an allocation regression check if that fits the suite), and the ChangeLog entry. Note the registration arity change means objects built against an older bind.h need a rebuild.

@brendandahl

Copy link
Copy Markdown
Collaborator

This looks good. For testing, add a test in test_other.py around the other embind tests. You should be able to override malloc and free and assert nothing is allocated while testing value_object and value_array.

@dimokol
dimokol marked this pull request as ready for review August 30, 2026 07:52
@dimokol

dimokol commented Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Done. Added other.test_embind_trivial_value_stack next to the other embind tests, with o2 and aot_js variants: malloc and free are overridden to count, and the trivial value_object/value_array loops assert zero allocations. It also covers zero-init of unregistered fields, a throwing conversion mid-marshal (stack pointer checked stable), the non-trivial control staying on the heap path with balanced allocs and destructors, and the alignas(64) fallback. Built with a stock toolchain the same test fails with 400 allocations, so it does guard the regression. ChangeLog entry added under 6.0.9, with a note that the registration arity change needs a rebuild of old objects. Taking it out of draft.

Copilot AI lite review requested due to automatic review settings August 30, 2026 07:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@dimokol
dimokol force-pushed the embind-trivial-value-stack branch from 801dfa4 to 1a0f62c Compare August 30, 2026 07:55

@brendandahl brendandahl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. A few little things.

Comment thread test/test_other.py
Comment thread src/lib/libembind.js
Comment thread system/include/emscripten/bind.h Outdated
@dimokol

dimokol commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all three. All four test variants (default, no_dynamic, aot_js, wasm64) plus test_embind, test_embind_aot_js and test_embind_no_dynamic pass locally.

@dimokol
dimokol force-pushed the embind-trivial-value-stack branch from 067cffc to 660e695 Compare September 10, 2026 07:20
@dimokol
dimokol requested a review from brendandahl September 11, 2026 09:02

@brendandahl brendandahl left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One last thing on the changelog and I'll get this merged in.

Comment thread ChangeLog.md
When a value type is trivially constructible and destructible (and
alignof(T) <= STACK_ALIGN), its argument temporaries no longer
round-trip through new T() plus destructor bookkeeping. The
registration passes sizeof(T) and a triviality flag; toWireType places
the temporary on the wasm stack when the invoker brackets the call in
stackSave/stackRestore (a null destructors argument is that contract),
zero-filled so unregistered fields and padding match the heap path's
value-initialization. The bracket is a try/finally, so a throwing
argument conversion or callee cannot leak stack. Callers that defer
destruction (emval returns, property setters) keep the heap path, as
do Asyncify builds and JSPI-async invokers, which outlive the frame.
Field and element writes skip their per-write destructors array when
the element type registers no destructor, the dominant source of
per-call garbage in large modules. The AOT generator mirrors the type
shape so invoker signatures stay in sync ('s' kind), and libsigs.js is
regenerated for the new registration parameters.

Note the registration arity change means objects built against an
older bind.h need a rebuild.
@dimokol
dimokol force-pushed the embind-trivial-value-stack branch from 660e695 to 86e6424 Compare September 13, 2026 10:18
@brendandahl
brendandahl merged commit cfb87fa into emscripten-core:main Sep 15, 2026
42 checks passed
Comment thread src/lib/libembind.js
// Zero-fill so unregistered fields and padding match the
// value-initialization the heap path's `new T()` performs.
ptr = stackAlloc(valueSize);
HEAPU8.fill(0, ptr, ptr + valueSize);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a zeroMemory helper for this

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missed this. Switching both spots over in a followup

Comment thread src/lib/libembind.js
HEAPU8.fill(0, ptr, ptr + valueSize);
} else {
ptr = rawConstructor();
if (destructors !== null) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we just use the truthiness of destructors here and above (rather than comparing explictly with null?)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it's only ever an array or null. Same followup.

$argsUseStackAlloc(argTypes) {
// Skip return value at index 0 - only arguments stack-allocate.
for (var i = 1; i < argTypes.length; ++i) {
if (argTypes[i] !== null && argTypes[i].argStackAlloc) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can argTypes[i] ever be null here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, for free functions _embind_register_function splices a null into slot 1 for the this type, and craftInvokerFunction checks argTypes[1] !== null to tell methods apart. usesDestructorStack above has the same guard for the same reason.

Comment thread src/lib/libembind.js
var rv;
// The frame must be released on every completion, including a throwing
// argument conversion or callee: a skipped stackRestore permanently
// leaks wasm stack.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this true? In other places in JS we don't restore the stack on the exception path (see withStackSave, for example). I think the idea is that when an exception happens the module is in an undefined state, no need to restore the sp in this case I think?

So maybe this try/catch can be removed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth keeping. The case it covers is a JS getter throwing during argument conversion, after stackAlloc already ran for that argument and with every wasm call so far returned cleanly. I tried it with the restore on the normal path only and the 1000-iteration getter loop in the new test leaks 16000 bytes. It's the same story for a C++ exception escaping to JS under wasm EH (a small probe leaks 32 bytes per throw) and the exceptions docs recommend exactly this stackSave/stackRestore around the catch for that (https://emscripten.org/docs/porting/exceptions.html#handling-c-exceptions-from-javascript)

withStackSave is probably fine promising less since its callers are runtime internals, but here the throws are things callers catch and carry on from, so the leak adds up.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But wouldn't anyone actually catching a C exception from JS need to restore the SP themselves? This restore might work for the leaf function but what if there is other LLVM stuff on the stack when the exception is thrown.

IIUC it should be up to the catcher to restore the stack since LLVM stack frames don't restore as they unwind.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, for a C++ exception the catcher has to restore anyway since the frames between don't. The case I care about is a plain JS throw during argument conversion (a getter throwing, or a later argument failing after an earlier one already took its frame) where no wasm frame is unwinding at all. Before this change an embind call never moved the stack pointer, so a JS-side throw couldn't move it either, and a JS caller has no reason to wrap an embind call in stackSave/stackRestore. The finally keeps that property, without it the getter loop in the test drifts by one frame per throw (16 bytes for the test's type). Happy to narrow the comment to say that's what it's for.

@sbc100 sbc100 Sep 17, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But your try/catch in the test, is wrapping native call Module['sumVec']. Any caller what is catching exceptions coming from a calling the Wasm module will need to restore the stack point if they want to continue to use the module and also avoid leakes.

Imagine for example that sumVec itself used some stack space and then trapped. IIUC the only safe thing to do is assume that stack space is leaked whenever an exception comes out of the Module.

The corollary of that is that code within the module should not need to worry itself about restoring the stack pointer when exceptions are thrown. Even trying to make a best effort to do this in some cases I think just muddies the water.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed on the rule. Once wasm has run, whoever catches owns the stack pointer, and embind shouldn't half-promise cleanup for exceptions coming out of the module. I'll drop the try/finally around the call.

The one case I'd still like to cover is different from that. A conversion error (missing field, or a getter throwing) happens in embind's own JS before the C++ function is entered, so from the caller's side no module code ran and there's nothing they'd know to restore. Before this change an embind call never moved the stack pointer, so that throw couldn't leak, and it's the case the getter loop in the test measures (16 bytes per throw without a restore). I could restore in a catch around the argument conversion only and leave the call itself as you describe, which keeps the rule intact, exceptions out of the module are the caller's, exceptions before it never touch the stack.

If you'd rather keep embind out of it entirely, I'll drop that too and take the throwing section out of the test.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we can/should consider conversion errors that happen at the boundary any differencly to wasm calls themselves. i.e. anyone wanting to recover from such error should really be doing stackRestore, just in case.

This is how I see it: As a general rule, if you call into emscripten generated code (either native wasm code, or JS library code, or embind wrapper code, it doesn't matter) and you want to recover from a thrown exception you should always handle the stack restoration before calling back in to the module code.

Emscripten does not, as a rule, take care of the shadow stack restoration during exception unwinding. Its up to each try/catch to handle that at the catch site.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the documenation was not completely clear on this I created #27748. I think this accurately reflects the reality.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, and the docs change settles it. I took the try/finally out and restore on the normal path only, and dropped the throwing section of the test: #27750

sbc100 pushed a commit that referenced this pull request Sep 16, 2026
Follow-ups for the review comments sbc100 left on #27610 after it
merged: zeroMemory instead of HEAPU8.fill for the stack temporaries,
truthiness checks on destructors, and my AUTHORS entry. The try/finally
question is answered on the original thread.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants