Zulip Chat Archive

Stream: lean4

Topic: How do tasks map to parallelism?


Henrik Böving (Jul 22 2023 at 16:37):

I have this piece of code on todays nightly:

def fib (n : Nat) : Nat :=
  match n with
  | 0 => 0
  | 1 => 1
  | n + 2 => fib n + fib (n + 1)

def main : IO Unit := do
  let tasks  List.range 8 |>.mapM fun i =>
    IO.asTask do
      IO.println s!"{fib (35 + i)}"
  for t in tasks do
    let _  IO.wait t

to try and get Lean to compute all of the fib numbers in parallel. However when I execute it and check my htop I see that Lean is going completely single core here. I thought that Tasks are our unit of parallelism but that doesn't seem to be happening here? What am I doing wrong?

Henrik Böving (Jul 22 2023 at 19:14):

Interesting. If I just spawn a ton of additional tasks I can see all of them in my htop, now I'm even more confused as to why these tasks where each of them is clearly taking a bit of time to compute do seemingly not show up there /o\

Mario Carneiro (Jul 22 2023 at 19:30):

what is the definition of fib?

Mario Carneiro (Jul 22 2023 at 19:30):

(I would hope that an actual computation of fib 35 is not so slow as to be good for benchmarking!)

Mario Carneiro (Jul 22 2023 at 19:32):

are you running this in the interpreter or compiled?

Henrik Böving (Jul 22 2023 at 19:33):

Edited. I am running compiled and the computation does take roughly 8 seconds overall and reports a cpu load of 99%.

Mario Carneiro (Jul 22 2023 at 19:35):

Looking at the C code, it appears the fib computation has been lifted out of the IO.asTask

Henrik Böving (Jul 22 2023 at 19:35):

Gaaaah

Henrik Böving (Jul 22 2023 at 19:36):

And I thought I had outsmarted the compiler by using a variable...

Mario Carneiro (Jul 22 2023 at 19:38):

actually, looking at the lean code this isn't even the compiler getting smart, there really isn't anything forcing the computation to be delayed

Mario Carneiro (Jul 22 2023 at 19:39):

because do x just macro expands to x

Mario Carneiro (Jul 22 2023 at 19:39):

meaning the code is essentially IO.asTask (IO.println (fib (35 + i)))

Mario Carneiro (Jul 22 2023 at 19:39):

which of course evaluates fib before constructing the task

Henrik Böving (Jul 22 2023 at 19:40):

Ah that also explains why it started working when I switched over to Task.spawn instead....

Henrik Böving (Jul 22 2023 at 19:40):

do we have an API that ...does not do this? For IO tasks that is

Mario Carneiro (Jul 22 2023 at 19:40):

if you do something like do pure (); IO.println s!"{fib (35 + i)}" it will probably work

Mario Carneiro (Jul 22 2023 at 19:41):

just anything to wrap a thunk around the computation

Henrik Böving (Jul 22 2023 at 19:41):

yeah that works...

Henrik Böving (Jul 22 2023 at 19:41):

Well thank you^^ But we should certianly fix this

Mario Carneiro (Jul 22 2023 at 19:42):

A version of asTask that takes Unit -> IO A would presumably work

Henrik Böving (Jul 22 2023 at 19:44):

I mean is it even reasonable to have asTask if it has this footgun? Why not just refactor generally?

Mario Carneiro (Jul 22 2023 at 19:44):

I wouldn't put the blame on asTask, this is true for anything that takes an argument of type IO A

Mario Carneiro (Jul 22 2023 at 19:45):

it's certainly a bit frustrating since IO is already a thunk type

Mario Carneiro (Jul 22 2023 at 19:47):

but when you write an expression which evaluates to a function type, how do you know whether it should be eta-expanded or not? I don't see how the do notation could do this correctly either

Henrik Böving (Jul 22 2023 at 19:51):

Right, by "refactoring in general" I didn't mean changing the IO behavior in general, I was more thinking about changing the type of IO.asTask. I believe this to be reasonable because the usual floating computations around behavior in a single threaded context is fine right. It should be about the same speed (modulo cache) and do the same interactions with the environment and produce the same result. But in an IO.asTask context lifting things out of the argument does actually (as we can see) potentially drastically impact what is going to happen.

Mario Carneiro (Jul 22 2023 at 19:56):

like I said, the same issue happens for any function which takes an argument of type IO A

Mario Carneiro (Jul 22 2023 at 19:56):

because if you have such an argument you presumably are interested in not running it yet

Mario Carneiro (Jul 22 2023 at 19:57):

so running the first part of it is probably wrong

Mario Carneiro (Jul 22 2023 at 19:58):

...except when you do want to precalculate part of it, and there are issues in the other direction too

Sebastian Ullrich (Jul 22 2023 at 20:00):

For many of these lifting pure computations over them does not matter. Changing the signature of asTask does not sound too far-fetched to me

Yury G. Kudryashov (Jul 22 2023 at 20:00):

BTW, what Lean does(not) calculate at compile time? AFAIR, Haskell inlines a lot.

Sebastian Ullrich (Jul 22 2023 at 20:02):

Inlining itself should not change when something is computed, except by enabling other optimizations

Sebastian Ullrich (Jul 22 2023 at 20:03):

In theory, do could insert a call of

def defer [Monad m] (f : Unit  m α) : m α :=
  pure () >>= f

but that's not as clear-cut in a language where one might actually want to look at the resulting term

Mario Carneiro (Jul 22 2023 at 20:10):

here's a trick that seems to work without changing the type signature of asTask:

@[macro_inline] def IO.asTask' (m : IO A) : BaseIO (Task (Except Error A)) :=
  IO.asTask fun w => m w

def main : IO Unit := do
  let tasks  List.range 8 |>.mapM fun i =>
    IO.asTask' do
      IO.println s!"{fib (35 + i)}"
  for t in tasks do
    let _  IO.wait t

Last updated: Dec 20 2023 at 11:08 UTC