背景

张量计算在AI兴起之后已经是一个很流行的概念了,可以认为是任意维度的矩阵,是相比矩阵更为泛化的概念。

在当前C#/.NET这边,除了常用的传统矩阵运算库MathNet.Numerics,也有一些张量计算库,比如NumSharp是一个pure C#实现的Numpy的对应物,而Tensorflow.NET则是借助于Tensorflow的native库封装出来的机器学习框架,而其中提供了Numpy的接口,借助于native库的强大性能来实现高效。

其实上面几个库会有这样几个问题:

  1. MathNet.Numerics类型非常局限,并且只是矩阵实现,并非张量运算。
  2. NumSharp首先在效率上是有不小的问题的,这是源于其底层实现有一点不合理的地方,例如其矩阵乘的实现除了只有Naive算法之外,在代码实现上也不太合理,用switch语句对于每种类型组合都把算子实现了一遍;其次,它更像一个Numpy的binding,而不是一个C#/.NET库,在顶层API上有一点因为想要和Numpy一致所以生搬硬凑之嫌;最后,它有一些API是没实现的,比如bool索引的set方法。
  3. Tensorflow.NET相比NumSharp效率高了很多,实测256x512规模的张量运算快了大概1000倍,因为它直接用了tensorflow的native库,但这也使得包的发布比较麻烦;此外在一个机器学习框架里面封装一些和Numpy对应的功能,总感觉有些头重脚轻;最后则是顶层设计跟NumSharp差不太多,也有2中的问题。

当然,这些库也有其优点:

  1. MathNet.Numerics矩阵运算功能极为强大,API很丰富,比如求秩、求逆等操作都很方便。
  2. NumSharp是pure C#实现,较为轻量且易用,API也比较丰富。
  3. Tensorflow.NET效率很高,在当前的实现里面如果既要高维张量还要效率,用Tensorflow.NET是不错的。

我觉得想要用pure C#做张量计算,可能主要考虑的有以下几点:

  1. 能不能做出一个高效的实现,使其效率接近cpp的实现?
  2. 能不能在底层实现上比较优雅,不要用大量的重复代码或者手动代码生成?
  3. 能不能在顶层设计上实现得比较贴近.NET风格的同时完成Numpy所有的对应API?

上面的每一个单独拎出来我觉得都可以实现,但是如果想同时要这三点,并不是一件容易的事情。

在早些时候,自己用C++和C#构建了一个实验性质的框架Tensor.NET,用Cpp做底层计算,然后C#在上层进行封装,用PInovke和native库做交互。这个框架里面算子只进行了Naive的实现,但是Benchmark对比后发现比NumSharp在256x512512x192这个规模的运算上快了28倍,这就说明在底层设计上,其实NumSharp还是颇有一些问题的。

但是,在我自己搭建框架过程中也发现,这种交互的方式是非常不方便的,一方面开发过程中要同时兼顾两边的匹配,添加一个算子要改动五六个层次上的代码,非常不利于开源和协作,自己也不想这样写下去;另一方面发包很不方便;再就是这样其实不是做了一个比较优雅的底层实现,只不过是把脏活交给了cpp和宏定义而已。

在后来的实验中我发现用pure C#其实也可以做到上述规模的矩阵乘Naive实现相对于NumSharp的23倍左右速度,再加上C# 11中的static abstract member function以及generic math,给张量相关的运算其实提供了不少便利,于是我就有了用pure C#完成这样一个张量运算库的想法,但是思考设计的过程中仍然发现很多难处,本文则是对此的一点思考。

用泛型类还是非泛型类

在最开始,不妨抛开底层的设计和实现细节,先考虑对用户应该提供一个什么样的API,是泛型的张量类还是非泛型的?

如果是泛型的,那么则是new Tensor<double>(), new Tensor<int>()这种写法,如果是非泛型的,那么则是new Tensor(DType.Float64), new Tensor(DType.Int32)这种写法。

在Python的框架中,无一例外都是用了后者,但这是由于Python是弱类型的语言,在C#中非泛型写法有几个致命的缺陷:

  1. 无法正常使用Indexer。正常来讲我们期待用户可以通过t[2, 3]这种形式来取出或设置张量t的(2, 3)处的元素值,但如果是非泛型封装,显然办不到这一点,因为编译器根本无法确定Indexer的返回值类型。这里可能会想到一个思路是定义一个“标量”的封装,再提供各个基础类型到标量的隐式转换,但这样会引发类型不明确的错误,会遇到的问题可以可以参考此discussion。这里说的Indexer也包括布尔索引等,这里不展开细讲。
  2. 会给集成带来不便。且不说System.Numerics.Tensors这种原生库里面都是用的泛型类,就算是自己写程序,如果要限定某个张量是某一类型,如果没有泛型类也是非常不方便的。
  3. 不方便使用C#/.NET中的一些功能,比如Span, Linq等。虽然我们可以通过t.AsSpan<float>()这种方式提供API,然后在内部检查用户输入的泛型参数是否正确,但这样第一给用户带来了不便,第二没有在编译期提供任何保证,第三会产生定义模糊的行为,试想如果t实际数据类型是int,那么上面这种调用该不该接受呢?Linq则是更不用说,没有了泛型能实现的也只有非泛型的IEnumerable

当然,如果我们选了泛型的封装,也会有致命的缺陷:

  1. 张量操作函数的封装非常丑陋。这是因为会遇到"Type Deduce"的问题,当我们做A类型和B类型张量的运算的时候,我们并不知道返回值应该是A类型还是B类型,在C#中并没有C++中的delctype这种法宝。这一问题在我写Tensor.NET这个实验性框架的时候就遇到了,可以参见Tensor.NET中使用的扩展方法,解决方案是在扩展方法里面根据具体类型的不同依次书写函数(用到了手动代码生成),但这样是十分丑陋的,如果我们支持10个基本类型,那么就要有接近100个重载。
  2. 二元运算符难以设计。这一问题其实本质上和1是相同的,既然不能做到编译期的“Type Deduce”,那么加减乘除这些二元运算符当然也无法推断返回类型,并且比1中情况更严重的是,运算符不能作为扩展方法,这就使得这一问题连丑陋一点的解决方案都没有,只能用函数类代替,见Tensor.NET中的运算符折衷。dotnet 7提供了抽象静态函数和Generic Math,但仍不见得可以对此做出一个优雅的解决。

在设计Tensor.NET之初,也想过这样一种解决方案:

  • 非泛型类作为抽象类,而泛型类继承自非泛型类,然后提供从泛型类向非泛型类的隐式转换,以及非泛型类向泛型类的显示强制转换。
  • 运算符和张量相关的函数放在非泛型类这一层,而Indexer以及SpanLinq等特性的实现则放在泛型类这一层。

这样一来可以部分解决掉上面的问题,但最终我没有采用这个设计是因为,这样做看似是把两种做法的优点进行了结合,但是也带来了新的缺陷:这样会带给用户不小的理解成本。并且对于用户来讲,用int a = ((Tensor<int>)t)[2, 3]亦或int a = t.As<int>()[2, 3]仍然都是充满遗憾的写法,因为这样放弃了编译期的检查,把可能的错误推到了运行期,而为了尽可能保证运行期不发生错误,用户要么得自己额外进行防御性编程,要么得绞尽脑汁思考"Type Deduce"的问题。

最后,近来又觉得以前被我轻易抛弃的一种方案或许也不算坏:

  • 只采用泛型类,抛弃非泛型类,又或者,非泛型类只作为一种可选的封装。
  • 二元运算符只支持相同类型之间的操作。
  • 在泛型类主体中定义(同类型间的)张量操作,不同类型间的张量操作作为可选择的扩展方法。

这样的优点在于:API简洁,可支持IndexerLinq等特性,提供了编译期的检查。

这样的缺点在于:仍然把一部分事情交给了用户去做,并且在效率上会有一定损失。

不过我认为在实践中,用到不同数据类型张量来进行二元运算的例子实在不是很多,用户大多数情况下其实应该不会需要写出var c = a.dot(b.As<float>())这样的代码的。在效率上固然会有所损失,但一方面类型转换是线性时间复杂度,另一方面这个可以通过底层实现来解决,比如令As<T>()这个函数不做任何数据赋值的操作,而是改变TensorView<TFrom, TTo>这一抽象的类型,底层运算通过TensorView来完成。

