apt1002 / mijit

Experimental JIT compiler generator
https://github.com/apt1002/mijit/
25 stars 4 forks source link

What code do we want to generate? #2

Closed apt1002 closed 2 years ago

apt1002 commented 4 years ago

Background

Mit and Welly each include a specializing interpreter, intended as a prototype of this project. Mijit's goal is to generate code on-the-fly, in contrast to the prototype, which separates profiling from compiling.

We think we have already worked out a lot of the necessary concepts in the context of the prototype. Let me summarize them here.

Histories

The code labels generated by the JIT correspond to "histories". A history is roughly a contiguous run of executed instructions, but it can also include control flow decisions such as "if" conditions and the results of dynamic type checks.

The history corresponding to a code label may be understood as the shortest sequence of VM instructions (etc.) that would result in the label being visited, starting from the main entry point of the mijit_run function. The history may dually be understood as the VM instructions that need to be retired in order to exit the mijit_run function by the shortest path.

Metadata

In addition to the code compiled for each history, Mijit maintains additional data about each history, including:

This metadata is not touched during normal execution (apart from incrementing the profiling counters). It is only needed for compiling new code.

Language

The finite set of histories (perhaps up to a million) for which code exists is the "language". The language grows over time as the JIT compiler discovers and compiles hot code.

Left and right trees

The language must satisfy some invariants at all times:

Thus, the language has an intricate double tree structure. The trees share a root (the empty history). Long histories are often leaves of both trees, but it is possible for a history to be a leaf of only one of the trees.

Fetch, retire

Control-flow proceeds along the branches of these two trees, as follows:

Execute

VM instructions are fetched and retired in order. Each VM instruction is executed between being fetched and being retired.

The VM instructions may be executed out of order. In other words, for each history, the JIT has freedom to decide which VM instructions in the history are "executed before" entering the code corresponding to the history, with the rest being "executed after".

For now, the simplest idea is to execute VM instructions immediately on fetching them, i.e. in order. In other words, we decide that all the instructions in the history are "executed before". This is what we did in the prototype. I expect this design will perform reasonably well on out-of-order CPUs. Scheduling instructions for in-order CPUs is an optimization for the future.

Profiling

The best place to put the profiling counters is on the retire transitions. There are generally fewer retire than fetch transitions (i.e. we retire more instructions per transition), and there is opportunity to increment a counter in parallel with other things going on at the time.

We know that for every history the number of times control flow enters it is equal to the number of times control flow exits it. Therefore, we can compute by induction on the right tree:

These forms of the profiling information are much more useful; they can be used to decide when to extend the language, and which new histories to construct. It will be necessary to convert profiling information back and forth between the form that is efficient to collect and the forms that are useful.

Probability model

There are various circumstances in which we have to guess statistics about histories that don't exist. For example:

The following approximations are plausible (f(h) is the number of times history h has occurred, including the times it was bypassed):

These two rules of thumb are equivalent, and perhaps a more practical expression is that f(abc) is approximately f(ab) * f(bc) / f(b).

Cost model

As compiling new code is expensive, we want to model the cost.

We resist compiling new code until the counter on a retire transition exceeds some threshold, e.g. 1000. Probably the best implementation is a counter that starts at 1000 and counts down to zero, but let's pretend that the counter counts upwards. The purpose of the counter is to ensure that we spend as much effort executing code as compiling it. Effectively, it prevents us compiling the cold code. The counter is the cheapest mechanism I can imagine that will do this job.

The total of all the counters is a measure of the total execution time. If we arrange that all counters are usually somewhere fairly random between 0 and 1000, their total will grow in proportion to the amount of code we generate. I don't think it's necessary to reduce the counter values when we compile new code; instead we should reassign some of the counts to the new code, preserving the total.

Behaviour

There are four main modes of execution within mijit_run:

Fetch and trap labels

For normal execution, we need one "fetch" label at which we can enter the code corresponding to each history. The code at that label first tries various fetch transitions. If successful, it (executes some instructions and) jumps to the fetch label of a longer history. If unsuccessful, it (executes some instructions and) follows the unique retire transition to the fetch label of a shorter history. These are hot paths.

