mithril.js icon indicating copy to clipboard operation
mithril.js copied to clipboard

Memory usage statistics

Open dead-claudia opened this issue 2 years ago • 3 comments

I did some research into our memory allocation patterns within Chrome.

  • Each vnode requires 52 bytes total allocated.
    • 10 slots for 10 properties, 4 bytes per slot
    • 12 bytes of general overhead per object
  • An empty attributes object allocates 28 bytes. Adding a single property (say, {a: 1}) reduces that allocation to only 16 bytes, indicating it's allocating 4 slots by default for empty objects and only the minimum slots needed for non-empty objects.
  • A simple <div class="foo">foo</div> inserted into the body as its first child requires 140 bytes for the <div> and 96 bytes for the inner text node, resulting in 236 bytes total.
  • A simple cached m("div.foo", "foo") allocates 80 bytes
    • 52 bytes for the vnode
    • 28 bytes for the (unnecessarily) cloned vnode.attrs assembled from the selector cache entry, created from an empty object with 1 property added
  • A simple cached m("div.container", m("div.foo", "foo")) allocates 252 bytes
    • 80 each for 2 vnodes, same as above
    • 92 bytes for a single-element array: 16 bytes for the wrapper object and 12 bytes of overhead + 16 slots reserved (as it's starting from empty) for the underlying elements array
  • A simple cached m("div.container", m("div.foo", "foo"), m("div.bar", "bar")) allocates 332 bytes
    • 80 each for 3 vnodes, same as above
    • 92 bytes for a single-element array: 16 bytes for the wrapper object and 12 bytes of overhead + 16 slots reserved (as it's starting from empty) for the underlying elements array
  • A full m.render(root, m("div.foo", "foo")) against an empty, pre-initialized detached DOM node allocates:
    • 236 bytes for the generated DOM, same as above:
      • 140 bytes for the <div>
      • 96 bytes for the detached text node
    • 108 bytes of object overhead
      • 80 bytes for the vnode, same as above
      • 28 bytes for the empty vnode.state object allocated for each vnode
  • A full m.render(root, m("div.container", m("div.foo", "foo"))) against an empty, pre-initialized detached DOM node allocates:
    • 376 bytes for the generated DOM, same as above:
      • 140 bytes each for 2 <div>s
      • 96 bytes for the detached text node
    • 308 bytes of object overhead
      • 252 bytes can be explained away per above
      • 28 bytes each for empty vnode.state objects allocated for 2 vnodes
  • A full m.render(root, m("div.container", m("div.foo", "foo"), m("div.bar", "bar")) against an empty, pre-initialized detached DOM node allocates:
    • 612 bytes for the generated DOM, same as above:
      • 140 bytes each for 3 <div>s
      • 96 bytes each for 2 detached text nodes
    • 416 bytes of object overhead
      • 332 bytes can be explained away per above
      • 28 bytes each for empty vnode.state objects allocated for 3 vnodes

I see a few easy ways to cut down on memory allocation dramatically:

  1. Fix execSelector to return the underlying frozen object directly rather than cloning it when no user-provided attributes exist
    • Saves 28 bytes per DOM vnode reusing a cached selector
  2. (Breaking) Ditch vnode.state on DOM elements - it's rarely used anyways
    • Saves 28 bytes per DOM vnode
  3. Optimize for single-element children (common case) to only allocate 1 slot for it
    • Saves 60 bytes per non-empty DOM vnode created via m("tag", child) (a relatively common case)

In total, I predict that would reduce total memory consumption of front-end view code by roughly 5-20% along with a 20-30% reduction in object generation depending on the nature of the trees (I'm accounting for style rendering in that overestimate):

  • A full m.render(root, m("div.foo", "foo")) against an empty, pre-initialized detached DOM node allocates:
    • 236 bytes for the generated DOM, same as above:
      • 140 bytes for the <div>
      • 96 bytes for the detached text node
    • 52 bytes of object overhead for the vnode
    • Savings: 16% memory (344 B → 288 B) + 40% objects (5 → 3)
  • A full m.render(root, m("div.container", m("div.foo", "foo"))) against an empty, pre-initialized detached DOM node allocates:
    • 376 bytes for the generated DOM, same as above:
      • 280 bytes for 2 <div>s
      • 96 bytes for the detached text node
    • 136 bytes of object overhead:
      • 52 bytes each for 2 vnodes
      • 32 bytes for a single-element array: 16 bytes for the wrapper object and 12 bytes of overhead + 1 slot for the underlying elements array
    • Savings: 25% memory (684 B → 512 B) + 22% objects (9 → 7)
  • A full m.render(root, m("div.container", m("div.foo", "foo"), m("div.bar", "bar")) against an empty, pre-initialized detached DOM node allocates:
    • 612 bytes for the generated DOM, same as above:
      • 140 bytes each for 3 <div>s
      • 96 bytes each for 2 detached text nodes
    • 332 bytes of object overhead:
      • 80 each for 3 vnodes, same as above
      • 92 bytes for a single-element array: 16 bytes for the wrapper object and 12 bytes of overhead + 16 slots reserved (as it's starting from empty) for the underlying elements array
    • Savings: 8% memory (1028 B → 944 B) + 23% objects (13 → 10)

Notes:

  • Those with lots of single-child nodes would see a much larger improvement in memory than those with relatively few.
  • Those with lots of empty nodes will see a much larger reduction in objects allocated (and thus reduced GC pressure and more nursery usage) than those with relatively few.

This would be relatively straightforward to implement:

  • The change in how we allocate attributes would just add an extra condition, and might even speed that path up slightly.
  • Removing vnode.state from DOM vnodes would be as simple as just moving that to the create component flow.
  • Fixing Vnode.normalizeChildren to special-case single-element arrays - hyperscriptVnode already returns them, but Vnode.normalizeChildren doesn't check for that.
    • That branch would also be very simple: if (input.length === 1) return [Vnode.normalize(input[0])]. It wouldn't need to verify keys as it's a degenerate case, so a mild perf boost may also come out of it.

dead-claudia avatar Feb 26 '22 20:02 dead-claudia

Very interesting and accurate findings!

tbreuss avatar Feb 27 '22 08:02 tbreuss

Great findings

StephanHoyer avatar Feb 27 '22 09:02 StephanHoyer

Also forgot to mention the part on reusing cached attributes will also moderately reduce object count and mildly memory usage on update.

dead-claudia avatar Feb 27 '22 19:02 dead-claudia