Skip to content
This repository was archived by the owner on Apr 25, 2025. It is now read-only.
This repository was archived by the owner on Apr 25, 2025. It is now read-only.

Alternatives to let? #44

Description

@jakobkummerow

We have heard from multiple directions (interpreters, toolchains, debuggers) that let is (surprisingly) hard to implement/use. As I am looking into implementing let in V8's non-optimizing compiler, add my name to that list. It's certainly all doable, but it's quite involved. And it would be sad if engine/interpreter implementors had to spend particular implementation effort (as well as runtime CPU+memory cost) on a feature that toolchains then barely use.

So I was wondering whether the let instruction as currently proposed really is the best solution we can collectively come up with.

IIUC, its primary purpose is to provide a way to have non-defaultable locals (in particular: non-nullable reference type locals) in a function. Please do point out if it has additional important use cases.

In the spirit of brainstorming, here are some alternatives that would solve that problem too:

(1) A "locals_initialized_barrier" instruction with semantics: before the barrier, locals may be uninitialized/null, and reading from them incurs the cost of a check; after the barrier, such checks are dropped as all locals are guaranteed to be initialized. Execution of the barrier checks that all non-defaultable locals have been initialized.

(2) A scope that indicates "within this scope, the given locals are non-null". Entering the scope performs null checks on the specified locals. Accessing these locals within the scope needs no null checks.

(3) Introduce "local initializers" modeled after the existing global initializers (which are solving the same problem). We'd need to figure out how exactly to encode these in the text and binary formats. Execution of a function would begin with evaluating these local initializers; afterwards all locals are guaranteed to be initialized. Similar to globals, the rules would be something like: only constant instructions are allowed as local initializers; they can read other locals but only those with lower indices.

(4) Require all locals to be pre-declared (at least by count, maybe also by type). Their initialization then still happens with let as currently proposed. That would prevent the size of the locals list/array/stack from changing dynamically, and would also keep each local's index constant throughout the entire function.

(5) Drop let entirely, at least for now. We can always add it later if we have enough evidence of a concrete need for it. In the meantime, a workaround is to factor out the body of what would have been a let-block as a function, and call that. A JIT might still decide to inline that function. (This would limit Wasm module's ability to fine-tune for maximum performance; but based on binaryen's feedback it's unclear to what extent they'd do that anyway. This is not my preferred solution, just mentioning it here for completeness.)

(I realize that there is a lot of conceptual overlap between these ideas -- which is unsurprising given that they're all solving the same problem, just with slightly different approaches.)

I'm sure there are other possibilities, please suggest them!
If the ideas above have critical flaws making them unsuitable, please point them out! And if you have a favorite among them, please say so as well.
As I wrote above, this issue is meant to be a collective brainstorming (and maybe eventually decision-making) effort.
We don't have to change anything; I just wanted to make sure that this design has received sufficient contemplation before we set it in stone. Especially in light of the feedback we've been getting so far.

Activity

  1. kripken commented on Jan 15, 2021

    @kripken
    Member

    About (3) (local initializers that are similar to global initializers), I think that could be simplified to just referring to an immutable global. That is, in global initializers we need a lot of generality, but here we just need to avoid a null value, and the default value will in most cases not be used and so not matter. So in a module each GC type could have a singleton immutable global containing a "dummy" instance of that type which locals would refer to.

    A more compact encoding of that could be to define types with default values. Such types would be defined after the global section, and just pair a type with an immutable global. This would avoid any extra bytes in functions. And such types with custom defaults may have other uses than this.

  2. tlively commented on Jan 15, 2021

    @tlively
    Member

    I like (4) because it has the best ratio of simplification to change, but I would be happy to consider larger changes as well.

    Taking @kripken's line of thought a step in the opposite direction that @00ff0000red took it, if every non-nullable type would have a dummy global default value that is never used in normal execution, then why waste code size and tool effort on defining those globals? Instead we could just have the engine generate a meaningless default value for each non-nullable type. The only difference between these dummy values and null values are that dereferencing the dummy values does not trap, but rather does nothing or yields another dummy value.

    The benefit of non-nullable values is that you know that there is always meaningful data in them, so I think any solution that involves global default values for non-nullable types somewhat diminishes their benefit to the point where we might as well not have them.

  3. skuzmich commented on Jan 15, 2021

    @skuzmich

    Do we still want let instruction, if we were to go with (4) ?

    Instead, local.set could form a scope till the end of the current block, where you can safely local.get non-nullable variable.

  4. tlively commented on Jan 15, 2021

    @tlively
    Member

    I think engines would have to validate that every local.get from a non-nullable local is dominated by a local.set to the same local. It looks like there are efficient algorithms for this, but it would certainly add validation complexity.

  5. kripken commented on Jan 15, 2021

    @kripken
    Member

    Speaking of domination, another option might be

    (6) Trap at runtime if a non-nullable local is used before being assigned to. I know that sounds bad, but in an optimizing tier SSA analysis will anyhow prove that uses are dominated by values (always the case unless the wasm emitter has a bug), so it has no overhead to either compile time or runtime. Of course, in a baseline compiler this will mean null checks on local.get. But it's simple and has no code size overhead.

  6. skuzmich commented on Jan 15, 2021

    @skuzmich

    @tlively we don't have to do general dominance algorithm (at least initially). Validation algorithm could be equivalent to that of let instruction. We still would need to check that struct.get is inside let block, if we were to use let.

  7. tlively commented on Jan 15, 2021

    @tlively
    Member

    @skuzmich I don't think I understood your suggestion, then. Right now local.set instructions do not introduce new block scopes. Are you suggesting that we have a version of local.set that does introduce a new scope? If so, how would that be different from let?

  8. skuzmich commented on Jan 15, 2021

    @skuzmich

    @tlively Please, allow me to clarify. In my suggestion local.set would not form a proper Wasm block, it would not have a dedicated end instruction. Instead, it would merely create a variable scope from local.set instructions till the end of the current block it is in.

    Benefits of this approach:

    • No let instruction in spec. No need to support it in decoder and execution. New rules for local.set apply only to validation phase and are as complex as let instruction rules.
    • No code size overhead for blocktype and end.

    As far as I remember, let was designed as it is to simplify function inlining. You would want a proper branch target for inlined returns, and localdef section with relative indexing for inlined locals. But since (4) removes relative indexing from locals defined in let, this is no longer the case, and we might not need this extra instruction.

  9. tlively commented on Jan 15, 2021

    @tlively
    Member

    Thanks @skuzmich, I understand your suggestion now 👍

  10. RossTate commented on Jan 16, 2021

    @RossTate

    In WebAssembly/design#1381, I did an analysis of initialization and of let. I found that let was not well-suited for initialization. One example I gave is where a local is initialized on separate paths that join together. Many of the suggestions above would also have problems with that case. At the time I had considered a variety of other fixes to locals/let, but they all made the type system more complicated and without solving more advanced cases like type refinement. The simplest and most expressive solution by far was to just use the stack and add these four simple stack instructions: WebAssembly/design#1381 (comment).

    The parts of the discussion in WebAssembly/design#796 regarding register allocation and this article make me think that such an approach could be easier for engines, though others here are better equipped to speak to this. The fact that there's a straightforward way to translate from using locals to using stack instructions makes me suspect it could be easier to generate, but that translation does require tracking more information about the current stack state, so I defer that judgement to others as well.

  11. RossTate commented on Jan 16, 2021

    @RossTate

    Oh, I forgot, regarding dummy values, that approach might not always be possible. For example, in the presence of polymorphism, you might not be able to construct a dummy value of a type ahead of time because you might not know what that type is. That is, of course, unless all value types are defaultable, which is a viable option but one that brings us back to #40.

  12. tlively commented on Jan 16, 2021

    @tlively
    Member

    Unfortunately stack manipulation instructions won't help in Binaryen because of its expression-based IR, but if they would be helpful in other contexts, I don't think that should be a big blocker. We're already hacking around things like multivalue results, block parameters, and let in Binaryen IR, so we would just hack around stack manipulation instructions as well. That being said, it would be nice if we had a solution that fit nicely into Binaryen IR.

  13. RossTate commented on Jan 16, 2021

    @RossTate

    Hmm, I feel like an expression-based IR shouldn't be a big problem. I would suspect this would only affect the serialization phase. That is, you're free to use (your own notion of) locals in your IR, but when you serialize your IR to WebAssembly, rather than emitting local.get/set $index_of_local_in_your_IR you emit get/set $index_of_local_on_stack. The complication is that $index_of_local_on_stack depends on $index_of_local_in_your_IR, which locals are currently initialized (as the uninitialized ones are not on the stack), and how many temporaries are currently on the stack, so your emitter would have to track that as it recurses through your expressions.

  14. 413 remaining items

  15. tlively commented on Jul 19, 2022

    @tlively
    Member

    It looks like we have a consensus coalescing around 1a (resetting initialization state at the end of every block) for the MVP. Let's try to resolve this discussion right now.

    Official online unanimous consensus vote

    React to this comment with a 👎 to object to the proposed resolution. If we have any 👎 by the end of day on Thursday this week, we will discuss this at the next meeting and resolve this conversation then instead. If there are no 👎 at the end of day Thursday, we will adopt 1a as the solution for the MVP.

  16. tlively commented on Jul 22, 2022

    @tlively
    Member

    Excellent, we will go with option 1a for the MVP.

  17. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    It's great to finally have a solution here! @askeksa-google raised one detail a couple of days ago: we still have a choice to make regarding loop.

    Contrary to block, there is no way to reach the end of a loop without executing all of its body at least once; so we could maximise applicability of "option 1a" by not resetting initialization status at the end of loops; in other words the rule would be "NNL initialization status is reset at the end of any block, if, try, as well as any else/catch/catch_all".

    The alternative is to reset NNL initialization status at every end (and else/catch/catch_all). Looking at #63, this might make the formulation of the formal spec a bit easier. It provides no meaningful simplification for engines. In theory, this option is wasteful (we'd be throwing out perfectly good initialized-ness information); in practice the difference is probably insignificant (because the situation of initializing an NNL in a loop and then using it afterwards is likely very rare).

    Regarding forward compatibility: we cannot change our minds and go from "not resetting" to "resetting". Changing behavior in the opposite direction is not a problem. This is independent of whether we eventually adopt "1b", "1c", or neither, or something else entirely. In particular, if we go with "not resetting" now, we can still do "1b" or "1c" later, we'll just have to maintain "not resetting" at that time (which is not a problem for "1b" and "1c").

    I think either option is better than spending another two years debating this, but we should pin it down one way or the other.

    I'm going to try an unofficial poll about this to see where we stand as a group. Please 👍 one of the next five posts:

  18. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    "I strongly believe that we should not reset NNL initialization status at the end of a loop, and would rather argue about it than be outvoted."

  19. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    "If it was up to me, I'd say we should not reset NNL initialization status at the end of a loop, but I'd rather be outvoted than spend time arguing about it."

  20. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    "I totally don't care one way or the other."

  21. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    "If it was up to me, I'd say we should reset NNL initialization status at the end of a loop, but I'd rather be outvoted than spend time arguing about it."

  22. jakobkummerow commented on Jul 22, 2022

    @jakobkummerow
    ContributorAuthor

    "I strongly believe that we should reset NNL initialization status at the end of a loop, and would rather argue about it than be outvoted."

  23. titzer commented on Jul 22, 2022

    @titzer
    Contributor

    It's sounds OK to me at first blush to not reset at loop ends, because the only way to get there is falling through.

  24. conrad-watt commented on Jul 22, 2022

    @conrad-watt
    Contributor

    @titzer this would mean that we break forwards compatibility with the version of 1b that @rossberg and I advocated for (recall the whole "local reasoning" debate). As @jakobkummerow mentioned, we can go from resetting now to not resetting later, so resetting is the most conservative choice in terms of future plans.

  25. rossberg commented on Jul 22, 2022

    @rossberg
    Member

    The current PR does "reset". Frankly, I don't think there is much of a choice here, since not resetting is incompatible with a uniform interpretation of block types under 1b and thus not really conservative, as we intended to be.

  26. titzer commented on Jul 22, 2022

    @titzer
    Contributor

    Ok, fair points. I agree that resetting is the conservative choice.

  27. jakobkummerow commented on Jul 25, 2022

    @jakobkummerow
    ContributorAuthor

    FYI, this has been implemented in V8.

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