-
Notifications
You must be signed in to change notification settings - Fork 74
Supporting Basic Generics before Advanced Generics #156
Comments
To be clear, this is about expressing covariant (source) arrays in terms of invariant (target) arrays? To play devil's advocate, what if WebAssembly were to be extended with a language-level covariant array that checks stores (e.g. by requiring an RTT argument)? |
Sigh. We really need to get away from all these false dichotomies.
I have made these observations before. The second I actually wanted to talk about on Tuesday, but I didn't get to it. And let me point out once more that any "nominal" semantics that we introduce must be "structuralised" at least at module boundaries, otherwise we would not be able to define linking. Once you accept that fact, it becomes obvious that nominal is only going to be a quasi notion and that much of this discussion is a red herring. For example, you can recast nominal recursive types as iso-recursive types in a structural manner. (OTOH, iso-recursive types are not enough for all languages either.) |
I believe everyone is on the same page about this. Type imports must fully describe the structure of the type that is being imported no matter what system we use so that the compilation phase can determine field offsets. Whether equi-recursive, iso-recursive, or nominal, we all agree that WebAssembly's types should serve only to describe the low-level structure of data so that engines can emit safe and efficient code. Nominal types are being proposed not to get around this or to serve some other purpose, but merely to improve the algorithmic complexity of type canonicalization and various post-MVP extensions. In particular, nominal types are not meant to embed source-level type systems into WebAssembly, which we all agree is a non-goal and also generally impossible. |
The problem with arrays arises more generally when the type system needs to simultaneously reason about subtype polymorphism and parametric polymorphism. For example, Kotlin arrays are invariant (putting aside dynamic concessions Kotlin makes on limited non-native platforms), but Kotlin also allows use-site (technically mixed-site) variance, which means one can have a (read-only) covariant Expressing the low-level structure of these types requires bounded quantification. Unfortunately, because structural types identify structure and types, this means they need impredicative bounded quantification to express these examples, meaning that they quantify over structural types themselves. All the known subtyping rules for impredicative bounded quantification that can express the above subtypings are undecidable by some reduction to the result in this 1993 paper. Nominal types, on the other hand, make a distinction between structures and types (the latter is a layer of abstraction above the former), which means they can express these examples with predicative bounded quantification, quantifying over the layer of abstraction above structures rather than structures themselves. The tech report I mentioned above provides a framework for developing predicative quantifications that can decidably express a wide variety of languages. |
I feel it's worth noting explicitly, as I discussed with @RossTate over email, that Kotlin's JVM backend implements (invariant) Kotlin arrays in terms of (covariant) JVM arrays. In this sense, the JVM backend does not succeed in "expressing the low-level structure" of the Kotlin array type, but nevertheless achieves performance which is far better than the I agree its preferable to allow as much expressiveness in Wasm as possible, and it may well be possible to make Wasm's type system expressive enough to handle this case fully faithfully, but the ability to "express the low-level structure" of a source-language type isn't a hard precondition for acceptable performance (and will not be possible in all cases). |
Unfortunately Wasm-level linking is not enough to express the source information necessary to do proper late binding of source constructs (e.g. inheriting from a class across a module boundary). It is not possible for a source language like Java that wants to provide proper separate compilation to rely on Wasm type imports (even with full a structural description) for this. That problem is entirely orthogonal to the structural/nominal discussion. |
I don't understand why covariant read-only array views with dynamic RTT-based casts to writable array views doesn't work. |
My understanding is that this (or something like it) would specifically solve the performance concerns raised for Java and C# arrays, and Kotlin arrays up to the approach they use for the JVM backend (which introduces some minor gotchas). If I'm understanding @RossTate's overarching proposal correctly, he knows of and has worked on a way of modelling covariant arrays with store checks, Kotlin arrays, C#'s reified generics, the Clearly all else being equal it's preferable to have one generic [sic] mechanism for making all of these different source language features efficient rather than plug one specific (but very important) set of cases with a comparatively bespoke feature. I believe @rossberg would also agree. @RossTate's position is that a unifying approach based on bounded quantification will only be possible under a nominal type system, because a structural type system with sufficient expressivity for the examples above would be undecidable. EDIT: it is worth noting that a Wasm-level covariant array would have the advantage that it could potentially be an MVP or soon-after-MVP feature. |
Classes and interfaces with (sound) generic methods also require bounded quantification to describe their structure, and you need to employ subtyping checks to ensure a class correctly implements its interfaces or extends its superclass. So again this is undecidable with structural types but decidable with nominal types. It seems that the decidability limitations of structural types mean that no polymorphic language would be able to target a structural GC type system without erasing all polymorphism in their structures and just casting everywhere instead, like they would do if they were to target the JVM. |
I feel this is what is essential to get some more agreement on, as a lot of design decisions hinge on how we view this. There's a lot of discussion that includes "but we must do it this way for languages to be able to do separate compilation" or "retain modularity". But it seems that, while languages can choose to compile what they consider a "program" consisting of many language level modules to many Wasm modules, you can't often escape the need for the language to have additional information (outside Wasm) on how those modules fit together, and thus really must consider that group of modules as one unit, where representational choices of imports/exports internally to that "program" may change incompatibly when source level constructs change. This informs our decisions of why GC types need uniform representation and/or subtyping etc, and to what extent implementations can specialize instead. @rossberg's argument of the existence of things like recursive polymorphic types entailing that we must therefore have these features may well change if we agree that modularity of a source language doesn't necessarily map to the same kind of modularity properties at the Wasm level. |
Lumen using the GC proposal is still iffy (we have a GC in Rust that works and are probably more likely to want stack walking to rewrite roots), but if we are to use it, we have no plans to compile each Erlang module to a separate WASM module. That's actually counter to the reasons for Lumen: we want whole program optimization and so want a single file at the end. If we wanted multiple files we could have more closely matched how the BEAM VM works with |
Yes, because it is a prominent use case. If you look at the landscape that exists already, then people do not actually want to build a new implementation of C# -- that would require duplicating a huge ecosystem effort. Instead, they want to implement .NET and reap the benefits of that. See Blazor. I suspect that similar considerations will hold for significant parts of the Java ecosystem. Non-modular approaches are a non-starter for that, because modularity is deeply engrained in these systems.
It's a bit more nuanced. In previous discussions, various people very much assumed and expected to be able to directly implement language-specific type tagging via Wasm-level runtime types. @RossTate in particular argued strongly for this being crucial in the case of OCaml's value representation. So I'm a bit fuzzy on what I am supposed to deduce from him making practically the opposite argument for a C#/.NET value representation now.
One point I was trying to make in my presentation (but didn't quite get to) was that the algorithmic complexity argument is a red herring. As I showed, there has to be an implementation of dynamic structural canonicalisation somewhere. Whether that's in user code or the engine does not change its complexity. The only leeway with it really is the semantics of recursive types, which affects the complexity canonicalisation. But that question is entirely independent from the whole nominal distraction of an argument.
Not with what's in the MVP, no. But how do OO engines implement dynamic linking? By additional indirections, i.e., with offset tables. So what we should enable is expressing these techniques in Wasm, e.g., by introducing "member references" as a post-MVP feature. I have not heard an argument why this should not be possible.
I do, but obviously the price has to be adequate. If it amounts to putting complex and OO-specific custom typing machinery into Wasm then it's not clear if that's worth paying. Bounded existential quantification as a general mechanism is desirable, but already magnitudes more complicated than anything that the CG has been willing to put up with in the past. I think this will change, but not as fast and radically as some people hope. It would certainly turn Wasm into a very different language. |
Apologies if I missed it in your presentation, but could you elaborate on why this is? |
OO engines do not use additional indirections, offset tables, etc to implement late binding. Late binding requires source information and types and no amount of indirection is a substitute for logic that applies name matching and overloading rules. E.g. declarative linking rules will never be enough to express even Java's semantics here. It requires a runtime system and the preservation of source level types and names. |
@titzer, what would you say the takeaway from that is for the current discussion? It seems to me that the complexities of late binding mean that it's not a problem we should be trying to solve in this MVP proposal, so we can mostly ignore it for now with the understanding that some future proposal will introduce a new staged or layered compilation/instantiation scheme to address it. |
Yes, additional source-level information for proper late binding is absolutely required for even moderately complicated languages such as Java. The good news is that it is possible to encode this information in the import system. I will shortly propose the necessary extension to make this possible, a moderate upgrade to the import system beyond simple type imports.
I'll put it this way: it's not just staging compilation, we need to upgrade the import mechanism in a specific way to achieve late binding. In general, code lowered to Wasm will never be able to cooperate across a module boundary while preserving source language invariants. (Implication: such code cannot/should not be lowered.You need a runtime system to do some lowering dynamically/at link time. There's not enough indirection in the entire universe to achieve that statically :-).) Without building a runtime system that does this, it's all murky. I built one for Java. I'll show what it does on Nov 17th and try to have a document up a few days before. Even with the above, the following issues remain, because even dynamic lowering has to lower to something.
The issues 1 + 2 need to be resolved somehow, because they are at odds. In my view, this basically boils down to the question: do modules need to import/export RTT values in order to cooperate, or can they implicitly access the "same" RTT values through the engine's canonicalization thereof? With my encoding of Java, RTTs become implementation details that are completely hidden behind an abstraction--i.e. it doesn't matter except as a contract between the language runtime and the engine; the user program/modules never see them. The choices we make have performance implications but do not affect expressiveness. For 3.) we either need to introduce covariant array views with castable writable views, or we need to introduce existential types along the lines of what Ross has done in his research papers, which he claims can also eliminate casts for virtual dispatch sequences. His claim is that this cannot be done for structural types that we have now. Personally I feel like he's been arguing to hold that door open for a long time but we haven't seen a proposal that actually walks through that door and adds existentials in a post-MVP world. So I don't know if it actually works or not. In what I will propose, for 3.) this becomes an implementation detail hidden behind an abstraction and never visible to user programs. It matters only to the implementation of the runtime system which lowers the source language to Wasm behind the scenes. |
It's worth noting that this encoding approach was being exercised in MSR even before Ross's more recent work. For example (as Ross linked in the OP) it was used in Bartok, the compiler behind a bunch of their verified OS projects (Verve, Singularity/Midori). IIUC, Ross's iTalX work subsequently modified Bartok to use his particular system, which required fewer type annotations. I found the original LIL_c paper (also MSR) to have the most easily understandable foundational examples for how the encoding works. @RossTate would you have the bandwidth to lay a bounded quantification extension on top of #119?
Also, we currently have to downcast the With reference to @rossberg...
We have a practical problem that some important OO features will likely have less-than-stellar performance in the MVP. This prompts the obvious question:
I would argue that without a clear answer to this, we're more likely to end up adding OO-specific machinery to Wasm, because we will be led by medium-term performance pressures towards ad-hoc features such as Wasm-level covariant arrays. |
I am very interested in what this looks like.
I am actually less concerned about this than I used to be. If achieving performance parity means adding OO-specific machinery, but the resulting system is actually easier to use and understand than, e.g. a system of existential types, then the resulting loss of generality might not be as bad a tradeoff. After all, as clunky as it can sometimes be, the JVM itself has had pretty considerable success as a compilation target for many, many languages, and that's without supporting first-class functions. I think we already in a position to do considerably better just by virtue of the coming function references proposal. If existential types are hard to use and that puts off potential producers, we'll be worse off overall. I'd like to see things played out to sufficient detail for us to make an informed decision. Which is why, in general, I am reluctant to make a major switch to nominal types without that actual benefit yet materializing in some tangible way. |
It follows from my examples and observations. See the slide "Observation 8" and following in particular, which state so. In short, generic functions can construct dynamic types from parameters, in unbounded numbers, and in multiple independent places. All must result in the same runtime type to maintain the observable semantics of (implicit or explicit) casts. Type expressions involving generics are inherently structural in nature.
With lazy loading, they at least have to use a stub to trigger loading and then jit compilation, which is a form of indirection. But that requires throwing jitting into the mix, which I don't think every such language would want to get into, especially not on Wasm. Indirections through indices or accessor functions are the jit-less alternative. But even with jitting, if you want to express that in Wasm user land, you'll somehow need to abstract field access through an accessor function import in the original module. That may be handled magically by the VM, but it's still conceptually an indirection, AFAICS. Isn't that true of your approach as well?
Right, but of course no sufficiently interesting language will have stellar performance in the MVP. FWIW, it is gonna be much worse for dynamic or functional languages, which mostly depend on encoding a uniform representation, and need way more casts.
There are many open questions post-MVP, but we need to take one step at a time if we want to make progress at all. I do think that some form of existential quantification should eventually make it into the language and nothing in the MVP prevents that, but extrapolating from other, way simpler proposals, that's realistically gonna take years of work and discussion. It's more constructive to focus on step 1 at this point than using all bandwidth for hypothesising about step N+1. |
Generally, no, an OO engine won't use an extra indirection in the object model for late bound field accesses. In Java the weirdness generally only comes when there is a link or validation error and the tricky bit is mostly to throw the error at the right time. IIRC Java needs to load all class definitions of types mentioned in a method in order to validate its code, which might happen for a whole class or just a method at a time upon its first execution (I don't remember how loose the spec is here). A link error might get thrown later though, at execution time, but getting the timing of exactly when this error is thrown is generally done by marking entries in metadata used by the interpreter as invalid, so the JVM interpreter will throw when executing them. In either case the JVM generally does not introduce an indirection in the object model, but instead such bad field access or call sites are either tripped over by the interpreter or JITed to throw a runtime error. So adding imported member references to Wasm would be no help in doing Java late binding properly. |
It's true that we can likely build any post-MVP strategy on top of any MVP - but I find it hard to see how we can add bounded (existential) quantification in a way that doesn't deprecate the constructs of the current MVP. I've been trying to find existing examples of equi-recursive encodings with bounded quantification for OO languages, but they all seem to fall into one of the following categories:
I feel we're heading for a situation where we start with equi-recursive structural types, add covariant arrays, then later add a disjoint feature-set of objects with nominal (or at least iso-recursive) subtyping + BQ. This wouldn't be a disaster, but I do want to understand if this is what @rossberg is expecting too. more tangential question re #156 (comment), which might be relevant to @tlively's question:
There's some kind of mismatch between this claim (with its related conclusion "dynamic RTT canonicalisation is necessary") and the LIL_c/Bartok/iTalX approaches that I don't fully understand. I don't know if @RossTate can elaborate? Email would be fine to avoid clutter. My understanding is that Observation 8 is stated in C#. The iTalX paper states that Bartok "fully instantiates generics before code generation". Does it fail for this example? The same paper also claims to have an alternative encoding, which if I'm reading correctly for Observation 8 would involve progressively nesting/packing an existential type, with the downcast corresponding to (implicitly) unpacking all of these existential wrappers. I think this could be characterised as similar in spirit to a producer-encoded naive structural check without canonicalisation - which is acceptable because |
Ah, but I am not suggesting to use structural types for that. OTOH, I also don't think that nominal types would deprecate structural types. They both serve different use cases. But it is a valid question what semantics we should adopt for type recursion.
That mismatch is resolved when you don't expect piggybacking: then the engine can use a simpler mechanism. But the price is duplicating the same (or similar, because more high-level) information in user code, and doing two layers of casting. Unfortunately, I've heard opposite opinions even in this thread about what people expect or find acceptable here.
It is very loose, it can even defer verification of each individual instruction until this very instruction is executed, which I assume is supposed to enable a byte code interpreter with no upfront verification. OTOH, a JVM is also allowed to do it eagerly for the whole dependency graph. I don't think any production JVM implementation uses either of these extremes, though. But yes, you are right, this generally allows loading a class whose field is accessed before compiling the code. However, in a Wasm context you could only make use of this if you also created the Wasm module representing a class only after knowing all relevant dependencies -- i.e., go all in on 2-layer jitting. Is that what you have in mind? |
Sorry for the dumb question, but what does "piggybacking" mean in this context?
We've often said in the past that Wasm's core language should be small and generic, because it's expected that there will be a small number of implementers who need to handle the whole thing, and a large number of producers who will each target some subset, implementing the machinery that's relevant to them at the Wasm-level. I would argue that requiring (alternatively, "enabling") producers to handle more of the type generation/casting logic themselves is analogous to requiring them to ship an implementation of |
Ah, it's the term I used in my presentation for expressing language-level casts directly with Wasm-level casts.
Well, as I pointed out in my presentation, it is a fact of life that the Wasm runtime type system can't possibly subsume every system of language-level runtime types, in the same way that isn't possible for static types. So in general, you have to implement at least parts of the casting mechanism in user space. I get the impression that this observation isn't widely appreciated yet. That makes it more a question of where to draw the line, and whether it's okay for the MVP to rely on user space entirely.
The argument (if you are referring to mine) was that type canonicalisation is necessary somewhere to implement languages like C#, no matter what type system we pick for the engine. Hence the argument against doing canonicalisation in the engine is in large parts a red herring. |
I find the discussion above to be hitting a lot of great topics. At the same time, I worry that it is hitting too many topics. So I am asking the following question in the hope that we might focus the discussion: @rossberg, do you have concerns with nominal GC types that are unrelated to linking? That is, if programs were only shipped as whole wasm modules whose only imports and exports were for interacting with the host, what concerns regarding nominal types persist? (Not trying to downplay the importance of linking; just trying to understand the domain of the concerns.) |
The "canonicalisation" required to implement C# seems to be much simpler than the canonicalisation currently required in engines. As I said above, in the example you gave the types to be matched form a well-founded tree structure and it's not overly onerous to check them at each site (potentially with produce-time optimisations in many common cases) as opposed to eagerly canonicalising at runtime. An argument against doing canonicalisation at the engine-level is to allow each individual producer to implement exactly the canonicalisation that they need, which for all the languages we've discussed so far is much simpler than the full equi-recursive algorithm that the engine would have to maintain to implement |
That's why I said "in large parts", and noted earlier that the semantics of type recursion is the key aspect that matters and that the discussion should probably focus on, and that is independent from nominal/structural. ;) Either can be inductive, in which case canonicalisation becomes an equivalent problem. |
Other concerns involve the simplicity and scope of the MVP (more constructs, a larger type system, e.g. because nominal typing will require introducing yet another typing relation, with structural semantics still being needed e.g. for describing imports or for checking the validity of the nominal relation in the first place), the consistency of the type language (function types are already structural, and intentionally so), the coherence of the validation-time vs link-time typing semantics, and maintaining meta properties of modules (such as the ability to merge them easily, which can be maintained if nominal types are done correctly, but some earlier suggestions would break that property). Probably some other points I'm forgetting right now. But of course, Wasm has modules for a reason, modularity and separate validation/compilation is a core feature, and a type system that would only be usable with whole-program compilation would be a non-starter. Closed-world whole-program approaches simplify various problems in compilation, not just typing, but such considerations tend to be of limited use for solving the actual problem. |
Regarding the simplicity and scope of the MVP, it would be a shame if we shipped a slightly simpler MVP but then found that it couldn't be extended or made more efficient in various ways without shipping an entirely disjoint system. It would also be good to continue focusing the discussion on more objective and concretely valuable properties like expressiveness and performance rather than more subjective or aesthetic properties. For example, the abstract benefit of having the same system for function and non-function types could never outweigh any concrete benefits to expressiveness or performance we might find we could get from a mixed system. And regarding modules, I agree that separate compilation is an important problem to solve, but all existing toolchains I know of (including prototypes targeting this GC MVP) either do whole-program compilation or do static linking or require some extension to WebAssembly's module system such as Interface Types in order to do separate compilation. It seems reasonable to expect toolchains to continue to do whole-program compilation or static linking until we introduce some mostly-orthogonal extra runtime layer like @titzer's upcoming late bindings proposal, so it's unclear whether we need to consider separate compilation in our GC design. Without a shared understanding of what such an extra layer would look like, it's impossible to argue effectively about what properties the GC proposal must have to support separate compilation. All this is to say that I believe expressiveness and performance are the key issues at stake here. |
Just to riff off @tlively's comment
We've always punted on language-interoperability as being a matter for the toolchain to make a convention for to the best of its ability. Similarly, we already expect that it's possible for two compilation units in the same language to be separately compiled to Wasm, and for the resulting two (sets of) modules to be unable to be composed together unless they were both compiled using some toolchain convention that deliberately facilitates this composition. Consider that even different optimisation levels of the same toolchain may pack/order struct fields differently. The "compositionality" property that @rossberg wants for the GC proposal is already wholly reliant on toolchain conventions for the above reasons (e.g. the Wasm modules must agree on object layout up to the depth they care about, even if they don't explicitly import each others' types). My impression is that any other flavour of proposal (let's say nominal) that requires a central coordinating module for types can have the same compositionality properties (wrt the source compilation units) so long as a toolchain convention handles where type definitions live, how they get plugged into different modules, and (if necessary) how to merge/pick a winner in the case of duplicate definitions. To paraphrase @tlively, I also think that questions about engine canonicalisation and type recursion should be answered in the wider context of what system gives us the best expressiveness and performance. The key battleground right now is arrays and reified generics, and for this question the nominal vs structural debate is front and center because of the proposed post-MVP bounded quantification encoding. |
I'm not sure whether that would be sufficient (@sbc100 would know), but if we have first-class instantiation, it seems reasonable to have first-class compilation as well, so I'm not sure how different those actually are for the sake of this discussion.
I don't understand the practical distinction you're drawing between underapproximating and overapproximating here. In either case, external linking machinery is required, and that external machinery may as well dynamically generate Wasm modules. For sufficiently general and language-agnostic use cases, that machinery can be standardized and layered on top of WebAssembly like Interface Types. But nontrivial language-specific linkage schemes will always require some sort of language-specific linking machinery, and that's ok. Trying to avoid that should be a non-goal.
I would agree with this in general, but in this case there is a potential performance trade off involved.
This is a good point. The module linking proposal makes WebAssembly imports/exports more expressive and demonstrates that to be sufficient for some use cases, including shared-nothing linking when paired with Interface Types. However, it is not a panacea. In particular, it is insufficient for |
@aardappel, unlike native, Wasm cannot "erase most/all type information". That is the very problem we are up against! Given that, the next best thing is to do what we can to make these types permissive enough to avoid unnecessary over-constraining.
The qualitative difference would not so much be in the engine but in the language's runtime: as mentioned, it would have to ship with a compiler backend and do its own jitting phase. Even if client-side compilation is adequate for some languages, that can't seriously be our only answer to enable client-side linking in respective cases. |
I think the point that people are making is not that client-side linking should be done with jitting, but rather that client-side linking is very diverse to the point that it should be able to incorporate jitting when necessary. That gives systems the flexibility to choose how much compilation they do ahead of time or just in time, a tradeoff that they can explore and determine what works best for their needs. For some languages, there won't be much flexibility due to the compilation/performance model of the language. Julia, for example, is designed entirely around the assumption that compilation will happen after linking, which it uses to gain multiple orders of magnitude of performance improvement. It even developed a new semantic concept specifically to support this compilation/performance model. But that's an extreme example. Another less-extreme example is Java. We know that Java benefits from closed-world optimizations after linking. But many parts of the program can be compiled ahead of time even without such optimization. And it might be that the payoffs of such optimizations aren't worth the slower load times, in which case they could move to nearly entirely ahead-of-time compilation. But even without closed-world optimizations and speculative optimizations, Java would need at least a small amount of jitting to have reasonable performance with client-side linking. In particular, classes in one Java module can extend classes in another Java module, and so the memory layouts of these classes cannot be determined until after linking has occurred. Once that's determined, Java would generate a very small wasm module that just specifies this structure, generates a corresponding rtt, and exports the accessors and mutators (or field references) to the relevant ahead-of-time compiled classes just waiting to know what these offsets are. Note that this doesn't require shipping an entire compiler—just a linker that does a small amount of wasm jitting and a lot of name resolution. So even though a language compiling its modules to WebAssembly modules does alleviate the need to ship an entire compiler, that does not alleviate the need to ship a linker. This linker can be very lightweight—resolve names, coordinate low-level constructs, and maybe jit some tiny modules—but it likely needs to be a program in and of itself in order to accommodate the diversity and expressiveness of linking systems out there. At present, such a program is only expressible in the embedder, but ideally we would find a way to make it expressible in wasm itself, as I and many others above have suggested. Of course, such a linker program could easily take responsibility for canonicalization (and according the language's type grammar rather than wasm's type grammar), making this benefit of structural types unnecessary. |
No disagreement here. Nobody said that languages shouldn't be able to do jitting. But other languages should likewise be able to not do it! That would be proper choice. An overconstraining type system prevents that.
The "jitting some tiny modules" part is where we enter completely different territory. You are assuming that you will be able to do that in all environments, which is not the case, let alone in a portable way. We need a proper answer to this problem without a dependency on first-class compilation in Wasm. First-class compilation is nowhere on the roadmap right now. Sorry, I really don't understand how this can even be a controversial question. Suddenly depending on dynamic compilation, only to be able to continue using Wasm's basic linking functionality in the presence of GC, would be a clear sign of a composability failure of the language. |
Then their linkers don't have to use jitting.
Structural versus nominal low-level heap types have no effect on whether jitting is necessary. For example, if your module needs a high-arity tuple, it can import a high-arity tuple type and provide another module that exports a high-arity tuple type. The linker would use this second module only if that high-arity tuple type had not already been defined by another module. I think this is essentially the "provide a default" approach that @jakobkummerow suggested in #148.
I am not assuming anything. I am pointing out what Java would need. As just mentioned, this need holds regardless of whether we use structural or nominal types. As a side note, in this case the need for dynamic compilation is very restricted, e.g. no need to jit functions (if we have field references).
Again, this is misconstrued. The suggestion has been that dynamic linking should be expressed through a custom program. Though that would make dynamic compilation during linking possible should it be expressible in wasm, this suggestion introduces no additional dependency on dynamic compilation, and all languages that could be linked without dynamic compilation using structural types can still be linked without dynamic compilation using nominal types. |
@RossTate, for dynamic linking of non-nominal types to work around a nominal-only regime, a language-specific custom linker would have to synthesise type definitions in a first-class manner (based on various brittle conventions). This cannot currently be expressed in Wasm, nor with any feature that we have ever discussed. The only way I could possibly see that being expressible in some future would be via synthesising and compiling appropriate modules dynamically from within Wasm in a first-class manner. There is no proposal in sight for this. What I'm asking is that we address basic use cases and requirements without references to vague future features that are way outside the current Wasm roadmap and resemble a jack-hammer against nut scenario. |
I think I just explained why this isn't true in my comment you just responded to. I'll repeat the explanation here:
To clarify, this approach generalizes to arbitrary structures.
The OP is raising concerns about how structural types will address basic use cases and requirements from multiple languages and pointing out that nominal types are already known to be able to address these basic use cases and requirements. The objection you are raising to that suggestion is based on an apparent incompatibility of nominal types with a vague future feature for a standardized declarative dynamic-linking proposal that you seem to be referencing and which many people are expressing reservations about. I raised the concerns in the OP years ago and I would like us to work on addressing them rather than continue to overlook them just because the known solution is incompatible with a phantom standardized declarative dynamic-linking system that after years still does not exist and by the various accounts many people have given above would likely be insufficiently flexible for most surface languages to use (including those that would not require jitting). |
How would that approach scale to generics, once we have them?
What are you talking about? Client-side linking is supported by Wasm today. You do not generally need any language-specific magic linker for that right now, just a host that resolves import/export names. A suitable set of out-of-band conventions is all it takes on the producer side. |
The mini-module would export a parameterized type.
As people have pointed out, this is insufficiently expressive for many systems. It is also prone to accidental collisions. But for systems where this is sufficient, these import/export names are just as applicable to types and so can similarly be used to coordinate nominal types via the approach @jakobkummerow suggested in #148. I believe you objected to that suggestion based on the issue of accidental collisions, from which I understood you thought this name-based approach to linking was haphazard. |
After talking with @RossTate (out-of-band) in more detail on this argument in particular, I'd like to see a concrete idea of how he expects this to look in Wasm. This is the central claim that puts shifting to nominal types on the table, and we wouldn't be having any of these other discussions without it. As @titzer noted, we've not yet been shown how the nominal post-MVP would accomplish these goals beyond paper citations. Looking at the cited papers, covariant arrays encoded using invariant ones seem relatively straightforward (given basic bounded quantification). But we also have the option of introducing language-level covariant arrays - so the value-add of the nominal direction has to be in increased generality. I'm personally most interested in eliminating casts of I'm currently finding it hard to see how the papers' approach to castless vtables would be translated to Wasm. Currently my interpretation is that a hypothetical nominal MVP would need be be extended with a distinguished post-MVP |
The reason such a proposal has not been put together is because people suspected that @rossberg's concerns were not related to such expressiveness, and so such a proposal would not help address his concerns and advance the discussion. Furthermore, I personally would like to develop such a proposal as a group so that everyone is in a position to both criticize and contribute to it. To summarize the pros and cons of structural types versus nominal types, structural types are familiar and potentially helpful for simple linking schemes but (according to the current state of the art) insufficiently expressive for expressing many languages (including polymorphic functional languages) without frequent casts—nominal types can express many languages (including polymorphic functional languages) without frequent casts but are unfamiliar and require more infrastructure for linking (that many languages would need anyways). Unfamiliarity and linking infrastructure can be overcome, but we cannot overcome theorems about undecidability (or tooling-relevant concerns like uninferability). In other words, the shortcomings of nominal types can be overcome through education, design, and engineering, whereas the shortcomings of structural types cannot be overcome (without major theoretical advances to the state of the art). |
@RossTate, sigh, you are again constructing the false dichotomy between structural and nominal types. Neither replaces the other. As I pointed out many times, structural types occur in almost all typed languages, in some form or another. All suggestions I have heard so far for dealing with them in a modular fashion are vague ideas of complicated infrastructure hacks around custom linkers, reflection, name-mangling, registries, collections of micro modules, or even dynamic compilation. All that to address a very basic typing problem. None of this has ever been worked out in any detail, let alone shown to be feasible or scalable in practice. So in addition to @conrad-watt's request, I would ask proponents of a nominal-only semantics to actually back up their claims that such an approach is feasible by designing a concrete infrastructure, prototyping it, plus providing at least a plausible sketch of how it would interact with the interface types proposal and with possible multi-language apps that that is intended to support, with the module linking proposal, and with possible extensions to Wasm-level polymorphism. Furthermore, I would ask them to analyse what Wasm language extensions would be necessary to be able to self-host this infrastructure in Wasm, in order to make such a language implementation portable across eco-systems. If all this can be demonstrated to be simpler than just providing boring structural types in Wasm proper, then let's talk again.
Integers are insufficiently expressive for many systems. That's why we have floats. But unless you're JavaScript and love to impose horrendous complexity on implementations to work around their lack, you also provide integers. Because they work better and more effectively and avoid a whole range of problems and complexities in a majority of use cases. IOW, Wasm modules are flexible and directly applicable to many use cases. Just because a full-blown JVM implementation needs to build something way more complicated and their own ecosystem, it doesn't mean that everybody should have to build something way more complicated and their own ecosystem.
That suggestion still uses structural typing, so I don't know what you're getting at. (And yes, it would create a global name space and ambient global VM state problem that is the opposite of modular.) |
Cool. Those are all easy to do, though I need you to clarify something. In your dynamic-linking semantics, what happens if a module imports a name that multiple modules export? My understanding is that common practice in other linking systems is for the name to be resolved to whichever module was first according to the order modules were loaded, but I wanted to confirm that's what happens in yours. |
Nothing special, why would it? There is no global name space for exports. That would be the antithesis to a module system. Wasm imports denote a module name explicitly. In general, think of it as a URI -- it is up to the host, linker, or glue code to interpret and resolve this to another Wasm binary (or host module). How module resolution works should be orthogonal to the typing problem (if it's not then that's a big red flag right there). |
Suppose a module named "A" imports type X from "Foo" "Bar" and a function of type Now the semantics of "always resolve to the first module loaded with the given name" has the nice property that it will ensure consistent resolution of modules, so I'll run with that semantics and consider separate compilation for, say, Haskell. A Haskell source module will compile to a wasm module called "MyHaskell" that imports from the "HaskellCore" module. It needs to do this at least to import the exception event to use for Haskell exceptions. It can also import various primitive types and operations from "HaskellCore". But let's suppose it uses some large tuple type, e.g. a 10-tuple, that isn't provided by "HaskellCore". Then "MyHaskell" can import that (parameterized) type and relevant operations from a "Haskell10Tuple" module. When one ships "MyHaskell" to the relevant host, one also provides with it a "Haskell10Tuple" module defining that (parameterized) type and relevant operations. If the host already has loaded "Haskell10Tuple", then this provided module will be ignored and "MyHaskell" will get linked to the same preexisting "Haskell10Tuple" module that every other Haskell module using 10-tuples has linked with, but if it hasn't been loaded yet then an implementation of "Haskell10Tuple" is available to use. Using this simple infrastructure, we can use low-level nominal types even for surface-level structural languages. So nominal types do not require more linking infrastructure than what already exists. I totally understand that you find this ugly, but what's important is that it works. The same cannot be said for low-level structural type systems trying to express Haskell without casts, for the reasons provided in the OP. If one were to ask a Haskell implementer "Would you rather have to cast every tuple access, cast the value returned from every closure with monomorphic return type, and have every closure implementation cast its environment and its monomorphic arguments, or would you rather either use arrays to implement high-arity tuples and high-arity closures or generate mini-modules for high-arity tuples and high-arity closures?", I imagine they'd pick the latter. |
Modules aren't associated with names in pure Wasm. As @rossberg said, it's up to the host to decide on a convention for what the import/export namespaces mean. On the Web, the "module" namespace for imports is defined on a per-instantiation basis by the provided import object (see here). It's perfectly fine to have a convention where modules are associated with names, instantiating the module puts the resulting exports in some associated global namespace, and subsequent instantiations of modules with the same name do something to resolve the name clash (e.g. only keep the first or last set of exports under that namespace). I think this is what you're suggesting. If it helps you to work through the examples @rossberg is asking for, you can assume that convention, but it's worth noting that this is something that is managed "outside" pure Wasm, so (e.g.) on the Web you would be implementing this convention in JavaScript as "top-level logic" code. FWIW I don't think that we'll get much insight from working through the "import infrastructure" scenarios until we get to post-MVP examples that the current MVP wouldn't really be efficient for either (such as weird setups in source languages with reified generics). If this is the kind of gotcha @rossberg is expecting, it would be good to get a little more detail about where he expects things to go wrong. We already have to deal with conventions for managing standard library definitions like |
@RossTate, as @conrad-watt said, modules themselves are nameless. Import names resolve to modules. If you interpret the module name in an import as a URL (or some subset thereof), as is e.g. common in a Web context (and the right approach in general), then there naturally can only be one module at each logical location (though URLs can also be relative, in which case their meaning depends on the location of the import). All that is essentially like binding and scoping, and completely orthogonal to typing. @conrad-watt, you seem to be implying that this is a concern with performance or only a problem post-(GC)-MVP. I don't follow. The challenge is to maintain the existing modularity properties of Wasm under the GC extension. In practice, a language should not have to build completely new and complicated linking infrastructure just because it wants to compile to GC types instead of linear memory. Out-of-band conventions are fine, but this would be something else entirely. |
Sorry, I wasn't clear. My current belief is that a host-managed linking convention with around the complexity of what @RossTate outlined just above would be sufficient for most examples that are relevant to the MVP. I wouldn't see a reliance on such a linking convention as being in conflict with Wasm's modularity properties. If you have examples which would require a significantly more complex linking convention (and wouldn't with the structural MVP), that would potentially be a productive area of discussion. |
@rossberg, I believe an implicit assumption @RossTate, @conrad-watt, and I are making here is that we will ship one or the other in the GC MVP and that whichever one we ship will inform the initial post-MVP extensions. Another implicit assumption is that we should take an evidence-based approach to figuring out what kind of type system would be most useful to ship in the MVP. Do you agree with these positions?
@rossberg, as I mentioned, I don't know of any language that does not already require its own linking infrastructure, so I don't know of any language that would require new infrastructure. I expect that languages would re-use what infrastructure they already have for linking functions and data to link types as well. For example, the problem of breaking the symmetry and picking a winner among multiple providers of a definition is already solved for functions and data in Emscripten's static and dynamic linking, so extending those schemes to do the same for types would be trivial.
+1 to this. @rossberg, if you could share examples of this, it would move the conversation forward tremendously. This would help convince us of your argument that nominal types would be more complex to use than structural types in practice. So far I have not been convinced of that. |
AFAIAC, that’s not necessarily the case, although most of the arguments for nominal types clearly have to do with potential future optimisations.
In my experience it’s more complicated than that. Every design is a trade-off between being useful, being simple, being general, and being consistent with the rest of the language. You can come up with lots of ideas that are “useful” but are totally ad-hoc or incoherent with the rest. I’ve seen that happen often enough, e.g. on TC39, to be very cautious about optimising for narrow notions of “usefulness” and local maxima while ignoring the big picture. I’m sure everybody knows that, but it’s still easy to forget. The challenge is to maintain the existing modularity properties of Wasm under the GC extension. In practice, a language should not have to build completely new and complicated linking infrastructure just because it wants to compile to GC types instead of linear memory
I can’t help the feeling that there is a tendency to overlook much of the intention and potential of Wasm's module system and the breadth of its use cases. It is not just a random mechanism for loading some monolithic piece of code onto a web page. One of the more exciting use cases for Wasm are environments that build a whole OS around it. In such a system, Wasm modules, or some extension thereof, have to represent both exes and dlls. Such a system might add some meta information to plain Wasm modules, e.g., like some of the tool conventions, but such information obviously has to be generic. Specific languages may rely on additional conventions about their modules, but the OS and the actual loading and linking mechanisms have to be independent of those. Just as for dlls in a real OS, for example. Overconstraining the Wasm type system such that languages cannot map their code to modules without putting language-specific mechanisms into the OS would be a non-starter in such a setting. This is not just some vague idea. There are concrete systems being built right now. I happen to be working on one of them. Similar assumptions about the general utility of Wasm modules show up in other places, e.g., the module linking proposal and its use cases, or to some extent, interface types and even WASI. The module linking primitives, for example, do not assume a custom linker being injected. With interface types, you should be able to do cross-language linking; that cannot work if each of the languages involved has to insist on its own custom linker. AFAICS, none of the various ad-hoc suggestions for dealing with the compilation of structural types are adequate solutions to all this. They are not expressible in Wasm, they do not compose, and they do not work with a language-agnostic linking mechanism. For the Dfinity platform I can say that the need for such kludges would pretty much break our use case of Wasm as a universal binary code format. |
These are strawman arguments that again try to paint nominal types as requiring a language-specific linking apparatus even though we demonstrated above that nominal types would even work in a limited linking apparatus. We asked for a concrete counterexample, but you have not provided one; the OS example seems to be addressed using the solution we already gave. Today is a major US holiday. It would be polite to defer further discussion until next week. |
I'm sorry, but you have not "demonstrated" any such thing yet, let alone one that is applicable to the concrete scenario I just described. |
Thanks for waiting until after the holiday! My response was brief given the circumstances, so now I can be more precise. Above I described how a URI-based ecosystem could still work with just nominal types. For example, a Haskell library or executable could reference the "HaskellCore" dll to get all the nominal types for all primitive types, and similarly a "HaskellStdLib" dll to get all the nominal types for common library types. "HaskellStdLib" would also reference the "HaskellCore" dll, so they would all be using the same nominal types to describe primitive types. If the Haskell library or executable needs large tuples, then it could reference (and ship with) the "HaskellLargeTuples" dll that all libraries/executables needing such tuples would reference, thereby ensuring consistent use of the nominal types for large tuples across multiple Haskell libraries/executables. Others expressed a sentiment that they felt this should be sufficient and requested a concrete counterexample if you believe it is insufficient, as it would help progress the conversation. The OS example you provided, though, seems to be another URI-based ecosystem, and so would seem to be addressed by the strategy above. |
We have settled on the type system we will use for the MVP and as pointed out above, we can consider useful ways to extend the type system or even add alternative type systems post-MVP. Until then, I'll close this issue. |
In yesterday's presentation, @rossberg recapped some of the advanced challenges to managing generics. While these advanced challenges are important, they are common to both structural and nominal types, and so I would like to first take a step back and call attention to some more basic challenges with generics that nominal types have already addressed but that structural types cannot overcome.
Consider the example of C# arrays that were prominently featured in the presentation. For 20 years, no one has been able to design a (decidable) structural type system that can express even just the low-level safety invariants of C# arrays. I warned of these issues when I first tried to get involved with WebAssembly, and more recently posted #138 to prompt broader group discussion of them, and there still seems to be no solution in sight even for easier cases like Java arrays. This means that C#, Java, Kotlin, and many other languages will have to compile their arrays (of reference types), regardless of the specific reference type, to just
array anyref
in the current structural Post-MVP and will always cast accessed values. Meanwhile, there is a 15-year-old paper showing how to express these arrays with nominal types, a 12-year-old paper actually putting nominal types into practice in an existing compiler for C#, and a 10-year-old paper showing how to make it practical to generate these types (plus an accompanying tech report providing the necessary theory for generalizing the approach to other classes of languages).As for typed functional languages, consider the example of polymorphic recursion that was also prominently featured in the presentation. As pointed out in #119 (comment), the common application of polymorphic recursion discussed in the literature is handling expansive-recursive types, with the function
collect
for flattening adatatype 'a T = EMPTY | NODE of 'a * (('a T) T)
into a list being the prominent used-in-industry ML example discussed. This type is inexpressible in the Post-MVP and in any decidable structural type system because expensive-recursive structural subtyping is known to be undecidable.1 The lack of expressiveness means that theNODE
wasm-structure will have to be a pair of'a
andT
-of-anyref
or the like, losing the invariant that this latter data structure is comprised of'a
s. And, since ML types are erased, there is no way to dynamically cast these values to'a
, which means thatcollect
cannot guarantee the resultinglist
is comprised of only'a
s. If'a
were to denotestring
in some context, then this means that there is astring list
that is not guaranteed (according to wasm's type system) to contain onlystring
values, which consequently means wasm functions takingstring list
cannot demand the list only contains strings. So any compilation of ML programs with expansive-recursive types into the current Post-MVP will have to ignore type arguments onlist
s (and in fact all generic types) and just insert casts everywhere, just like C#/Java/Kotlin arrays have to. Meanwhile, nominal types are able to express this feature because they allow one to define an expansive structure without also making subtyping expansive (just like how the type'a T
in ML denotes an expansive structure but is not a subtype of anything).So, in debating between structural and nominal types, before advancing into discussing the likes of how a nominal Post-MVP will represent the RTT for
string[][][]
(which the above three papers can all do) or a((String T) T) T
(which the techniques in the above tech report can do), we should first acknowledge that a (decidable) structural Post-MVP cannot even express the structural types ofstring[]
orString T
to begin with.1Right now we can decide subtyping between equirecursive types by viewing them as finite automata, with (unparameterized) fixpoint type variables serving as the back edges. Unfortunately, when the variables can be parameterized, that corresponds to generating a new set of automaton states rather than being able to cyclically reuse existing automaton states, meaning the automaton is no longer finite. One can also encode a Turing machine as an infinite automaton, incorporating the state of the string directly into the states of the automaton, and it turns out expansive-recursive types can express this encoding. See, for example, this encoding of Turing machines using expansive recursion in Java generics—an encoding can easily be translated to use structural types instead. This is not problematic for Java at the low level because Java erases its generics, but C# reifies generics and consequently disallows expansive inheritance to prevent this problem (due to this paper). And it is known that expansive nominal inheritance is not used in practice, even when permitted, though expansive structures are.
The text was updated successfully, but these errors were encountered: