dotnet / runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.
https://docs.microsoft.com/dotnet/core/
MIT License
15.47k stars 4.76k forks source link

Feature Proposal: Const Generics #89730

Open hez2010 opened 1 year ago

hez2010 commented 1 year ago

Const Generics

"Const Generics" stands for allowing constant value to be used in a type parameter.

A fully working MVP implementation for CoreCLR can be found here: https://github.com/dotnet/runtime/pull/89636

And an implementation including the managed part can be found here: https://github.com/hez2010/runtime/tree/feature/const-generics-managed

Link to the language proposal: https://github.com/dotnet/csharplang/discussions/7508

Background and Use Cases

"Const Generics" enables the use cases where developers need to pass a const value through a type parameter.

Typical use cases are templating for things like shuffle (its basically a guaranteed constant) as well as for numerics, tensors, matrices and etc.

For example, fixed buffer and vector types [1], jagged arrays/spans [2], constrained shape of arrays [3], numeric types and multiplier types especially in graphics programming [4], expression abstractions [5], and value specialization [6].

For [1], we can have a type struct ValueArray<T, int N> to define a type of array of T with N elements. This can also be useful in variadic parameters. For example, a params ValueArray<int, 5> can represent a variadic parameter that receives only 5 int arguments. Beside, we can also leverage the ValueArray<T, int N> type to implement params {ReadOnly}Span<T>.

For [2], we can use the const type parameter to define a Span<T, int Dim>, so we can use Span for multi-dimension arrays as well.

For [3], we can constrain the shape of an array. This is especially useful when you are dealing with matrix or vector computations. For example, you now can define a matrix using class Matrix<T, int Row, int Col>. When you implement the multiplication algorithm, you can simply put a signature Matrix<T, Row, NewCol> Multiply<NewCol>(Matrix<T, Col, NewCol> rMatrix). This can make sure users pass the correct shape of the matrix while doing multiplication operations.

For [4], we can embed the coefficient into a multiplier type. This is especially useful in graphics programming. For example, when you are working with things about illumination, you will definitely want some multiplier types with coefficients (which are basically floating point numbers) that are guaranteed to be constants. While building AI/ML models, we are also often use such constant coefficients. Also, we will be able to create a floating point type with user specified epsilon, such as

struct EpsilonFloating<T, T Epsilon> where T : INumber<T>
{
    public static bool operator ==(EpsilonFloating<T, Epsilon> a, EpsilonFloating<T, Epsilon> b) => T.Abs(a.value - b.value) <= Epsilon;
}

and then use it like global using MyFloatWithEpsilon = EpsilonFloating<float, 1e-6f>.

For [5], we can have several types that can embed constant values to abstract an expression, then we can validate the expression at compile time, hence no runtime exception will happen. For instance, we can have below interface types:

Then we can use IBinExpr<MulOp, IBinExpr<AddOp, IConstExpr<int, 42>, IConstExpr<int, T>>, IConstExpr<int, 2>> in a type class Foo<int T> to represent 42 * (T + 2), then we can use it like a type and let the compiler to verify whether the given const type argument satisfies the expression or not.

For [6], we will be able to provide a generic Vector type and specialize SIMD-width types with extensions:

struct Vector<T, int Size> { }

static class VectorExtension
{
    public Vector<int, 4> Multiply<T>(this Vector<int, 4> v, Vector<int, 4> right) { } // Vector64
    public Vector<int, 8> Multiply<T>(this Vector<int, 8> v, Vector<int, 8> right) { } // Vector128
    public Vector<int, 16> Multiply<T>(this Vector<int, 16> v, Vector<int, 16> right) { } // Vector256
    public Vector<int, 32> Multiply<T>(this Vector<int, 32> v, Vector<int, 32> right) { } // Vector512
    public Vector<int, Size> Multiply<int Size>(this Vector<int, Size> v, Vector<int, Size> right) { } // For other sizes allowing a software fallback
    // ...
    public Vector<T, Size> Multiply<T, int Size>(this Vector<T, Size> v, Vector<T, Size> right) { } // For other types and sizes allowing a software fallback
}

Design

Wording

Const Type Parameter

⭕ This part is already implemented in the MVP implementation

New design:

To support const generics, we need a way to declare a const type parameter that will carry the const value after instantiation. Due to the fact that a const type parameter behaves no difference than a normal type parameter until instantiation, here we can treat the type of a const type parameter as a special generic constraint.

We want to emit the type of a const type parameter as TypeSpec, but in order to distinguish this type token from other generic constraints, we can introduce a mdtGenericParamType and then emit the type of const type parameter with mdtGenericParamType, and make sure it will always be the first entry in generic constraints.

To load the type of a type parameter, we simply look up the first entry in generic constraints and see if it's mdtGenericParamType. If yes, then replace it with mdtTypeSpec using (token & ~mdtGenericParamType) | mdtTypeSpec. When loading generic constraints, if we see a generic constraint has type mdtGenericParamType, we can skip it directly.

While an alternative approach (which is also the approach I preferred) is, use a type like System.Runtime.CompilerServices.LiteralType<T> as the generic constraint, and special case it. So a class Foo<int T> will be emitted to class Foo<T> where T : LiteralType<int>. But in the MVP implementation I don't touch the managed libraries so I don't have the type can be used for this.

Old design:

To support const generics, we need a way to declare a const type parameter that will carry the const value after instantiation. Due to the fact that a const type parameter behaves no difference than a normal type parameter until instantiation, here we can reuse the existing generic metadata and rules, and add a Type token to the GenericParamRec schema. To determine whether a type parameter is a const type parameter or not, simply check the Type token to see if it's valid by using RidFromToken.

To summarize:

Added a column to GenericParameterRec to save a mdToken which represents the type of a const generic parameter. Changed the reserved DWORD to mdToken to save the type of a const generic parameter. This requires a change to the existing metamodel. But worth to note that we don't need a new COM interface as we are reusing the reserved parameter in GetGenericParamProps, both mdToken and the reserved DWORD are exactly DWORD.

BTW: actually we have another way without upgrading the existing metadata: we can downgrade the metadata version from the current v2.0 to v1.1, where in v1.1 metadata the GenericParamRec table has a Kind field which is exactly what we need for const generics.

Const Type Argument

⭕ This part is already implemented in the MVP implementation

A const type argument contains the actual constant value in the instantiation. Here we can introduce a new element type ELEMENT_TYPE_CTARG which stands for const type argument.

A const type argument can be encoded as follows:

ELEMENT_TYPE_CTARG <element type of const value> <const value>

Note that the size of the const value is determined by its element type. For example, an int 42 will be encoded as:

 ELEMENT_TYPE_CTARG ELEMENT_TYPE_I4     42
|      1 byte      |     1 byte    | 4 bytes |

While a double 3.1415926 will be encoded as:

 ELEMENT_TYPE_CTARG ELEMENT_TYPE_R8 3.1415926
|      1 byte      |     1 byte    | 8 bytes |

While we'd better to save all constants to the constant table in the metadata, then instead of inlining the const value type and const value in the signature directly, we can use the constant token in the signature which is fix-sized and easier to decode, and use the type token instead of CorElementType so that we can also support const values of enums, int128, string and arbitrary value types as well.

IL Parser

⭕ This part is already implemented in the MVP implementation

We can reuse the keyword literal in IL to indicate the type argument contains a const value. Particularly, we can use the keyword literal to differentiate a const type argument/parameter from a type argument/parameter. For example, literal int32 T.

For const type argument, we can simply use int32 (42) to express an int constant with the value 42.

This is following the rule how we are expressing "const field" today.

We need to change the parser to parse "literal" type typeName as a const type parameter, and type '(' value ')' as a const type argument. You can define and use const generics as the examples at the bottom of this proposal.

Type Desc

⭕ This part is already implemented in the MVP implementation

A const type parameter has no more difference than the additional type token, so we can reuse the TypeVarTypeDesc and add a field m_type to save the type of const type if it's a const type parameter.

A const type argument is exactly a constant value, so we need a separate TypeDesc for it. Therefore, a ConstValueTypeDesc can be added to save the type and the value of a const type argument.

We can support up to 8 bytes of constant value if we use a uint64_t as the storage.

class ConstValueTypeDesc : TypeDesc {
    TypeHandle m_type;
    uint64_t m_value;
};

To read the constant value from a ConstValueTypeDesc, we need to reinterpret the storage based on the type of constant value. For example, while reading a constant value which is a float, we can simply use *(float*)&m_value.

Actually I'm doubting whether an uint64_t is enough here, because we may support int128 or other types as primitive types in the future. Should we use size_t here instead? This can make sure we are always able to save a pointer here and in case the size of size_t is not enough for some types, we can allocate to save the value on the Non-GC heap and save its pointer to the Non-GC heap in this field:

enum {
    CONST_VALUE_INLINE = 1,
    CONST_VALUE_INDIRECT = 1 << 1,
};

class ConstValueTypeDesc : TypeDesc {
    TypeHandle m_type;
    size_t m_value;
    DWORD m_flag;
};

if ((m_flag & CONST_VALUE_INDIRECT) == CONST_VALUE_INDIRECT)
{
    // get size and layout info from m_type
    // load the pointer from m_value
    // deference the pointer to get the value
}
else
{
    // get size and layout info from m_type
    // load the value from m_value directly
}

Or, if we go with the constant token approach which was mentioned in the "Const Type Argument" section, we may simply use the token of constant value instead:

class ConstValueTypeDesc : TypeDesc {
    TypeHandle m_type;
    mdToken m_value;
};

But this soon brings another issue where making a new const value type using reflection APIs will create a new constant record that is not present in the metadata.

Method Table

⭕ This part is already implemented in the MVP implementation

Similar to function pointers, we don't need a MethodTable for const value.

Type Loader

⭕ This part is already implemented in the MVP implementation

We can always load constant values in the CoreLib module because a constant value is independent from the assembly, the same constant value can be served from any assembly. To avoid loading the same constant value other than once, once we load a constant value, we can save it into a hash table m_pAvailableParamTypes. Whenever we load a constant value, we first lookup in the hash table, if found then we load the TypeHandle from the hash table directly, otherwise we allocate a new ConstValueTypeDesc for it.

Value Loading

⭕ This part is already implemented in the MVP implementation

We need to use the const value from a type parameter, here we can reuse the ldtoken instruction to achieve this. Instead of loading the TypeHandle of the type parameter, we need to load the constant value and push it to the stack directly when we see the type parameter is a const type parameter.

JIT

⭕ This part is already implemented in the MVP implementation

We only need to handle ldtoken here, so we can change the impResolveToken to resolve the information about the const value as well, and then use the information to determine whether we should load a type handle or a const value to the stack. So we only need a minor necessary change in the importation phase.

Further changes would probably necessary after we introduce types like Vector<T, int Length>, as the JIT needs to recognize it to allow hardware acceleration.

Generic Sharing

⭕ This part is already implemented in the MVP implementation

We don't share the implementation among const generic type parameters. Each const type argument gets specialized so we can always import the const type argument as a real type-rich constant value anytime.

Type Unloadability

⭕ This part is already implemented in the MVP implementation

They are just constant values and can be reused by any other assemblies, so we don't need to unload them at all.

Type Validation

⭕ This part is already implemented in the MVP implementation

We need to validate whether the const value type can be passed to a const type parameter. We can do it during checking the generic constraints: whenever we meet a const value, we can simply check whether the const value type is equivalent to the type saved in generic param props. Alternatively, we can also do it at the token resolution.

Generic on Const Generic Type Parameter

⭕ This part is already implemented in the MVP implementation

We can also support generic type on a const generic type parameter.

For example,

.class public auto ansi beforefieldinit Test`2<T, literal !T N>
{
  .method public hidebysig newslot virtual 
        instance void M<U, literal !!U V> () cil managed 
    { }
}

Here we can leverage the type field in the GenericParamRec to save a type spec, then we will be able to look up the type parameter.

This will allow us to write something like struct ValueArray<T, TSize, literal TSize Size> and use it with ValueArray<int, int, 42424242>, ValueArray<int, long, 42424242424242>, and etc.

Also we can leverage this feature to define a ConstValueExpression<TValue, TValue Value> and use it while implementing a compiler/interpreter.

Overloading

❌ This part is NOT yet implemented in the MVP implementation 🚧 This part still needs more discussions to reach a conclusion

In this design, we are differentiating the calling target at the call site, so we can support overloading on const generic type parameters without any issues.

call instance void Foo`1<float32 (42.42)>::.ctor(); // instantiate the Foo`1<float32 (42.42)>
call instance void Foo`1<int32 (42)>::.ctor(); // instantiate the Foo`1<int32 (42)>
call instance void Foo`1<int32 (42)>::A<int32 (42)>(); // calling the Foo`1<int32 (42)>::A<int32 (42)>()
call instance void Foo`1<int32 (42)>::A<float32 (42.42)>(); // calling the Foo`1<int32 (42)>::A<float32 (42.42)>()

.class public auto ansi beforefieldinit Foo`1<literal int32 N>
{
    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed { ... }

    .method public hidebysig newslot virtual
        instance void A<literal int32 X>() cil managed { ... }

    .method public hidebysig newslot virtual
        instance void A<literal float32 X>() cil managed { ... }
}
.class public auto ansi beforefieldinit Foo`1<literal float32 N>
{
    // ...
}

This would require us to consider the type of a type parameter while resolving tokens, i.e., making the type of a const type parameter part of the signature. We need to decide whether to support it or not before we are actually shipping const generics, because once we ship const generics, we can't afford a breaking change around signature encoding.

While given the fact that we can support generics on const generic type parameter, the overloading support is not so much necessary IMO.

Constraints

❌ This part is NOT yet implemented in the MVP implementation

It's useful to constraint a const type parameter. For example, the dimension of a nd-Span ref struct Span<T, int Dimension> should not be less than 1, and the length of a struct ValueArray<T, int Length> should not be less than 0.

We can add the below APIs to achieve arithmetic constraints.

namespace System.Runtime.CompilerServices;

public abstract class Operator
{
    public abstract class UnaryOperator : Operator 
    {
        // ...
    }
    public abstract class BinaryOperator : Operator
    {
        public sealed class AdditionOperator : BinaryOperator { }
        public sealed class SubtractionOperator : BinaryOperator { }
        public sealed class MultiplyOperator : BinaryOperator { }
        public sealed class DivisionOperator : BinaryOperator { }
        public sealed class EqualityOperator : BinaryOperator { }
        public sealed class LessThanOperator : BinaryOperator { }
        public sealed class ConjunctionOperator : BinaryOperator { }
        public sealed class DisjunctionOperator : BinaryOperator { }
        // ...
    }
}
public interface IExpression
{
    public interface IUnaryExpression<TOperator, TOprand> : IExpression where TOperand : IExpression where TOperator : UnaryOperator { }
    public interface IBinaryExpression<TOperator, TLeft, TRight> : IExpression where TLeft : IExpression where TRight : IExpression where TOperator : BinaryOperator { }
    public interface IConstantExpression<TValue, TValue Value> : IExpression { }
    ...
}

Then we can evaluate the expression when we validate the generic constraints. For example, to constraint N to be greater than 0 and less than 20, we can use:

class Foo<int T> where T : > 0, < 20 { }

And this got lowered to:

.class public auto ansi beforefieldinit Foo<literal int32 (
            class BinaryExpression`3<
                class Operator/BinaryOperator/GreaterThanOperator,
                class IExpression/IConstantExpression`2<int32, !!T>,
                class IExpression/IConstantExpression`2<int32, int32 (0)>
            >,
            class BinaryExpression`3<
                class Operator/BinaryOperator/LessThanOperator,
                class IExpression/IConstantExpression`2<int32, !!T>,
                class IExpression/IConstantExpression`2<int32, int32 (20)>
            >
        ) T>
       extends [System.Runtime]System.Object { }

I have a naive prototype commit in another branch for show case only: https://github.com/hez2010/runtime/commit/e1fa0c307d5a38d0dd27e2d827a8752777d2e0d7

However, those expression types are actually not being implemented by any types, but we still use them in the generic constraints which let them look like interface constraints but behave as expression evaluation, which is not intuitive.

For example, we can add something like constexpr constraints in the metadata and allow it to be emitted directly, so class Foo<T, U, V> where V : == T + U where T : != 0 can be represented in IL as:

.class public auto ansi beforefieldinit Foo<literal int32 (constexpr (!T != int32 (0))) T, literal int32 U, literal int32 (constexpr (!V == !T + !U)) V>

Const Arithmetic

❌ This part is NOT yet implemented in the MVP implementation 🚧 This part still needs more discussions to reach a conclusion

It's useful to have arithmetic support for const generics.

For example, the signature of a Push method of ValueArray<T, int N> type can be ValueArray<T, N + 1> Push(T elem), and the signature of a Concat method can be ValueArray<T, N + M> Concat<int M>(ValueArray<T, M> elems).

This would require embedding the arithmetic operations in the type and implementing dependent/associated types, which is a non-trivial work.

While an alternative is to use constraints to achieve it. So for the example of Push method, we can use ValueArray<T, U> Push<int U>(T elem) where U : (T + 1), and the constraint T + 1 can be expressed using IBinaryExpression<Add, IConstantExpression<int, T>, IConstantExpression<int, 1>>. Then we can validate the constraint at runtime.

Although we need to specify the value such as Push<7>(42) while calling on ValueArray<int, 6>, the C# compiler may automatically infer the type of U so developers don't have to explicitly specify the value of U every time.

However, consider the below code:

class Foo<int T>
{
    private Foo<T + 1> foo;
}

Are we going to enforce users to introduce a new type parameter on Foo? I.e.,

class Foo<int T, int U> where ...
{
    private Foo<U> foo;
}

If yes, whenever we want to introduce a new "computed" const type parameter on a method of the class, we will need to add it to the class signature, which will lead to breaking changes. This seems quite unfortunate, and unacceptable.

Therefore, we cannot just rely on generic constraints to serve const arithmetic.

However, if we have runtime support for dependent/associated types in the future, this can be simply resolved by using:

class Foo<int T>
{
    type N = T + 1;
    private Foo<N> foo;
}

And also, if we have the support for defining an associated type inside a method, we can do:

class Foo
{
    U Method<int T>()
    {
        type U = T + 1;
    }
}

We still need some discussion to design around here.

Maybe we can just skip const arithmetic for the first version, and implement const arithmetic in the future once we have proper runtime support?

Built-in ValueArray Intrinsic Type

❗ The implementation can be found here, though this part is not included in the MVP implementation

We need a built-in ValueArray, aka. FixedBuffer type for use, and it will play an important role in public APIs. A ValueArray is basically the InlineArray we already have today plus the ability to specify arbitrary length without the need to define a new InlineArray type.

Below is the dummy C# code for ValueArray:

struct ValueArray<T, int N>
{
    private T elem; // Repeat the field elem for N times
    public int Length { get; } // ldtoken !N; ret;
    public ref T this[int index] { ... }
}

This can be used together with params:

Foo(1, 2, 3, 4, 5);
// a method that only receives 5 int arguments
void Foo(params ValueArray<int, 5> args) { }

Particularly, in C# we can lower all fixed buffer types to ValueArray, and it can perfectly serve all features like params Span<T> and stackalloc T[].

Reflection APIs

❗ The implementation can be found here, though this part is not included in the MVP implementation

To support reflection, we need something like MakeGenericType for a const value as well, so I have the below API proposal:

namespace System;
public abstract class Type
{
    public virtual bool IsConstValue { get; }
    public virtual object ConstValue { get; }

    public static Type MakeConstValueType(object value);
}

This can make sure we can instantiate a type/method that contains const type parameters, and also get the const value from a constructed type argument.

Some use patterns of reflection:

class Foo<T, int N> { }

var foo = new Foo<string, 42>();
foo.GetType(); // Foo<string, int (42)>
foo.GetType().GetGenericArguments()[0]; // Type: System.String
foo.GetType().GetGenericArguments()[1].IsConstValue; // true
foo.GetType().GetGenericArguments()[1].HasElementType; // true
foo.GetType().GetGenericArguments()[1].ConstValue; // 42
foo.GetType().GetGenericArguments()[1].GetElementType(); // System.Int32

var t = Type.MakeConstValue(42);
var d = typeof(Foo<,>);
d.GetGenericArguments()[1].IsConstValue; // false
d.GetGenericArguments()[1].HasElementType; // true
d.GetGenericArguments()[1].ConstValue; // InvalidOperationException
d.GetGenericArguments()[1].GetElementType(); // Type: System.Int32
d.MakeGenericType(typeof(string), t); // Foo<string, int (42)>

An interesting idea is to allow typeof(value) for the Type.MakeConstValue, for example, typeof(42) to get a Type that contains a value 42.

