Skip to content

Cube: ViewRowData row objects fall into V8 dictionary mode - per-View codegen'd assigner measures ~5-6x smaller rows at typical view widthsΒ #4502

Description

@amcclain

Summary

Follow-up to #4500, extending the same analysis to the Cube/View subsystem. Every row a View produces gets a ViewRowData whose field values are installed via dynamic keyed-store loops - the same construction route that demotes objects to V8 dictionary ("slow") mode. Class instances get slightly more headroom than plain objects: demotion sets in between 20 and 30 view fields (F=20 stays fast, F=30 is dictionary-mode for every instance in steady state). Wide views in data-intensive apps - exactly the ones built over large cubes - pay full price.

Measured on the same harness as #4500: a per-View codegen'd named-property assigner keeps ViewRowData instances in fast-properties mode with shared hidden classes - ~5-6x smaller rows and order-of-magnitude faster row construction, with instanceof, prototype getters, and the entire updates machinery untouched.

These rows are heavily retained - View._rowCache, _leafMap, result.rows, and as StoreRecord.raw in every View-bound store - so for a 50k-leaf, 30-field view this is ~83MB β†’ ~17MB of ViewRowData per view, before counting aggregate rows. Dashboard-style apps run several views. Note #4500 already covers the Cube's internal Store and the bound stores' own data objects; the ViewRowData population is what remains.

Current implementation

  • data/cube/row/LeafRow.ts - ctor: view.fields.forEach(({name}) => this.data[name] = rawRecord.data[name]) - one leaf per cube record in the view.
  • data/cube/row/BaseRow.ts - initAggregate() (Aggregate + Bucket rows): view.fields.forEach(({name}) => data[name] = null) + Object.assign(data, appliedDimensions), then computeAggregates() rewrites values in place.
  • data/cube/row/BaseRow.ts - initAggregate() also builds canAggregate per aggregate row via keyed reduce into {} - same dictionary fate at β‰₯~30 fields (smaller population, worth fixing in passing).
  • Per-row shape: id + cubeRowType/cubeLabel/cubeDimension + F fields + children (+ cubeBuckets when bucketed, _cubeLeafChildren with provideLeaves).
  • Value-rewrite paths (computeAggregates, applyLeafDataUpdate, applyDataUpdate) only overwrite existing properties - rewrites never demote, so the updates machinery needs no changes.

V8 mechanics

Background and citations in #4500. The specific mechanism here, verified by probe: V8's fast-properties limit applies to keyed stores that create new map transitions; constant-key (named) stores are exempt. A new Function-compiled assigner with literal property keys (d["fieldA"]=v[0];...) therefore keeps class instances fast with a shared hidden class through ~120 total properties.

A notable probe artifact worth preserving: keyed stores that follow existing transitions do not demote - once one object completes the shape fast, subsequent identically-shaped objects can stay fast even via keyed loops. Real Views never get this benefit (no instance ever completes the chain fast), but it cleanly confirms the mechanism.

Measurements

Node 24 / V8 13.6, 50k leaf-style rows, forced-GC heap deltas, fresh field-name chains per experiment (mode verified via %HasFastProperties on first/mid/last instance):

view fields today (dict) B/row assigner (fast) B/row ratio build 50k rows
30 1,658 337 4.9x 76 β†’ 5ms
57 3,192 553 5.8x 107 β†’ 18ms

Aggregation-style reads (per-field sweeps over rows, the SumAggregator pattern) run ~1.55x faster against fast-mode rows (800 β†’ 516ms, 10 sweeps x 30 fields x 50k rows). Read gains are smaller than #4500's grid case because cube reads are keyed-by-variable-name (megamorphic) rather than fixed-name monomorphic.

Steady-state mode by construction route (all 50k instances):

route F=20 F=30 F=50 F=80
ViewRowData + keyed loop (today) FAST dict dict dict
+ codegen'd named assigner FAST FAST FAST FAST (to ~120)

Proposed change

Build on #4500's RecordDataFactory (same module, shared guards):

  1. Add RecordDataFactory.createAssigner(fieldNames) - compiles (target, values) => void with literal keys; returns null under the same guards (field count over threshold, __proto__ field name, no unsafe-eval, internal kill switch).
  2. View compiles one assigner per query (recompiled on updateQuery when fields change) and exposes it internally to its rows.
  3. LeafRow ctor and BaseRow.initAggregate() use the assigner when present - each keeps its current loop as the fallback; initAggregate seeds values as appliedDimensions.hasOwnProperty(name) ? appliedDimensions[name] : null, folding away the separate Object.assign.
  4. canAggregate built via the same assigner (boolean values).
  5. Late named adds (children, cubeBuckets, _cubeLeafChildren) unchanged - probe-verified shape-safe.

No public API changes. ViewRowData remains a class with its getters and index signature; updates flow untouched.

Validation plan

  1. Headless: extend Store: record data objects fall into V8 dictionary mode β€” per-store codegen'd literal factory measures 6-8Γ— smaller records at typical widthsΒ #4500's probe suite - assigner-built rows FAST + shared maps at 30-120 fields (incl. post-children/cubeBuckets adds), value parity vs the legacy loops, guard behavior (threshold/CSP/__proto__).
  2. In-app (Toolbox cube tester): query round-trips with dimensions and buckets, leaves included/excluded, updateDataAsync deltas flowing up through aggregates, bound-store loads, getDimensionValues.
  3. Regression: omitFn/lockFn paths, provideLeaves, row-cache reuse across repeated queries, updateQuery with changed field sets (assigner recompile).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions