-
Notifications
You must be signed in to change notification settings - Fork 188
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
Effects: double translation of functions and dynamic switching between direct-style and CPS code #1461
base: master
Are you sure you want to change the base?
Conversation
I think this PR is best reviewed as a whole, not commit by commit. |
It looks like these two effect handler benchmarks are slower with this PR, 18 % and 8 % slower, respectively. I need to spend some time on it to understand why.
|
Chameneos runs are too short. You should increase the input size. It takes the input size as a command line argument. Something that runs for a second is more representative as it eliminates the noise due to JIT and other constant time costs. |
Good point. I find that chameneos is 9,8 % slower with this PR, 3.428 s versus 3.753 s. My theory is that effect handlers are slightly slower due to the fact that function calls in CPS code cost an additional field access (applying |
I agree with the reasoning and do not expect real programs to behave like |
Btw, the original numbers aren't useful to understand the improvements brought about by this PR. For this, you need 3 variants:
I'd be interested to see the difference between (2) and (3) in addition to the current numbers which show the difference between (1) and (3). |
Note that I had to do that in #1397: js_of_ocaml/compiler/lib/generate.ml Lines 954 to 959 in 3b3f66b
|
I believe that the form
Here are the graphs showing the difference between |
Thanks. The execution time improvement is smaller than what I would have expected. Is that surprising to you or does it match your expectation? Also, it would be useful to have all the variants plotted in the same graph with |
It more or less matches my expectation. My reasoning is the following: on most of these small, monomorphic benchmarks, the static analysis will eliminate most CPS calls at compile time. Therefore, the dynamic switching will not change the run time a lot and maybe slightly worsen it. On benchmarks that heavily use effect handlers, I also expect the run time to be worse: most of the time is spent in CPS code anyway, the dynamic switching only adds overhead. I therefore expect the biggest improvements to happen on larger programs, on which the static analysis does not work as well due to higher-order and mutability; and which do not spend most of their time in effect handlers. If my hypothesis is verified, then the question is: is this trade-off acceptable? Keeping in mind that there might be ways to improve this PR to save more performance. |
I have updated the PR message with new graphs showing all the variants. |
I tried to build a benchmark that uses Domainslib, as an example of a more typical effect-using program. But the linker complains that the primitive I tried to add it in a new Also, when I build js_of_ocaml I’m getting a lot of primitive-related messages that I don’t really understand:
|
I solved it by downgrading to lockfree 0.3.0 as suggested by @jonludlam. But the resulting program never completes. I assume that the mock parallelism provided by the runtime doesn’t suffice for using Domainslib—a “domain” must be stuck forever spinwaiting or something. |
5362ff4
to
91e352e
Compare
I think that this PR is ready for review. The only two problems that prevent the CI from being green are:
|
Just add it to the stdlib module compiler/lib/stdlib.ml |
Lambda_lifting doesn't seem to be used anymore, is it expected ? Should Lambda_lifting_simple replace Lambda_lifting ? |
From a quick look at the PR, the benefit of such change is not clear, can you highlight examples where we see clear improvements ? |
0625907
to
3cd6c95
Compare
As I discovered just today, There are now two lambda lifting passes for two different reasons. For this reason I am not convinced that there is an interest in merging the two modules.
I am convinced that most real-world effect-using programs will benefit from this PR, for the reasons given in my above message; but it’s hard to prove it, because we don’t have (yet) examples of such typical programs that work in Javascript. Programs using Domainslib don’t work well with js_of_ocaml (and are arguably not really relevant as JS is not a multicore language). Concurrency libraries like Eio are a more natural fit. I am currently trying to cook up a benchmark using the experimental JS backend for Eio. |
Given the size impact of this change, it would be nice to be able to disable (or control) this optimization. There are programs that would not benefit from this optimization, it be nice to not have to pay the size cost for no benefits. The two lambda_lifting modules are confusing, we should either merge them (allowing to share duplicated code) or at least find better names.
Do you know where this come from ? does it mostly come from the new lambda lifting pass ? or are other passes heavily affected as well (generate, js_assign, ..) |
Thank you for the review and sorry for the response delay, I have been prioritizing another objective in the previous weeks. One update is that there is no performance gain on programs that use Eio, which is a shame as it is expected to be one of the central uses of effects. More generally, when the program stays almost all the time within at least one level of effect handlers, there is essentially no performance gain as we run the CPS versions of every function. And I expect this programming pattern (installing a topmost effect handler at the beginning of the program) to be the most common with effects. So it is unclear to me yet if the implementation of double translation can be adapted to accommodate this. |
I pushed a commit which adds a new primitive, |
a66832d
to
8fa2eb8
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
9d8a3da
to
1477ca8
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
1477ca8
to
067473b
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
067473b
to
ce5e4b0
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
ce5e4b0
to
23f32a1
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
23f32a1
to
f10d86a
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
9a1e0c6
to
954395d
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
3239166
to
f473083
Compare
Coming back to this after spending time on the merge between js_of_ocaml and wasm_of_ocaml. I’ve rebased this PR on master, re-aligned things where needed, and made the CI happy again. It’s ready for review. cc @vouillon |
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
f473083
to
0c20907
Compare
... dynamic switching between direct-style and CPS code. (ocsigen#1461)
Passing a function [f] as argument of `caml_assume_no_effects` guarantees that, when compiling with `--enable doubletranslate`, the direct-style version of [f] is called, which is faster than the CPS version. As a consequence, performing an effect in a transitive callee of [f] will raise `Effect.Unhandled`, regardless of any effect handlers installed before the call to `caml_assume_no_effects`, unless a new effect handler was installed in the meantime. Usage: ``` external assume_no_effects : (unit -> 'a) -> 'a = "caml_assume_no_effects" ... caml_assume_no_effects (fun () -> (* Will be called in direct style... *)) ... ``` When double translation is disabled, `caml_assume_no_effects` simply acts like `fun f -> f ()`. This primitive is exposed via `Js_of_ocaml.Js.Effect.assume_no_perform`.
0c20907
to
a34c448
Compare
In the message of the second commit, you write:
Maybe I'm missing something, but I don't see why this should be true. Performing an effect will result in calling function In any case, you should add a test for that. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here is a first batch of comments
let p, trampolined_calls, in_cps = Effects.f ~flow_info:info ~live_vars p in | ||
let p = if Config.Flag.double_translation () then p else Lambda_lifting.f p in | ||
p, trampolined_calls, in_cps) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let p, trampolined_calls, in_cps = Effects.f ~flow_info:info ~live_vars p in | |
let p = if Config.Flag.double_translation () then p else Lambda_lifting.f p in | |
p, trampolined_calls, in_cps) | |
p | |
|> Effects.f ~flow_info:info ~live_vars | |
|> map_fst (if Config.Flag.double_translation () then Fun.id else Lambda_lifting.f)) |
else | ||
( p | ||
, (Code.Var.Set.empty : Effects.trampolined_calls) | ||
, (Code.Var.Set.empty : Effects.in_cps) ) | ||
, (Code.Var.Set.empty : Code.Var.Set.t) ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
, (Code.Var.Set.empty : Code.Var.Set.t) ) | |
, (Code.Var.Set.empty : Effects.in_cps) ) |
if (d === 0) return f(...args); | ||
else if (d < 0) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if (d === 0) return f(...args); | |
else if (d < 0) { | |
if (d === 0) { | |
return f.apply(null, args); | |
} else if (d < 0) { |
@@ -85,7 +85,7 @@ function caml_call_gen(f, args) { | |||
args[args.length - 1] = k; | |||
return caml_call_gen(g, args); | |||
}; | |||
return f.apply(null, args); | |||
return f(...args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return f(...args); | |
return f.apply(null, args); |
} | ||
function caml_call_gen_cps(f, args) { | ||
var n = f.cps.l >= 0 ? f.cps.l : (f.cps.l = f.cps.length); | ||
if (n === 0) return f.cps(...args); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need this line? (And the corresponding line in stdlib.js
.)
let idx = Var.idx x in | ||
if idx < Array.length var_depth then var_depth.(idx) <- depth) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comparison should always be true since we are marking bound variables before modifying the body.
let idx = Var.idx x in | |
if idx < Array.length var_depth then var_depth.(idx) <- depth) | |
var_depth.(Var.idx x) <- depth) |
, Var.Map.union (fun _ _ -> assert false) (snd lifters) (snd lifters') ) )) | ||
else | ||
(* We lift possibly mutually recursive closures (that are created by | ||
contiguous statements) together. Isolated closures are lambda-lifted |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a function (or a set of recursive functions) has no free variable, we could just move it to toplevel.
List.fold_left | ||
current_contiguous | ||
~f:(fun st (_, _, pc, _) -> | ||
traverse ~to_lift var_depth st pc (depth + 1)) | ||
~init:st |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We already called traverse
recursively when pushing the function into current_contiguous
.
List.fold_left | |
current_contiguous | |
~f:(fun st (_, _, pc, _) -> | |
traverse ~to_lift var_depth st pc (depth + 1)) | |
~init:st | |
st |
match l with | ||
| i :: rem -> | ||
let rem', st = rewrite_body [] st rem in | ||
i :: rem', st | ||
| [] -> [], st) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Could you try to refactor the code so that we don't have three copies of this piece of code?
Maybe we could have this function rewrite_body
that just traverse the block's body and separate helper functions to rewrite closures?
i :: rem, st | ||
| [] -> [], st | ||
in | ||
( List.map current_contiguous ~f:(fun (f, params, pc, args) -> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suspect you are reversing the functions' order. This should not make any semantics difference, but it is probably better for debuggability to preserve this order.
This feature makes programs that use OCaml 5 effects run faster in Javascript, by running as little continuation-passing style (CPS) code as possible. Based on an initial suggestion by @lpw25, we generate two versions for each functions that may be called from inside an effect handler (according to the existing static analysis): a direct-style version and a CPS version. At runtime, direct-style versions are used, except when entering an effect handler, in which case only CPS is run until the outermost effect handler is exited. This approach trades speed for program size, since—because a number of functions are compiled to two versions—the generated programs are bigger. For this reason, the feature is opt-in behind the
--enable doubletranslate
flag. This is a joint work with @vouillon.We encountered a design difficulty: when functions are transformed into pairs of functions, it is unclear how to deal with captured identifiers when the functions are nested. To avoid this problem, functions that must be transformed are lambda-lifted, and thus no longer have any free variables except toplevel ones.
The transform is rather successful in preserving the performance of small / monomorphic programs.
I hypothesize thatEdit: I am not able to reproduce a slowdown on hamming in my latest round of benchmarks.hamming
is slower for the same reason as on current master: it uses lazy values, which are an obstacle for the global flow analysis.fft
is slightly slower, however. The generated codes look very similar.The difference becomes negligible on large programs. CAMLboy is actually… 3 % faster with effects enabled (compared to 25 % slower previously): 520 FPS instead of 505 FPS, although the standard deviation is high at ~11 FPS, so it would be fair to say that the difference is not discernable.
ocamlc
is not discernably slower, either (compared to 10 % slower previously).#1461 (comment) breaks down which parts of program are actually made faster or slower, and why typical effect-using programs will be faster with this feature.
As some functions must be generated in two versions, the size of the generated code is larger (up to 76 % larger), a few percents larger when compressed.
Compiling
ocamlc
is about 70 % slower; the resulting file is 64 % larger when compressed.A caveat of this approach is that all benefits are lost as soon as an effect handler is installed. This is an issue for scheduling libraries such as Eio, as they usually work by having an effect handler installed for the program’s entire lifetime. To mitigate this, we provide
Js_of_ocaml.Js.Effect.assume_no_perform : (unit -> 'a) -> 'a
. Evaluatingassume_no_perform f
runs the direct style version off
—the faster version. This also applies to transitive callees off
that do not use effect handlers. The programmer must ensure that these functions do not perform effects (not without installing a new effect handler).