Tape anatomy
Operations
The very core of every tape is a list of operations. Let's take a look at one particular tape:
using Umlaut
foo(x) = 2x + 1
_, tape = trace(foo, 2.0)
print(tape)
Tape{Umlaut.BaseCtx}
inp %1::typeof(Main.foo)
inp %2::Float64
%3 = *(2, %2)::Float64
%4 = +(%3, 1)::Float64
Each indented line in this output represents an operation. The first 2 designate the tape inputs and have type Input
. Note that the traced function itself is also recorded as an input and can be referenced from other operations on the tape, which is a typical case in closures and other callable objects. We can set new inputs to the tape as inputs!(tape, foo, 3.0)
.
Operations 3 and 4 represent function calls and have type Call
. For example, the notation %4 = +(%3, 1)
means that variable %4
is equal to the addition of variable %3
and a constant 1
(we will talk about variables in a minute). The easiest way to construct this operation is by using mkcall
.
Although constants can be used directly inside Call
s, sometimes we need them as separate objects on the tape. Constant
operation serves exactly this role.
Finally, there's an experimental Loop
operation which presents whole loops in a computational graphs and contain their own subtapes.
Variables
Variable
(also aliased as just V
) is a reference to an operation on tape. Variables can be bound or unbound.
Unbound variables are constructed as V(id)
and point to an operation by its position on a tape. Their primary use is for indexing and short-living handling, e.g.:
import Umlaut.V
op = tape[V(4)]
%4 = +(%3, 1)::Float64
On the contrary, bound variables (created as V(op)
) point to a specific operation on the tape. Even if the tape is modified, the reference is preserved. Here's an illustrative example:
vu = V(4) # unbound
vb = V(tape[vu]) # bound, can also be created as `bound(tape, vu)`
# insert a dummy operation
insert!(tape, 3, Constant(42))
println(tape)
println("Unbound variable is still $vu")
println("Bound variable is now $vb")
Tape{Umlaut.BaseCtx}
inp %1::typeof(Main.foo)
inp %2::Float64
const %3 = 42::Int64
%4 = *(2, %2)::Float64
%5 = +(%4, 1)::Float64
Unbound variable is still %4
Bound variable is now %5
Most functions in Umlaut create bound variables to make them resistant to transformations. Note, for example, how in the tape above the last operation automatically updated itself from +(%3, 1)
to +(%4, 1)
. Yet sometimes explicit rebinding is neccessary, in which case rebind!
can be used. Note that for rebind!
to work properly with a user-defined tape context (see below), one must also implement rebind_context!
Transformations
Tapes can be modified in a variaty of ways. For this set of examples, we won't trace any function, but instead construct a tape manually:
using Umlaut
import Umlaut: Tape, V, inputs!, mkcall
tape = Tape()
# record inputs, using nothing instead of a function argument
v1, v2, v3 = inputs!(tape, nothing, 1.0, 2.0)
3-element Vector{Variable}:
%1
%2
%3
push!
is the standard way to add new operations to the tape, e.g.:
v4 = push!(tape, mkcall(*, v2, v3))
println(tape)
Tape{Dict{Any, Any}}
inp %1::Nothing
inp %2::Float64
inp %3::Float64
%4 = *(%2, %3)::Float64
insert!
is similar to push!
, but adds operation to the specified position:
v5 = insert!(tape, 4, mkcall(-, v2, 1)) # inserted before v4
println(tape)
Tape{Dict{Any, Any}}
inp %1::Nothing
inp %2::Float64
inp %3::Float64
%4 = -(%2, 1)::Float64
%5 = *(%2, %3)::Float64
replace!
is useful when you need to replace an operation with one or more other operations.
new_op1 = mkcall(/, V(2), 2)
new_op2 = mkcall(+, V(new_op1), 1)
replace!(tape, 4 => [new_op1, new_op2]; rebind_to=2)
println(tape)
Tape{Dict{Any, Any}}
inp %1::Nothing
inp %2::Float64
inp %3::Float64
%4 = /(%2, 2)::Umlaut.UncalculatedValue
%5 = +(%4, 1)::Umlaut.UncalculatedValue
%6 = *(%2, %3)::Float64
deleteat!
is used to remove entries from the tape.
deleteat!(tape, 5; rebind_to = 1)
println(tape)
Tape{Dict{Any, Any}}
inp %1::Nothing
inp %2::Float64
inp %3::Float64
%4 = *(%2, 2)::Float64
%5 = +(%1, %3)::Float64
%6 = +(%1, %2)::Float64
Although trace
creates a tape consisting only of primitives, tape itself can hold any function calls. It's possible to decompose all non-primitive calls on the tape to lists of corresponding primitives using primitivize!
.
import Umlaut: primitivize!
f(x) = 2x - 1
g(x) = f(x) + 5
tape = Tape()
_, x = inputs!(tape, g, 3.0)
y = push!(tape, mkcall(f, x))
z = push!(tape, mkcall(+, y, 5))
tape.result = z
primitivize!(tape)
Tape execution & compilation
There are 2 ways to execute a tape. For debug purposes it's easiest to run play!
:
using Umlaut
import Umlaut: play!
foo(x) = 2x + 1
_, tape = trace(foo, 2.0)
play!(tape, foo, 3.0)
7.0
compile
turns the tape into a normal Julia function (subject to the World Age restriction):
using Umlaut
import Umlaut: compile
foo(x) = 2x + 1
_, tape = trace(foo, 2.0)
foo2 = compile(tape)
foo2(foo, 3.0) # note: providing the original `foo` as the 1st argument
7.0
It's possible to see what exactly is being compiled using to_expr
function.
Context (again)
We have already discussed contexts as a way to customize tracing in the Linearized traces section, but here we need to emphasize context's role as a storage. Context is attached to a Tape
and can be accessed throughout its lifetime. For instance, imagine that you are working on a DSL engine which traces function execution and enriches the resulting tape with domain-specific operations. You also want to keep track of all added operations, but don't want to pass around an additional object holding them. You can attach a custom context to the tape and reference it as tape.c
:
using Umlaut
import Umlaut: Variable
dsl_function(x) = ...
mutable struct DSLContext
added_variables::Vector{Variable}
end
_, tape = trace(dsl_function, 2.0; ctx=DSLContext([]))
function add_operations(tape::Tape{DSLContext})
v = push!(tape, ...)
push!(tape.c.added_variables, v)
...
end
function process_dsl_tape(tape::Tape{DSLContext})
vars = tape.c.added_variables
...
end
Just to remind you, if your context contains variables and you plan to use rebind!
, you must also implement rebind_context!
for your specific context type.