Stack-allocate trivial value_object/value_array argument temporaries - #27610
Conversation
|
This looks good. For testing, add a test in |
|
Done. Added |
801dfa4 to
1a0f62c
Compare
brendandahl
left a comment
There was a problem hiding this comment.
Looking good. A few little things.
|
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. |
067cffc to
660e695
Compare
brendandahl
left a comment
There was a problem hiding this comment.
One last thing on the changelog and I'll get this merged in.
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.
660e695 to
86e6424
Compare
| // 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); |
There was a problem hiding this comment.
We have a zeroMemory helper for this
There was a problem hiding this comment.
Missed this. Switching both spots over in a followup
| HEAPU8.fill(0, ptr, ptr + valueSize); | ||
| } else { | ||
| ptr = rawConstructor(); | ||
| if (destructors !== null) { |
There was a problem hiding this comment.
Can we just use the truthiness of destructors here and above (rather than comparing explictly with null?)
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
Can argTypes[i] ever be null here?
There was a problem hiding this comment.
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.
| var rv; | ||
| // The frame must be released on every completion, including a throwing | ||
| // argument conversion or callee: a skipped stackRestore permanently | ||
| // leaks wasm stack. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Since the documenation was not completely clear on this I created #27748. I think this accurately reflects the reality.
There was a problem hiding this comment.
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
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.
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 passessizeof(T)and a triviality flag;toWireTypeplaces the temporary on the wasm stack when the invoker brackets the call instackSave/stackRestore(a nulldestructorsargument is that contract), zero-filled so unregistered fields and padding match the heap path's value-initialization. The bracket restores the frame in afinally, 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, andlibsigs.jsis 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:
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, andother.test_embind_no_dynamicpass 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.hneed a rebuild.