Lift Inference
Important: With release 0.4.0, Effekt moved away from this implementation strategy to explore other novel implementation techniques. This site is kept for historic purposes, only.
Programs in the Effekt surface language are translated to the underlying Core language,
which is in explicit capability-passing style. Core corresponds to System Xi
in
this paper and
System C
in this paper.
In order to efficiently compile programs in Core, the overhead incurred by using effect handlers should be minimized. In particular, the search for the correct handler at runtime should be avoided. This can be facilitated by making so-called lifting information explicit and using this information for a translation to iterated CPS.
Lifting Information
What does lifting information mean? Consider the following example:
effect Exc1(): String
effect Exc2(): String
def main() =
try {
def abort(): String / {} = { do Exc1() }
try {
abort()
} with Exc2 {
() => "aborted two"
}
} with Exc1 {
() => "aborted one"
}
Generated Core
def main2916() =
try { (){Exc1$capability4096} =>
def abort2920() = Exc1$capability4096.Exc1()
try { (){Exc2$capability4099} =>
abort2920()
} with Exc22915 {
def Exc22918(){resume2921} = "aborted two"
}
} with Exc12914 {
def Exc12917(){resume2919} = "aborted one"
}
main()
In this program two exception effects, Exc1
and Exc2
are declared. The block abort
is defined within the outer handler, which handles Exc1
, and simply performs the effect
operation Exc1
. As is visible in the signature of abort
(with an empty effect set) the
effect is handled by the outer handler, even though abort
is called inside of the inner
handler. The result of running the program thus is "aborted one"
.
Below the source the corresponding code in Core is shown. There the effect
operations become explicit capabilities bound at the corresponding handlers. When abort
is called, the Exc1
-capability is executed and has to jump over the inner handler to its
binding site, or to put it differently, it has to be lifted. At runtime this amounts to
searching for the correct handler on the stack. By making explicit which capabilities
have to be lifted to which handlers, such a runtime search can be rendered unnecessary.
Regions and subregion evidence
A structured way to make explicit which capability has to be lifted to which handler is to introduce a region system and explicit subregion evidence. Every handler opens a new region in its body and the corresponding capability has to run in this exact region. Each handler then additionally binds evidence that the new region it opens is a subregion of the region it is defined in. The capability bound by the handler can then be called in every subregion of the handler by providing it with the correct subregion evidence.
Consider the following variation of the above example without the abort
-block:
effect Exc1(): String
effect Exc2(): String
def main() =
try {
try {
do Exc1()
} with Exc2 {
() => "aborted two"
}
} with Exc1 {
() => "aborted one"
}
Generated Lifted Core
def main(ev4497: EV): String =
try { (ev4499: EV, Exc1$capability: Exc1) =>
try { (ev4501: EV, Exc2$capability: Exc2) =>
Exc1$capability.Exc1(<ev4501>)
} with Exc2 {
def Exc2(ev4500: EV) =
shift(<ev4500>) { (resume: (EV, String) => String) =>
"aborted two"
}
}
} with Exc1 {
def Exc1(ev4498: EV) =
shift(<ev4498>) { (resume: (EV, String) => String) =>
"aborted one"
}
}
Below the source, the Lifted Core intermediate representation (which corresponds
to LambdaCap
in this paper) is shown.
In Lifted Core the subregion evidence is explicit. The inner handler binds evidence
that its new region is a subregion of the region of the outer handler and this evidence is
then provided in the call of Exc1
, making it explicit that Exc1
has to jump over the
inner handler to run in the region of the outer handler.
Region and evidence inference
Back to the original example, where Exc1
is called in the definition of abort
. But
what is the correct evidence for Exc1
then? The problem is that at the point of the
definition of abort
it is not yet known where abort
will be called. In the example
abort
is called inside the inner handler and hence Exc1
should again be provided with
the evidence bound at the inner handler. But in general abort
could also be called in a
different region.
To account for this, it is possible for functions in Lifted Core to abstract over evidence. The idea is to have a new region for each function which is later instantiated with the region where the function is called. The function thus abstracts over evidence that this new region is a subregion of the region at the definition-site of the function.
This is illustrated in the Lifted Core code for the original example repeated below.
effect Exc1(): String
effect Exc2(): String
def main() =
try {
def abort(): String / {} = { do Exc1() }
try {
abort()
} with Exc2 {
() => "aborted two"
}
} with Exc1 {
() => "aborted one"
}
Generated Lifted Core
def main(ev4153: EV): String =
try { (ev4155: EV, Exc1$capability: Exc1) =>
def abort(ev4158: EV): String =
Exc1$capability.Exc1(<ev4158>)
try { (ev4157: EV, Exc2$capability: Exc2) =>
abort(<ev4157>)
} with Exc2 {
def Exc2(ev4156: EV) =
shift(<ev4156>) { (resume: (EV, String) => String) =>
"aborted two"
}
}
} with Exc1 {
def Exc1(ev4154: EV) =
shift(<ev4154>) { (resume: (EV, String) => String) =>
"aborted one"
}
}
Here abort
abstracts over evidence which is then passed to Exc1
, since this evidence
states that the new region of abort
is a subregion of the region of the outer handler,
where Exc1
has to run. When abort
is called it needs to be provided the correct evidence,
which here is the one bound by the inner handler (because abort
runs in the region of that
inner handler). Since that evidence is then passed to Exc1
this is exactly right.
Higher-order Functions
The inference of regions and subregion evidence also works for higher-order blocks, but things get a bit more complicated there.
The problem is that block parameters can be effectful, too, which means that they also have to be passed appropriate evidence. Which evidence is, however, only known when the block parameter is instantiated. Therefore, a block needs to abstract additional evidence for each of its block parameters. This additional evidence describes that the region of the block itself is a subregion of the region where the block argument was defined.
Look at the following (somewhat silly) variation of the example above with a very simple
higher-order function call
, which simply calls its block argument.
effect Exc1(): String
effect Exc2(): String
def main() = {
def call { f: () => String } = { f() }
try {
def abort(): String / {} = { do Exc1() }
try {
call { abort }
} with Exc2 {
() => "aborted two"
}
} with Exc1 {
() => "aborted one"
}
}
Generated Lifted Core
def main(ev4190: EV): String =
def call(ev4196: EV, ev4197: EV, f: (EV) => String): String =
f(<ev4197>)
try { (ev4192: EV, Exc1$capability: Exc1) =>
def abort(ev4195: EV): String =
Exc1$capability.Exc1(<ev4195>)
try { (ev4194: EV, Exc2$capability: Exc2) =>
call(<ev4194, ev4192>, <ev4194>, abort)
} with Exc2 {
def Exc2(ev4193: EV) =
shift(<ev4193>) { (resume: (EV, String) => String) =>
"aborted two"
}
}
} with Exc1 {
def Exc1(ev4191: EV) =
shift(<ev4191>) { (resume: (EV, String) => String) =>
"aborted one"
}
}
In Lifted Core call
has two evidence parameters, where the second one is for the block
parameter f
, and is also passed to f
. When call
is applied it gets two evidence
arguments. The first one is the composition of the evidence of both handlers, since call
was defined outside of both. The second evidence consists only of the one bound by the inner
handler, as this is the one passed to the block argument abort
(defined in the region of
the outer handler) which then passes it on to Exc1
, which again is exactly right.