This would either require us to:

  1. Use the ldtoken instruction for this, and we will need to introduce a new instruction for loading a const type argument to the stack, for example, an instruction called ldctarg (load const type argument).
  2. Introduce a new instruction for this.
  3. No new instruction, and just compile it to Type.MakeConstValue.

Changes to ECMA-335

Basically the new element type ELEMENT_TYPE_CTARG.

Compatibility Concerns

Tooling

Disassembler

Both ILSpy and dnSpy should able to special case the mdtGenericParamType while loading generic constraints.

Profilers and Debuggers

They need to support decoding new types or methods which contain ELEMENT_TYPE_CTARG/CORINFO_TYPE_CTARG on the signature.

As for debuggers, they need to add support for the extended ldtoken instruction.

EnC

We don't support modifying generic type signatures today, so no actions are needed.

Other 3rd Party Tools

With the new design, we are not breaking the metadata so no concern here.

Other Useful APIs

Other many APIs can make use of const generics to provide valuable features and abilities for users:

  1. Matrix<T, int Row, int Col>: fixed-sized matrix to supersede Matrix3x3, Matrix4x4 and etc.
  2. Vector<T, int N>: fixed-sized vector to supersede Vector2, Vector3 and etc.
  3. Tensor<T, int Rank>: tensor types for AI/ML purpose
  4. Span<T, int Dim>: ND-span that can support multiple dimension arrays
  5. List<T, int N>, Array<T, int N>...: arbitrary list types can have a fixed size now
  6. ... and more

Future Considerations

Support for Strings and Arbitrary Value Types

This can be done by changing the parser to allow strings and arbitrary value types as well.

For example,

// value types
.class C`1<literal valuetype Foo T> { }

call C`1<valuetype Foo (bytearray ( 01 00 00 00 02 00 00 00 03 00 00 00))>::.ctor()

// string
.class D`1<literal string T> { }

call D`1<string ("hello world")>::.ctor()

where Foo is a Vector3<int>, so we are passing a Vector3<int> { X = 1, Y = 2, Z = 3 } here.

And as for the implementation, we can use the m_type in ConstValueTypeDesc to save the TypeHandle of the type, and m_value to save the address or constant record token. In this way, we can extend Const Generics to strings and arbitrary value types as well.

We only need to extend the encoding of const type arguments as following:

This won't be a breaking change so we can do this later.

Fully Working Prototype

This prototype is based on the old design with a breaking change to the metadata, while the latest (current) design doesn't have any breaking changes to the metadata

I have done the fully working prototype of C# compiler, language server and CoreCLR runtime, and successfully built a SDK for it (Windows only).

If you want to have a try on const generics, you can download the SDK here: https://1drv.ms/u/s!ApWNk8G_rszRgrxP32IMKhW-V8iWug?e=JBn8wU

Be sure to follow the README.txt in the SDK.

Version: 20230912 Build 1 Checksum: a8c9ee29d1accd14797f60bedced312f9524391b

This prototype branch:

I may update the SDK without posting a new comment but change the version and checksum in the above, while the sharing link won't change.

This prototype supports all things in this proposal except generic constraints on const type parameter and const arithmetic. For example, you can do the following things:

  1. Declare a const generic type, eg. class Foo<T, int N>.
  2. Use a const generic type, eg. new Foo<int, 42>().
  3. Declare a const generic method, eg. void Foo<int X>.
  4. Use a const generic method, eg. Foo<42>().
  5. Generics on const type parameter, eg. class Foo<T, T X>, then you can use it with Foo<int, 42> as well as Foo<float, 42.42424f>.
  6. Use const type parameter as constant directly. eg. calling Console.WriteLine(X) in the type class Foo<int X>.
  7. typeof support. eg. typeof(42).
  8. Casting support in const type argument. eg. new Foo<(short)42>, typeof((short)42)
  9. A built-in value type ValueArray<T, int X> that can be used as a fix-sized type with type T and length X.
  10. A niche syntax for declaring a ValueArray type, eg. int[42].
  11. Full reflection support.
    • To check whether a type parameter is const type parameter, use type.IsGenericParameter && type.HasElementType.
    • To get the type of a const type parameter, use type.GetElementType().
    • To check whether a type argument is const type argument, use type.IsConstValue.
    • To get the type of a const type argument, use type.GetElementType().
    • To get the value of a const type argument, use type.ConstValue.
    • To make a const value type, use Type.MakeConstValueType()

Code Examples

A basic example

.assembly _ {}

.class public auto ansi beforefieldinit Foo`2<T, literal int32 N>
       extends [System.Runtime]System.Object
{
  .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void [System.Runtime]System.Object::.ctor()
        ret
    }

  .method public hidebysig newslot virtual 
        instance void M<literal int32 V, literal int32 W> () cil managed 
    {
        .maxstack 1
        .locals init (
            [0] int32 v
        )

        newobj instance void class Foo`2<string, int32 (42)>::.ctor()
        call instance void class Foo`2<string, int32 (42)>::M<!!V, !!V>()
        newobj instance void class Foo`2<string, !!V>::.ctor()
        call instance void class Foo`2<string, !!V>::M<!N, int32 (42)>()
        newobj instance void class Foo`2<string, !N>::.ctor()
        call instance void class Foo`2<string, !N>::M<!!V, !!W>()

        ldtoken !!V
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !!W
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !N
        call void [System.Console]System.Console::WriteLine(int32)
        ret
    }
}

This can be interpreted to the following dummy C# code:

class Foo<T, int N>
{
    public void M<int V, int W>()
    {
        new Foo<string, 42>().M<V, V>();
        new Foo<string, V>().M<N, 42>();
        new Foo<string, N>().M<V, W>();
        Console.WriteLine(V);
        Console.WriteLine(W);
        Console.WriteLine(N);
    }
}

Generic Virtual Method with Const Type Parameters

.assembly _ {}

.class private auto ansi beforefieldinit Program
    extends [System.Runtime]System.Object
{
    .method private hidebysig static 
        void Main (
            string[] args
        ) cil managed 
    {

        .maxstack 8
        .entrypoint

        newobj instance void class Bar`2<string, int32( 42 )>::.ctor()
        call instance void class Bar`2<string, int32( 42 )>::N<int32( 42 ), int32( 42 )>()

        ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void [System.Runtime]System.Object::.ctor()
        ret
    }

}

.class public auto ansi beforefieldinit Foo`2<T, literal int32 N>
       extends [System.Runtime]System.Object
{
  .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void [System.Runtime]System.Object::.ctor()
        ret
    }

  .method public hidebysig newslot virtual 
        instance void M<literal int32 V, literal int32 W> () cil managed 
    {
        .maxstack 8

        ldstr "From Foo::M"
        call void [System.Console]System.Console::WriteLine(string)
        ldtoken !!V
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !!W
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !N
        call void [System.Console]System.Console::WriteLine(int32)
        ret
    }

  .method public hidebysig newslot virtual 
        instance void N<literal int32 V, literal int32 W> () cil managed 
    {
        .maxstack 8

        newobj instance void class Foo`2<string, int32( 42 )>::.ctor()
        call instance void class Foo`2<string, int32 (42)>::M<!!V, !!V>()
        newobj instance void class Foo`2<string, !!V>::.ctor()
        call instance void class Foo`2<string, !!V>::M<!N, int32 (42)>()
        newobj instance void class Foo`2<string, !N>::.ctor()
        call instance void class Foo`2<string, !N>::M<!!V, !!W>()
        ret
    }
}

.class public auto ansi beforefieldinit Bar`2<T, literal int32 N>
       extends class Foo`2<!T, int32 (128)>
{
  .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void class Foo`2<!T, int32 (128)>::.ctor()
        ret
    }

  .method public hidebysig virtual 
        instance void M<literal int32 V, literal int32 W> () cil managed 
    {
        .maxstack 8
        .locals init (
            [0] string v
        )
        ldstr "From Bar::M"
        call void [System.Console]System.Console::WriteLine(string)
        ldtoken !!V
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !!W
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !N
        call void [System.Console]System.Console::WriteLine(int32)

        ret
    }

  .method public hidebysig virtual 
        instance void N<literal int32 V, literal int32 W> () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void class Foo`2<!T, int32 (128)>::M<!!V, !!W>()

        ldarg.0
        callvirt instance void class Foo`2<!T, !N>::M<!!V, !!W>()

        ret
    }
}

This will yield the below execution result:

From Foo::M
42
42
128
From Bar::M
42
42
42

Generic Virtual Method with Generic on Const Type Parameters

.assembly _ { }

.class private auto ansi beforefieldinit Program
    extends [System.Runtime]System.Object
{
    .method private hidebysig static 
        void Main (
            string[] args
        ) cil managed 
    {

        .maxstack 8
        .entrypoint

        newobj instance void class Bar`2<float32, int32( 42 )>::.ctor()
        call instance void class Bar`2<float32, int32( 42 )>::N<float32( 42.42 ), int32( 42 )>()

        ret
    }

    .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void [System.Runtime]System.Object::.ctor()
        ret
    }

}

.class public auto ansi beforefieldinit Foo`2<T, literal int32 N>
       extends [System.Runtime]System.Object
{
  .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void [System.Runtime]System.Object::.ctor()
        ret
    }

  .method public hidebysig newslot virtual 
        instance void M<literal !T V, literal int32 W> () cil managed 
    {
        .maxstack 8

        ldstr "From Foo::M"
        call void [System.Console]System.Console::WriteLine(string)
        ldtoken !!V
        box !T
        call void [System.Console]System.Console::WriteLine(object)
        ldtoken !!W
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !N
        call void [System.Console]System.Console::WriteLine(int32)
        ret
    }

  .method public hidebysig newslot virtual 
        instance void N<literal !T V, literal int32 W> () cil managed 
    {
        .maxstack 8

        newobj instance void class Foo`2<int32, int32( 42 )>::.ctor()
        call instance void class Foo`2<int32, int32 (42)>::M<!!V, !!V>()
        newobj instance void class Foo`2<int32, !!V>::.ctor()
        call instance void class Foo`2<int32, !!V>::M<!N, int32 (42)>()
        newobj instance void class Foo`2<int32, !N>::.ctor()
        call instance void class Foo`2<int32, !N>::M<!!V, !!W>()
        ret
    }
}

.class public auto ansi beforefieldinit Bar`2<T, literal int32 N>
       extends class Foo`2<!T, int32 (128)>
{
  .method public hidebysig specialname rtspecialname 
        instance void .ctor () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void class Foo`2<!T, int32 (128)>::.ctor()
        ret
    }

  .method public hidebysig virtual 
        instance void M<literal !T V, literal int32 W> () cil managed 
    {
        .maxstack 8
        .locals init (
            [0] string v
        )
        ldstr "From Bar::M"
        call void [System.Console]System.Console::WriteLine(string)
        ldtoken !!V
        box !T
        call void [System.Console]System.Console::WriteLine(object)
        ldtoken !!W
        call void [System.Console]System.Console::WriteLine(int32)
        ldtoken !N
        call void [System.Console]System.Console::WriteLine(int32)

        ret
    }

  .method public hidebysig virtual 
        instance void N<literal !T V, literal int32 W> () cil managed 
    {
        .maxstack 8

        ldarg.0
        call instance void class Foo`2<!T, int32 (128)>::M<!!V, !!W>()

        ldarg.0
        callvirt instance void class Foo`2<!T, !N>::M<!!V, !!W>()

        ret
    }
}

This will yield the below execution result:

From Foo::M
42.42
42
128
From Bar::M
42.42
42
42

Minimal ValueArray Type Implementation

.class public sequential ansi sealed beforefieldinit System.ValueArray`2<T, literal int32 Length>
    extends [System.Runtime]System.ValueType
{
    .field private !T elem

    .method public hidebysig specialname instance !T& get_Item (int32 index) cil managed 
    {
        .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.UnscopedRefAttribute::.ctor() = (01 00 00 00)
        .maxstack 8

        ldarg.1
        ldc.i4.0
        blt.s OutOfRange

        ldarg.1
        ldarg.0
        call instance int32 valuetype System.ValueArray`2<!T, !Length>::get_Length()
        blt.s GetItem

        OutOfRange:
        call void valuetype System.ValueArray`2<!T, !Length>::ThrowIndexOutOfRange()

        GetItem:
        ldarg.0
        ldflda !0 valuetype System.ValueArray`2<!T, !Length>::elem
        ldarg.1
        call !!0& [System.Runtime]System.Runtime.CompilerServices.Unsafe::Add<!T>(!!0&, int32)
        ret
    }

    .method public hidebysig specialname instance int32 get_Length () cil managed 
    {
        .maxstack 8
        ldtoken !Length
        ret
    }

    .method private hidebysig static void ThrowIndexOutOfRange () cil managed 
    {
        .maxstack 8

        newobj instance void [System.Runtime]System.IndexOutOfRangeException::.ctor()
        throw
    }

    .property instance !T& Item(int32 index)
    {
        .get instance !0& System.ValueArray`2::get_Item(int32)
    }

    .property instance int32 Length()
    {
        .get instance int32 System.ValueArray`2::get_Length()
    }
}
ghost commented 1 year ago

Tagging subscribers to this area: @dotnet/area-meta See info in area-owners.md if you want to be subscribed.

Issue Details
# Const Generics "Const Generics" stands for allowing constant value to be used in a type parameter. An MVP implementation for CoreCLR can be found here: https://github.com/dotnet/runtime/pull/89636 ## Background and Use Cases "Const Generics" enables the use cases where developers need to pass a const value through a type parameter. Typical use cases are templating for things like shuffle (its basically a guaranteed constant) as well as for numerics, tensors, matrices and etc. For example, fixed buffers [1], multi-dimension arrays/spans [2] and constrained shape of arrays [3]. For [1], we can have a type `struct ValueArray` to define a type of array of T with N elements. This can also be useful in variadic parameters. For example, a `params ValueArray` can represent a variadic parameter that receives only 5 `int` arguments. For [2], we can use const type parameter to define a `Span`, so we can use `Span` for multi-dimension arrays as well. For [3], we can constraint the shape of an array. This is especially useful when you are dealing with matrix/vector computations. For example, you now can define a matrix using `class Matrix`. When you implement the multiplication algorithm, you can simply put a signature `Matrix Multiply(Matrix rMatrix)`. This can make sure users pass the correct shape of matrix while doing multiplication operations. ## Design ### Wording - Const type parameter: a type parameter that carries a const value. - Const type argument: the constant value for a type parameter in the instantiation. ### Const Type Parameter To support const generics, we need a way to declare a const type parameter that will carry the const value after instantiation. Due to the fact that a const type parameter behaves no difference than a normal type parameter until instantiation, here we can reuse the existing generic metadata and rules, and add a `Type` token to the `GenericParamRec` schema. To determine whether a type parameter is a const type parameter or not, simply check the `Type` token to see if it's valid by using `RidFromToken`. To summarize: - Added a column to `GenericParameterRec` to save a `mdToken` which represents the type of a const generic parameter. - Changed the reserved `DWORD` to `mdToken` to save the type of a const generic parameter. This requires a change to the existing metamodel. But worth to note that we don't need a new COM interface as we are reusing the reserved parameter in `GetGenericParamProps`, both `mdToken` and the reserved `DWORD` are exactly `DWORD`. ### Const Type Argument A const type argument contains the actual constant value in the instantiation. Here we can introduce a new element type `ELEMENT_TYPE_CTARG` which stands for const type argument. A const type argument can be encoded as follows: ``` ELEMENT_TYPE_CTARG ``` Note that the size of const value is determined by its element type. For example, a `int 42` will be encoded as: ``` ELEMENT_TYPE_CTARG ELEMENT_TYPE_I4 42 | 1 byte | 1 byte | 4 bytes | ``` While a `double 3.1415926` will be encoded as: ``` ELEMENT_TYPE_CTARG ELEMENT_TYPE_R8 3.1415926 | 1 byte | 1 byte | 8 bytes | ``` ### IL Parser We can add a keyword `const` in IL to indicate the type argument contains a const value. Particularly, we can use the keyword `const` to differentiate a const type argument/parameter from a type argument/parameter. For example, `const int32 T` and `const int32 (42)`. We need to change the parser to parse `"const" type typeName` as a const type parameter, and `"const" type '(' value ')'` as a const type argument. You can define and use const generics as the examples at the bottom of this proposal. ### Metamodel Version Given that we are changing the metamodel, we need to bump the version of metamodel. To summarize what we need to do: - Bumped the metadata version to v3.0 (or v2.1?) - Handled back-compatibility for v2.0, this can be done by replacing the `GenericParamRec` schema to the old one while loading a dll with v2.0 metadata. ### Type Desc A const type parameter has no more difference than the additional type token, so we can reuse the `TypeVarTypeDesc` and add a field `m_type` to save the type of const type if it's a const type parameter. A const type argument is exactly a constant value, so we need a separate `TypeDesc` for it. Therefore, a `ConstValueTypeDesc` can be added to save the type and the value of a const type argument. We can support up to 8 bytes of constant value if we use a `uint64_t` as the storage. ```cpp class ConstValueTypeDesc : TypeDesc { TypeHandle m_type; uint64_t m_value; }; ``` To read the constant value from a `ConstValueTypeDesc`, we need to reinterpret the storage based on the type of constant value. For example, while reading a constant value which is a float, we can simply use `*(float*)&m_value`. ### Method Table Similar to function pointers, we don't need a `MethodTable` for const value. ### Type Loader We can always load constant values in the CoreLib module because a constant value is independent from the assembly, a same constant value can be served from any assembly. To avoid loading the same constant value other than once, once we load a constant value, we can save it into a hash table `m_pAvailableParamTypes`. Whenever we load a constant value, we first lookup in the hash table, if found then we load the `TypeHandle` from the hash table directly, otherwise we allocate a new `ConstValueTypeDesc` for it. ### Value Loading We may need to use the const value from a type parameter, here we can reuse the `ldtoken` instruction to achieve this. Instead of loading the `TypeHandle` of the type parameter, we need to load the constant value and push it to the stack directly when we see the type parameter is a const type parameter. ### JIT We only need to handle `ldtoken` here, so we changed the `impResolveToken` to resolve the information about the const value as well, and then use the information to determine whether we should load a type handle or a const value to the stack. So we only need a minor change on the importation phase. ### Generic Sharing We don't share the implementation among const generic type parameters. Each const type argument get specialized so we can always import const type argument as a real type-rich constant value anytime. ### Overloading In this design, we are differentiate the calling target at the callsite, so we can support overloading on const generic type parameters without any issue. ``` call instance void Foo`1::.ctor(); // calling the Foo`1 call instance void Foo`1::.ctor(); // calling the Foo`1 .class public auto ansi beforefieldinit Foo`1 { ... } .class public auto ansi beforefieldinit Foo`1 { ... } ``` ### The Built-in `ValueArray` Intrinsic Type We need a built-in `ValueArray`, aka. `FixedBuffer` type for use, and it will play an important role in public APIs. A `ValueArray` is basically the `InlineArray` we already have today plus the ability of specifying arbitrary length without need of define a new `InlineArray` type. Below is the dummy C# code for `ValueArray`: ```csharp struct ValueArray { private T elem; // Repeat the field elem for N times public int Length { get; } // ldtoken N; ret; public T this[int index] { ... } } ``` This can be used together with `params`: ```csharp Foo(1, 2, 3, 4, 5); // a method that only receives 5 int arguments void Foo(params ValueArray args) { } ``` ### Reflection APIs To support reflection, we need something like `MakeGenericType` for a const value as well, so I have the below API proposal: ```csharp namespace System; public class Type { public bool IsConstValue { get; } public Type? ConstValueParameterType { get; } public object? ConstValue { get; } public static Type MakeConstValueType(object value); } ``` This can make sure we can instantiate a type/method that contains const type parameters, and also can we get the const value from a constructed type argument. Some use patterns of reflection: ```csharp class Foo { } var foo = new Foo(); foo.GetType(); // Foo foo.GetType().GetGenericArguments()[0]; // Type: System.String foo.GetType().GetGenericArguments()[1].IsConstValue; // true foo.GetType().GetGenericArguments()[1].ConstValue; // 42 foo.GetType().GetGenericArguments()[1].ConstTypeParameterType; // null var t = Type.MakeConstValue(42); var d = typeof(Foo<,>); d.GetGenericArguments()[1].IsConstValue; // false d.GetGenericArguments()[1].ConstValue; // null d.GetGenericArguments()[1].ConstTypeParameterType; // Type: System.Int32 d.MakeGenericType(typeof(string), t); // Foo ``` An interesting idea is to allow `typeof(value)` for the `Type.MakeConstValue`, for example, `typeof(42)` to get a `Type` that contains a value `42`. This would either require us to: 1. Use the `ldtoken` instruction for this, and we will need to introduce a new instruction for loading a const type argument to the stack, for example, an instruction called `ldctarg`. 2. Introduce a new instruction for this. 3. No new instruction, and just compile it to `Type.MakeConstValue`. I prefer the 3rd solution here as we don't need to add any new instruction. ### Support for arithmetic operations/constraints It's useful to have arithmetic operations/constraints support on const generics. For example, the signature of a `Push` method of `FixedArray` type can be `FixedArray Push(T elem)`, and the signature of a `Concat` method can be `FixedArray Concat(FixedArray elems)`. This would require embedding the arithmetic operations in the type and implement dependent types, which is a non-trivial work. While an alternative is to use generic constraints to save the arithmetic constraints. So for the example of `Push` method, we can use `FixedArray Push(T elem) where U : (T + 1)`, and the constraint `T + 1` can be expressed using `IBinaryOperation, IIntegerConstant<1>>`, where `IBinaryOperation` is `IBinaryOperation`, and `IIntegerConstant` is `IIntegerConstant`. Then we can compute the constraint at runtime. Although we need to specify the value such as `Push<7>(42)` while calling on `FixedArray`, the C# compiler may automatically infer the type of `U` so developers don't have to explicitly specify the value of `U` every time. ### Generic on Const Generic Type Parameter We can also support generic type on a const generic type parameter. For example, this will allow us to write a `struct FixedBuffer` and use it with `FixedBuffer`, `FixedBuffer` and etc. This is doable because we can also leverage the `mdToken` field in the `GenericParamRec` to save a `mdGenericParam`, and both `mdToken` and `mdGenericParam` are `DWORD`. ### Changes to ECMA-335 The main changes to ECMA-335 are (1) the new element type `ELEMENT_TYPE_CTARG` (2) the new `Type` field in `GenericParamRec`, which has been discussed above. ## Compatibility Concerns ### Assembly Loading Behavior The most concern here is what the behavior will be if I try to load an assembly with a new metamodel on an old runtime? The answer is: both .NET Framework and .NET (Core) will throw an exception `Unhandled exception. System.BadImageFormatException: Old version error. (0x80131107)`, which can be caught and won't lead the process down. This basically has no difference than loading an assembly targeted to a newer runtime on an old runtime: they both throw an exception to indicate you cannot load the assembly. ### Tooling #### Disassembler Both ILSpy and dnSpy can successfully load the assembly with a newer metamodel, while types containing const generics cannot be disassembled correctly. #### Profilers and Debuggers They need to support decoding new types or methods which contain `ELEMENT_TYPE_CTARG`/`CORINFO_TYPE_CTARG` on the signature. As for debuggers, they need to add support for the extended `ldtoken` instruction. #### EnC We don't support modifying generic type signature today, so no actions are needed. #### Other 3rd Party Tools They need to update to a new runtime to support loading assemblies with metadata v3.0. But given the fact that we are already not able to load an assembly targeted to a new runtime on an old runtime, so this won't be a concern. ## Code Examples ### A basic example ```il .assembly _ {} .class public auto ansi beforefieldinit Foo`2 extends [System.Runtime]System.Object { .method public hidebysig specialname rtspecialname instance void .ctor () cil managed { .maxstack 8 ldarg.0 call instance void [System.Runtime]System.Object::.ctor() ret } .method public hidebysig newslot virtual instance void M () cil managed { .maxstack 1 .locals init ( [0] int32 v ) newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() ldtoken !!V call void [System.Console]System.Console::WriteLine(int32) ldtoken !!W call void [System.Console]System.Console::WriteLine(int32) ldtoken !N call void [System.Console]System.Console::WriteLine(int32) ret } } ``` This can be interpreted to the following dummy C# code: ```csharp class Foo { public void M() { new Foo().M(); new Foo().M(); new Foo().M(); Console.WriteLine(V); Console.WriteLine(W); Console.WriteLine(N); } } ``` ### Generic Virtual Method with Const Type Parameters ```il .assembly _ {} .class private auto ansi beforefieldinit Program extends [System.Runtime]System.Object { .method private hidebysig static void Main ( string[] args ) cil managed { .maxstack 8 .entrypoint newobj instance void class Bar`2::.ctor() call instance void class Bar`2::N() ret } .method public hidebysig specialname rtspecialname instance void .ctor () cil managed { .maxstack 8 ldarg.0 call instance void [System.Runtime]System.Object::.ctor() ret } } .class public auto ansi beforefieldinit Foo`2 extends [System.Runtime]System.Object { .method public hidebysig specialname rtspecialname instance void .ctor () cil managed { .maxstack 8 ldarg.0 call instance void [System.Runtime]System.Object::.ctor() ret } .method public hidebysig newslot virtual instance void M () cil managed { .maxstack 8 ldstr "From Foo::M" call void [System.Console]System.Console::WriteLine(string) ldtoken !!V call void [System.Console]System.Console::WriteLine(int32) ldtoken !!W call void [System.Console]System.Console::WriteLine(int32) ldtoken !N call void [System.Console]System.Console::WriteLine(int32) ret } .method public hidebysig newslot virtual instance void N () cil managed { .maxstack 8 newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() newobj instance void class Foo`2::.ctor() call instance void class Foo`2::M() ret } } .class public auto ansi beforefieldinit Bar`2 extends class Foo`2 { .method public hidebysig specialname rtspecialname instance void .ctor () cil managed { .maxstack 8 ldarg.0 call instance void class Foo`2::.ctor() ret } .method public hidebysig virtual instance void M () cil managed { .maxstack 8 .locals init ( [0] string v ) ldstr "From Bar::M" call void [System.Console]System.Console::WriteLine(string) ldtoken !!V call void [System.Console]System.Console::WriteLine(int32) ldtoken !!W call void [System.Console]System.Console::WriteLine(int32) ldtoken !N call void [System.Console]System.Console::WriteLine(int32) ret } .method public hidebysig virtual instance void N () cil managed { .maxstack 8 ldarg.0 call instance void class Foo`2::M() ldarg.0 callvirt instance void class Foo`2::M() ret } } ``` This will yield the below execution result: ```plaintext From Foo::M 42 42 128 From Bar::M 42 42 42 ```
Author: hez2010
Assignees: -
Labels: `area-Meta`, `untriaged`, `feature-request`
Milestone: -
AaronRobinsonMSFT commented 1 year ago

Thanks @hez2010 Appreciate this issue and the effort put into the prototype as well as details above. I'm going to move this to .NET 9 since there is no chance this is a .NET 8 request.

I will also tag a few people for visibility. Note that since we are closing down .NET 8, this is likely to receive little feedback right now. I would ping this issue in mid-September when people start mulling over vNext.

/cc @jaredpar @stephentoub @davidwrighton @tannergooding @jkotas @VSadov @tommcdon @MichalStrehovsky @lambdageek

lambdageek commented 1 year ago

This is interesting. There are quite a few details glossed over that I think are hiding serious work:

nike4613 commented 1 year ago

why be stingy with IL opcodes and reuse ldtoken rather than adding a new opcode?

This was discussed fairly heavily in the C# discord. At various points, we'd talked about using ldtoken followed by a RuntimeHelpers call, and a separate opcode, but ldtoken alone was what landed. The basic reason is that ldtoken const !0 doesn't really have a meaningful token to return, aside from the constant value itself. It could return the type token for the type of the parameter (to allow e.g. typeof), but that seemed somewhat wrong, because the parameter is a value, not a type, so ldtoken would be skipping over the value in that case. Plus, that information is already implicitly known by looking at the signature (even in the case where we have <TValue, const TValue Value>; the type is statically known to be TValue) so loading the type that way is redundant and unnecessary. The only other piece of information that would be useful to have for such a value is the value itself, thus ldtoken loads that value.

Arithmetic Operations

There were also other alternatives discussed for doing arithmetic on values:

huoyaoyuan commented 1 year ago
  • If we're bumping the IL metadata version, why be stingy with IL opcodes and reuse ldtoken rather than adding a new opcode?

An out-of-scope topic here: if we are bumping IL metadata version, we may have a chance to change some new concepts encoded by custom attributes to new flags. Please consider overhauling the IL itself.

tfenise commented 1 year ago

I imagine it should be possible to implement the ValueArray<,> with pure managed code, just like Action<...> or Func<...>:

[System.Runtime.CompilerServices.InlineArray(N)]
public struct ValueArray<T, int N>
{
    private T elem; // Repeat the field elem for N times

    //public members omitted
}

Sadly, this has not been implemented even with conventional type generics. https://github.com/dotnet/csharplang/discussions/6923

hez2010 commented 1 year ago

I imagine it should be possible to implement the ValueArray<,> with pure managed code

Unfortunately it's not possible as attribute doesn't support using open generic type at all.

KirillAldashkin commented 1 year ago

I created an IL implementation of Span`2<T, int Rank> and Matrix`2<int Rows, int Cols> and wrote two executable examples. This is not a language/API proposal, only a showcase of a feature and can be ilasm'ed and corerun'ed using current MVP implementation

Full IL listings here: https://gist.github.com/KirillAldashkin/62eee5a20f6cc3e71920ea7d40ba5cb2

C# dummy code (type[num] == FixedBuffer<type, num>):

readonly ref struct Span<T, int Rank>
{
    private ref T _reference;
    private int[Rank] _lengths;

    Span(ref T reference, params int[Rank] lengths);
    nuint TotalLength();    
    int Length(int rank);
    ref T GetItem(params int[Rank] indices);
}

struct Matrix<int Rows, int Cols>
{
    private int[Cols][Rows] _matrix;

    ref float GetItem(int row, int col);
}

static class MatrixMath
{
    static Matrix<Rows, Cols> Multiply<int Rows, int Cols>(Matrix<Rows, Cols> mat, float factor);
    static Matrix<Rows, Cols> Multiply<int Rows, int Mids, int Cols>(Matrix<Rows, Mids> a, Matrix<Mids, Cols> b);
}

int Main_MatrixSample()
{
    Matrix<2, 3> MatA = new([20, 0, 30],
                [30, 0, 20]);
    Console.WriteLine($"A = {MatA}");

    Matrix<3, 4> MatB = new([5, 0, 7, 0],
                [0, 0, 0, 0],
                [0, 7, 0, 5]);
    Console.WriteLine($"B = {MatA}");

    MatA = MathMatrix.Multiply(MatA, 0.1f);
    Console.WriteLine($"A = A * 0.1 = {MatA}");

    Matrix<2, 4> MatC = MatrixMath.Multiply<2, 3, 4>(MatA, MatB);
    Console.WriteLine($"C = A * B = {MatC}");
}

int Main_MultidimensionalSpanSample()
{
    int* ptr = stackalloc int[1000];
    for(int i = 0; i < 1000; i++) *(ptr+i) = i;
    Span<int, 3> span = new(ptr, 10, 10, 10); 
    Console.WriteLine(span[5, 6, 7]);
}
jkoritzinsky commented 1 year ago

I imagine it should be possible to implement the ValueArray<,> with pure managed code

Unfortunately it's not possible as attribute doesn't support using open generic type at all.

I think it's important to note that the custom attribute blob format can version separately from the greater ECMA metadata format.

hez2010 commented 1 year ago

@lambdageek I have changed part of the proposal.

why be stingy with IL opcodes and reuse ldtoken rather than adding a new opcode

In my earliest prototype, I added a new IL opcode ldctarg for loading the const value of a const type argument, and keep the ldtoken as is. But soon I found that a const type argument is exactly a const value, so it doesn't make sense to use ldtoken to load the TypeHandle of a const value, neither do we support typeof(42). So I end up using ldtoken to load the const value from a const type argument.

tfenise commented 1 year ago

About const arithmetic, maybe we could allow using a special static class for a const generic argument. To use a static class for a const generic argument, the static class shall have a special initonly field of the desired type initialized by the static constructor. For example:

[ConstGenericArgument(typeof(int))]
static class Sum<int N1, int N2>
{
    public static readonly int __value = N1 + N2;
}

static ValueArray<T, Sum<N1, N2>> Concat<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2) {...}

The common arithmetic operations could be included in the BCL, and C# (or other languages) provides shorthand syntaxes for them.

Constant const generic arguments could also be represented in this way like

static void Foo<int N>() {...}

[ConstGenericArgument(typeof(int))]
static class Const42
{
    public const int __value = 42;
}

Foo<Const42>();

This way, it might not be necessary to bump the IL metadata version.

Const arithmetic leads to problems. Consider:

static ValueArray<T, Sum<N1, N2>> ConcatButWithReversedArgumentOrder<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2)
    => Concat(array2, array1);

Concat(array2, array1) would be of type ValueArray<T, Sum<N2, N1>>, which is not "obviously" ValueArray<T, Sum<N1, N2>>. My opinion on this problem is simply to do nothing on the runtime side. It is up to C# (or other languages) and its compiler to decide whether they can verify this kind of type identity transforms statically, or they require some explicit conversion expression in the source code and possibly insert runtime checks in the IL.

About Constraints, I don't think they are really necessary on the runtime side or the runtime should enforce them. If an invalid const generic argument is given, just throw an exception in the constructor or method. As const generic is never shared, the AOT/JIT should be able to remove these checks.

What's more, I can't see how const arithmetic and statically enforced constraint could live together peacefully. Consider

static ValueArray<T, Sum<N1, N2>> Concat<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2)
    where N1 >= 0, N2 >= 0
    {...}

static void Foo<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2)
    where N1 >= 0, N2 >= 0
{
    ValueArray<T, Sum<N1, N2>> concat = Concat(array1, array2);//Let's say Foo concats them as an implementation detail.
    ...
}

If ValueArray<T, int N> is constrained to N >= 0, then ValueArray<T, Sum<N1, N2>> would not compile, because N1 + N2 may overflow and be negative. It would have to add another constraint N1 + N2 >= 0, which is really annoying, especially for Foo as it only uses ValueArray<T, Sum<N1, N2>> as an implementation detail.

It could be helpful to have some custom attributes describing the desired constraints or analyzers detecting invalid const generic arguments, though.

It may be helpful to also start an issue or discussion in dotnet/csharplang or repos of other languages.

hez2010 commented 1 year ago

Constant const generic arguments could also be represented in this way like

  1. A static class can not be used as a type argument
  2. We want to specialize the type for different const values and avoid generic sharing
  3. A generated type simply doesn't work because you can not expose it in ABI. Imagine you have a public void Foo(ValueArray<int, 42> arr) in assembly A, and the type of arr got lowered to an A.ValueArray42<int>. Now, in another assembly B, I have the generated type B.ValueArray42<int> as well. Those two ValueArray42<int>s are not the same type, so we can not pass a B.ValueArray42<int> to a parameter that expects an A.ValueArray42<int>. If you want to say "We can use implicit casting for it", but still, they are not the same type and have different type tokens in the metadata, so we are not able to say whether those two types are the same at runtime. And implicit casting cannot handle cases like casting a Foo<A.ValueArray42<T>> to Foo<B.ValueArray42<T>>.
  4. Generated types can not handle cases where N is not statically known, so your approach is blocking reflection usage. With the real const generics, a Foo<1> is just an instantiation of Foo<int T> so we can instantiate Foo<2>, Foo<3>, etc. using reflection without emitting any new type at runtime. But with your approach, we need to emit new types Foo2, Foo3, etc. at runtime because we don't have a type called Foo2 or Foo3 in the assembly, which is also not supported by NativeAOT.
  5. Atrributes don't support using open generic type parameters so your approach is not able to support generic on const type parameter.

This way, it might not be necessary to bump the IL metadata version.

We also have other features that we want to get in but are not able to do due to the metadata neck. This 21-year-old metadata v2.0 is limiting too many things today, and we can not afford consistently to apply a workaround upon a workaround in the runtime. Const generics is only one of them.

About Constraints, I don't think they are really necessary on the runtime side or the runtime should enforce them. If an invalid const generic argument is given, just throw an exception in the constructor or method.

With the constraint approach, we are already checking it only at the time an instantiated type is loading and has never been loaded before, so each type instantiation only needs to be checked once.

because N1 + N2 may overflow and be negative

This can be simply checked at runtime to make sure an exception will be thrown when an overflow is encountered.

It may be helpful to also start an issue or discussion in dotnet/csharplang or repos of other languages.

It's already on my mind and we will start to work on a design and spec for this once work on net8.0 RTM is done :)

tfenise commented 1 year ago

My suggestion is only a representation of const generic arguments in IL. So ValueArray<int, Const42> is ValueArray<int, 42>, and ValueArray<int, Sum<42, 1>> is ValueArray<int, 43>. At runtime, these Const42 and Sum<42, 1> vanish when used as const generic arguments.

A static class can not be used as a type argument

What I meant is to allow using a special static class to represent a const generic argument.

A generated type simply doesn't work because you can not expose it in ABI. Imagine you have a public void Foo(ValueArray<int, 42> arr) in assembly A, and the type of arr got lowered to an A.ValueArray42. Now, in another assembly B, I have the generated type B.ValueArray42 as well. Those two ValueArray42s are not the same type, so we can not pass a B.ValueArray42 to a parameter that expects an A.ValueArray42. If you want to say "We can use implicit casting for it", but still, they are not the same type and have different type tokens in the metadata, so we are not able to say whether those two types are the same at runtime. And implicit casting cannot handle cases like casting a Foo<A.ValueArray42> to Foo<B.ValueArray42>.

I never said to generate types like ValueArray42<>.

Generated types can not handle cases where N is not statically known, so your approach is blocking reflection usage. With the real const generics, a Foo<1> is just an instantiation of Foo so we can instantiate Foo<2>, Foo<3>, etc. using reflection without emitting any new type at runtime. But with your approach, we need to emit new types Foo2, Foo3, etc. at runtime because we don't have a type called Foo2 or Foo3 in the assembly, which is also not supported by NativeAOT.

I never said to generate types like Foo2 or Foo3. I said to generate special static classes like Const42, but that is only used to represent the const generic argument in IL, and reflection may well instantiate Foo<2> Foo<3> without requiring a Const2 or Const3.

Atrributes don't support using open generic type parameters so your approach is not able to support generic on const type parameter.

The attribute is not an essential thing of my suggestion. The attribute may be placed somewhere else like:

[ConstGenericArgumentClass]
static class Sum<int N1, int N2>
{
    [ConstGenericArgumentField]
    public static readonly int __value = N1 + N2;
}

Or we could make attributes support using open generic type parameters if we are already talking about big changes like const generic.

As a side point, I don't quite see how generic on const type parameter is useful. If the example of usage is struct FixedBuffer<T, TSize, const TSize Size>, I don't see any problem with just struct FixedBuffer<T, long Size>.

The main point of my suggestion is not to avoid bumping IL version, but to support const arithmetic.

If constraints are only checked at runtime, I don't see much necessity of them. For example, instead of

static ValueArray<T, N1 + N2> Concat<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2)
    where N1 >= 0, N2 >= 0, N1 + N2 >=0
    {...}

why not just

static ValueArray<T, N1 + N2> Concat<T, int N1, int N2>
    (ValueArray<T, N1> array1, ValueArray<T, N2> array2)
{
    if (N1 < 0 || N2 < 0 || N1 + N2 < 0) throw new ArgumentException();
    ...
}
hez2010 commented 1 year ago

What I meant is to allow using a special static class to represent a const generic argument

Sorry for I misread your previous comment.

I don't quite see how generic on const type parameter is useful

This is useful especially in graphics programming, where the multiplier can be either float or double. And in the design of generic constraint, we also need this feature otherwise we cannot define a IConstExpression<TValue, TValue Value>.

Even your proposed Sum type can be:

[ConstGenericArgumentClass]
static class Sum<T, T N1, T N2>
{
    [ConstGenericArgumentField]
    public static readonly T __value = N1 + N2;
}

Otherwise, we have to consider generic overloading (otherwise you cannot define Sum<int N1, int N2> and Sum<float N1, float N2> at the same time), which is also a non-trivial work and requires a metamodel change too.

If constraints are only checked at runtime

I implemented the prototype to check them at runtime, which doesn't mean we won't check them at compile time. We don't have the design for C# yet, so I cannot say anything about the compiler implementation.

tfenise commented 1 year ago

I still have trouble imagining a situation where a const generic parameter of type other than int is useful.

This is useful especially in graphics programming, where the multiplier can be either float or double.

Yes, the multiplier can be either float or double, but does the multiplier need to be a const generic parameter?

For [4], we can embed the coefficient into a multiplier type. This is especially useful in graphics programming. For example, when you are working with things about illumination, you will definitely want some multiplier types with coefficients (which are basically floating point numbers) that are guaranteed to be constants.

Is storing the coefficients into readonly fields not enough to guarantee them to be constant for the lifetime of the multiplier instance?

struct FixedBuffer<T, TSize, const TSize Size> also sounds questionable. What is a FixedBuffer<int, long, 42424242424242>? A pointer to a buffer of length 42424242424242? ValueArray<,> is useful because it avoids heap allocations. Where is FixedBuffer useful? Such large sizes often come from runtime calculations and cannot be easily provided as compile-time const generic arguments.

hez2010 commented 1 year ago

Is storing the coefficients into readonly fields not enough to guarantee them to be constant for the lifetime of the multiplier instance?

Then you will need to save them as a const field to guarantee them to be constant for the runtime. But this means you cannot change them after you defined the type. While with const generics, you can change them by providing the const type argument then you can get a new instantiation that carries the constants you want. And if you need both float and double variants, without generics on type parameter you will need to define two distinct types because we don't support overloading on generics.

Another reason here is that we want to use generic on const type parameter to get rid of the need of overloading on generics, the latter is basically not achievable because implementing overloading on generics would break backward compatibility (an assembly already compiled by an older compiler won't be able to differentiate call targets while running on a new runtime).

And, actually, it's basically free to have the support for generic on const generic type parameter, we already have almost all the necessary infrastructure to support it. So why not?

What is a FixedBuffer<int, long, 42424242424242>?

It should be ValueArray here, I just forgot to rename FixedBuffer to ValueArray in the proposal.

tfenise commented 1 year ago

ValueArray<int, long, 42424242424242> sounds problematic to me because there would be a StackOverflowException every time a variable of this type is stored on stack. It would be only possible to use it indirectly by ref ValueArray<int, long, 42424242424242> or ValueArray<int, long, 42424242424242>*, which just sounds undesirable to me.

Speaking of const/literal field,

  1. Is this supported?
class MyClass<int N>
{
    const int AnotherN = N;
}
  1. If so, considering const arithmetic, is this supported?
class MyClass<int N>
{
    const int NSquared = N * N;
}
  1. If so, is even this supported?
class MyClass<double X>
{
    const double CosX = Math.Cos(X);
}

Math.Cos(X) is not even guaranteed to be exactly equal across different runtime setups, but surely this is desirable if const generic of type double is supported, right?

There could be two answers to these problems.

  1. Don't support them, because const/literal fields are supposed to be compile time constants. Especially const double CosX = Math.Cos(X); should not be supported because Math.Cos(X) is not strictly a constant, even at runtime. Just use static readonly fields for these scenarios.
  2. Support them, because it is desirable to support the following, which seemingly should not be possible with static readonly fields:
class MyClass1<double X>
{
    ...
}

class MyClass2<double X>
{
    const double CosX = Math.Cos(X);

    MyClass1<CosX> Foo() {...}
}

This looks like a similar problem with custom attribute arguments not allowed to be open generic types.

hez2010 commented 1 year ago

Speaking of const/literal field,

For 1, 2 and 3, you need to use static readonly field instead of const field. The const field in IL can only express a literal value so you cannot write anything except the literal value itself. The JIT is able to fold a static readonly field into constant at runtime.

This looks like a similar problem with custom attribute arguments not allowed to be open generic types.

Yeah this is a bit unfortunate. I would expect we can bring the support for const arithmetic later once we have associated types.

hez2010 commented 1 year ago

For someone who wants to try out the reflection APIs and ValueArray support, I have them implemented in another branch: https://github.com/hez2010/runtime/tree/feature/const-generics-managed The implementation of runtime support for ValueArray is pretty straight-forward as we can reuse all the existing InlineArray implementation.

rickbrew commented 1 year ago

3. If so, considering const arithmetic, is this supported?

class MyClass<int N>
{
    const int NSquared = N * N;
}

What happens if N * N would overflow? This is well-defined at runtime, it depends on use of checked or unchecked, or a compiler flag to set the default.

hez2010 commented 1 year ago

For anyone who wants to try out const generics, I have the full CoreCLR and Roslyn implementation (prototype) in

If you want to give it a quick try, you can download the pre-built binaries and then you are ready to go (please follow the instructions in README.txt): https://1drv.ms/u/s!ApWNk8G_rszRgrxL0ch8e9h-gBiVaw?e=cwpUVD

You will need the latest preview version of Visual Studio 2022.

This is how it looks in Visual Studio:

image

To run the built artifacts, you need to use corerun in Core_Root.

FaustVX commented 1 year ago

Hi @hez2010

Incredible work you have done here. I've tried your solution in VS, but I have the following warning

Code Description File Line
MSB3270 There was a mismatch between the processor architecture of the project being built "MSIL" and the processor architecture of the reference "System.Private.CoreLib", "AMD64". This mismatch may cause runtime failures. Please consider changing the targeted processor architecture of your project through the Configuration Manager so as to align the processor architectures between your project and references, or take a dependency on references with a processor architecture that matches the targeted processor architecture of your project. C:\Program Files\Microsoft Visual Studio\2022\Preview\MSBuild\ Current\Bin\amd64\ Microsoft.Common.CurrentVersion.targets 2364

I installed every vsix files (exept the arm64 one) I have no other errors/warnings in VS and the project compile fine, but I can't run it, I have this error in the console

Unhandled exception. System.BadImageFormatException: Old version error. (0x80131107)

...\ConstGenerics.NET\ConstGenerics\bin\Release\net8.0\ConstGenerics.exe (process 32660) exited with code -532462766.

If you prefer, I can write an issue in one of your repo, just tell me which one.

hez2010 commented 1 year ago

@FaustVX The System.Private.CoreLib issue is because I built the coreroot with x64 architecture, which is not platform neutral. You can just ignore this warning. As for the latter issue, you need to run your code using corerun in Core_Root:

corerun.exe --clr-path <absolute_path/to/Core_Root> <path/to/your/assembly>
KirillAldashkin commented 1 year ago

@hez2010 can you please send a checksum for that prebuilt binaries archive? 7zip says it's corrupted

hez2010 commented 1 year ago

Hey everyone, I've successfully made an SDK for it, so you can build and run your code directly just use Visual Studio or Visual Studio Code.

If anyone wants to try it, you can download the SDK, VS extensions and the language server here: https://1drv.ms/u/s!ApWNk8G_rszRgrxP32IMKhW-V8iWug?e=JBn8wU

Version: 20230912 Build 1 Checksum: a8c9ee29d1accd14797f60bedced312f9524391b

Please do follow the README.md.

It supports all things that has been marked with ⭕ or❗ in this feature proposal, for example:

  1. Declare a const generic type, eg. class Foo<T, int N>.
  2. Use a const generic type, eg. new Foo<int, 42>().
  3. Declare a const generic method, eg. void Foo<int X>.
  4. Use a const generic method, eg. Foo<42>().
  5. Generics on const type parameter, eg. class Foo<T, T X>, then you can use it with Foo<int, 42> as well as Foo<float, 42.42424f>.
  6. Use const type parameter as constant directly. eg. calling Console.WriteLine(X) in the type class Foo<int X>.
  7. typeof support. eg. typeof(42).
  8. Casting support in const type argument. eg. new Foo<(short)42>, typeof((short)42)
  9. A built-in value type ValueArray<T, int X> that can be used as a fix-sized type with type T and length X.
  10. A niche syntax for declaring a ValueArray type, eg. int[42].
  11. Full reflection support.
    • To check whether a type parameter is const type parameter, use type.IsGenericParameter && type.HasElementType.
    • To get the type of a const type parameter, use type.GetElementType().
    • To check whether a type argument is const type argument, use type.IsConstValue.
    • To get the type of a const type argument, use type.GetElementType().
    • To get the value of a const type argument, use type.ConstValue.
    • To make a const value type, use Type.MakeConstValueType()
hez2010 commented 1 year ago

@hez2010 can you please send a checksum for that prebuilt binaries archive? 7zip says it's corrupted

I uploaded a new one (and fixed a bug) in the above post. Please use that instead.

Checksum 653667f63ad239cfd80c856f60ea2f91934ad654

Fabi commented 1 year ago

Hey everyone, I've successfully made an SDK for it, so you can build and run your code directly just use Visual Studio or Visual Studio Code.

If anyone wants to try it, you can download the SDK, VS extensions and the language server here: https://1drv.ms/u/s!ApWNk8G_rszRgrxP32IMKhW-V8iWug?e=JBn8wU

Version: 20230903 Build 1 Checksum: 0e64e1425519774ae2166aed23eda6a7a0fe13da

Please do follow the README.md.

It supports all things that has been marked with ⭕ or❗ in this feature proposal, for example:

  1. Declare a const generic type, eg. class Foo<T, int N>.
  2. Use a const generic type, eg. new Foo<int, 42>().
  3. Declare a const generic method, eg. void Foo<int X>.
  4. Use a const generic method, eg. Foo<42>().
  5. Generics on const type parameter, eg. class Foo<T, T X>, then you can use it with Foo<int, 42> as well as Foo<float, 42.42424f>.
  6. Use const type parameter as constant directly. eg. calling Console.WriteLine(X) in the type class Foo<int X>.
  7. typeof support. eg. typeof(42).
  8. Casting support in const type argument. eg. new Foo<(short)42>, typeof((short)42)
  9. A built-in value type ValueArray<T, int X> that can be used as a fix-sized type with type T and length X.
  10. A niche syntax for declaring a ValueArray type, eg. int[42].
  11. Full reflection support.

    • To check whether a type parameter is const type parameter, use type.IsGenericParameter && type.HasElementType.
    • To get the type of a const type parameter, use type.GetElementType().
    • To check whether a type argument is const type argument, use type.IsConstValue.
    • To get the type of a const type argument, use type.GetElementType().
    • To get the value of a const type argument, use type.ConstValue.
    • To make a const value type, use Type.MakeConstValueType()

the VS code part works great. the VS extensions break the component cache of VS constantly (latest preview)

jl0pd commented 1 year ago
  • To get the value of a const type argument, use type.ConstValue.
  • To make a const value type, use Type.MakeConstValueType()

Maybe names should use Literal to align with FieldInfo.IsLiteral ?

hez2010 commented 1 year ago
  • To get the value of a const type argument, use type.ConstValue.
  • To make a const value type, use Type.MakeConstValueType()

Maybe names should use Literal to align with FieldInfo.IsLiteral ?

Sounds great. We may want all const words to be literal to be consistent with the metadata.

hez2010 commented 1 year ago

@AaronRobinsonMSFT Ping. It's already mid-Sept :)

AaronRobinsonMSFT commented 1 year ago

Yep, Thanks @hez2010. This is something I am watching closely. Your enthusiasm here is most welcome and we appreciate how much effort has been put into addressing many of the community concerns. It is also impressive how much you've been able to enable.

This is a rather fundamental change that is going to require substantial scrutiny. The changes here impact the entire .NET ecosystem due to the metadata changes and that I fear is likely to get serious push back. Not unlike the various other proposals that have attempted broad metadata changes. Related to the ecosystem impact is the scenarios this unblocks. There is no doubt this feature enables currently unsupported scenarios, but the question is about the impact to the ecosystem, the cost, relative to the unblocking of new scenarios, the benefit.

Please don't take my word as the official perspective here, but something this profound is going to need, at a minimum, everyone I mentioned at https://github.com/dotnet/runtime/issues/89730#issuecomment-1658861829 to weight in and that is going to take some time.

davidwrighton commented 1 year ago

I agree with @AaronRobinsonMSFT, I've been traveling much of the summer, and unable to look deep into proposals like this, but the necessary step to actually getting general agreement to move the entire ecosystem forward is to come up with a reason to do so. When a change does not require performing an ecosystem shift, it is much easier to justify, as the costs are dramatically lower, and effectively restricted to the development effort to build a feature/support it. However, for anything that requires many components to change has significant costs to many people and organizations, so we need greater justification for any such change. Looking at your proposal, I see the main benefit proposed as providing for an excellent abstraction for the creation of fixed sized arrays, matrices and vectors. While I see that this would have applications in the development of AI, numeric computing, and graphics programs, I fail to see an analysis of what those improvements are, and whether or not they are really worth the cost to the ecosystem of continuing with this proposal as compared to taking other changes to improve those facets of computing in .NET (and frankly whether or not those scenarios are more or less important to the broader set of .NET developers which are developing web based applications or client line of business applications.)

For instance, we have been making other improvements that make it easier to develop manually specified constant array types (see https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/inline-arrays). While the InlineArray certainly does not have all of the capabilities as the generalized const generics feature you have developed here, we expect the feature to enable the usage of Span<T> over fixed size buffers, and we expect customers to develop multidimensional arrays, large stack variables, and other solutions that broadly resemble the many of the new capabilities that generic constants can also provide on top of the InlineArray capability that is now present in .NET 8.

All of that said, I personally have wanted something like this for many years and am very excited to watch what you are building here to see if we can develop a reason for the wider ecosystem to embrace a change like this. Once we have a good set of reasons why we might want to build this feature, and scenarios enabled/unblocked, we will also need to take a really deep look into the costs, and those can be quite surprising to developers who focus only on enabling new capabilities. For instance, additional generic specialization seems like a great win, but it tends to come with significant costs to the startup of applications. It is possible that shipping a feature like this would require us to also change our model for handling canonicalization and specialization within the runtime to maintain acceptable startup performance. Some implementations of .NET may not be able to do that and may experience different performance characteristics as compared to the CoreCLR implementation, what impact does it have on Native AOT code generation (does it encourage code patterns which cannot effectively be compiled ahead of time, or require excessive precompiled binary sizes) etc.

hez2010 commented 1 year ago

@AaronRobinsonMSFT @jkotas @davidwrighton While actually we are breaking metadata because we want the type of a const type parameter to be part of the type parameter (and it should), so I need to add a field to the GenericParamRec table. But if you can accept something like class Foo<[LiteralTypeParameter<int>] T> or class Foo<T> where T : LiteralType<int> to be a solution then we don't need to make any break changes to the metadata.

However, it exposes an issue where we have an almost non-extensible metadata, and we definitely have some work to do around this in the future (at least adding a field to an existing table shouldn't be a breaking change). I would like to see const generics can be a push for this work.

xcaptain commented 1 year ago

@hez2010 Based on your current work, do you think it's possible to implement https://github.com/dotnet/csharplang/discussions/1315

hez2010 commented 1 year ago

@hez2010 Based on your current work, do you think it's possible to implement dotnet/csharplang#1315

They're unrelated features and should be implemented independently.

hez2010 commented 1 year ago

I have updated the proposal and my MVP implementation. Now we no longer have any breaking changes to the existing metadata! All existing ecosystem and tooling can continue to be used without any major concern of compatibility.

davidwrighton commented 1 year ago

@hez2010 The issue isn't that we can't break metadata. The issue is that we need a great deal of justification to make changes that cause significant fractions of the sets of tools in use with .NET to experience significant breaks, and even more so we need justification for anything that provides substantial new capabilities. Even if we avoid changing the metadata format for this sort of change, we still need to weigh the costs and risks to the platform and the impacts the new code will have on the ecosystem. The technical work to enable a feature to work or not work, is one thing, and you've done much of that research already, but we also need to have a good reason to bring new features into the platform. The more the feature will have viral impact on the ecosystem, the more justification we typically need. And my judgement, is that this is a fairly viral feature that may impact large amounts of code.

For instance, what scenarios (as in applications that can more easily/efficiently/performantly) be developed with the change you are presenting here? Why is this better than alternative approaches? What are the alternatives? These are all questions that need to be answered before I can even start to review the technical changes you've made here.

huoyaoyuan commented 1 year ago

I think it's time to update metadata for the upcoming type system improvement of C#. Extension is tricky in today's type system. There are also other currently hard features like varadic generics, and HKT. It would be long-term hard task of course. I want to bring it to design discussion, together with C# language design.

colejohnson66 commented 6 months ago

Any update on this? I've long desired const generics. They allow you to express units of measurement very elegantly. With the current system, each unit you require needs its own dedicated type. For example, if you have Distance, Time, and Speed, creating a Speed from Distance/Time requires dedicated operator overloads. Want to add Acceleration? Now you require another type! There's also the minor annoyance of "which type does this overload go under? Is it overloading Distance or is it overloading Time?"

With const generics, you could instead have a base Unit<int NTime, int NDistance /* other bases */>. Distance would become aliased to Unit<0,1/*...*/>, Speed would become Unit<-1,1/*...*/>, etc. and dividing two of them would yield Unit<NTimeA - NTimeB, NDistanceA - NDistanceB /* other bases */>.


This is actually a major annoyance at my job; Unit handling is a major component of our applications. Specifically, we have at least ten different unit systems we need to keep track of — each with their own dedicated type and custom operator overloads. I've long wondered about ways to reduce the bloat. A dedicated source generator has helped, but it still doesn't solve all the problems.

For alternatives: I've considered a simple Unit that brings along integers for each dimension (maximum of seven for all SI bases), but that would make a type that was previously four or eight bytes long (a single float or double) is now 4*N (N=tracked dimensions) bytes larger, which destroys the ability to hold them in registers.

If you instead factor out the "dimension" struct into a record (reducing Unit to sizeof(float)+sizeof(nint) or sizeof(double)+sizeof(nint)), you now allocate a new "dimension" struct with each multiplication or division. And every mathematical operator (even those that don't allocate) now has to chase a pointer to the dimension structs. Lastly, regardless of inline or extracted dimensions, math is performed for each dimension in addition to the desired one; Adding two Unit values must verify they have the same dimension before it adds the inner values - all at runtime.

Const generics allow compile time checking of all of that.

AaronRobinsonMSFT commented 6 months ago

The current state is captured in the last comment from @davidwrighton - https://github.com/dotnet/runtime/issues/89730#issuecomment-1725954556.

There is currently no plan to accept this proposal without starting with the thorough ecosystem analysis asked for in the aforementioned comment. Event after that analysis, it is possible the benefits will not be worth the breaking change.

xcaptain commented 6 months ago

Maybe you should add @hez2010 to the core team to push forward this proposal or I'm afraid it will be declined forever

Perksey commented 6 months ago

In any case a business case would be required and an impact analysis on other active business cases today. There is just no reason for const generics to exist that warrants the cost of implementing it, and other languages having the feature is not a reason unto itself. C# is not an openly-developed language, in that a feature being designed and developed outside of Microsoft does not negate Microsoft's processes for ensuring all additions to it have well-understood benefits to important revenue-driving applications of .NET today.

This is not an authoritative view on .NET development and I am not employed by Microsoft, just trying to echo the views already expressed concisely.

tannergooding commented 6 months ago

Maybe you should add @hez2010 to the core team to push forward this proposal or I'm afraid it will be declined forever

This level of feature requires involvement from the various .NET Architects and other high level engineers across the runtime, libraries, and languages (not just C# either, but also F#, C++/CLI, etc). It is not something that can be driven by a single individual, regardless of where they worked or what team they are on.

Const generics is a very interesting feature and there are a number of people that would enjoy seeing it and who may have uses for it (I know I have some in the BCL from the numerics side of things). But at the same time, it is just one of many features that people want and it starts from a position of much higher cost due to the potential need to version IL for such support to exist. So not only does it have to be prioritized with respect to every other feature that has to be done, but it also has to have significantly more justification to show the break is worthwhile or additional analysis has to be done to weigh the alternative approaches that may not require versioning IL and introducing such a break.

Throwing more people/money at the problem space doesn't solve it either and would likely cause the feature to take even longer to get looked at. Not everything is parallelizable or infinitely scalable, there are fundamental bottlenecks required as part of the design and development process due to the entire picture needing to be looked at, including with respect to every other feature actively being worked on.

It's also worth noting that while the work hez2010 did here is significant and their enthusiasm for this space is much appreciated, it is only a very small minority of the work that will actually be required. This is also why its typical for OSS repos to ask for developers to open an issue and engage with the correct teams first, so they don't spend a significant amount of time doing work around an area that may not actually make it in or which may go about it in the wrong way. Actually doing the implementation work is really the smallest portion of getting any feature into the ecosystem, and it is a far minority of the total work required. The actual bulk of the work comes from designing the feature. This includes, but is not limited to, doing a deep dive analysis into how it will impact the ecosystem (from both a backwards and forward thinking perspective), how it would integrate with other feature work (either ongoing or planned), what it means for tooling, what kind of breaks it may require, whether the cost/complexity of the feature justifies the benefits of the feature, who is going to use the feature and how it will be used, etc. -- The team and community has tons of brilliant engineers and many features can have an MVP knocked together within a week, but even simple ones really take a full year to get properly designed, tested, and integrated throughout the ecosystem. Others that are more complex (such as generic math, const generics, discriminated unions, etc) can take years due to the impact and broader considerations around the ecosystem.