perf(core): build expensive component method surfaces lazily (roArray, roAssociativeArray, roList, roRegion, roBitmap) - #1208
Merged
Conversation
`BrsComponent` already has a `buildMethods()` hook that defers a component's method
and interface maps to the first lookup — `RoString`, `ContentNode` and `RoSGNode` use
it. `RoArray` and `RoAssociativeArray` did not, so every instance eagerly allocated
its interface objects and `Callable`s in the constructor: four interface objects and
~13 `Callable`s for an array, one and ~13 for an associative array.
Those two are among the most frequently allocated components in the engine — every
`[...]`/`{...}` literal, every container copy on a node field read or write, every
`vector2d`/`rect2d` write — and the large majority never have a method called on them.
Measured (dev build, macOS, Node 22; 3-4 runs each, spread under 2%):
layout-perf-app (70 self-measuring components) ~300 ms -> ~210 ms (-30%)
200k array literals 980 ms -> 292 ms (3.4x)
200k associative array literals 608 ms -> 293 ms (2.1x)
No behaviour change: `ensureMethods()` already guards every method and interface read,
including the external reflection paths in `GlobalUtilities` (`GetInterface`,
`FindMemberFunction`, `ObjFun`). Verified `push`/`count`, `ifEnum`
(`IsNext`/`Reset`/`Next`), `for each`, `Keys`, `DoesExist`, and an unknown interface
still answering `invalid`.
The e2e `global-utilities` fixture already exercised `GetInterface`/`FindMemberFunction`
against an associative array but not an array — the one uncovered path that reads
`interfaces` directly — so it now covers both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same `buildMethods()` deferral as the containers, applied to two more components with expensive surfaces: - `roList` carries no methods of its own — its whole surface comes from six interface objects (`ifList`, `ifListToArray`, `ifArray`, `ifArrayGet`, `ifArraySet`, `ifEnum`, ~30 `Callable`s) — so moving registration defers all of it. The engine mints one for every XML query, registry/filesystem listing and `roXMLList`, most of which are only iterated or converted to an array. - `roRegion` is allocated per sprite and per `Copy()`, so games create them in bulk. Deferring registration alone would only have saved `IfDraw2D`'s 16 `Callable`s, because its own 21 methods were class property initializers that run whatever the hook does — they are now getters, matching `RoString`/`RoSGNode`, so the deferral covers all 37. Measured (dev build, macOS, Node 22; A/B is the same build with only these two files reverted): 100k roList 552 ms -> 151 ms (3.7x) 100k roRegion 870 ms -> 178 ms (4.9x) layout-perf-app unchanged (~213 ms, already improved by the containers) `roRegion` had NO automated coverage — only manual scripts under `test/simulator/` — so `test/cli/resources/roRegion.brs` now pins its whole surface: every ifRegion getter/setter, `Offset`, `Copy`, `GetBitmap`, the ifDraw2D drawing calls, and the `GetInterface`/ `FindMemberFunction` reflection paths that read the interface map directly. `roList` gains `GetInterface(..., "ifList")` coverage alongside the `ifArray` case. Both new tests were also run against the pre-refactor build and pass identically, so they characterize existing behaviour rather than the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same `buildMethods()` deferral, applied to `roBitmap`. Unlike the containers, `roList` and `roRegion`, this one is NOT a measurable app-level win and is not claimed as one: a bitmap is cached rather than allocated in bulk — one per unique image URI via `RoTextureManager`, one per keyboard 9-patch, one per `MaskGroup` — and there are only five engine allocation sites. No benchmark moves. It earns its place by removing provably dead work. All but one of a bitmap's methods come from `IfDraw2D` (16 `Callable`s), and `MaskGroup.renderNodeContent` allocates a scene-sized bitmap every frame and then builds its OWN `IfDraw2D` over it — so the eagerly-registered one was constructed and thrown away, once per frame, for every masked subtree. Per-instance construction cost (dev build, macOS, Node 22; A/B is the same build with only this file reverted): 20k roBitmap 8x8 269 ms -> 176 ms (-35%) 2k roBitmap 256x256 29 ms -> 20 ms (-31%) 2k roBitmap 1920x1080 131 ms -> 109 ms (-17%) Construction is close to size-independent at small sizes, because node-canvas allocates the backing store lazily — which is why the method surface was a large share of the cost. `ifBitmap.GetName` (a bitmap's only own method), the PNG/byte-array getters, and the `GetInterface`/`FindMemberFunction` reflection paths had no automated coverage, so `test/cli/resources/roBitmap.brs` pins them alongside the ifDraw2D calls other fixtures already exercise. It passes identically against the pre-refactor build, so it characterizes existing behaviour rather than the change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
3 tasks
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.



Follow-up to #1207, which noted this while measuring the cost of the copy-on-assign policy it introduced.
What
BrsComponentalready has abuildMethods()hook that defers a component's method and interface maps to the first lookup —RoString,ContentNodeandRoSGNodeuse it. Five components with expensive surfaces did not, so every instance built its whole surface in the constructor.roArrayCallables)[...]literal, every container copy, everyvector2d/rect2dfield writeroAssociativeArray{...}literal, every container copyroListroXMLListroRegionIfDraw2D's 16 = 37Copy(); games create them in bulkroBitmapIfDraw2D's 16 = 17For
roRegion, deferring registration alone would only have savedIfDraw2D's 16, because its own 21 methods were class property initializers, which run regardless of the hook. They are now getters, matching the patternRoStringandRoSGNodealready use, so the deferral covers all 37.Measured
Dev build, macOS, Node 22; 3-4 runs each, spread under 2%. A/B is the same build with only the changed files reverted.
layout-perf-app(70 self-measuring components)roListroRegionroBitmap8x8roBitmap1920x1080layout-perf-appis the committed benchmark fromdocs/scenegraph-layout-passes.md, so the headline figure is a real engine workload rather than a microbenchmark.Caveat on
roBitmap, stated plainlyUnlike the other four,
roBitmapis not a measurable app-level win and is not claimed as one. Bitmaps are cached, not allocated in bulk — one per unique image URI viaRoTextureManager, one per keyboard 9-patch, one perMaskGroup— and there are only five engine allocation sites. No benchmark moves.It earns its place by removing provably dead work:
MaskGroup.renderNodeContentallocates a scene-sized bitmap every frame and then builds its ownIfDraw2Dover it, so the eagerly-registered one was constructed and thrown away, once per frame, for every masked subtree.(Construction is close to size-independent at small sizes because node-canvas allocates the backing store lazily — which is why the method surface was a large share of the cost.)
Why it is behaviour-neutral
ensureMethods()already guards every path that reads the method or interface maps —getMethod,hasInterface, and the external reflection helpers inGlobalUtilities(GetInterface,FindMemberFunction,ObjFun) which readinterfacesdirectly and already call it. None of the five is subclassed, and no constructor calls its own methods.Tests
Full suite 2804 passing;
npm run build,npm run lint,npm run prettierclean.Coverage was the real gap here, since reflection is exactly what lazy building could break silently:
roRegionhad no automated coverage at all — only manual scripts undertest/simulator/.test/cli/resources/roRegion.brsnow pins the whole surface: everyifRegiongetter/setter,Offset,Copy,GetBitmap, theifDraw2Ddrawing calls, and both reflection paths.roBitmap'sifDraw2Dcalls were covered by other fixtures, butifBitmap.GetName(its only own method), the PNG/byte-array getters and the reflection paths were not.test/cli/resources/roBitmap.brspins those.global-utilitiesfixture already exercisedGetInterface/FindMemberFunctionagainst an associative array but not against an array or an roList; it now covers both.All three new tests were also run against the pre-refactor build and pass identically, so they characterize existing behaviour rather than the change. One thing they capture without endorsing:
roRegion.Copy()returns a region with the defaultwraprather than the source's — pre-existing, and flagged in the test as unverified against a device.Not in this PR
RoArray/RoAssociativeArraystill allocate 9 and 12Callables respectively as property initializers. Converting them to getters (as done here forroRegion) measured a further 292 -> 185 ms on 200k array literals with the suite green — left out to keep this PR's diff mechanical.RoInt,RoFloat,RoDouble,RoBoolean,RoInvalid) are still eager and sit on the same field-copy path viaboxForFieldCopy. Unmeasured; they register far fewer methods, so the win is likely smaller.roSGNodeEventis allocated once per observer firing and builds 5Callables as property initializers. The saving would only apply to notifications whose handler never reads the event.Explicitly not worth it, checked against a large production SceneGraph app's
CreateObjectprofile:roDateTime,roTimespan,roDeviceInfo(70 methods, the most expensive component in the engine),roRegistrySection,roRegex,roByteArray,roUrlTransfer. All are created in order to call a method, so deferral buys nothing.🤖 Generated with Claude Code