For other cases, we need a "trap" label at which we can enter the code that will retire all the instructions in the history and return to the root history. The code at this label is identical with the code for retiring instructions during normal execution, except that it must jump to the trap label of the shorter history, not the fetch label. This is a cold path.

Sharing retire code

I think it is possible to share the code to retire instructions, without slowing down the hot path. If so, it is probably desirable, as it is quite a large fraction of the code.

As described above, the fetch label attempts various fetch transitions. If it is unsuccessful, it jumps to a shared "retire" label. The "retire" label retires some instructions and jumps to the fetch label of the shorter history. The same code has been executed as in the description above.

To effect a trap, we must set things up in such a way that all attempts to follow a "fetch" transition fail. For example, we may set the register than normally holds IR to a special value, while saving the real IR value somewhere else. There may be several such "special values", each indicating a different kind of trap.

The root

The retire label of the root history obviously must do something different (in the prototype this label was called A_FALLBACK). It must restore the correct value of IR, and dispatch to whatever code is appropriate for handling the trap. Examples include:

In all but the first case, execution continues at the fetch label of the root history.

Evolution

Let us imagine how the code for a history changes over time. At first, the history is not in the language and no code exists. Eventually, the history is added to to the language, and we construct its retire label. At this point its fetch label coincides with its retire label, i.e. there are no fetch transitions from this history. Then, we add fetch transitions, one at a time.

Each time we add a fetch transition, we compile "fetch code" for it. This code checks (e.g.) that the next few VM instructions match the transition, and if so executes appropriate code for the transition. This code must be inserted into the existing control flow path, after the existing fetch code for the history (if any) and before the retire label.

Patching

Inserting a chunk of code into an existing control-flow graph requires some care.

First, we must ensure that no thread is executing the code while we're modifying it. This is trivial in the single-threaded case.

Then, we must allocate memory for the new code, and fill it. The new code will generally not be contiguous with the old code. The new code ends with a jump to the old code (e.g. a retire label).

Then, we must find all instructions that jump to the old code, and rewrite them so that they jump to the new code. Jump instructions have a simple encoding, so this is quite feasible. However, it requires that we use the most general form of the jump instruction, with the largest available offset, even if it is not initially necessary.

Many jump instructions can be patched at once by making them indirect through a shared code pointer. However, this comes at the cost of performance and additional maintenance.

Summary

For each history, we need:

apt1002 commented 4 years ago

Some further thoughts follow, about:

Histories, really

I said above "A history is roughly a contiguous run of executed instructions, but it can also include control flow decisions such as "if" conditions and the results of dynamic type checks". Let's firm that up: a history is a path through the control-flow graph of Mijit's bootstrap interpreter.

Finite state machine

In a traditional interpreter loop, every control-flow arc starts and ends in the same place, being the start of the loop. The history is then a string of those arcs, and any such string is a possible history. Then, the empty string is a prefix and suffix of all other histories, and the fetch and retire transitions form trees, with the empty history as the root, as I described above.

However, Mijit will need to support interpreters with a more complex control-flow graph. Mijit allows the control-flow of the bootstrap interpreter to be a finite state machine. Here are some examples of why that might be useful:

Therefore, in general, histories do not always start and end in the same state of the finite state machine. This means that not all strings of control flow arcs are valid histories; two histories can only be concatenated if one ends where the other starts.

Multiple roots

Having multiple control-flow states makes things slightly more complicated, though not really any more difficult:

Strings of booleans

The VM specification data structure defines the semantics of the VM by expressing the bootstrap interpreter in a domain-specific language in which the only control-flow construct is "if". When the Mijit boots up, the bootstrap interpreter will be compiled directly from that specification in a fairly naive way.

The states of the finite state machine are the "if" statements, and there are two control-flow arcs exiting each state. The number of control-flow arcs entering a state can vary.

