Open stefnotch opened 1 month ago
Alternative: There is a slight variation of this proposal that could be a worthwhile alternative.
mod Foo<A> {
@group(0) @binding(3) var<uniform> count: u32;
...
}
mod Foo_Helper {
@group(0) @binding(3) var<uniform> count: u32;
}
mod Foo<A, count=Foo_Helper.count> {
...
}
For example, instead of repeatedly writing my_generic_func
(3)
Are module generics necessarily the solution for DRYing out call site references to generic elements? Module generics seem nifty. But AFAIK module generics are not a common feature in other languages.. which makes me wonder - what makes wesl different?
without requiring full WGSL type checking
If wgsl were to solve this problem, would they implement type inference instead? If so, should we explore some kind of cheap type inference?
Or If type inference is too complicated for now but the natural solution is type inference on generic elements, should look at a less clever but less ornate solution in the interim until type inference is available? (e.g. I think alias
was mentioned elsewhere).
Perhaps discussing the motivation for module generics should go in a separate github issue? Feel free to split it out if so.
Are module generics necessarily the solution for DRYing out call site references to generic elements?
@mighdoll generic modules are not about DRY imo, generic functions are. One reason wgsl is different is that code depends heavily on bindings, which the modules can use but should not declare. That is the responsibility of the caller. So we need a mechanism to inject code into modules. One way is passing pointers to all functions (but that is troublesome), another is virtual or overridable module declarations, and another is generic modules.
We have been exploring these different possibilites. @ncthbrt 's proposal has generic modules and mine has overridable declarations. The two approaches seem to achieve the same effect.
It does raise the questions: Are there other ways? Would it be sufficient to provide a way to override just bindings and pipeline-overridable constants?
We should make another issue.
Regarding passing bindings in as generic parameters, I think that could be simply syntactic sugar for something like the following:
mod A<Binding> {
fn fun_func() {
return 2.0 + Binding::value();
}
}
override my_override: f32;
mod MyOverride {
@inline
fn value() {
return my_override;
}
}
alias MyA = A<MyOverride>;
It'd look nearly the same from the perspective of the user if we later added that sugar but implementation wise, would reduce the number of constructs generic modules would have to handle. Particularly in the beginning
I originally proposed them because shaders are typically a lot less object oriented than most other languages.
I think of them as zero cost static classes.
@stefnotch I think bindings should be allowed for use cases like bevy's. Perhaps the specialisation algorithm could test if the bindings resolve to the same type? And if so allow them to be shared.
@stefnotch I think bindings should be allowed for use cases like bevy's. Perhaps the specialisation algorithm could test if the bindings resolve to the same type? And if so allow them to be shared.
@ncthbrt The variation of this proposal would do exactly that, using the existing algorithm. https://github.com/wgsl-tooling-wg/wesl-spec/issues/40#issuecomment-2364732805
@mighdoll Yes, the motivation for module generics in the first place is a separate issue that I've just glossed over. My main focus was "how would we make this work, and what would the desired semantics be".
If we end up settling for anything else, I suspect that we will end up with a very similar algorithm for bindings and overridables. So this proposal really is about the semantics.
We have been exploring these different possibilites. @ncthbrt 's proposal has generic modules and mine has overridable declarations. The two approaches seem to achieve the same effect.
@k2d222 I agree that they achieve the same thing. In fact, I'd go so far as to saying the overrides that you have described behave like
@override(texture_bind_group: 3, texture_type: u32)
import util/sample_texture/{sample as sample_u32};
@override(texture_bind_group: 5, texture_type: f32)
import util/sample_texture/{sample as sample_f32};
(sample, (3, _, u32))
(sample, (5, _, u32))
Regarding generics vs overrides: As I've started implementing generics, I realised that there is a need for optional, named generic arguments with default values, which is even closer semantically to overrides
@ncthbrt We just have to be careful to make the overrides semantics "take a module and a set of overrides, and use that to create a new module".
Otherwise we run into unsolvable conflicts when two libraries try to override the same module in different ways.
Yes. I was thinking of it in those terms @stefnotch. It'd be akin to named optional arguments in languages like JavaScript
Generic modules are a reasonably convenient way of introducing powerful generics without requiring full WGSL type checking. For example, instead of repeatedly writing
my_generic_func<u32>(3)
, one could reasonably import a generic module that is specialized foru32
s.Background info on monomorphization
Normal generics, that being generic functions and generic structs, get monomorphized. During monomorphization, they also get a mangled name which encodes
(function name, first generic, second generic, ...)
.If we have code like the following
then
foo<f32, u32>()
results in(foo, u32, u32)
(bar, 332)
(bar, u32)
Meanwhile
foo<u32, u32>()
results in(foo, u32, u32)
(bar, u32)
A good implementation of monomorphization will also replace aliases with the actual type.
Generic Module Monomorphization
A generic module has a set of global declarations, just like normal WGSL code
var
var<workgroup>
andvar<private>
var<storage>
override
which is host visibleconst
alias
const_assert
struct
I propose first lowering a generic module, and subsequently monomorphizing it with the rules from above. For simplicity, we will only look at structs and functions.
To lower this, we
struct Cat<A> { ... }
fn bar<A>(...) { ... }
fn demo<T, B>(...) { ... }
This guarantees that only the bare minimum of code gets duplicated.
To deal with pipeline-overridable constants and bindings, we pass them in as generic parameters.
Then, if two separate parts of our library tree depend on the same assertions module, we correctly deal with all cases.
Case 1: Sameness
e.g. They pass the same counter to the assertions module, and both enable it. Then we end up with a single monomorphized
(assert, my_counter, true)
. Both parts of the module tree use the same counterCase 2: Differences
e.g. We use two separate counters for
bevy
's assert dependency and formath_utils
's assert dependency. Then, we end up with the expected two separate assertion functions.(assert, bevy_counter, true)
,(assert, math_counter, true)
.Case 3: Differences, but same binding
e.g. We want to use the same binding, because our host code demands there to be one binding. However, we want to turn off the
math_utils
assertions. In that case, our monomorphization also generates two separate functions(assert, my_counter, true)
,(assert, my_counter, false)
.Why not have bindings and pipeline-overridable constants in generic modules?
It is very reasonable to instantiate a generic module twice, with either the same bindings, or with different bindings.
If we want a
Foo<u32>
and aFoo<f32>
, we now have a conflicting binding at group 0, binding 3. To resolve this, we would need to introduce an additional mechanism to override a binding of a generic module.However, once that is done, the minimal monomorphization algorithm becomes significantly more complicated. To give an example,
If we depend on
Foo<true>
, we get the following code. Please ignore the specifics of the name mangling scheme.Then, if we depend on
Foo<false>
, we get an additionalAnd now if we depend on a
Foo<false>
with an overridencount
, then monomorphization gives us the following.To implement such behaviour, one essentially ends up treating bindings and pipeline-overridable constants as generic parameters. Which certainly raises the question of whether we need that additional bit of complexity. (Figuring out the exact algorithm and coming up with further examples is left as an exercise to the reader.)
Finally, I believe that disallowing them in generic modules mostly removes an easy footgun for our users. Instead, we should push users towards creating modules that do not depend on a very specific binding.