ES6 Feature Complete
As of r202125, JavaScriptCore supports all of the new features in the ECMAScript 6 (ES6) language specification. All of the new ES6 features are available in the latest WebKit Nightly and Safari Technology Preview. While we have an implementation of modules, no web-facing API has been finalized yet. ES6 adds a huge number of great features to the JavaScript language and all the JavaScriptCore contributors are excited about the future of JavaScript. We have worked hard to not just implement the new ES6 features but also make sure those features perform exceptionally. Today, however, we want to talk about a different but equally important aspect of our ES6 feature development, maintaining the current performance of existing ES5 websites and applications.
RegExp changes in ES6
ES6 adds an incredible amount of customizability to JavaScript regular expressions. It allows developers to customize how the String.prototype
functions handle a RegExp
argument via Symbol
property methods. For example, String.prototype.match()
attempts to use the Symbol.match
property on its first argument in order to execute the match[1]. By default, RegExp.prototype
has functions for each of the corresponding String.prototype
operations (Symbol.replace
, Symbol.search
, Symbol.match
, etc), which can be changed if a developer wants custom behavior. Additionally and more importantly, ES6 specifies how each of the RegExp.prototype
functions access or call all the RegExp
properties, such as flags
, global
, and exec
. If naively implemented, these new features, while providing more functionality for developers who want to use them, come at a performance cost for webpages that do not.
One of the goals of the JavaScriptCore Virtual Machine (VM) is to ensure that developers don’t pay a cost for features that they don’t use. In the case of RegExp
functions, our goal means existing webpages should not see an impact in the performance of the page’s RegExp
code. In order to maintain the same performance, JavaScriptCore needs to be able to avoid looking up and executing functions and getters, when possible. For example, if JavaScriptCore can ensure that every call to String.prototype.match()
is passed a RegExp
object that does not override or modify any of match
’s relevant properties (Symbol.match
, exec
, global
, and unicode
) it can execute match()
with a faster, specialized implementation. With the knowledge that these properties have not changed, the specialized implementation can avoid potentially costly property lookups and can load any information the match
function needs directly off of the RegExp
object. This also allows the specialized implementation to inline the exec
function.
The JavaScriptCore VM uses a multi-tiered engine design where the each tier does progressively more advanced optimizations. The highest tiers of JavaScriptCore make speculations about what the code is doing, which enables optimizations that would otherwise be impossible. More information about the tiered architecture of JavaScriptCore can be found in previous blog posts[2][3]. In order to make speculations about the properties on RegExp
objects in the optimizing tiers of our engine, we needed a way to look up those properties without observable effects.
To do this, a new bytecode was added, TryGetById, which acts similarly to the bytecode for a normal property lookup on an identifier, GetById. If the property is either unset or is a normal value (JavaScript has some special properties with special effects, such as [].length
, which are excluded) then TryGetById just returns the appropriate value. Otherwise, if the property is an accessor, TryGetById returns our internal GetterSetter object, which is compared to the expected original value. Since accessors have a getter and setter associated with them, GetterSetter objects are an internal object the VM uses to hold those functions. By itself, TryGetById does not solve the performance problems associated with the ES6 behavior since TryGetById does work to prove that the values are what the VM expects. As code moves into the VM’s optimizing compilers, however, information gathered from TryGetById in the lower tiers allows us to remove almost all the checks needed to execute the specialized code.
Structures
Before going into detail on how TryGetById is optimized, it is useful to understand the concept of Structures. Readers that are already familiar with the concept of Structures (also commonly referred to as shapes or maps) may want to skip to the next section. Originating in the 80s as an optimization for the Smalltalk and Self languages[4], Structures are a way to represent memory layout of properties on an object. A naive way to store the properties on an object in JavaScript would be to have every object be a hash map from an identifier to its corresponding value/accessor. While using a hash map does work, it does not take advantage of how objects are commonly used. In JavaScript, it is very common to create objects by calling a constructor, which adds several properties. Most of the time, objects constructed by the same function will share the same set of properties. Structures are a way to take advantage of the similarity between these objects. Consider the following:
function Point(i, j) {
this.x = i;
this.y = j;
}
let p1 = new Point(1, 2);
let p2 = new Point(3, 4);
Even though p1
and p2
have different values, they are both initialized in the same way. Namely, when calling new Point()
, a new, empty object, o
, is allocated. Then the property x
is added to that object, followed by the property y
. In the above example, when the code assigns this.x
to i
, the VM starts by looking up property x
on the structure of o
. Since o
is initially an empty object, it will have the initial Structure shared by all normal JavaScript objects with no properties. That structure will not have an entry for property x
. Hence, it will need to be added.
Since Structures are immutable, in order to add the x
property to the object, the VM will need to replace the object’s Structure with another one that has an entry for x
in addition to all of the object’s old properties. This replacement of Structures is what we call a transition. Besides adding a new property, there are many other reasons why a transition might be necessary, such as, deleting properties, changing the attributes of a property (via Object.defineOwnProperty
), or changing the prototype.
Whenever performing a Structure transition to add a new property, the VM first looks to see if any other objects, sharing the same Structure, have added a property with the same identifier as the one we are about to add. If such a Structure exists, it is reused. On the other hand, if one does not exist, a new Structure is allocated, copying all the properties on the current Structure before adding the new property to the new Structure. Then, we change the object’s Structure pointer to the new Structure.
In JavaScriptCore, object properties are stored in an array-like object called the Butterfly. The Butterfly is pointed to by the object. The Structure stores a hash map from identifiers to the offset in the Butterfly where that property’s value is stored. Each new property is simply given the next free offset. Once the VM has fully initialized the new Structure, it marks on the old Structure that any other new property transition adding the same identifier should reuse the new Structure.
For example, in the figure above we can see how a Point
instance transitions at each line of a call to new Point(3, 4)
. Each time a new property is added, the Point
instance changes its structure and adds a new offset to the Butterfly. Note that Structure 1 is marked with a transition to Structure 2 when a property x
is added to it. Similarly, Structure 2 is marked with a transition to Structure 3 when property y
is added to it. When a subsequent instance of Point
is created and initialized, it will reuse the same Structures 1, 2, and 3, and will have the same Butterfly layout as that of the first Point instance.
Using Structures provides two main benefits. First, it can save a significant amount of memory. If a program allocates thousands of Points, without structures, each Point
needs to hold a hash table of its properties. With Structures, only a constant amount of memory is used to map properties and each object only needs an array of its properties. Additionally, most of the time, objects that have the same properties also have the same prototype. Hence, instead of storing the pointer to the prototype in each of these objects, JavaScriptCore only stores one copy of the prototype pointer on the common Structure shared by these objects. The figure below shows how the prototype chain for a Set
instance object might look.The second, and perhaps more important benefit of Structures comes from the fact that each object that shares a Structure has the same layout of properties in its Butterfly. JavaScriptCore capitalizes on this fact to perform various optimizations throughout the engine.
Object Property Conditions and Adaptive Watchpoints
Since TryGetById participates in the same inline caching system that GetById uses, which you can read more about in previous blog posts, TryGetById can take advantage of all the optimizations that GetById has. Inline caching is relatively simple when caching a property located on the object itself (also referred to as an own property). After the first access, to ensure all subsequent accesses are fast, all the VM needs to do is repatch a couple of instructions with the Structure and offset of the property. Next time the GetById/TryGetById is executed, the program will check if the new object has the same Structure as the last, then the VM can load the property from the cached offset skipping the hash table lookup on the Structure to find the offset. Verifying the Structure of an object is one the VM knows about is referred to as a Structure check and is used for many different purposes.
Loading properties from the prototype, as is the case for all of the new ES6 RegExp
changes, is a much harder problem. While a Structure check guarantees that any object with that Structure does not have the desired property it does not guarantee that the object’s prototype does or does not have that property as well. Since the Safari 9.0 release, our inline caches for prototypes have been made significantly more powerful with the addition of two new concepts, Object Property Conditions and Adaptive Watchpoints. In particular, Object Property Conditions and Adaptive Watchpoints allow JavaScriptCore, in some cases, to completely eliminate all heap loads and almost all Structure checks in optimized code for property accesses.
Object Property Conditions
An Object Property Condition (OPC) is a constraint that validates some heap access when looking up a property on the prototype chain. An Object Property Condition, much like its name suggests, holds an object and some condition on that object. There are three kinds of property conditions used by GetById/TryGetById: Presence, Absence, and Equivalence. Presence and Absence are simple. Presence says that the object in the OPC has a property with a given identifier. Absence, on the other hand, says that the object in the OPC does not have a property with that identifier. The Equivalence watchpoint will be discussed a little bit later. For any GetById/TryGetById on an object there needs to be an OPC for each object in the prototype chain between the base object and the prototype with the property. When no object on the prototype chain has the desired property then there needs to be an OPC on every object in the prototype chain.
function foo() {
let r = new Set();
console.log(r.toString());
}
In the example above, if we wanted to quickly load the toString
property off a newly created Set
instance object, we would need to have a two OPCs and Structure check. The first OPC says Object.prototype
has a Presence condition for the toString
property, and the second says Set.prototype
has a Absence condition for the toString
property. With those conditions and a Structure check, our GetById/TryGetById inline caches can directly load the current value of Object.prototype.toString
. Note, a single Structure check is sufficient because the Structure tells us both the prototype of the object and that the object has no toString
property.
Adaptive Watchpoints
Just because an OPC was valid when it was created does not mean that it will remain so later. It is entirely possible that a programmer deletes a property that was present before, or adds a property with the same identifier on the prototype chain, intercepting the old property. This is where Adaptive Watchpoints come in. Whenever the VM creates an OPC for some object, it also create an Adaptive Watchpoint to attach to the condition’s object’s Structure to ensure that the condition remains valid. If an object with the watched object’s Structure ever transitions, the Adaptive Watchpoint is fired. Once fired, an Adaptive Watchpoint checks if its OPC is still valid on the watched object. If the transition is on the watched object and has invalidated the OPC, any code that depends on that condition will be thrown away. Otherwise, the Adaptive Watchpoint relocates itself to the watched object’s new Structure. The figure below illustrates how Adaptive Watchpoints and Object Property Conditions would interact with the prototype chain when looking up the toString
property on a Set
instance object.
Once some code tiers up to one of JavaScriptCore’s optimizing compilers, the VM starts to utilize the Equivalence conditions mentioned before. An Equivalence condition is essentially a stronger Presence condition. It tells us not only that a property, p, is present on an object, but also that p has a specific value. Knowing that a GetById/TryGetById may return a constant allows our optimizing compliers to make numerous optimizations that would otherwise be impossible.
As the VM tiers up a piece of code to the optimizing compilers, the VM examines the cases each GetById/TryGetById saw in the lower tiers. If the VM finds that every case loads from a Presence condition on the same prototype object, the VM attempts to convert that Presence condition into an Equivalence condition. In order to ensure that an Equivalence condition remains valid, the Adaptive Watchpoint holding the Equivalence condition needs to ensure no property store to the watched object replaces the existing value for the condition’s property. Although there is a significant cost to checking each store to the watched object, in practice, stores to prototype objects are quite rare and the gains in the optimizing compilers tend to be much larger.
Now that we have an understanding of the way JavaScriptCore does property load optimizations, let’s look at why TryGetById can be used to improve the performance of regular expressions. Since most code does not change properties on the RegExp.prototype
object, the VM is usually able to convert all the TryGetByIds on the relevant properties into constants via Equivalence conditions. Our optimizing compilers are then able to recognize that all the pre-checks before our specialized code are redundant and eliminate them. In the String.prototype.match
example from before, we can eliminate the checks that none of Symbol.match
, RegExp.prototype.exec
, RegExp.prototype.global
, and RegExp.prototype.unicode
have been overridden. The resulting optimized code need only perform a single Structure check on the argument’s RegExp
object, then it can then go straight to the fast, specialized code.
Conclusion
The JavaScriptCore team is very excited about all of the new features in ES6 and we think developers will get a lot of mileage out of them. Moving forward, we will continue to make those features faster and we plan on putting out more blog posts on our progress as we go. For now, you can check out the current implementation of our ES6 features in WebKit nightly or Safari Technology Preview. As always, let us know what you think and let us know about any issues or bugs you experience.