`Object.upgrade()`: Like `Object.setPrototypeOf()` but only for prototype specialization at the leafs

Why?

We have Object.setPrototypeOf() but it's so slow in the general case that the messaging is basically "never use this".

However, literally every single use case I've ever encountered for it was much more narrowly scoped, not just a general "change an object's prototype to whatever". Namely, it satisfied these two invariants:

  1. all were around specializing an existing prototype to a different prototype that inherits from it, not changing it to a completely different shape
  2. all were around upgrading leafs, i.e. objects that aren't generally used as prototypes themselves in the codebase.

This is also how a custom element upgrade works: first the HTML parser creates an HTMLElement instance when encountering an unknown hyphenated element, then once a definition is registered current and future instances get "upgraded" to a subclass of HTMLElement . Except, instead of referencing an ES concept, this is defined somewhat ad hoc in the HTML spec. A first-class language concept could even allow generalizing this concept, opening up a lot of nice use cases around lazily upgrading instances as needed (more broadly, not just around DOM use cases).

My hypothesis is that a separate function that preserves these invariants (and throws if they are not followed):

  • More clearly communicates intent. You no longer need to reach for a machete when a nail file would do. E.g. I can imagine style guides allowing Object.upgrade() but forbidding Object.setPrototypeOf().
  • Prevents unintentional footguns.
  • Could potentially be implemented more efficiently. AFAIK a lot of the cost of Object.setPrototypeOf() is around the ripple effect of mutating an object that is itself a prototype of other objects, and the prototype chain being unstable. Object.upgrade() guarantees that objects are only ever extended at the very end, their shape only ever extended rather than mutated โ€” no different than adding a bunch of properties.

How?

For the first invariant (specializing, not replacing), the behavior would go a bit like this (illustrative; assuming no methods are shadowed):

Object.upgrade(obj, proto) {
  const cur = Object.getPrototypeOf(obj);
  if (cur === proto) return obj; // no-op

  if (cur !== null && !cur.isPrototypeOf(proto)) {
    // not an extension of the current chain
    throw new TypeError(/* ... */);
  }

  return Object.setPrototypeOf(obj, proto);
}

The second invariant (no ripple effects) is trickier. How do we guarantee that the object being upgraded is a leaf, i.e. not a prototype of anything else?

  1. :cross_mark: Restrict it to certain types of objects
    • But any object can be a prototype of another, either manually or via Object.create() ! Reject.
  2. :cross_mark: Restrict it to objects that are not currently prototypes (GC dependent)
    • Probably too unstable, might require additional tracking by engines, exposes GC, and is not justified by use cases. Reject.
  3. Restrict it to objects that have never been prototypes
    • New [[HasBeenPrototype]] internal property
    • At least V8 already tracks this
    • Problem: the committee has previously felt strongly against notifying objects that they were extended and throwing exposes this, but perhaps this is more agreeable given the much more limited surface and the cost-benefit?

For that to work, it's important that this operates on the internal [[Prototype]] and is not affected by Symbol.hasInstance and friends, otherwise we're back in the land of Anything Goes.

Thoughts? Iโ€™d especially love to hear from implementors whether this may actually be more efficient. If folks think it may be worth it, I can turn it into a proposal pretty quickly.

I'm not sure I understand the motivation. If the problem is an optimization question, why can't the answer be an optimization of the engines for these use cases? Aka if there are common use cases for updating an object's prototype, can't the engines avoid deopt in those cases?

The main problem is that it'll bust a bunch of inline caches -- anything that had ever seen the un-upgraded object. That's a double whammy if not a triple whammy. You paid a cost to create optimized code, then you pay a cost deoptimize the code, then you pay a cost to reoptimize the formerly-monomorphic code as polymorphic code, and finally you pay the cost of operating permanently in the slower polymorphic regime (or megamorphic regime even perhaps).

My impression then is that Object.upgrade() would be no more and no less of a potential perf footgun than Object.setPrototypeOf() already is. Sometimes it would be fine, and sometimes it would cause massive slowdown. Only a few people would have the depth of knowledge to understand how to get the good result

1 Like

Two of the main sources of difficulty:

  1. ICs are allll over the place and they need to be very memory-compact.
  2. Even the fast monomorphic property access (which compiles to pointer arithmetic) needs to be able to validate each time it is used that the conditions for the optimization remain valid.

Therein lies the trickiness: for memory offset access to be a good optimization, the check to make sure the optimization is still valid cannot be significantly slower than the operation itself. You can't pair a slow prototype-chain-walking deopt check with a super fast single-memory-access operation. Even if the slow check would allow you to realize that the optimization was still valid, the fact that you have to do the slow check every time would eliminate all the perf benefits of the fast implementation

Right now engines implement the check for a monomorphic IC as a single numeric comparison: is the address of the root node of the property transition chain exactly the same as it was? This is very fast/efficient, but leaves no room to handle subtyping in any other way than deoptimization.

1 Like

Not sure this is needed or if it helps but I don't find current upgrade particularly slow, or better, specially with DOM elements is not slower than just creating nodes, which is an inevitable payload.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <script type="module">
    import custom from 'https://esm.run/custom-function/factory';

    class LI extends custom(HTMLLIElement) {
      get text() {
        return this.textContent;
      }
    }

    const many = 1000;
    const items = [];

    console.time('create');
    for (let li, i = 0; i < many; i++) {
      li = document.createElement('li');
      li.textContent = `Item ${i}`;
      items.push(li);
    }
    console.timeEnd('create');

    list.append(...items);

    console.time('upgrade');
    for (let li, i = 0; i < many; i++)
      new LI(items[i]);
    console.timeEnd('upgrade');

    console.log(items[0].text);
  </script>
</head>
<body>
  <ul id="list"></ul>
</body>
</html>

You could comment out the textContent = .... and see that upgrade costs almost like create but it's true that upgrading feels unnecessarily slow, with or without the reference being known or leaked around already, see this variant with creation time almost doubled despite the node being instantly upgraded:

import custom from 'https://esm.run/custom-function/factory';

class LI extends custom(HTMLLIElement) {
  get text() {
    return this.textContent;
  }
}

const many = 1000;
const items = [];

console.time('create');
for (let li, i = 0; i < many; i++) {
  li = new LI(document.createElement('li'));
  li.textContent = `Item ${i}`;
  items.push(li);
}
console.timeEnd('create');

list.append(...items);

console.log(items[0].text);

@WebReflection

AFAIK the DOM is slow enough that it dwarfs any language-level feature. Proxies are the same: while they are considered slow for many pure JS use cases, they are totally fine for most DOM use cases because the relative difference is meaningless compared to how long DOM operations take.

true and true but, to be clear, no Proxy in here, just the setPrototypeOf update for that DOM node. I am not sure that DOM node is to blame for slowness but the custom utility is nothing more than this custom-function/esm/factory.js at 6d72d2a294f7090fa0ed5a21d3c7b8b464c92ffd ยท WebReflection/custom-function ยท GitHub

It could work with literally any other native JS API but back to raw operation, it's kinda slow indeed ...

const { setPrototypeOf } = Object;

class Put extends Map {
  put(key, value) {
    super.set(key, value);
    return value;
  }
}

const many = 1000;
const items = [];

console.time('create');
for (let i = 0; i < many; i++) {
  items.push(new Map);
}
console.timeEnd('create');

console.time('upgrade');
for (let i = 0; i < many; i++)
  setPrototypeOf(items[i], Put.prototype);
console.timeEnd('upgrade');

console.log(items[0].put('key', 'value'));

On average that's slower than just creating a new instance but I guess this kind of micro-benchmark might not reveal all the details ... but then again, it sums up even on direct creation without leaking further any reference around:

const { setPrototypeOf } = Object;

class Put extends Map {
  put(key, value) {
    super.set(key, value);
    return value;
  }
}

const many = 1000;
const items = [];

console.time('create');
for (let i = 0; i < many; i++) {
  items.push(setPrototypeOf(new Map, Put.prototype));
}
console.timeEnd('create');

console.log(items[0].put('key', 'value'));

compared to just new Put that's like 1.5x slower

a thing I've been thinking about is the Object.getOwnPropertyDescriptors(Class.prototype) used as Object.defineProperties(ref, that_result) which should at least grant semantics and maybe not be too slower on the process ... as an upgrade, that looks like a better variant, isn't it?