Skip to content

perf(core): build expensive component method surfaces lazily (roArray, roAssociativeArray, roList, roRegion, roBitmap) - #1208

Merged
lvcabral merged 3 commits into
masterfrom
perf/lazy-container-methods
Sep 2, 2026
Merged

lvcabral merged 3 commits into
masterfrom
perf/lazy-container-methods

Conversation

@lvcabral

@lvcabral lvcabral commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Follow-up to #1207, which noted this while measuring the cost of the copy-on-assign policy it introduced.

What

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. Five components with expensive surfaces did not, so every instance built its whole surface in the constructor.

component per-instance surface why it is allocated in bulk
roArray 4 interface objects (~15 Callables) every [...] literal, every container copy, every vector2d/rect2d field write
roAssociativeArray 1 interface object (~15) every {...} literal, every container copy
roList 6 interface objects (~30) — no methods of its own every XML query, registry/filesystem listing, roXMLList
roRegion 21 own methods + IfDraw2D's 16 = 37 one per sprite and per Copy(); games create them in bulk
roBitmap 1 own method + IfDraw2D's 16 = 17 not bulk-allocated — see the caveat below

For roRegion, deferring registration alone would only have saved IfDraw2D's 16, because its own 21 methods were class property initializers, which run regardless of the hook. They are now getters, matching the pattern RoString and RoSGNode already 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.

benchmark before after
layout-perf-app (70 self-measuring components) 304 / 302 / 291 / 303 ms 207 / 209 / 213 ms -30%
200k array literals 980 ms 292 ms 3.4x
200k associative array literals 608 ms 293 ms 2.1x
100k roList 552 ms 151 ms 3.7x
100k roRegion 870 ms 178 ms 4.9x
20k roBitmap 8x8 269 ms 176 ms -35%
2k roBitmap 1920x1080 131 ms 109 ms -17%

layout-perf-app is the committed benchmark from docs/scenegraph-layout-passes.md, so the headline figure is a real engine workload rather than a microbenchmark.

Caveat on roBitmap, stated plainly

Unlike the other four, roBitmap is not a measurable app-level win and is not claimed as one. Bitmaps are cached, not 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: 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.

(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 in GlobalUtilities (GetInterface, FindMemberFunction, ObjFun) which read interfaces directly 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 prettier clean.

Coverage was the real gap here, since reflection is exactly what lazy building could break silently:

  • roRegion had no automated coverage at all — only manual scripts under test/simulator/. test/cli/resources/roRegion.brs now pins the whole surface: every ifRegion getter/setter, Offset, Copy, GetBitmap, the ifDraw2D drawing calls, and both reflection paths.
  • roBitmap's ifDraw2D calls were covered by other fixtures, but ifBitmap.GetName (its only own method), the PNG/byte-array getters and the reflection paths were not. test/cli/resources/roBitmap.brs pins those.
  • The e2e global-utilities fixture already exercised GetInterface/FindMemberFunction against 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 default wrap rather than the source's — pre-existing, and flagged in the test as unverified against a device.

Not in this PR

  • RoArray/RoAssociativeArray still allocate 9 and 12 Callables respectively as property initializers. Converting them to getters (as done here for roRegion) measured a further 292 -> 185 ms on 200k array literals with the suite green — left out to keep this PR's diff mechanical.
  • The boxed primitives (RoInt, RoFloat, RoDouble, RoBoolean, RoInvalid) are still eager and sit on the same field-copy path via boxForFieldCopy. Unmeasured; they register far fewer methods, so the win is likely smaller.
  • roSGNodeEvent is allocated once per observer firing and builds 5 Callables 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 CreateObject profile: 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

lvcabral and others added 2 commits September 2, 2026 13:50
`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>
@lvcabral lvcabral changed the title perf(core): build roArray/roAssociativeArray method surfaces lazily perf(core): build expensive component method surfaces lazily (roArray, roAssociativeArray, roList, roRegion) Sep 2, 2026
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>
@lvcabral lvcabral changed the title perf(core): build expensive component method surfaces lazily (roArray, roAssociativeArray, roList, roRegion) perf(core): build expensive component method surfaces lazily (roArray, roAssociativeArray, roList, roRegion, roBitmap) Sep 2, 2026
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

@lvcabral
lvcabral merged commit 0436460 into master Sep 2, 2026
3 checks passed
@lvcabral
lvcabral deleted the perf/lazy-container-methods branch September 2, 2026 23:42
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.

1 participant