Libtask
Libtask is best explained by the docstring for TapedTask
:
Libtask.TapedTask
— TypeTapedTask(taped_globals::Any, f, args...; kwargs...)
Construct a TapedTask
with the specified taped_globals
, for function f
, positional arguments args
, and keyword argument kwargs
.
Extended Help
There are three central features of a TapedTask
, which we demonstrate via three examples.
Resumption
The function Libtask.produce
has a special meaning in Libtask. You can insert it into regular Julia functions anywhere that you like. For example
julia> function f()
for t in 1:2
produce(t)
t += 1
end
return nothing
end
f (generic function with 1 method)
If you construct a TapedTask
from f
, and call Libtask.consume
on it, you'll see
julia> t = TapedTask(nothing, f);
julia> consume(t)
1
The semantics of this are that Libtask.consume
runs the function f
until it reaches the call to Libtask.produce
, at which point it will return the argument to Libtask.produce
.
Subsequent calls to Libtask.produce
will resume execution of f
immediately after the last Libtask.produce
statement that was hit.
julia> consume(t)
2
When there are no more Libtask.produce
statements to hit, calling Libtask.consume
will return nothing
:
julia> consume(t)
Copying
TapedTask
s can be copied. Doing so creates a completely independent object. For example:
julia> t2 = TapedTask(nothing, f);
julia> consume(t2)
1
If we make a copy and advance its state, it produces the same value that the original would have produced:
julia> t3 = copy(t2);
julia> consume(t3)
2
Moreover, advancing the state of the copy has not advanced the state of the original, because they are completely independent copies:
julia> consume(t2)
2
TapedTask-Specific Globals
It is often desirable to permit a copy of a task and the original to differ in very specific ways. For example, in the context of Sequential Monte Carlo, you might want the only difference between two copies to be their random number generator.
A generic mechanism is available to achieve this. Libtask.get_taped_globals
and Libtask.set_taped_globals!
let you set and retrieve a variable which is specific to a given Libtask.TapedTask
. The former can be called inside a function:
julia> function f()
produce(get_taped_globals(Int))
produce(get_taped_globals(Int))
return nothing
end
f (generic function with 1 method)
The first argument to Libtask.TapedTask
is the value that Libtask.get_taped_globals
will return:
julia> t = TapedTask(1, f);
julia> consume(t)
1
The value that it returns can be changed between Libtask.consume
calls:
julia> set_taped_globals!(t, 2)
julia> consume(t)
2
Int
s have been used here, but it is permissible to set the value returned by Libtask.get_taped_globals
to anything you like.
Implementation Notes
Under the hood, we implement a TapedTask
by obtaining the IRCode
associated to the original function, transforming it so that it implements the semantics required by the produce
/ consume
interface, and placing it inside a MistyClosure
to make it possible to execute.
There are two main considerations when transforming the IRCode
. The first is to ensure that the "state" of a TapedTask
can be copied, so that a TapedTask
can be copied, and resumed later. The complete state of a TapedTask
is given by its arguments, and the value associated to each ssa (these are initially undefined). To make it possible to copy the state of the ssa values, we place Base.RefValue{T}
s into the captures of the MistyClosure
which implements the TapedTask
, one for each ssa in the IR (T
is the type inferred for that ssa). A call is replaced by reading in values of ssas from these refs, applying the original operation, and writing the result to the ref associated to the instruction. For example, if the original snippet of IRCode
is something like
%5 = f(%3, _1)
the transformed IR would be something like
%5 = ref_for_%3[]
%6 = f(%5, _1)
ref_for_%5[] = %6
Setting things up in this manner ensures that an independent copy is made by simply copying all of the refs. A deepcopy
is required for correctness as, while the refs do not alias one another (by construction), their contents might. For example, two refs may contain the same Array
, and in general the behaviour of a function depends on this relationship.
The second component of the transformation is implementing the produce
mechanism, and the ability to resume computation from where we produced. Roughly speaking, the IRCode
must be modified to ensure that whenever a produce
call in encountered, the MistyClosure
returns the argument to produce
, and that subsequent calls resume computation immediately after the produce
statement. This resumption is achieved by setting the value of a counter prior to returning following a produce
statement – a sequence of comparisons against this counter, and GotoIfNot
statement are inserted at the top of the IR. These are used to jump to the point in the code from which computation should resume. These are set up such that, when the TapedTask
is first run, computation start froms the first statement. Observe that this is also facilitated by the ref mechanism discussed above, as it ensures that the state persists between calls to a MistyClosure
.
The above gives the broad outline of how TapedTask
s are implemented. We refer interested readers to the code, which is extensively commented to explain implementation details.
The functions discussed in the above docstring (in addition to TapedTask
itself) form the public interface of Libtask.jl. They divide neatly into two kinds of functions: those which are used to manipulate TapedTask
s, and those which are intended to be used inside a TapedTask
.
Manipulation of TapedTask
s:
Libtask.consume
— Functionconsume(t::TapedTask)
Run t
until it makes a call to produce
. If this is the first time that t
has been called, it starts execution from the entry point. If consume
has previously been called on t
, it will resume from the last produce
call. If there are no more produce
calls, nothing
will be returned.
Base.copy
— MethodBase.copy(t::TapedTask)
Makes a completely independent copy of t
. consume
can be applied to either the copy of t
or the original without advancing the state of the other.
Libtask.set_taped_globals!
— Functionset_taped_globals!(t::TapedTask, new_taped_globals)::Nothing
Set the taped_globals
of t
to new_taped_globals
. Any calls to get_taped_globals
in future calls to consume(t)
(either directly, or implicitly via iteration) will see this new value.
Functions for use inside a TapedTask
s:
Libtask.produce
— Functionproduce(x)
When run inside a TapedTask
, will immediately yield to the caller, returning value x
. Users will typically hit this function when calling consume
.
See also: Libtask.consume
Libtask.get_taped_globals
— Functionget_taped_globals(T::Type)
When called from inside a call to a TapedTask
, this will return whatever is contained in its taped_globals
field.
The type T
is required for optimal performance. If you know that the result of this operation must return a specific type, specify T
. If you do not know what type it will return, pass Any
– this will typically yield type instabilities, but will run correctly.
See also set_taped_globals!
.
An opt-in mechanism marks functions that might contain Libtask.produce
statements.
Libtask.might_produce
— Methodmight_produce(sig::Type{<:Tuple})::Bool
true
if a call to method with signature sig
is permitted to contain Libtask.produce
statements.
This is an opt-in mechanism. the fallback method of this function returns false
indicating that, by default, we assume that calls do not contain Libtask.produce
statements.