JavaScript engine basics: prototype optimization. Part 2

Original author: https://twitter.com/mathias
  • Transfer
Good afternoon friends! The course "Security of information systems" has been launched, in connection with this we are sharing with you the final part of the article "Fundamentals of JavaScript engines: optimization of prototypes", the first part of which can be read here .

We also remind you that the current publication is a continuation of these two articles: “Basics of JavaScript engines: general forms and Inline caching. Part 1 " , " Basics of JavaScript engines: general forms and Inline caching. Part 2 " .



Classes and Prototype Programming

Now that we know how to get quick access to the properties of JavaScript objects, you can take a look at the more complex structure of JavaScript - classes. This is what the class syntax looks like in JavaScript:

class Bar {
	constructor(x) {
		this.x = x;
	}
	getX() {
		return this.x;
	}
}

Although this seems like a relatively new concept for JavaScript, it is just “syntactic sugar” for the prototype programming that has always been used in JavaScript:

function Bar(x) {
	this.x = x;
}
Bar.prototype.getX = function getX() {
	return this.x;
};

Here we assign the property getXto the object Bar.prototype. This will work just like with any other object, since prototypes in JavaScript are the same objects. In prototype programming languages ​​such as JavaScript, methods are accessed through prototypes, while fields are stored in specific instances.

Let's take a closer look at what happens when we create a new instance Bar, which we will name foo.

const foo = new Bar(true);

An instance created using this code has a form with a single property ‘x’. A prototype foois one Bar.prototypethat belongs to a class Bar.



This one Bar.prototypehas the form of itself, containing a single property ‘getX’, whose value is determined by the function ‘getX’that returns when called this.x. A prototype Bar.prototypeis one Object.prototypethat is part of the JavaScript language. Object.prototypeIs the root of the prototype tree, while its prototype matters null.



When you create a new instance of the same class, both instances have the same form, as we already understood. Both instances will point to the same object Bar.prototype.

Access prototype properties

Well, now we know what happens when we define a class and create a new instance. But what happens if we call the method on the instance, as we did in the following example?

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^

You can consider any method call as two separate steps:

const x = foo.getX();
// is actually two steps:
const $getX = foo.getX;
const x = $getX.call(foo);

The first step is to load the method, which is actually a property of the prototype (whose value is a function). The second step is a function call with an instance, for example, a value this. Let's take a closer look at the first step where the method is loaded getXfrom the instance foo.



The engine starts the instance fooand realizes that the form foohas no property ‘getX’, so it has to go through the prototype chain to find it. We get to Bar.prototype, look at the prototype shape, see that it has a property ‘getX’at zero offset. We look for meaning at this offset in Bar.prototypeand find the JSFunction getXone we were looking for.

The flexibility of JavaScript allows prototype chain links to change, for example:

const foo = new Bar(true);
foo.getX();
// → true
Object.setPrototypeOf(foo, null);
foo.getX();
// → Uncaught TypeError: foo.getX is not a function

In this example, we call
foo.getX()
twice, but each time it has completely different meanings and results. That is why, despite the fact that prototypes are just objects in JavaScript, speeding up access to prototype properties is an even more important task for JavaScript engines than speeding up their own access to properties on regular objects.

In everyday practice, loading prototype properties is a fairly common operation: this happens every time you call a method!

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}
const foo = new Bar(true);
const x = foo.getX();
//        ^^^^^^^^^^

Earlier, we talked about how engines optimize the loading of regular properties by using forms and inline caches. How can I optimize the loading of prototype properties for objects of the same shape? From above we saw how properties are loaded.



In order to do this quickly with repeated downloads in this particular case, you need to know the following three things:

  • The form foodoes not contain ‘getX’and it has not changed. This means that no one has changed the foo object by adding or removing a property or changing one of the property attributes.
  • The prototype foo is still the original one Bar.prototype. So no one changed the prototype foousing Object.setPrototypeOf()or assigning it to a special _proto_property.
  • The form Bar.prototypecontains 'getX'and has not changed. This means that no one has changed Bar.prototypeby adding or removing a property or changing one of the property attributes.

In the general case, this means that you need to make one check of the instance itself and two more checks for each prototype up to the prototype that contains the desired property. 1 + 2N checks, where N is the number of prototypes used, does not sound so bad in this case, since the prototype chain is relatively shallow. However, engines often have to deal with much longer prototype chains, as is the case with regular DOM classes. For instance:

const anchor = document.createElement('a');
// → HTMLAnchorElement
const title = anchor.getAttribute('title');

We have HTMLAnchorElementand we call the method getAttribute(). The chain for this simple element already includes 6 prototypes! Most of the DOM methods we are interested in are not in the prototype itself HTMLAnchorElement, but somewhere up the chain.



The method getAttribute()is in Element.prototype. This means that every time we call anchor.getAttribute(), the JavaScript engine needs:

  1. Check that 'getAttribute'it is not an anchorobject in itself ;
  2. Verify that the final prototype is HTMLAnchorElement.prototype;
  3. Confirm the absence 'getAttribute'there;
  4. Verify that the next prototype is this HTMLElement.prototype;
  5. Reaffirm absence 'getAttribute';
  6. Check that the following prototype is Element.prototype;
  7. Check what is present in it 'getAttribute'.

A total of 7 checks. Since this type of code is quite common on the web, engines use various tricks to reduce the number of checks required to load prototype properties.

Returning to an earlier example, in which we did only three checks when we requested 'getX'for foo:

class Bar {
	constructor(x) { this.x = x; }
	getX() { return this.x; }
}
const foo = new Bar(true);
const $getX = foo.getX;

For each object that occurs before the prototype containing the desired property, it is necessary to check the forms for the absence of this property. It would be nice if we could reduce the number of checks by presenting the prototype check as a check for the absence of a property. In essence, this is exactly what engines do with a simple trick: instead of storing the prototype link to the instance itself, the engines store it in form.



Each form indicates a prototype. This means that every time a prototype changes foo, the engine moves to a new form. Now we need to check only the shape of the object to confirm the absence of certain properties, as well as protect the prototype link (guard the prototype link).

With this approach, we can reduce the number of required checks from 2N + 1 to 1 + N to speed up access. This is still a fairly expensive operation, as it is still a linear function of the number of prototypes in the chain. Engines use various tricks to further reduce the number of checks to a certain constant value, especially in the case of sequential loading of the same properties.

Validity cells

V8 processes prototype forms specifically for this purpose. Each prototype has a unique form that is not used in conjunction with other objects (in particular, with other prototypes), and each of these forms of the prototype has a special ValidityCellone that is associated with it.



ThisValidityCelleach time it is disabled when someone changes the prototype associated with it or any other prototype above it. Let's see how it works.
To speed up subsequent downloads from prototypes, V8 places the Inline cache in a place with four fields:



When the inline cache is heated the first time the code is run, V8 remembers the offset at which the property was found in the prototype, this prototype (for example Bar.prototype), the shape of the instance (in our case form foo), and also binds the current one ValidityCellto the prototype obtained from the form instance (in our case it is taken Bar.prototype).

The next time you use the inline cache, the engine needs to check the shape of the instance and ValidityCell. If it is still valid, the engine directly uses the offset on the prototype, skipping the extra search steps.



When changing the prototype, a new shape is highlighted, and the previous cell is ValidityCelldisabled. Because of this, the Inline cache is skipped the next time it starts, which leads to poor performance.

Let's go back to the example with the DOM element. Every change in Object.prototypenot just invalidates Inline caches Object.prototype, but also for any of the prototype circuit under it, including EventTarget.prototype, Node.prototype, Element.prototype, and so on. E. To the HTMLAnchorElement.prototype.



In fact, modifying Object.prototypewhile the code is executing is a terrible loss of performance. Do not do this!

Let's look at a specific example to better understand how this works. Say we have a class Barand a function loadXthat calls a method on objects of typeBar. We call the function several times loadXwith instances of the same class.

class Bar { /* … */ }
function loadX(bar) {
	return bar.getX(); // IC for 'getX' on `Bar` instances.
}
loadX(new Bar(true));
loadX(new Bar(false));
// IC in `loadX` now links the `ValidityCell` for
// `Bar.prototype`.
Object.prototype.newMethod = y => y;
// The `ValidityCell` in the `loadX` IC is invalid
// now, because `Object.prototype` changed.

The inline cache in loadXnow points ValidityCellto Bar.prototype. If you then mutate Object.prototype, which is the root of all prototypes in JavaScript, ValidityCellit becomes invalid and existing Inline caches will not be used the next time, resulting in poor performance.

Change Object.prototypeis always a bad idea, as it invalidates any Inline caches for loaded prototypes at the time of change. Here is an example of how NOT to do:

Object.prototype.foo = function() { /* … */ };
// Run critical code:
someObject.foo();
// End of critical code.
delete Object.prototype.foo;

We are expanding Object.prototypethat all Inline prototype caches loaded by the engine at this point are disabled. Then we will run some code that uses the method described by us. The engine will have to start from the very beginning and configure Inline caches for any access to the prototype property. And then, finally, “clean up” and remove the prototype method that we added earlier.

You think cleaning is a good idea, right? Well, in this case, it will further worsen the situation! Removing properties changes Object.prototype, so all Inline caches are disabled again, and the engine has to start work from the very beginning again.

Summarize. Despite the fact that prototypes are just objects, they are specially processed by JavaScript engines in order to optimize the performance of method searches by prototypes. Leave the prototypes alone! Or if you really need to deal with them, do it before executing the code, so you will at least not invalidate all attempts to optimize your code during its execution!

To summarize,

we learned how JavaScript stores objects and classes, and how forms, inline caches, and validity cells help optimize prototype operations. Based on this knowledge, we understood how to improve performance from a practical point of view: do not touch prototypes! (or if you really need it, do it before executing the code).

First part

Was this series of publications useful to you? Write in the comments.

Also popular now: