Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handler runtime improvements #282

Open
wants to merge 30 commits into
base: hkmc2
Choose a base branch
from

Conversation

AnsonYeung
Copy link

@AnsonYeung AnsonYeung commented Feb 13, 2025

Changes:

  • Moved handler runtime function to Runtime.mls
  • Massive rewrite of handler runtime logic, simplifies it a lot, removes the need for fixup code and matches closer to what is described in the report. I have some further improvement in mind which should allow some program that cannot be run currently to work fine. (Update: done)
    • Namely, resume function will be able to be used entirely outside of the handler, experimentation still need to be done to verify what I have in mind is correct.
  • Improvement of debuggability of handler runtime, via generation of class name with source code line info so that the continuation can be traced back to the belonging code and also added a pretty printer.
  • Removal of double underscore in name
  • Enable tail call optimization in stack safety, this removes the need of continuation class in the handler and reduces code size. (Note: tail call optimization != optimizing tail resumptive handlers)

Problem:
I added Runtime module in decl/Prelude.mls just to get the class symbols. I think this is not correct as it cause Runtime to pass elab but I don't know how to get the class symbols otherwise. (Runtime.mls has dependence on these classes, and Predef.mls imports Runtime.mls, so I don't think these classes can go to Predef?)

  • Fixed by shadowing Runtime during elab

TODO:

  • Right now the continuation class rely on Origin, which turns out to emit absolute path in MLsCompiler.scala. It should not use full path.
  • There is some test diff that I missed when I committed the change. Need to address these issues.
  • Implement further improvment mentioned above
  • Move enterHandleBlock to Predef (as it's intended to be used by programmers directly) and document its semantics there
  • Add a tests mirroring the examples at https://ocaml.org/manual/5.3/effects.html, most notably the fork/yield/xchg one

Test diffs:

  • UserThreads*.mls: these tests are malformed, and the new implementation throws Error on them
  • LeakingEffects.mls: the new test output matches with intended semantics
  • ZCombinator.mls: the stack depth changes because of Runtime.stackDepth is a selection which gets sanity checks. The sanity check moves the selection before the stack depth is increased for the function call console.log.

Follow-up:

  • Decouple non-local return from HandlerLowering
  • HandleBlock can be separated as instantiation of the handler and binding of the handler, this may work better with IR and syntax changes.
    • e.g. have handler as a normal let declaration syntax allowing handler method, and have a handler binding syntax which behaves the same as the current enterHandleBlock
    • Current JS includes 2 key constructs:
      • The handler method in HandlerLowering is lowered to (...handlerArgs) => return mkEffect(h, (k) => handlerMtdBody);
      • The handler body is essentially enterHandleBlock(h, () => handlerBody)
    • So in the syntax, we only need to ensure that h and k is clear in the handler method, i.e. we can even have standalone handlers like
      let h = new Object()
      handler[h, k] fun raise(x) = k(x)
      handle h in raise(3)
      
      Note: h is a value, but k is binded as param, so it is kind of awkward. Also for type checking this means you retroactively add behaviour to h which is not great and probably won't work.
      while awkward, Object are not essential for codegen to work, h can be anything that can be equality checked, i.e. everything in JS is technically fine (but this is kind of awkward and might not work well with type checking).
      handler[123, k] fun raise(x) = k(x)
      handle 123 in raise(3)
      
      So we can also have a relatively minor adjustment to the current syntax and get (currently I prefer this syntax)
      let h = handler with
        handler fun raise(x)(k) = k(x)
      handle h in raise(3); 4
      handle h in raise(3); "abc"
      
      This syntax allow normal methods and other class members, and behave like an object except occurence of handler fun inside it is legal.
      It might be worthwhile to add yet another keyword for handler fun for clarity and conciseness.

@LPTK
Copy link
Contributor

LPTK commented Feb 15, 2025

  • Some illustrative test cases to add to the tests
:re
handle h = Object with
  fun write(x)(k) =
    if x < 10 do write(10)
    [x, k(())]
h.write(1)
h.write(2)
//│ ═══[RUNTIME ERROR] Error: Unhandled effects

handle h = Object with
  fun write(x)(k) =
    if x < 10 do runtime.enterHandleBlock(this, () => write(10))
    [x, k(())]
h.write(1)
h.write(2)
//│ = [1, [2, ()]]

@AnsonYeung AnsonYeung marked this pull request as ready for review February 15, 2025 14:54
Copy link
Contributor

@LPTK LPTK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very good improvements! But let's please remove the Prelude hack.

fun enqueue(k) =
tasks.push(k)
fun enqueue(k, x) =
tasks.push(k.bind(null, x))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this bind needed, here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is essentially () => k(x), I guess it is confusing so I'm gonna change it to a explicit lambda.

@LPTK
Copy link
Contributor

LPTK commented Feb 22, 2025

Follow-up [...]

Thanks for writing your thoughts and suggestions!

Some more thoughts on this: eventually, we'll probably want to make the effect handler and type class/contextual parameters features work in synergy. This will allow programmers to pass handlers around implicitly instead of having to manually thread them everywhere.

Adapting an example from https://effekt-lang.org/tour/effects, we could have:

// Interface definition
abstract class Yield[A] with
  fun yield(x: A): Unit

// Interface use
fun fib(using Yield[Int]) =
  fun inner(a, b) =
    use[Yield].yield(a)
    inner(b, a + b)
  inner(0, 1)

// Handler creation (as a type class)
fun genFibs(limit: Int): Array[Int] =
  let
    count = 0
    fibs = []
  handle Yield[Int] with // Define a type class instance AND handle it
    effect yield(x) =
      if count < limit do
        count += 1
        fibs.push(x)
        resume(()) // <- we resume the computation where yield was invoked
  in fib()
  fibs

Of course, type classes would not be necessary to use effect handlers. We could also just use normal classes and plain handle statements:

// Handler creation (as a normal class)
class YieldUntil(n: Int) extends Yield[Int] with
  val results = []
  effect done() =
    results
  effect yield(x) =
    if x === 42 do done()
    results.push(x)
    resume(())

print of
  let h = YieldUntil(42)
  handle h
  // or just: `handle h = YieldUntil(42)`
  y.yield(1)
  y.yield(2)
  y.done()
  ??? // never reached

The type system can keep track of the fact that effect methods are not used outside of corresponding handlers. Note that for effect hygiene, it's critical that the effect be attached to a specific object instance and not just a label.

What do you think?

Co-authored-by: Lionel Parreaux <lionel.parreaux@gmail.com>
@AnsonYeung
Copy link
Author

I think type classes should work quite well with handlers. Does handle h always bind the contextual parameter of the type of h or only handle Yield[Int] binds it? the type of h might not be known yet.

I think it would be good to implement the explicit syntax first as the next step from here.

P.S. On the other hand,

// Handler creation (as a normal class)
class YieldUntil(n: Int) extends Yield[Int] with
  val results = []
  effect done() =
    results
  effect yield(x) =
    if x === 42 do done() // this makes effect call, which doesn't work now
    results.push(x)
    resume(())

For this type of recursive binding, I think the semantics for this can be:

  effect yield(x) =
    handle this
    if x === 42 do done() // this makes effect call
    results.push(x)
    resume(())

But I'm unaware of other implementation of algebraic effect doing this, raising effect in a handler body means raising an effect in the outer context.

@LPTK
Copy link
Contributor

LPTK commented Feb 22, 2025

Note that for effect hygiene, it's critical that the effect be attached to a specific object instance and not just a label.

Wait, in fact I remember precisely why I proposed the original coupled design in the first place. It was not accidental.

The problem is that we want to prevent programmers from being tempted to write code like this:

// In some place:
object Foo(p) with
  effect raise(x) = ... resume ...

// In another place:
fun test(f) =
  handle Foo(...)
  xs map(x => if x < 0 then Foo.raise(x) else f(x))

The problem is that if a user calls foo in a context like this:

handle Foo(arg)
test(x => ... Foo.raise ...)

Then there is an unhygienic interaction that may be surprising: the person who wrote map did not expect to unintentionally handle effects coming from f. This is a known problem in the literature.

So I think it's better to ensure that a fresh, unique object instance is generated whenever an effect is handled in some scope.

I think type classes should work quite well with handlers. Does handle h always bind the contextual parameter of the type of h or only handle Yield[Int] binds it? the type of h might not be known yet.

So actually I don't think the general handle h form should be supported at all. One would use handle Yield[Int] and handle Yield[Int] as h (if a name is needed).

But I'm unaware of other implementation of algebraic effect doing this, raising effect in a handler body means raising an effect in the outer context.

Yeah, it's probably better to follow the standard practice, and we could allow handle this as a special form specifically for this use case.

@AnsonYeung
Copy link
Author

The problem is that we want to prevent programmers from being tempted to write code like this:

// In some place:
object Foo(p) with
  effect raise(x) = ... resume ...

// In another place:
fun test(f) =
  handle Foo(...)
  xs map(x => if x < 0 then Foo.raise(x) else f(x))

Right, but I think the problem here lies in how the contextual parameter is handled. Suppose in test(x => ... Foo.raise ...) is expanded as let h = ...; test(x => ... h.raise ...) it would bind it directly. But if the lambda is passed around with a contextual parameter unexpanded then it can be bound accidentally within test.

@AnsonYeung
Copy link
Author

The suggested scheme doesn't involve much change for handler lowering; during lowering everything involving contextual parameter should be resolved already. Again for the example above:

fun test(f) =
  handle Foo(...)
  xs map(x => if x < 0 then Foo.raise(x) else f(x))

The type of f can be required to be free of implicit contextual parameter, then f(x) cannot refer to the new contextual parameter.

So actually I don't think the general handle h form should be supported at all. One would use handle Yield[Int] and handle Yield[Int] as h (if a name is needed).

Instead of handle h we might have handle Yield[Int] = h. The reason for the IR change was to allow binding of this type:

The reason for introducing this type of binding was due to usage of recursive handlers in the old UserThread test. We can go without this syntax while still allowing a special syntax for the semantics of the recursive binding (handle this at top of handler).

But the test at RecursiveHandlers.mls:40:

handle h = Object with
  fun write(x)(k) =
    if x < 10 do
      print(enterHandleBlock(this, () => write(10)))
    [x, k(())]

showed that it is possible to create many surprising patterns, including rebinding the same handler at different levels, which may result in surprising behaviour if a function rebinds a given handler. So I agree that it should probably not be introduced. On the other hand, recursive binding seems fine for now and the usage pattern seems rather restricted and cannot cause such rebinding problem, except the resume function (the resulting (current) behaviour is that handle this will not affect the execution of resume at all, and effect raised within the handler is handled inside the function).

@LPTK
Copy link
Contributor

LPTK commented Feb 22, 2025

Right, but I think the problem here lies in how the contextual parameter is handled. Suppose in test(x => ... Foo.raise ...) is expanded as let h = ...; test(x => ... h.raise ...) it would bind it directly. But if the lambda is passed around with a contextual parameter unexpanded then it can be bound accidentally within test.

Sorry, I realize I messed up the example. I meant to make Foo an actual, unparameterized object.
And I was assuming there were no contextual parameters, here.

// In some place:
object Foo with
  effect raise(x) = ... resume ...

// In another place:
fun test(f) =
  handle Foo
  xs map(x => if x < 0 then Foo.raise(x) else f(x))

handle Foo
test(x => ... Foo.raise ...)

I can see where your remark is coming from: by using type classes like Yield[Int], as in

fun test(f) =
  handle Yield[Int] with ...
  xs map(x => if x < 0 then use[Yield].yield(x) else f(x))

it would seem like we would lose the hygienic property I was talking about. But that's not the case.

Contextual parameters are resolved and expanded statically, at compile time. They're just as if the programmer had manually passed the parameters manually. They're not dynamic and require an explicit type annotation.

With test as written above, it is impossible for the mapped function f to capture the local Yield instance.

To make f capture the local contextual value, one would have to write something like:

fun test(f: (Int)(using y: Yield[Int]) ->{y} Int) =
  handle Yield[Int] with ...
  xs map(x => if x < 0 then use[Yield].yield(x) else f(x))

and then it would be completely clear that this capture is the intended semantics.

(Notice the y in ->{y} which is needed to keep track of exactly the effect instance being thrown, without which things can't type check.)

Instead of handle h we might have handle Yield[Int] = h. The reason for the IR change was to allow binding of this type:

The reason for introducing this type of binding was due to usage of recursive handlers in the old UserThread test.

Not sure what you are referring to. Which kind of binding specifically, and how was it used in the old UserThread test?

except the resume function (the resulting (current) behaviour is that handle this will not affect the execution of resume at all, and effect raised within the handler is handled inside the function).

I guess that's as expected though because we don't bind handlers dynamically.

@AnsonYeung
Copy link
Author

AnsonYeung commented Feb 22, 2025

Oh the type of binding I refer to is exactly the one you wrote (equivalent to arbitrary usage of enterHandleBlock). So yes, we should not allow binding handlers directly (and remove usage of enterHandleBlock once recursive handler is settled).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants