给 .NET 实现了 Const Generics

303 天前
 hez2010

.NET 的泛型一直以来只能传递类型,而不能传递值。

在 Const Generics (常量泛型)中,允许常量被作为泛型参数传递到泛型变量中,代码会根据常量参数而进行特化,从而确保无开销,并可以直接在代码中作为常量来使用。这个特性在 Rust 中也叫做 Const Generics , 而在 C++ 中叫做模板值特化。

这个特性在各种数值计算、图形/游戏编程、AI/ML 等场景都及其有用。

于是我最近花了一些时间,给 .NET 实现了原生的常量泛型支持,也就是说,.NET 的泛型支持传递常量了!并且我是直接在底层 IL 和 runtime 的层面提供原生的支持,意味着 .NET 上的任何语言都将能够享受到常量泛型。

首先来看一个最基本的例子:

.class public sequential ansi sealed beforefieldinit Test`1<literal int32 T>
       extends [System.Runtime]System.ValueType
{
    .pack 0
    .size 1

    // 我们定义的 Add 方法
    .method public hidebysig newslot virtual 
        instance int32 Add<literal int32 U>() cil managed
    {
        .maxstack 8

        ldtoken !T
        ldtoken !!U
        add

        ret
    }
}

上面这段代码简单翻译成伪 C# 代码就是:

public struct Test<int T>
{
    public int Add<int U>()
    {
        return T + U;
    }
}

现在我们调用 new Test<1>().Add<2>() 看看效果。

Main 函数代码:

.method private hidebysig static 
        void Main () cil managed 
{
    .locals init (
        [0] valuetype Test`1<int32 (1)>
    )

    .maxstack 8
    .entrypoint

    ldloca.s 0
    dup
    initobj valuetype Test`1<int32 (1)>
    call instance int32 valuetype Test`1<int32 (1)>::Add<int32 (2)>()
    call void [System.Console]System.Console::WriteLine(int32)

    ret
}

运行后输出 3

再看看 Main 函数的反汇编:

sub      rsp, 40

mov      ecx, 3
call     [System.Console:WriteLine(int)]
nop

add      rsp, 40
ret

非常干净!可以看到,等价于直接调用 Console.WriteLine(3),我们的常量泛型参数确实被当作常量处理,于是直接在编译期就计算完结果了。

如果我们手动禁止 Add 方法被内联的话,可以看到 Add 方法的反汇编代码:

mov      eax, 3
ret

你会发现,Add 方法的代码居然直接就返回了 3!这是因为我在实现 .NET 的常量泛型时,为不同的常量泛型参数都进行了特化,因此这个 Add 方法实际上是 Test<1>.Add<2>,所有的常量泛型参数都是编译期已知的。

除了上面的简单例子,我还实现了虚方法的支持,因此子类型多态在有常量泛型的场景之下仍然能够正常工作。

另外,在常量泛型参数类型上的泛型我也一并实现了,于是你可以写类似下面的代码:

void Foo<T, T Value>()
{
    Print(Value);
}

[MethodImpl(MethodImplOptions.NoInlining)]
void Print<T>(T value)
{
    Console.WriteLine(value);
}

我们调用 Foo<int, 42>Foo<double, 12.3>,可以得到以下输出:

42
12.3

上面两次方法调用同样分别对 4212.3 特化出了两个不同的 Foo 的代码。

42 版本的 Foo

sub      rsp, 40

mov      edx, 42
call     [Test:Print[int](int):this]
nop

add      rsp, 40
ret

12.3 版本的 Foo

sub      rsp, 40
vzeroupper

vmovsd   xmm1, qword ptr [reloc @RWD00]
call     [Test:Print[double](double):this]
nop

add      rsp, 40
ret

; RWD00   dq      402899999999999Ah

上面这些预计最早明年的 .NET 9 就能正式和大家见面。

除此之外,目前我还在着手设计和实现常数泛型的算术约束和算术依赖类型相关的支持。

例如,有了算术约束,将能够约束常量泛型参数值,比如可以约束 N > 10,又或者 N > U && N < U + 10 等等。而有了算术依赖类型,将能实现诸如 Array<T, N + 1> Push<T, int N>(Array<T, N> array, T elem) 的方法。

相信这些基础设施将能为 .NET 的类型系统带来更好的灵活性和表达力,使得 .NET 上所有的语言都能够从中受益。

最后是一些感想。

这次实际上接触和编写了 CoreCLR (.NET 的运行时)的源代码之后,发现尽管 runtime 核心( type loader 、jit 等等)是 C++ 写的,但是代码居然出乎意料地干净易懂,注释也写得非常的详细,上手和调试都很容易。代码里面定义了很多宏用来做 contract ,只需要摆在函数的最开头就行,可以自动验证各种前置/后置条件,以及对 GC 、异常行为等等进行约束,有点类似高级版本的 C++ concepts (之所以说高级版本是因为这些宏既能做编译时验证也能做运行时验证)。

除了注释写的很详细之外,代码中还有大量的 assert ,这些 assert 给我实现 Const Generics 带来了巨大的帮助,因为通过这些 assert 你能立马知道哪里需要修改、哪里做错了等等,甚至不需要了解全部的代码,只通过 assert 就能知道一处代码的改动会影响哪些地方。而且这些 assert 只在 debug 时生效,所以对于实际的性能也没有任何影响。这比从单元测试来猜测试所跑的代码路径中哪一部分出了问题方便多了。

不过 .NET runtime 源代码同样是禁止使用 C++ STL 的,但是代码仓库里面有各种他们自己实现的 utils ,例如 LookupMap 和 Hashtable 等等,用起来非常方便。

另外 .NET 还有 JIT Dump 这种非常好用的设施,不需要挂着调试器就能观察 JIT 的编译过程,有点类似 LLVM Opt Pipeline 但是比 LLVM Opt Pipeline 输出的东西更详细,从 IL 导入到 Tree/IR ,到 SSA 的构建,到 inline 决策,到各种优化 pass ,再到寄存器分配过程全都一目了然。

最后就是非常感谢 .NET runtime 的官方开发人员和社区的开发人员,在我实现 Const Generics 的过程中给了我非常大的帮助,提出的问题也能很及时地得到回应,同时还帮我提意见和测试,使得我能够不断地完善 Const Generics 的设计和实现。

多亏了上面这些,给 .NET 实现 Const Generics 的过程非常顺利。不得不说这个 runtime 是真的写得很棒。

2598 次点击
所在节点    程序员
32 条回复
thinkershare
302 天前
@hez2010 要做算术约束了嘛?现在一堆重载太丑了。
hez2010
302 天前
@thinkershare 算术约束我已经另一个分支实现了 prototype ,只不过最终实现方案还有待讨论。
thinkershare
302 天前
@hez2010 非常需要这些针对泛型的增强功能,我本来以为 C#6.0/7.0 就应该修改 runtime ,然后实现约束。结果 C#6-C#9 更新的都是些我不怎么 care 的,除了 ref/span ,其它都是些不痛不痒的特性。
leeg810312
302 天前
好牛 X ,期待在后续版本中体验这个特性
nikenidage1
302 天前
主要是 CLR 或者说 IL 始终没有更新,从 2.0 开始就这样,导致很多特性功能束手束脚
geelaw
297 天前
一个小问题:此前我实现这种泛型的时候,都是直接把所有的常量都塞到一个 struct 里面,现阶段的 JIT 编译器不能成功优化吗?

public interface IConstants
{
public int V { get; }
}

public class G<T> where T : IConstants
{
public static void Run() { Console.WriteLine(default(T).V); }
}

public static class Program
{
struct C : IConstants { int IConstants.V { get { return 1; } } }
public static void Main() { G<C>.Run(); }
}

我感觉最简单的实现方式是把 const generics 弄成语法糖……?
hez2010
295 天前
@geelaw 弄成语法糖显然是不行的,因为编译器生成的类型只会在它所在的项目编译出来的程序集里存在,假设你在 A 项目里用了个 `Foo<42>`,而 B 项目里用了个 `Bar<42>`,你会发现尽管 42 在两侧值都是一样的,但实际上不是一个类型(前者是 `struct A.IntConstant42`,后者是 `struct B.IntConstant42`),于是相互是不兼容的,于是无法相互传递。而且由于你在 A 处压根不知道 B 处的 `IntConstant42` 类型,隐式转换的代码也是无法生成的(隐式转换操作符不支持从接口转换,也不支持泛型)。
另外就是,这么一来,如果你在运行时通过反射调用包含 const generics 的 API ,那你就不得不现场 emit IL 产生一个新的类型,这和 nativeaot 就是完全不兼容的,通过使用 rd.xml 也无法解决。
geelaw
295 天前
@hez2010 #27 第一个场景确实(这个类似于匿名类型的情况),但我没有理解第二个场景(反射 const generics )。

如果用反射为新的 T 调用 class G1<T> where T : struct { } 当然要现场生成代码。
用反射为新的 int 值调用 class G2<int t> { } 也需要现场生成新代码对不对?
hez2010
295 天前
@geelaw 不是的,对于后者,新的值只需要提前提供 instantiation 的参数就能让编译器提前生成代码,比如 42 ,你可以通过 rd.xml 之类的方法来指定需要的泛型参数,就能使得从任何地方调用 `G2<42>` 成功,例如:
```xml
<Type Name="G2`1">
<GenericArgument Value="42" Type="System.Int32" />
</Type>
```
而前者的话无法通过 rd.xml 来指定,因为在编译期对运行时创建的新类型一无所知,通过 rd.xml 只能指定类型的名字,而无法编写类型的实现。
geelaw
295 天前
@hez2010 #29 那结论是如果希望用旧方法指定泛型参数的话就要在编译到原生代码的时候提前把类型建立出来,类似于用 rd.xml 设置所有可能需要的值的方式。
hez2010
295 天前
@geelaw 提前建立出来也是有最开始提到的问题的,建立出来的类型一定是在某个程序集中的,于是 A.Integer42 和 B.Integer42 不是同一个 42 ,于是会导致各种类型上的问题。而原生的 const generics 则不存在这个问题。
到头来原本想通过语法糖的方式简化实现,结果反而将一大堆东西复杂化了,最后结论是这个东西一定不能做成语法糖。
hez2010
295 天前
@geelaw 其实有一个非常简单的方法用来判断一个特性能否不需要修改 IL 来实现。只要一个特性涉及到跨 ABI ,那就不能只在上层语言层面做实现。

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/962851

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX