Skip to content

Pure calls / future of call.without.effects #7574

Description

@kripken

Atm we provide a way to say that a call has no side effects with an intrinsic, which is just an imported function:

//
// (import "binaryen-intrinsics" "call.without.effects"
// (func (..params..) (param $target funcref) (..results..)))
//
// call.without.effects can take any parameters, and in addition a funcref,
// and return any result.
//
// Precise semantics:
//
// * The optimizer will assume this instruction has no side effects.
// * Final lowering turns a call.without.effects into a call of the given
// function with the given parameters. (This will either be a direct call,
// or a call_ref; note that either way, the function reference that appears
// here must have the proper type - if not, you will get an error.)
//

The wasm spec has been adding similar code metadata in the form of branch hints and compilation hints. While call.without.effects is not meant to be used by VMs (it only make sense at the toolchain level), it may be nice to implement it similarly, to be consistent. That is, instead of the current intrinsic which is a specially-named function import, we could annotate the code the way those two proposals do:

  • In the text format, using custom annotations, something like:
(@metadata.code.pure "\00")
(call $target)
  • In the binary format, add a custom section, and the binary offsets there point to the instructions that are annotated.

The binary format takes more work this way - in particular, it is easy to get the offsets wrong - but it is more consistent with other things in wasm. Thoughts on the tradeoff?

Separately, there are two changes we may want to make to call.without.effects if we make a new version of it:

  • We can annotate the function itself (once) rather than all calls to it (many). That is, unless there are cases where some calls to the same target should be considered effect-free, but not others?
  • Atm call.without.effects only considers side effects (discussion), but it does not guarantee that it returns the same value for the same inputs. In particular, marking a call to a JS export that does Math.random() as call.without.effects would be wrong - two such calls cannot be folded together. We could make the new version also assume it returns the same value, and call it "pure", perhaps?

Activity

  1. rluble commented on May 12, 2025

    @rluble
    Contributor

    We can annotate the function itself (once) rather than all calls to it (many). That is, unless there are cases where some calls to the same target should be considered effect-free, but not others?

    It makes sense to make it a function property rather than a callsite property and thatit is the way we model the internally in J2CL. While in a function could be be side-effectfull or not depending on the values passed making it a callsite property, we don't really do an analysis that can determine that property.

    Atm call.without.effects only considers side effects (discussion), but it does not guarantee that it returns the same value for the same inputs. In particular, marking a call to a JS export that does Math.random() as call.without.effects would be wrong - two such calls cannot be folded together. We could make the new version also assume it returns the same value, and call it "pure", perhaps?

    I think returning the same value on the same params (i.e. being really a function :) is a different property. Math.random is for sure side-effectfull, since each time it is called it will change the global state.

    We do mark some functions that change the global state as side-effect free, when it makes sense, for example, for lazy initialization. So these are conceptually side-effect free even though they change the global state.

  2. kripken commented on May 13, 2025

    @kripken
    MemberAuthor

    @rluble

    It makes sense to make it a function property rather than a callsite property

    👍

    We do mark some functions that change the global state as side-effect free, when it makes sense, for example, for lazy initialization. So these are conceptually side-effect free even though they change the global state.

    Do all the functions you use call.without.effects on behave that way?

    The specific question I am looking for an answer for is if we need two properties:

    1. A property where (drop (call $target)) can be removed.
    2. A property where we can also assume that if x = (call $target); y = (call $target) then x == y, and we can turn that into x = (call $target); y = x. (necessary for call.without.effects is always possible for CSE #7568)

    The current call.without.effects has property 1, but not 2 (so we may have been missing some optimization opportunity, and if all users want 2 anyhow, maybe the new version can include 2.)

  3. rluble commented on May 13, 2025

    @rluble
    Contributor

    The functions that we mark as side effect free fall into property 1; we also mark some that don't modify the global state nor escape values so that binaryen also removes nested calls (drop (call $target1 (call $target2)) where both targets are marked as having no side-effects. The main reason we need this hint is because these function have side-effects but it is ok to ignore that fact (caching, ...)

    IMHO Property 2 in should probably be determined by an analysis and it would cover most of the cases. In J2Wasm we are currently marking some functions that would be of this property as side effect free to aid the removal of nested calls but I don't think it would make much difference to have a more precise marking here.

    One pattern that comes to mind for property 2 is that of property accessors for immutable objects (or more complex computations that only depend on immutable objects as their parameters, since simple accessors are probably inlined in many scenarios).

    My intuition is that we are getting a lot of the benefits already by inlining but if we had such an analysis it might allow to dial inlining down and still get the same level of optimization (same performance, smaller code size).

  4. tlively commented on May 13, 2025

    @tlively
    Member

    To bikeshed the annotations a bit, I think it would make the most sense to use (@binaryen.pure). I also agree it would make the most sense to annotate the function as pure rather than its calls.

    We could have another annotation (@binaryen.idempotent) for things like clinits that have side effects on the first call but not on subsequent calls.

    Both these annotations could be either supplied in the input or inferred via analysis.

  5. kripken commented on May 13, 2025

    @kripken
    MemberAuthor

    @rluble

    Thanks for the info. To make sure I follow, you would want both 1 and 2 as options, that is both (@binaryen.pure) and (@binaryen.other-name)? (or however we choose to name them)

    @tlively

    Which of 1 and 2 would pure and idempotent be? (my confusion is that "idempotent" doesn't quite match up to either 1 or 2 IIUC)

    One option might be 1 = effectless (no effects, hence can remove a dropped call) and 2 = pure (pure function, returns the same output for the same inputs, so two call's results can be merged).

  6. tlively commented on May 13, 2025

    @tlively
    Member

    (drop (call $target)) can be removed if $target is pure because it will never have any side effects.

    x = (call $target); y = (call $target) can be turned into x = (call $target); y = x if $target is idempotent because the initial call might have side effects, but the subsequent call will not. (This optimization is also valid if $target is pure because pureness implies idempotency.)

    Furthermore, (drop (call $target)); (drop (call $target)) can be optimized to (nop) if $target is pure, but only to (drop (call $target)) if $target is idempotent and not pure.

  7. tlively commented on May 13, 2025

    @tlively
    Member

    One option might be 1 = effectless (no effects, hence can remove a dropped call) and 2 = pure (pure function, returns the same output for the same inputs, so two call's results can be merged).

    I don't think it makes any sense for a function to be effectless but not pure. If a function is not pure, then it must observe some global state to determine what different results to produce.

  8. rluble commented on May 13, 2025

    @rluble
    Contributor

    I think all the usages we currently have for sideeffect-free functions could also classify as pure. Calls can be dropped if the return value is not used, and they can be assumed that they are real functions in the mathematical sense; i.e. @binaryen.pure should be enough.

    I am not sure I like the term idempotent (although we have also used it in J2CL to mean exactly what you are proposing), since in math it has a very specific meaning Vx. f(f(x)) = f(x).

  9. kripken commented on May 13, 2025

    @kripken
    MemberAuthor

    @rluble

    Makes sense, thanks. Ok, maybe we just need @pure then.

    @tlively

    Oh, I was confused by your first definition of "idempotent":

    have side effects on the first call but not on subsequent calls.

    That by itself isn't enough to merge x = (call $target); y = (call $target) since the second call might have no effects but also return another value. But we could define it to include that property too.

  10. kripken commented on May 13, 2025

    @kripken
    MemberAuthor

    @tlively

    I don't think it makes any sense for a function to be effectless but not pure. If a function is not pure, then it must observe some global state to determine what different results to produce.

    Sorry, I wasn't clear enough. I meant "effectless" in the sense of not having side effects / write effects, but perhaps having read effects. A pure function would also not have read effects.

    E.g. a function that reads a sensor would be effectless (in that sense) but not pure.

  11. tlively commented on May 13, 2025

    @tlively
    Member

    @rluble, for better or worse, "idempotent" appears to be standard with the meaning in a computer science context: https://en.wikipedia.org/wiki/Idempotence#Computer_science_meaning

  12. dschuff commented on May 14, 2025

    @dschuff
    Member

    For more fun variations, check out all the options gcc has for similar properties in C: https://gcc.gnu.org/onlinedocs/gcc/Common-Function-Attributes.html (e.g. const, pure, and reproducible).

  13. gkdn commented on May 14, 2025

    @gkdn
    Contributor

    +1 to using functions annotations. Current approach required explicit handling to see beyond "call.without.effects"; for example I had a note about checking the impact of OnceReduction not seeing through such "call.without.effects" calls.

    re. pure vs. side effect free:
    Currently not all our usages conform to pure functions though we might be able to get away with the introduced behavior difference for the current usage set. (for example we would like to drop Integer "boxing" call as a side effect free call, however different boxing calls would return different box instances so it is not pure).

    And if we map HasNoSideEffects (our annotation) to Binaryen's "pure", we can potentially introduce similar problems like above. It might be good idea to keep the "side effect free" and introduce another property for "idempotence". Also to me it makes sense since they also imply different optimizations. And I think one can annotate with both to cover "pure" function scenarios.

    (FWIW we already sneaked idempotence property to binaryen with naming convention in J2clOpts.cpp "isOnceFunction" 😁. I think OnceReduction is also another attempt to detect and take advantage of idempotence.)

  14. osa1 commented on May 14, 2025

    @osa1
    Contributor

    We don't use call.without.effects in dart2wasm yet but we want to use it.

    Adding hints for "pure" and "idempotent" (instead of importing per-function-type wasm-opt primitives) has some advantages for us:

    • Generating hints are easier than generating per-function-type imports. (though this may say more about dart2wasm code than imports vs. hints)
    • We currently don't run wasm-opt when not optimizing. With imports, we either have to run it always (so that when not optimizing the imports will be removed), or make dart2wasm aware of optimization levels and not generate the imports. With hints, we don't have to change anything, hints will just be ignored when not running wasm-opt.

    So 👍 from me for having hints instead of imports.

    Regarding the use cases, we have use cases for "pure", but I'm not aware of any functions that would be "idempotent" but not "pure".

    Two use cases for "pure":

    • For complicated type checking operations we generate functions (instead of inline code) to avoid generating large amounts of duplicate code. When we have a type-switch (or if-then-else chain) for checking type of a value and branching, sometimes wasm-opt is able to prune branches, but it leaves the type checking function calls behind. We know those functions are "pure" and ideally we'd like to drop them as well. That saves both binary space and runtime.

    • In the JS value wrapper classes we have many methods that are pure, but since those methods are just imports to JS code, wasm-opt is unable to do CSE the calls or eliminate them. For example, the TypedArray.prototype.length exposed via Dart members for the typed array wrapper classes, and String.length property. Being able move length calls out of loops would be great.

  15. kripken commented on May 14, 2025

    @kripken
    MemberAuthor

    @gkdn

    Currently not all our usages conform to pure functions though we might be able to get away with the introduced behavior difference for the current usage set. (for example we would like to drop Integer "boxing" call as a side effect free call, however different boxing calls would return different box instances so it is not pure).

    Thanks, makes sense. We should support that too.

    Such a boxing function is what Binaryen calls "generative", a function that might not have side effects but that can return different values (it "generates" values, e.g. reading a sensor, allocating, etc). I suppose another way to look at it is that allocating a box is a side effect. (Binaryen itself doesn't consider allocations to be effects, though - we intentionally don't try to model host limitations like OOMs - but we can pick a name regardless of that, since we should look into adding this name to the tool-convention repo eventually.)

  16. 16 remaining items

  17. gkdn commented on Feb 6, 2026

    @gkdn
    Contributor

    Can't Binaryen copy such annotations from function definitions to call-sites as a normalization to get best of both worlds?

  18. kripken commented on Feb 6, 2026

    @kripken
    MemberAuthor

    Yes, good point. That would require us to allow callsite annotations as part of the spec (if we wanted Binaryen's internal state to be valid in the spec, which I think we do) - so maybe we should just allow both callsites and functions to be marked, as the inlining hint already does.

  19. kripken commented on Feb 12, 2026

    @kripken
    MemberAuthor

    @binaryen.removable.if.unused landed, as has a pass to strip toolchain annotations (which you want to do for the final binary), so the first intrinsic here is fully usable and testable now. Let me know if you see any issues. Docs here:

    https://github.com/WebAssembly/binaryen/wiki/Optimizer-Cookbook#intrinsics

  20. kripken commented on Feb 20, 2026

    @kripken
    MemberAuthor

    There is now a draft PR for the second intrinsic discussed here, for idempotency:

    #8354

    One thing I don't think we discussed explicitly here is parameters to the call. I assume the full definition should be this?

    An idempotent function has no effects when called a second time with the same parameters, and returns the same value (if it returns a value). If called with different parameters, there are no guarantees on effects or return value.

    That is,

    ;; second has no effects, since no params
    (call $idempotent)
    (call $idempotent)
    
    ;; second has no effects, since params match
    (call $idempotent (i32.const 10))
    (call $idempotent (i32.const 10))
    
    ;; second CAN have effects, since params do NOT match
    ;; (return value can also differ)
    (call $idempotent (i32.const 10))
    (call $idempotent (i32.const 20))
  21. added a commit that references this issue on Feb 23, 2026
  22. kripken commented on Feb 24, 2026

    @kripken
    MemberAuthor

    About idempotency, @tlively raised a question here:

    https://github.com/WebAssembly/binaryen/pull/8369/changes/BASE..18a545c8957b03951071675899b5797323c0fa02#r2849321126

    Should effects in the middle stop optimization of idempotent functions? For example,

    x = idempotent();
    foo(x);
    y = idempotent();

    Can x and y be assumed to be equal?

    The C spec mentions idempotency in a way that does not allow this:

    An evaluation E is idempotent if a second evaluation of E can be sequenced immediately after the original one [emphasis mine]

    and gcc's pure attribute etc. is the same.

    However, for our use cases here, I think it would be safe to optimize that - am I wrong?

  23. bashor commented on Feb 25, 2026

    @bashor

    Should effects in the middle stop optimization of idempotent functions? For example,

    I think it should be allowed to be optimized.

  24. bashor commented on Feb 25, 2026

    @bashor

    How about multithreaded applications? Should the following be optimized for multithreaded apps?

    idempotent();
    idempotent();
    

    IMO: yes

  25. tlively commented on Feb 25, 2026

    @tlively
    Member

    I agree that the presence of threading shouldn't inhibit optimizations that would otherwise be allowed.

    I would prefer to follow gcc/clang's precedent and have possible intervening writes inhibit the optimization. The mental model for this is that the function is idempotent when considered as taking its arguments and the program state as input. (This interpretation is also the connection to the mathematical definition of an idempotent function f as f(f(x)) = f(x). Here x is a program state and f is seen as a function of program states to program states.)

  26. kripken commented on Feb 25, 2026

    @kripken
    MemberAuthor

    @tlively But I don't think we are forced to follow gcc/clang's example here, and there would be a very big benefit to doing something more general.

    In Java for example the class initializers do have the more general property that allows optimization even with effects in the middle, and we have been optimizing them that way so far. That allows stuff like this, which is common after inlining:

    initFoo();
    ..lots of code with effects..
    if (..) {
      initFoo(); // removable
      ..more effects..
    } else{
      ..and more..
      initFoo(); // removable
    }
    ..still more..
    initFoo(); // removable
    
    =>
    
    initFoo();
    ..lots of code with effects..
    if (..) {
      ..more effects..
    } else{
      ..and more..
    }
    ..still more..

    We would be leaving a lot of performance on the table for more than one language if we don't do the more general thing. So I agree with @bashor

  27. kripken commented on Feb 25, 2026

    @kripken
    MemberAuthor

    (I forgot to say, there is a big performance benefit not just to avoiding the cost of all those later initFoo() calls themselves, but also because after removing them there are fewer effects around that inhibit other optimizations.)

  28. tlively commented on Feb 25, 2026

    @tlively
    Member

    Fair enough. It would be good to document this difference.

  29. gkdn commented on Feb 26, 2026

    @gkdn
    Contributor

    Note that <clinit< doesn't return anything so the example x = idempotent(); foo(x); y = idempotent(); doesn't directly match that scenario.

    The risk here is that if x is not deeply immutable and foo is a mutating function, the application behavior will change even though function idempotent is actually idempotent.

    That being said, IMO marking a function that returns a mutable object as idempotent can be considered as a toolchain bug (i.e. it is our responsibility to not do that).

  30. kripken commented on Feb 26, 2026

    @kripken
    MemberAuthor

    @gkdn That sounds right to me. There is a risk of toolchains getting this wrong, but given the large benefits, I think it's worth it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions