Specialize ARM Thumb interface dispatch - #11339
Open
Eric Anderson (humanapp) wants to merge 13 commits into
Open
Specialize ARM Thumb interface dispatch#11339Eric Anderson (humanapp) wants to merge 13 commits into
Eric Anderson (humanapp) wants to merge 13 commits into
Conversation
…safe, share dispatch thunks, fast-path string-map set
Contributor
There was a problem hiding this comment.
Pull request overview
This PR adds a set of Thumb-native compiler/backend specializations to reduce code size and overhead in interface dispatch-heavy programs (method calls, accessors, and map/field operations), primarily affecting the pxtcompiler/emitter pipeline and the generated Thumb helpers/vtables.
Changes:
- Adds a final-pass analysis (
markExactIfaceWrappers) to safely bypass_argswrappers for eligible iface-dispatched procs by emitting/using a lightweight_ifaceentry point. - Specializes dispatch-heavy sites by introducing shared helpers/thunks (iface calls, checked field loads, map-set-by-field-id) driven by per-site emission counters.
- Adds a new Thumb runtime entry (
_pxt_map_set_by_string) and updates vtable/iface table emission to support the wrapper-skip path and an empty itable fast representation.
Show a summary per file
| File | Description |
|---|---|
| pxtcompiler/emitter/ir.ts | Adds useExactIfaceWrapper state and clarifies that vtLabel() is the _args wrapper entry label. |
| pxtcompiler/emitter/hexfile.ts | Emits empty iface tables with mult=0 and routes iface entries to _iface when useExactIfaceWrapper is set. |
| pxtcompiler/emitter/emitter.ts | Records iface/map/checked-field counts, flags dynamic iface calls, introduces markExactIfaceWrappers, and selects new map-set runtime call. |
| pxtcompiler/emitter/backbase.ts | Implements helper/thunk specialization logic, emits _iface stubs, and adds _pxt_map_set_by_string plus map-set-by-field-id specialization. |
Review details
- Files reviewed: 4/4 changed files
- Comments generated: 2
- Review effort level: Low
Remove unused method tryGetFieldInfo Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
tests/thumb-test compiles programs to native ARM Thumb in-process against a captured micro:bit (mbcodal) CompileOptions fixture and asserts on the generated assembly listing; runs offline via gulp testthumb. lang-test0 gains three semantic files covering condition truthiness and lowering and interface dispatch (including both sides of the call-site-count thresholds that gate dispatch specialization), plus a deliberately disabled repro of the default-parameters-on-dynamic-dispatch defect and a coverage map (README-codegen.md) tying failure modes to tests and layers.
Cross-platform node scripts (macOS/Linux/Windows) to build candidate and reference hexes, flash a micro:bit, capture serial, and diff the traces, with a gcStats-based soak mode for leak detection. Driven by one wrapper: npm run hwab -- ab truthiness.
Findings from running the capture against a real micro:bit: - DAPLink buffers serial while no host is reading, so a fresh capture can open onto the previously flashed program's output, including its PASS banner. Verdicts are now read only from the flashed program's own HWAB START banner onward, and --expect <case> (passed by hwab) requires the banner and verdict to name the flashed case. Timeout diagnostics report ignored foreign output and point at FAIL.TXT when no banner appears. - Panic does not flush the serial FIFO, which truncated the assert id on the wire. The generated device assert now drains for 250 ms between logging the id and panicking.
- gate the five dispatch specializations behind a noIfaceSpec compile switch; with it set, output matches the pre-specialization codegen and no helper text is emitted - fix the typed index-signature store specialization leaking into the JS backend, which called a runtime entry that only exists on thumb (broke two language tests, one pre-existing) - emit the index-signature store helper only when a program uses it, instead of in every build - flip the ifacebaseline expectations to the specialized codegen; add a noIfaceSpec variant pinning the codegen the switch restores - verified: testlang 57, testthumb 7, A/B hexes differ under the switch
- flip the fieldbaseline expectations to the specialized codegen; add a noIfaceSpec variant pinning the codegen the switch restores - verified: testthumb 9, testlang 57, floors fault-injected
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.
Hey all. Here's my final compiler optimization for your consideration. I saved this one for last because it's the most gnarly, but the gains are significant. Savings scale with how dispatch-heavy the app is: how many calls there are to class methods and accessors, and additional savings for repeated calls to the same class method.
Measured impact
For programs with lots of classes and polymorphism, this optimization will provide a nice size reduction. It shrank the MicroCode hex by ~27k. Programs that are mostly global functions and arithmetic won't see such savings. Do you know what else is dispatch heavy? Arcade. Building an empty Arcade project today results in a binary.hex of 706,046 bytes. With this change: 671,486 bytes. An ~11k savings, before any user code. With all compiler optimizations active, Arcade empty project savings is 34k, and Space Rocks Revenge shrinks by a whopping 69k. It compiled small enough for Meowbit.
Summary
When MakeCode compiles a program for hardware, calls to class methods and property accessors go through an interface dispatch table (a lookup mechanism for finding the right code to run for a given object). This is a flexible, safe way to find the right override, but it is generic machinery and comes with overhead. The optimization here is to bypass that machinery for scenarios where the right code for the object can be determined more directly, resulting in leaner code.
This involves specializing three things:
The mechanical pieces
Exact-wrapper selection -- Every iface-dispatched proc normally emits an
_argswrapper that shuffles/pads arguments. A new analysis pass calledmarkExactIfaceWrappers()checks each call site and if every one of them passes enough args, it marks the proc foruseExactIfaceWrapper. Its iface-table entry then points at a 1-instruction_iface: b _nochkstub instead of the full wrapper, and the wrapper body is skipped entirely when the proc isn't also used as a value.Shared dispatch thunks -- Hot field reads with call count >= 3 emit a single shared helper and
blto it, instead of repeating the dispatch setup inline at each site. Hot field reads also share a checked-load helper, threshold 5.Direct field/map reads & writes -- Property writes to a statically-known field id and string-keyed map writes take a short path with C++ fallback; writes to a known field id share a thunk that bakes in the field id.
Risks
Overall risk assessment: Medium.
This touches the iface dispatch path, which has broad blast radius. The failure mode that matters is a proc whose wrapper is skipped but which is then reached by a call site that didn't pass enough arguments -> registers underfilled -> undefined behavior at runtime (not something you'd catch at compile time).
Specific risk areas, and how each is handled:
Completeness of call counting. The selection trusts
bin.ifaceCallCounts/bin.dynamicIfaceCallsto see every dispatch site. A review pass found and fixed one hole: dynamic field access on a non-class receiver(obj: any).fooconstructed an iface call without flagging it dynamic. It now setsdynamicIfaceCalls, which disqualifies the wrapper-skip. If a future code path introduces another uncounted iface call site, it would reintroduce this class of bug -- this is the thing to guard when modifying the emitter's call paths.ABI contract. The vtable shape is untouched. The only binary-format-ish change is that empty interface tables now emit
mult=0and skip the hash section; verified safe because the thumb dispatch short-circuits onmult=0before reading the hash table, and no C++ runtime code reads the table at a fixed offset.toStringspecial-case.canUseExactIfaceWrapperexplicitly excludestoStringbecause it's also reached via a fixed vtable slot that needs the full_argswrapper. This exclusion is important for correctness -- any new fixed-slot vtable consumer must add a matching exclusion (this is documented in the code).Back-compat on the string-map fast path.
_pxt_map_set_by_stringfalls back to_pxt_map_set(interface dispatch) for non-RefMappointer receivers rather than panicking, preserving behavior for cast-violating code. Only genuinely unrecoverable inputs (tagged-int/null) still panic -- matching the pre-optimization path.Two-pass timing assumption. The specialization decisions assume counts are fully populated before asm emission reads them. True today (IR is built completely, then walked), but a future change that interleaves the two would silently regress the optimizations. They would not break the code, but they might not be applied where they would otherwise have qualified.
Helper emission. This change unconditionally emits a few helper methods, costing ~888 bytes of always-present code. An earlier iteration conditionally emitted only the helpers that ended up being used, but the code was complicated and possibly fragile. This could be revisited.
cc: Thomas Ball (@thomasjball)