Type inference in Spidermonkey

30 July 2012

I’ve been slowly learning how type inference works in SpiderMonkey. As I understand it, SpiderMonkey’s type inference scheme is the brain child of one Brian “Hack-it”, coder extraordinaire. You may have seen a recent PLDI publication on the topic. You may, like me, have read that publication. You may, also like me, have walked away thinking, “um, I don’t really understand how that works.” In that case, dear reader, this blog post is for you. Well, actually, it’s for me, to try and document what I gleaned from a conversation or two. It it is almost certainly not entirely accurate and it may or may not be helpful.

UPDATED 2012/07/31: I made some substantial edits based on feedback from djvj which clarified the interaction between property type sets and the type sets associated with each load. This actually makes much more sense to me now than it did before, maybe to you too.

The key distinction of SM’s Type Inferencer (hereafter, just TI) is that it is optimistic, not conservative. It is also dynamic, meaning that it adjusts during the execution of your program. Basically, as new types are observed, TI will adjust its estimate of what types are likely to appear at each point.

Type objects and type sets

In a traditional static type inference scheme, the inferencer will create a fixed number of summary objects to represent the objects that will exist at runtime. Each runtime object will be associated with a summary object; usually the summary objects are mapped to new statements in the source code, so each runtime object is associated with the summary object corresponding to the line where it was instantiated.

TI has a similar concept called type objects. Each JavaScript object is associated with a type object, usually corresponding (again) to the line where it was allocated. Each function also has its own type object and so forth. The type object will summarize the types for a (potentially infinite) group of objects at runtime.

Each Type Object contains a mapping from properties to type sets. A type set is a set of possible types that this property might have; these can be either the primitive types or other type objects. So, if you have some code like this:

var a = {f:3};
var b = {g:a};

then the type object for b will have a property g with a type set containing the type object for a. In general, I will use the convention that objects are named with lower-case letters and their corresponding type objects are named with upper-case letters. So, here, a/b are the objects and A/B are the corresponding type objects. Therefore, we can say that B.g includes A.

Graphically, this can be depicted like so:

 +-----+                 +-----+
 | "A" |                 | "B" |
 +-----+     /-------\   +-----+     /-----\
 | "f" |---->| "int" |   | "g" |---->|     |
 +-----+     \-------/   +-----+     \-----/
    ^                                   |
    |                                   |
    +-----------------------------------+

Here, the square-edged (+) boxes depict type objects, and the rounded-edged (/) boxes depict type sets. Each type object has a list of properties and each property has an associated type set. The edge from the type set for b.g leads to the type object for a.

Type sets are also use to summarize the set of types at various program points. For example, each function has a type set for each of its parameters as well as its return type. When analyzing a function, there will be typesets also for intermediate variables and also for bytecodes. So if I have an instruction like:

next = d.e + 1

then the read from d.e will have an associated type set recording what types it saw at this particular property load. This is an important point, because the type set D.e that summarizes all types that may be in the property d.e may be larger than the set of types that actually get read at this particular spot in the code. In other words, if there are two kinds of d objects, some with integers and some with strings, then D.e will contain both integers and strings. Due to the imprecision of the analysis, both types of objects are being lumped together into the same type object. But perhaps this particular load only ever sees the objects that contain integers. As we’ll see in a bit, type inference handles this case elegantly, allowing us to continue generating code that assumes d.e is an integer, while recovering if that should prove false.

Type barriers

During execution, type sets generally only record the values which have actually been observed. What this means is that merely storing a value into a property is not enough to affect its type set. So, for example, suppose that there is a sequence of code like this:

a = b.c
d.e = a

Now, support that before this code runs, the type set D.e associated with d.e only contains integers. Further suppose that the value a is a string. The state of the system might then look something like this:

 +-----+                 +-----+
 | "B" |                 | "D" |
 +-----+     /-------\   +-----+     /-------\
 | "c" |---->| "str" |   | "e" |---->| "int" |
 +-----+     \-------/   +-----+     \-------/

Here you see that the type set B.c has strings in it, and the type set for D.e has integers in it.

If this were a standard type inference algorithm, the above state would not be possible. This is because a value is being copied from b.c into d.e, and hence the type set D.e ought to be a superset of the typeset B.c. But this is not so. “Ok”, you might think, “perhaps the algorithm waits until the assignment occurs to do the propagation”. In that case, after the code executed, the type set D.e would contain both int and str. In fact that is what happens, but there is a twist.

While it’s true that the type set for D.e is updated, it’s not true that the type sets for loads of the property e are updated. When the store executes, the engine observes that a string has been added to D.e, but it prefers to take an optimistic approach and assume that those places which read d.e will continue to only see integers and not observe this string value. But of course we can’t ignore the string type completely. So instead of modifying the type set for each load, the engine adds a type barrier. This is basically a note on the load saying, “Hey, some of the objects you are loading from may contain strings, but I’ve never actually seen one come out.”

To understand the practical impact of a type barrier, consider a snippet of code like the following:

next = d.e + 1

Here the value d.e is being read, where d is the same object that was stored into before (or actually any object associated with the type object D). Remember that there will be a type set for D.e, indicating all values that may possible be read, and a separate type set (let’s call it L) for the load itself. Before the str is added to D.e, these two type sets are the same, and hence the JIT will assume that d.e will produce an integer and generate specialized code that simply reads the value out of the property, casts it to an integer, and adds 1 to it. But once the store to d.e occurs with a string value, D.e will contain an int and a str. This does not (yet) modify L, however, so the JIT will still assume that d.e is an integer. But it does cause a type barrier to be added to L, so the JIT will add additional code to check and make sure that it saw an integer and not a string (if there was existing JIT code, adding a type barrier might entail throwing it out or performing on-stack replacement).

Now so long as that string is never actually read, the type set L continues to contain only an integer, but every access to the property will check for a string. Once a string is actually observed, the type set L will be updated. This can trigger additional code invalidation for those places which relied upon reading only an integer.

Updates to type sets also triggers a certain amount of propagation based on a traditional static constraint graph generated from the code. I don’t fully understand the constraint graph in detail yet. One thing I do know is that, unlike a static constraint graph, it contains two sorts of edges (in reality, probably more). One edge is a traditional subset edge that ensures that one type set is a subset of the other. The second edge is a barrier edge which says “if this type set A is bigger than B, it is possible that values from A have been stored into B, so add a type barrier to watch for them”. This is the same mechanism that we just discussed. Basically it’s more efficient and accurate to use traditional constraint edges, but they can lead to a loss of overall precision, so there are some heuristics that guide the decision about which kind of edge to use in order to get the best overall results.

Conclusion

The key idea of the TI system is to be optimistic. As much as possible, it resists changing type sets until it is forced to by actually observing new values. However, via barriers, it always retains a summary of what kinds of values might be in each type set which it is currently ignoring.

By associating type sets not only with the heap objects but with the loads of properties themselves, TI is also able to shape specialized paths and to overcome some of the inherent imprecision in the type analysis itself.

As usual, writing up and explaining something is a good way to find the limits of your understanding. I think I have a feeling for how this propagation works, but it’s not quite as crystal clear as I’d like it to be. In particular I don’t understand the particulars of the constraint graph and I don’t quite understand when constraints are active.