总的来说,我认为在C#中实现张量运算库采用泛型会比非泛型在设计上要好,和强类型语言特性更符合,也更有利于集成。在上述这些设计中,近来我比较倾向于最后一种,可以在对用户影响较小的情况下实现泛型封装。

算子实现——C# 11后的分水岭

到了底层算子实现部分,需求会发生一些变化,在这里对性能会更加敏感,高效是我们的首要目标。

首先我们需要明确一下衡量自己的设计的效率高低的标准:

  1. 应该在算子都是Naive实现的情况下进行比较。
  2. 应该对大小规模的张量都进行比较。

如果一上来就去和一些成熟的张量计算库做比较是不合适的,因为算子实现的技巧在其中占了不小的比重。

那么接下来就分析一下C#中算子实现的痛点。

C#中实现算子最大的痛点在于C#是泛型而不是模板,对于C++,我们可以写出以下代码:

template<typename TA, typename TB, typename TC>
void add(TA* a, TB* b, TC* c){
    *c = *a + *b; // loop this
}

但是在C#中我们并不能写出下列代码:

void Add<TA, TB, TC>(TA* a, TB* b, TC* c) where TA: unmanaged where TB: unmanaged where TC: unmanaged
{
    *c = *a + *b; // loop this
}

这样做会提示加号运算符找不到定义,这就是面临的一大问题。

即便不考虑dotnet 7的Generic Math,这个问题其实也有个算是办法的办法:

void Add<TA, TB, TC, TM>(TA* a, TB* b, TC* c) where TA: unmanaged where TB: unmanaged where TC: unmanaged
    where TM: IAddable<TA, TB, TC>, new()
{
    TM m = new();
    m.Add(a, b, c); // loop this
}

其实思路很简单,就是定义一个专门用来执行运算的方法的接口,然后找一个类实现它,这样曲线救国完成算子实现里面的基本运算操作的支持。

但这样的实现方式有两个问题:

  1. 需要定义很多个这样的方法类,以上面为例,我们需要定义Addable<int, int, int>, Addable<float, int, float>等等各种运算符。当需要支持的类型很多时,其实这样一群方法类也是非常不堪的。
  2. 效率会有问题,如果阅读dotnet runtime的源码就会发现,像int,double之类的基础类型的加减乘除等运算符并不会给出显式的实现,而是编译器直接帮忙生成汇编。实测中也确实是这样,即使用上AggressiveInlining,也要慢上整整一倍。

C# 11/.NET 7提供了Generic Math,我们可以更优雅地完成这样一个过程,并且在实测中效率几乎没有损失。

void Add<T>(T* a, T* b, T* c) where T: unmanaged, IAdditionOperators<T, T, T>
{
    *c = *a + *b; // loop this
}

但这样仍然有两个问题:

  1. 只能支持同类型之间的运算操作。诚然,我们可以自己给某个类型定义很多接口实现,比如让一个类实现IAdditionOperators<T, U, W>, IAdditionOperators<T, M, N>等,但是这对于基本类型是行不通的,因为他们并不定义在自己的程序集里面。如果要强行如此做,只能用一个struct把基本类型包装起来,但这样做添加的麻烦是非常多的,而且效率上一定会受损。
  2. 不能向后兼容dotnet 6以前的版本,别说很常用的dotnet core 3.1了,就算是dotnet framework现在也有不少人在用。

其实对于第一个问题我觉得是可以接受的,就像上一部分讨论泛型类与非泛型类中的那样,我们可以进行转换,使得所有的操作化归为同一类型张量之间的操作。同理,在底层算子实现这一部分,也可以永远传入相同类型的指针(类型转换算子除外),由于这种转换过程是线性时间复杂度的,我们的实现效率,尤其是对于矩阵乘等非线性时间复杂度的算子而言,是可以接受的。

对于第二个问题目前实在没有找到好的解决方法,感觉只能使用预编译指令来将这两种情况区分开,对于所损失的效率,恐怕也只能咬牙接受。

总结

C#进行张量运算库的设计其实不止这些痛点,但目前思考还比较成熟的只有这两个方面,日后会继续更新本文。