ports/webassembly: Add cooperative VM yield to the JS event loop.#19427
Draft
Gadgetoid wants to merge 6 commits into
Draft
ports/webassembly: Add cooperative VM yield to the JS event loop.#19427Gadgetoid wants to merge 6 commits into
Gadgetoid wants to merge 6 commits into
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #19427 +/- ##
=======================================
Coverage 98.51% 98.51%
=======================================
Files 177 177
Lines 22927 22927
=======================================
Hits 22586 22586
Misses 341 341 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Code size report: |
84f6d03 to
676df50
Compare
mp_js_hook (the Node stdin poll for the interrupt character) passed a
spurious argument to mp_hal_get_interrupt_char(), which takes none. Recent
Emscripten builds assert on the argument-count mismatch and abort the VM
mid-execution ("native function mp_hal_get_interrupt_char called with 1
args but expects 0"), which surfaces once the VM hook fires during a
longer-running script. Drop the argument.
Signed-off-by: Phil Howard <[email protected]>
676df50 to
e0f217f
Compare
Contributor
Author
|
This is possibly of interest to @LeaNumworks upon whose work I have no doubt been building. Right now the Numworks simulator has A Bad Time if you're so bold as to use a loop. This is exactly the sort of behaviour this PR fixes successfully for our own simulator.
And since a picture - or a live demo - is worth a thousand words: https://gadgetoid.github.io/upyweb/ |
e0f217f to
14f7e4d
Compare
Long-running or self-looping Python (a bare `while True:` game loop, a
busy computation) currently blocks the browser's main thread for as long
as it runs, because the MicroPython entry points execute to completion
synchronously. Add an optional cooperative yield so the VM periodically
hands the JS event loop a turn without the script having to `await`.
mpconfigport.h: MICROPY_ENABLE_VM_YIELD (default off) routes the existing
VM hook to mp_js_yield().
main.c: mp_js_yield() issues a throttled emscripten_sleep() from the hook,
and mp_js_sleep_ms() backs mp_hal_delay_ms(). Both suspend only when the
whole JS->MP call chain is suspend-capable (external_call_depth ==
suspendable_depth), so a synchronous re-entry from JS can't be unwound. A
MP_WEAK mp_js_yield_hook() lets an embedding run work at a safe point.
mphalport.c: mp_hal_delay_ms() sleeps via the event loop when safe.
api.js: an invoke()/settle() indirection issues the entry-point call the
right way for the build's backend (JSPI promising export, Asyncify
ccall({async:true}), or a plain synchronous ccall) and awaits the result
only when it is a Promise. The backend is announced by async_asyncify.js
and async_jspi.js, added to SRC_JS by the variant; a plain build stays
synchronous.
Makefile: make CSTD and SUPPORT_LONGJMP overridable by variants; add a
test_async target that runs the async-only fixtures (which SKIP elsewhere)
against a suspend-capable build.
variants/asyncify: reference Asyncify build with the yield enabled.
variants/jspi: experimental build using Emscripten JSPI (Wasm stack
switching) instead of Asyncify.
tests/ports/webassembly/async_yield.mjs: yield smoke test, skipped unless
the build is suspend-capable.
tools/ci.sh: build the asyncify variant and run `make test_async` on it.
The default standard and pyscript variants are unchanged and remain
synchronous.
Signed-off-by: Phil Howard <[email protected]>
A self-looping program (a bare `while True:` game loop) never returns to the top level, which broke two assumptions of the port: - Stopping meant tearing the whole instance down. Expose interrupt() (mp_sched_keyboard_interrupt) so a host can raise KeyboardInterrupt and stop a running script in place, then reuse the live instance. mp_js_sleep_ms() now slices the sleep and handles pending exceptions between slices, so time.sleep() is promptly interruptible too. - Under MICROPY_GC_SPLIT_HEAP_AUTO, gc_collect() defers collection to the top level, which a forever-loop never reaches, so the heap grew unbounded to the wasm ceiling. Collect mid-execution from the VM yield hook instead. Asyncify scans the stack + registers; JSPI can't (switched stack, no register spill), so it scans the tracked C-stack range with bounds captured at the outermost entry. gc_alloc_threshold bounds the transient garbage held between collects. main.c: interrupt / sliced sleep, and the mid-execution collector (gc_collect_if_pending, gc_collect_cstack, MP_JS_NOTE_CSTACK_TOP, and the MICROPY_GC_SCAN_REGISTERS / MICROPY_GC_TRACK_CSTACK selection). api.js: expose interrupt(). variants/asyncify, variants/jspi: enable the auto split heap (and memory growth) so a looping program is the case the mid-loop collector bounds. tests/ports/webassembly: async_interrupt, async_restart and async_gc fixtures for interrupt, in-place restart and bounded GC. Signed-off-by: Phil Howard <[email protected]>
A browser can't open raw sockets, so the socket-based `requests` / `urllib.urequest` from micropython-lib don't work in this port. Add a small `_jsfetch` module that does HTTP(S) via the browser's fetch(), for Python code (or a higher-level pure-Python client) to build on. _jsfetch.request() kicks off the fetch then suspends the WASM stack (via emscripten_sleep) until it settles, giving Python an ordinary blocking call, so it needs a suspend-capable build. It is gated on MICROPY_PY_JSFETCH and enabled by the asyncify and jspi variants. jsfetch/jsfetch.c: the `_jsfetch` C module (request()). jsfetch/jsfetch.js: the fetch() half, as an emscripten --js-library. jsfetch/jsfetch.mk: build fragment; include it from a suspend build. mpconfigport.h: MICROPY_PY_JSFETCH gate (default off). variants/asyncify, variants/jspi: include jsfetch/jsfetch.mk. tests/ports/webassembly/async_jsfetch.mjs: cover a blocking GET against a local server. Signed-off-by: Phil Howard <[email protected]>
Asyncify instruments every function that could be on the stack at a suspend. For MicroPython that is almost the whole runtime: indirect calls (the NLR jump callbacks and the print/type-slot vtables) are conservatively assumed to reach a suspend, so even float boxing in a hot loop is instrumented (ASYNCIFY_ADVISE reported ~3200 functions). Add an asyncify-fast variant that turns on ASYNCIFY_IGNORE_INDIRECT and re-adds just the indirect-dispatch sites that genuinely sit above a suspend (the VM call machinery + protocol slots, in asyncify_add.txt); ASYNCIFY_ADD re-roots the analysis so their direct callers are picked up automatically. It also needs the Wasm longjmp backend, as MicroPython's NLR wraps execution in setjmp and the emscripten invoke_* trampolines abort once indirect auto-detection is off. The result runs the arithmetic / alloc / object-op hot path uninstrumented - about 2.5x faster, and 31% smaller wasm here. EXPERIMENTAL: the ADD list must stay complete - a suspend reachable through an un-listed indirect call would corrupt the unwind silently. variants/asyncify-fast: reuse the asyncify variant, add the instrumentation flags and the Wasm exception/longjmp backend. asyncify_add.txt: the curated dispatch-site list. tests/ports/webassembly/asyncify_fast.mjs: force a suspend through every dispatch path and cross-check a checksum against the full build. tools/ci.sh: build the asyncify-fast variant and run its fixture. Signed-off-by: Phil Howard <[email protected]>
There is no native code emitter on wasm (MICROPY_EMIT_NATIVE == 0), so @micropython.native was a SyntaxError. Native code has identical Python semantics to bytecode - only faster - so allow a port to compile it as bytecode instead: code written for hardware that decorates hot functions with @Native then runs unchanged (just not accelerated). @micropython.viper is deliberately left erroring, as its semantics differ. This adds the mechanism only, as an opt-in that defaults off (MICROPY_NATIVE_NOOP). No variant here enables it, so the default builds are unchanged and native/viper feature detection is unaffected; a consumer enables it in its own variant. py/mpconfig.h: add the MICROPY_NATIVE_NOOP option (default off). py/compile.c: when it is enabled and there is no native emitter, map @micropython.native to MP_EMIT_OPT_BYTECODE in compile_built_in_decorator. Signed-off-by: Phil Howard <[email protected]>
14f7e4d to
984cdc0
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
I appreciate this is an enormous tangled mess of very complicated, very deep-rooted and very scary changes.
I'm raising this for visibility, rather than as a serious proposal for upstream in its current form, but it does present some useful points.
Chiefly that JSPI is the future (A JSPI build run a Mandelbrot demo about 10x faster than its Asyncify counterpart)
, and we ought to seriously look into a cooperative WebAssembly port. The current setup functionally requires async to be usable beyond trivial things, and absolutely falls apart if you allow users to do something so brazen as
while True:. It's great for what it's great for (folks have built some genuinely amazing stuff) but isn't exactly representative of MicroPython.Additionally there are many challenges to running useful MicroPython code in the browser. This PR includes
jsfetchwhich gives MicroPython the ability to fetch URLs. Some simple wrappers around urllib and requests make this more or less an implementation detail and allow our (Pimoroni's) Badgeware code to run unmodified on both real hardware, and in browser, even when it's fetching data from APIs.I also include a no-op for
@micropython.nativeand have been investigating doing something similar for Viper, but the latter is rather more difficult to syntax verify but not actually compile. My focus has been on parity between our devices and the web simulator, so little bits of friction like this would otherwise stop a user from just copy-pasting code between the two.Testing
I'm running this setup (shipping both Asyncify and JSPI alongside each other and auto-picking the best one for any given browser) in our prototype Badgeware simulator and it'll also be powering live examples in our documentation.
Trade-offs and Alternatives
This is the culmination of some months looking in to making MicroPython's WebAssembly port useful for my specific purposes. As such it's a hulking big change that - while it doesn't (afaik) break any existing uses of this port - carries a burden of understanding that I can't shoulder alone. Some input from other port users would be useful, and certainly some insight from new users of this port - I hope to put together a nice turnkey demo you can build on - would be appreciated.
Generative AI
I used generative AI tools when creating this PR, but a human has checked the
code and is responsible for the code and the description above.