This suggests that a history could be represented as its start state and a string of booleans. Its end state is implied. That idea works, and is nicely general, and is a useful concept. However, a string of booleans is probably not the best data structure to use to represent histories, because:

Nested structure

The way the JIT constructs histories naturally leads to a nested structure.

A history is a string of transitions

There is a unique path to each history through its right tree from the whichever empty history shares its start state. This expresses the history as a concatenation of fetch transitions. Dually, its left tree expresses it as a concatenation of retire transitions. This reduces the problem of representing histories to that of representing (say) fetch transitions.

Following a sequence of fetch transitions leads from the least specialized state (an empty history) through a sequence of gradually more specialized states. We hope that fetch transitions from specialized states do more work, measured e.g. as a number of booleans. If so, the length in transitions of a history should grow sub-linearly with its length in booleans.

A transition is a string of transitions

For each history, exactly one fetch transition enters it, and exactly one retire transition exits it. When a history is first constructed, these are the only transitions to or from the history. The hope is that the newly compiled code for these transitions is used instead of less specialized code that was compiled earlier. In more detail:

Thus, we can represent a fetch transition as the sequence of fetch transitions it replaces (the retire transitions are implied).

The base case

Thus, we can represent histories and transitions using an inductive structure. The inductive step was compiling new code. The base case is a transition that is part of the original bootstrap interpreter. The initial fetch transitions are in 2:1 correspondence with the "if" statements of the VM specification (since each "if" has two branches).

Optimizer

The optimizer is called once for each history, when the history is first constructed. It must generate code for the unique fetch transition that enters it and the unique retire transition that leaves it.

It will be useful to recall that the code for a fetch transition will start with an "if" that tests whether the transition can occur; the "if" condition is determined by the end state of the "before" history, and the transition advances the end state. The code for a retire transition from a history will be used only when all outgoing fetch transitions (if any) have been eliminated, i.e. their "if" conditions have been evaluated to false. The "if" condition of the eliminated transitions is determined by the end state of the "after" history, and the transition preserves that state.

The concatenation of the two new transitions represents a specialized code sequence which we hope to use in place of a less specialized sequence of existing transitions that were compiled earlier. The old sequence starts with a retire transition and ends with a fetch transition. In the case where some "if" conditions are proved to be constant, the old sequence may contain additional fetch transitions, and for various reasons it may contain additional retire transitions.

Inputs

The optimizer has the following inputs:

Outputs

The optimizer has the following outputs:

Calling conventions

The calling convention of the history before the sequence summarizes all the information we have accumulated about the state of the virtual machine by fetching that history. This includes, for example:

The calling convention of the history after the sequence summarizes all the information that we need in order to retire that history. This includes, for example:

Cache

Part of the calling convention of a history, including the register allocation, may be interpreted as a cache state. Bits of virtual machine state, including memory contents and computed values, are held in locations that are cheap to access, including registers, spill slots, and even constants. This mental picture provides useful intuition and terminology.

In hardware CPUs, the cache state is dynamic; it is computed on the fly. In contrast, the optimizer must compute the cache state statically; anything else would be too expensive. In other words, at each point in the compiled code, the same values are always cached in the same places.

Dataflow graph

In overview, the optimizer has the following steps:

Constructing

The input code probably uses the cache suboptimally. Fetch transitions generally cache additional values, and retire transitions generally flush values from the cache. Therefore, any retire transition that comes before a fetch transition risks flushing values from the cache and then reloading them. Removing these inefficiencies is a large part of what the optimizer does.

The optimizer computes the dataflow graph by symbolically executing the input code, starting from the given "before" cache state, and recording what happens. Instructions that access (or otherwise compute) cached values are removed, or replaced by cheaper instructions. Instructions that access (or otherwise compute) uncached data are preserved, and their results are added to the cache. At the end, instructions are added to the dataflow graph to flush values from the cache in order to match the given "after" cache state.

Optimizing

Some optimizations are performed automatically by the cache:

An additional pass over the code is needed for other optimizations:

Scheduling

The simplest idea is to leave the instructions in their original order.

A more ambitious idea is to model how long it takes to compute each value, and to sort the instructions accordingly. This is an "inifinite parallelism" approximation.

A yet higher ambition is to model the behaviour of a particular target CPU, including the limits on its decoding and execution parallelism. Instructions on the critical path to the next "if" condition are given the highest priority, and are scheduled as early as possible. Other instructions are scheduled in parallel with them provided doing so is free. Any left over instructions are scheduled at the end.

Split

The simplest idea is to split the code just before the instructions that flush the cache. Everything before that point becomes the fetch transition, and everything after becomes the retire transition.

A more ambitious idea is to split the code at the earliest point where the next "if" condition can be tested.

Complexity

The cost of generating code for a history is roughly proportional to the size of the dataflow graph. This is bounded by the total length of the code for the replaced transitions. Importantly, that code has already been optimized. The original number of VM instructions may be much larger, but it is pleasingly irrelevant.

We may also hope that as we compile more specialized transitions, we do more useful work per transition. Suppose each new transition replaces a sequence of on average k transitions. Then, after specializing each part of the flow graph n times, we could get transitions that do O(k^n) work. Of course, I don't expect exponential speed-up in most programs; it is possible in ideal situations, but there are a number of factors that prevent code sequences being optimized away to nothing.

Strategy

Recall:

Rewinding execution

In the prototype, the strategy was to compile code starting at the history before the transition whose counter overflowed. Instead, I think it might be better to rewind execution to an earlier history, and start there. The goal is to generate code for a long history, in spite of the tendency for short histories to reach their counter threshold first.

It is not necessary to rewind history perfectly, and in particular it is not necessary to record the execution trace. We can use the probability model to follow the execution trace backwards, stopping if the probability of being right drops too low, and backtracking (forwards) if our guess turns out to be logically impossible. We must start the new code just before a retire transition, so it may be necessary to backtrack (forwards) a little more.

Multiguessing

In the prototype, the strategy was to construct the language as if fetch transitions fetch only single VM instructions, and then post-process the language to find opportunities to fetch multiple instructions at a time. In the context of a JIT, there is no opportunity to do a post-processing pass. Instead, we must construct fetch transitions with more specific "if" conditions than the ones they replace.

We can do this by tracing execution forwards using the probability model, stopping if the probability of being right drops too low. On this path, we collect "if" conditions that can be merged with the first one on the path. For example, if the first "if" tests a bit of a value, we collect "if"s that test other bits of the same value. For another example, if the first "if" tests that a value is less than a constant, we collect "if"s that compare the same value to other constants. This is fairly straightforward, because the possible "if" conditions are restricted by the domain-specific language used to specify the VM.

We then construct a new fetch transition whose "if" condition is the conjunction of all the collected conditions. Perhaps it will not be used, because the conjunction of conditions is never true; in that case we wait for a different profiling counter to overflow and compile an alternative fetch transition. For the case when the conjunction is true, we generate code up to the next "if" whose condition remains uncertain.

There will sometimes be cases where the conjunction is a stronger condition than is necessary to reach the next uncertain "if". For example, suppose the first three conditions are "bit 0 of IR; something else; bit 1 of IR". Then, the conjunction is "bits 0 and 1 of IR" but the new code will stop before testing "something else". It is nonetheless worth testing the stronger condition and leaving the result in the optimization cache, so that we can assume it when compiling the following fetch transition. For example, the following fetch transition might test "something else", then assume "bit 1 of IR".

apt1002 commented 3 years ago

Most of this design has now been implemented. The remaining obstacles to closing this issue are:

These should become separate issues, maybe with a bit of rethinking.

apt1002 commented 3 years ago

Section "Profiling" edited into #33.

apt1002 commented 2 years ago

Exceptions implemented (in a slightly different way) in #38.

apt1002 commented 2 years ago

Section "Strategy" must be roughly right, but it's unlikely to be right in detail. I'm not confident enough about it that it is worth keeping this issue open.