mirror of
https://github.com/tinygo-org/tinygo.git
synced 2026-08-14 07:53:40 +00:00
interp: rewrite entire package
For a full explanation, see interp/README.md. In short, this rewrite is
a redesign of the partial evaluator which improves it over the previous
partial evaluator. The main functional difference is that when
interpreting a function, the interpretation can be rolled back when an
unsupported instruction is encountered (for example, an actual unknown
instruction or a branch on a value that's only known at runtime). This
also means that it is no longer necessary to scan functions to see
whether they can be interpreted: instead, this package now just tries to
interpret it and reverts when it can't go further.
This new design has several benefits:
* Most errors coming from the interp package are avoided, as it can
simply skip the code it can't handle. This has long been an issue.
* The memory model has been improved, which means some packages now
pass all tests that previously didn't pass them.
* Because of a better design, it is in fact a bit faster than the
previous version.
This means the following packages now pass tests with `tinygo test`:
* hash/adler32: previously it would hang in an infinite loop
* math/cmplx: previously it resulted in errors
This also means that the math/big package can be imported. It would
previously fail with a "interp: branch on a non-constant" error.
This commit is contained in:
committed by
Ron Evans
parent
e9d549d211
commit
30df912565
+92
-37
@@ -6,50 +6,81 @@ possible and only run unknown expressions (e.g. external calls) at runtime. This
|
||||
is in practice a partial evaluator of the `runtime.initAll` function, which
|
||||
calls each package initializer.
|
||||
|
||||
It works by directly interpreting LLVM IR:
|
||||
This package is a rewrite of a previous partial evaluator that worked
|
||||
directly on LLVM IR and used the module and LLVM constants as intermediate
|
||||
values. This newer version instead uses a mostly Go intermediate form. It
|
||||
compiles functions and extracts relevant data first (compiler.go), then
|
||||
executes those functions (interpreter.go) in a memory space that can be
|
||||
rolled back per function (memory.go). This means that it is not necessary to
|
||||
scan functions to see whether they can be run at compile time, which was very
|
||||
error prone. Instead it just tries to execute everything and if it hits
|
||||
something it cannot interpret (such as a store to memory-mapped I/O) it rolls
|
||||
back the execution of that function and runs the function at runtime instead.
|
||||
All in all, this design provides several benefits:
|
||||
|
||||
* Almost all operations work directly on constants, and are implemented using
|
||||
the llvm.Const* set of functions that are evaluated directly.
|
||||
* External function calls and some other operations (inline assembly, volatile
|
||||
load, volatile store) are seen as having limited side effects. Limited in
|
||||
the sense that it is known at compile time which globals it affects, which
|
||||
then are marked 'dirty' (meaning, further operations on it must be done at
|
||||
runtime). These operations are emitted directly in the `runtime.initAll`
|
||||
function. Return values are also considered 'dirty'.
|
||||
* Such 'dirty' objects and local values must be executed at runtime instead of
|
||||
at compile time. This dirtyness propagates further through the IR, for
|
||||
example storing a dirty local value to a global also makes the global dirty,
|
||||
meaning that the global may not be read or written at compile time as it's
|
||||
contents at that point during interpretation is unknown.
|
||||
* There are some heuristics in place to avoid doing too much with dirty
|
||||
values. For example, a branch based on a dirty local marks the whole
|
||||
function itself as having side effect (as if it is an external function).
|
||||
However, all globals it touches are still taken into account and when a call
|
||||
is inserted in `runtime.initAll`, all globals it references are also marked
|
||||
dirty.
|
||||
* Heap allocation (`runtime.alloc`) is emulated by creating new objects. The
|
||||
value in the allocation is the initializer of the global, the zero value is
|
||||
the zero initializer.
|
||||
* Stack allocation (`alloca`) is often emulated using a fake alloca object,
|
||||
until the address of the alloca is taken in which case it is also created as
|
||||
a real `alloca` in `runtime.initAll` and marked dirty. This may be necessary
|
||||
when calling an external function with the given alloca as paramter.
|
||||
* Much better error handling. By being able to revert to runtime execution
|
||||
without the need for scanning functions, this version is able to
|
||||
automatically work around many bugs in the previous implementation.
|
||||
* More correct memory model. This is not inherent to the new design, but the
|
||||
new design also made the memory model easier to reason about.
|
||||
* Faster execution of initialization code. While it is not much faster for
|
||||
normal interpretation (maybe 25% or so) due to the compilation overhead,
|
||||
it should be a whole lot faster for loops as it doesn't have to call into
|
||||
LLVM (via CGo) for every operation.
|
||||
|
||||
As mentioned, this partial evaulator comes in three parts: a compiler, an
|
||||
interpreter, and a memory manager.
|
||||
|
||||
## Compiler
|
||||
|
||||
The main task of the compiler is that it extracts all necessary data from
|
||||
every instruction in a function so that when this instruction is interpreted,
|
||||
no additional CGo calls are necessary. This is not currently done for all
|
||||
instructions (`runtime.alloc` is a notable exception), but at least it does
|
||||
so for the vast majority of instructions.
|
||||
|
||||
## Interpreter
|
||||
|
||||
The interpreter runs an instruction just like it would if it were executed
|
||||
'for real'. The vast majority of instructions can be executed at compile
|
||||
time. As indicated above, some instructions need to be executed at runtime
|
||||
instead.
|
||||
|
||||
## Memory
|
||||
|
||||
Memory is represented as objects (the `object` type) that contains data that
|
||||
will eventually be stored in a global and values (the `value` interface) that
|
||||
can be worked with while running the interpreter. Values therefore are only
|
||||
used locally and are always passed by value (just like most LLVM constants)
|
||||
while objects represent the backing storage (like LLVM globals). Some values
|
||||
are pointer values, and point to an object.
|
||||
|
||||
Importantly, this partial evaluator can roll back the execution of a
|
||||
function. This is implemented by creating a new memory view per function
|
||||
activation, which makes sure that any change to a global (such as a store
|
||||
instruction) is stored in the memory view. It creates a copy of the object
|
||||
and stores that in the memory view to be modified. Once the function has
|
||||
executed successfully, all these modified objects are then copied into the
|
||||
parent function, up to the root function invocation which (on successful
|
||||
execution) writes the values back into the LLVM module. This way, function
|
||||
invocations can be rolled back without leaving a trace.
|
||||
|
||||
Pointer values point to memory objects, but not to a particular memory
|
||||
object. Every memory object is given an index, and pointers use that index to
|
||||
look up the current active object for the pointer to load from or to copy
|
||||
when storing to it.
|
||||
|
||||
Rolling back a function should roll back everyting, including the few
|
||||
instructions emitted at runtime. This is done by treating instructions much
|
||||
like memory objects and removing the created instructions when necessary.
|
||||
|
||||
## Why is this necessary?
|
||||
|
||||
A partial evaluator is hard to get right, so why go through all the trouble of
|
||||
writing one?
|
||||
|
||||
The main reason is that the previous attempt wasn't complete and wasn't sound.
|
||||
It simply tried to evaluate Go SSA directly, which was good but more difficult
|
||||
than necessary. An IR based interpreter needs to understand fewer instructions
|
||||
as the LLVM IR simply has less (complex) instructions than Go SSA. Also, LLVM
|
||||
provides some useful tools like easily getting all uses of a function or global,
|
||||
which Go SSA does not provide.
|
||||
|
||||
But why is it necessary at all? The answer is that globals with initializers are
|
||||
much easier to optimize by LLVM than initialization code. Also, there are a few
|
||||
other benefits:
|
||||
The answer is that globals with initializers are much easier to optimize by
|
||||
LLVM than initialization code. Also, there are a few other benefits:
|
||||
|
||||
* Dead globals are trivial to optimize away.
|
||||
* Constant globals are easier to detect. Remember that Go does not have global
|
||||
@@ -60,5 +91,29 @@ other benefits:
|
||||
* Constants are much more efficent on microcontrollers, as they can be
|
||||
allocated in flash instead of RAM.
|
||||
|
||||
The Go SSA package does not create constant initializers for globals.
|
||||
Instead, it emits initialization functions, so if you write the following:
|
||||
|
||||
```go
|
||||
var foo = []byte{1, 2, 3, 4}
|
||||
```
|
||||
|
||||
It would generate something like this:
|
||||
|
||||
```go
|
||||
var foo []byte
|
||||
|
||||
func init() {
|
||||
foo = make([]byte, 4)
|
||||
foo[0] = 1
|
||||
foo[1] = 2
|
||||
foo[2] = 3
|
||||
foo[3] = 4
|
||||
}
|
||||
```
|
||||
|
||||
This is of course hugely wasteful, it's much better to create `foo` as a
|
||||
global array instead of initializing it at runtime.
|
||||
|
||||
For more details, see [this section of the
|
||||
documentation](https://tinygo.org/compiler-internals/differences-from-go/).
|
||||
|
||||
Reference in New Issue
Block a user