Rust中几何代数的消失零

2020-12-07 03:18:26

这些是关于我上周对Rust所做的一些说明,在此过程中,我进行了一些高度实验性的工作,结果很可能会变得过于复杂。 (我很想知道其他编程语言以及Rust的其他实现方法!)但是它涉及一些很酷的想法-抽象解释,Rust中的类型级别编程,自动优化的数据结构以及一些3D图形的几何代数-和我想分享我学到的东西。

另外:这些注释中有很多代码片段,以及两个Rust Playground链接。目前,该代码几乎没有任何用处,几乎没有注释。这些说明是第一篇文档。

最近,我制作了一些动画(涉及Truchet磁贴和Hilbert曲线),但是我很懒,并在C中将它们砍在一起。笨蛋!我错过了在Rust中练习编码的好机会。

对于我的下一个动画,我将使用Rust,并且我想做一些3D图形。我不认为自从成为工作室以来就没有做过任何3D图形,所以我需要重新整理自己的知识并提醒自己它是如何工作的。

那时,标准3D图形技术是基于投影几何和齐次坐标的。我发现,如今四元数通常更适合进行旋转而不是使用矩阵。但是,甚至四元数也被取代。

几何代数是一个框架,可以概括诸如复数,四元数,向量,矩阵等之类的东西。它可以用来对事物进行建模,我只了解其中的几个:欧几里得和非欧几里得几何,射影几何,爱因斯坦空间几何。时间,量子力学等。我将把它用于3D投影几何,如SIGGRAPH2019教程中有关计算机图形的几何代数的幻灯片所述。您可以在biVector.net上找到SIGGRAPH课程笔记以及更多其他内容。我还发现Euclidean Space网站和Eric Chisolm的介绍很有帮助。我的朋友和同事Rich Warehamdid拥有几何代数的博士学位,用于计算机图形学,包括非欧几里德和共形几何。

这些注解更多关于Rust而不是几何代数。但是几何代数就是为什么我要用Rust做一些野性的事情。荒诞的事物回答了如何优雅而有效地表示几何对象的问题,但是是什么使最小或不雅呢?

几何代数与称为多重向量的对象一起使用。 在PGA3D(用于3D图形的投影几何代数)中,多矢量有5个部分: 1:一个矢量部分,由四个数字组成,在PGA3D中表示一个平面或该平面中的旋转,如四元数。 2:一个由六个数字组成的bivector零件,在PGA3D中代表直线,如Plücker坐标。 3:一个三向量部分,由四个数字组成,在PGA3D中代表一个点或方向,类似于均质坐标中的向量。 总共有16个数字,与我十几岁时用于3D图形的4x4转换矩阵相同。 好的,因此,一方面,我们有一种优雅的方法,可以根据包含16个数字的多矢量来描述各种几何对象和操作。 另一方面,在更传统的设置中,我们为点(4个数字),线(6个数字)和旋转(4个数字)有专门的对象。

在处理大量对象的程序中,我们希望它们尽可能紧凑,以免浪费内存或总线带宽。

而且,多重矢量所使用的额外空间令人讨厌地浪费:通常为零,如果我们知道多重矢量所代表的对象是什么,我们就知道它的哪一部分必须为零。

创建一个表示在运行时消失的零的类型:它们不占用空间。我可以实现Rust的运算符重载特性,以便可以以通常的方式在运算中使用消失的零。例如,当我将数字加零时,我会得到相同的数字:

impl Add< Num>对于零{类型,输出=数值; fn add(self,other:Num)->编号{其他}}

暗示Mul< Num>对于零{类型输出=零; fn mul(self,_:Num)->零{零}}

除了:我已经毫无意义地声明了类型别名类型Num = f64,以防我想更改它,即使我可能不会这样做。

使用我定义的一些帮助程序类型V0 ... V4,以防止我混淆各个部分。

通过将相应的类型参数设置为零,我可以使部分多矢量消失。

另外:该Multivec从源代码的不同部分可以具有不同的形状的角度来看,它是可变大小的,但是每种形状在运行时都是固定大小的。用Rust术语来说,所有实例均已调整大小。

高效意味着多重矢量专用于其表示的几何对象,并且任何已知为零的部分都消失了:它们不存在于内存中,并且不对其进行任何计算。

优雅意味着我编写的代码看起来可以与通用的多向量一起工作:我不必详细告诉编译器每个变量具有哪种专用类型,因为编译器可以自行解决。

当我的代码同时关心效率和优雅时,我应该能够以优雅的风格编写一个转换,而只需指定结果的类型。例如,我可能想确保旋转点仍然只是一点。如果我弄错了,编译器应该可以告诉我。

我想在没有太多机械的情况下做到这一点。将两个多重向量相乘,这是一个完整的几何积,是一个16x16→16的怪兽表达式。我想编写一次,然后让编译器针对各种特殊情况自动将其简化。

这个想法在实践中看起来像什么?让我们尝试一些简化版本的一些基本测试,该简化版本适用于复数而不是多矢量。

首先,效率:实部或虚部为零的复数应与正态数相同。

编译器已经为自己计算出实数和虚数部分都不为零,因此结果必须包含两个数字。

回到效率上:我可以添加复数而不必说任何关于结果类型的信息,而半角复数在不需要时不会变大。

令re2 = re + re; assert_eq!(size_of_val(& re2),size_of ::< Num>());令im2 = im + im; assert_eq!(size_of_val(& im2),size_of ::< Num>());

assert_eq!(re2,2.0 * re); assert_eq!(im2,im * 2.0); assert_eq!(im * im,-re); assert_eq!(re * im,im); assert_eq!(c * im,im-re); assert_eq!(c,1.0 + im);

编译器通常会有一些有关表达式输入的部分信息,并且它需要弄清楚该信息如何在表达式中传播。这是抽象的解释。

在我们的例子中,我们要跟踪的信息是一个值是否始终保证为零。当我们对simplescalar值进行运算时,传播零的规则很简单:

新增|零| Num Mul |零| Num ----- + ------ + ----- ----- + ------ + -----零|零|零号|零|零数字|编号Num Num |零|数词

用于乘以多向量的零规则要复杂得多!但是,当我们乘以复数时,大多数困难也以相同的方式发生,因此,我将解释如何以消失的实部和虚部实现复杂的乘法。稍后,我们将回到扩展到多向量的问题。

fn mul(a:复杂,b:复杂)->复杂{让Complex(ar,ai)= a;令Complex(br,bi)= b;复杂(ar * br-ai * bi,ar * bi + ai * br)}

作为编译器内置分析的一部分,抽象解释是编译器将对此表达式执行的操作。我想在编译器之上而不是内部实现我自己的零度分析,所以我不会像这样进行抽象解释。取而代之的是,除了这个描述如何在运行时将数字相乘的表达式之外,我将编写第二个表达式来计算在编译时的零值。

第二个表达式在Rust的类型系统中进行计算。它计算出运行时表达式各部分的类型(零或数字),以描述我们在编译时知道的哪些部分在运行时始终为零。然后,编译器使零值和表达式消失。

类型级别函数的参数是(隐式)Self类型,我们将为其实现特征。类型级别函数的结果是关联的类型Output。 (它可以有任何名称,但Output是常规名称。)

该Sum类型级别函数的主体是所有impl Sum traitimplementation。每个impl Sum都有一个自类型,这是其自变量模式,而实现的主体是该自变量的结果。因此,使用模式结果子句的集合定义类型级别的函数,就像Haskell中的函数一样。

例如,如上表所示,这是我们对两个值进行加法(或减法)的零度分析的类型级别函数定义。

(零,零){类型输出=零; }(0,Num)的impl Sum {类型Output = Num; }(Num,Zero)的impl Sum {类型Output = Num; }(Num,Num)的impl Sum {类型Output = Num; }

在我的Sum类型级别的函数中,我使用了一个元组将两个参数捆绑为一个参数。但是,正如我即将与Mul一起演示的那样,我们还可以按某种Cureded非对称样式定义类型级别函数。

稍后,我将展示如何调用类型级别的函数,但是首先,我发现我需要的某些类型级别的函数是由Rust预先定义的。

在我努力使之起作用的早期阶段,有一次,我有自己的求和和乘积的类型级函数(类似于上面的Sum),守卫着一个用于计算运行时间结果的表达式,但其余的类型是-级别的机械仍不见了。

现在,在Rust中,当您编写一个做算术的(普通)泛型函数时,需要在类型参数上加上特征界限以告知编译器该算术可以工作。例如,这里的Mul特征值告诉编译器*运算符将对T类型的值起作用。

struct Bar T(T); fn babar T(a:Bar T,b:Bar T)- Bar< T>其中T:Mul<输出= T>。 {Bar(a.0 * b.0)}

另外:我希望Rustaceans会认为这是对特质界限的非常奇怪的描述。是!是的。但是我希望这种奇怪的观点在我们进行类型级函数调用和表达式时会更有意义。

我得到了Rust编译器来帮助我填补缺失的typemachines,当我意识到错误消息告诉我为Mul添加与我自己的抽象抽象零型函数完全平行的特征范围时,我感到很高兴。

为了在运行时实现零运算符重载的运算符重载乘法(或更确切地说,告诉编译器可以优化它们),我需要一些std :: ops :: Multrait的实现:

暗示Mul零。对于零{类型输出=零; fn mul(self,_:Zero)->零{零}}表示Mul< Zero>。对于Num {类型,输出=零; fn mul(self,_:Zero)->零{零}}表示Mul< Num>对于零{类型输出=零; fn mul(self,_:Num)->零{零}}

Rust不假定运算符是可交换的,因此我需要两个实现Num * Zero和Zero * Num。 Rust的f64 * f64实现已经涵盖了Num * Numcase。

Rust编译器正在对myrun-time表达式进行自己的抽象解释,以证明重载的运算符具有必需的实现,并找出其输出类型是什么。

我的类型级别零度计算必须与我的运行时表达式相对应,并且它需要计算相同的输出类型,因此它可以搭载在现有的操作员重载机器上!

真是太好了!而且,我喜欢Rust编译器推动更好的设计的方式。 (这已经发生在我身上几次了!)

但是,一切都不是甜蜜和淡淡的。我遇到了一些问题,这些问题使我的类型表达式爆炸并变得难以处理。让我们看一个简单的例子,然后将清楚类型是如何出错的。

通常,在定义重载运算符时,您要么处理具体类型(如上述用于零和数字的Mul的实现),要么处理参数和结果类型相同的通用类型(如上述babar()所示)。无论哪种方式,类型表达式都保持相当简单。

但是,我消失的零必须完全通用,因为我数字的每个部分可能都是零或Num。这是乘以复数时的样子:

使用std :: ops :: {Add,Mul,Sub};结构群< R,I>(R,I); impl< Ar,Ai,Br,Bi> Mul< Br,Bi>用于络合物< Ar,Ai>。其中Ar:复制,Ai:复制,Br:复制,Bi:复制,Ar:Mul Bi,Ar:Mul Bi,Ai:Mul Br,Ai:Mul Bi,Ar作为Mullt ; Br> ::输出:以A 1表示为Mul Bi::Output; Ar表示为Mul; Bi::输出:Add <Ai表示为Mul。 > :: Output>,{类型Output = Complex< << Ar作为Mul< Br>> ::输出作为Sub< < Ai作为Mul< Bi> :: Output> ::: Output,< Ar作为Mul< Bi>> :: Output as Add< < Ai as Mul< Br> ::: Output>> ::: Output,> fn mul(自身,其他:配合物Br,Bi)-> f n mul。 Self :: Output {让Complex(ar,ai)= self;令Complex(br,bi)=其他;复杂(ar * br-ai * bi,ar * bi + ai * br)}}

我们对复杂的隐式Mul也完全通用,每个参数的每个组成部分都有四个独立的类型变量。

这些行说我们需要能够复制每个参数的每个部分,以便可以在运行时表达式的两个部分中使用它们。 (因此,对编译时间不感兴趣。)

这些行说我们需要能够将a的每个部分与b的每个部分相乘。这些特征边界有两件事:一是允许我在运行时进行乘法,如ar * br中所述;二是让我调用类型级函数在编译时求零。

Ar的种类含义:Mul Br。是一个声明,我要使用参数Ar和Br调用类型级别的函数Mul。

此表达式是实际的类型级别的函数调用。它从trait实现中提取调用的结果,它是Output关联的类型。它对应于运行时表达式ar * br的类型(和零度)。

给定我们四个乘法的结果,我们需要做一个加法和一个减法。这有点毛茸茸:

这使我可以在运行时表达式中编写ar * br-ai * bi,并在编译时可以计算结果的实部类型。该类型的缩写形式如下:

并且您可以在Output = Complex< ...>的定义内看到它的全部内容。相关类型:

好的,如果您将运行时计算的大小与编译时计算的大小进行比较,那已经很不愉快了。如果我们尝试将其扩展到多向量,会发生什么情况?

很好,每个多向量参数具有5个泛型类型参数,因此impl Mul具有10个类型参数,并且其特征范围列表为25。组合。

但是真正的问题在于总和。在一个完整的几何积中,有16个结果表达式,并且许多表达式是16个积的和。

在特征范围内,我们需要将每个子表达式的类型完整,完整地编写两次,并且无法缩写该类型:

卫生阻止了我们使用C样式的文本宏来通过单个宏调用生成诸如多个特征范围之类的事物。

我在尝试寻找一种方法来说服Rust编译器方面付出了很多努力,该方法肯定可以自己编译出这些中间类型,但是没有,它必须由手工来完成。

编译器发现它很困难,因为它正在处理一个开放世界。与Rust枚举进行对比的模式匹配:我们知道可能性的集合是固定的,因此编译器可以检查模式是否详尽。但是有了类型变量的特征界限,就可以有人来定义新的类型和新的特征实现,从而打破了通用函数中的假设。因此,必须写下并检查这些假设。

我想知道是否可以说服编译器说通用类型参数只能是零或Num,而不能是其他类型,但是我们只能说在特征范围内该类型必须实现一个特征,并且特征可以扩展。

拆分每个结果部分的运行时表达式(对于复数,实数部分或虚数部分),并使它们完全具体。这意味着无需说服编译器允许我将f64添加到f64。

根据结果​​类型(而不是参数类型)选择要调用的每个结果表达式的具体版本。结果的每个部分可以是零或数字。因此,对于每个部分,我只需要两个具体的实现:无操作零或完整的结果作为Num表达式。

更多地依靠优化器。上面的Complex Mul使用的是重载运算符,当两个自变量中的任何一个为零时,它们都被定义为无操作。在具体版本中,代码表示在执行计算之前,参数中的每个零都应消失为0.0;我假设将对genericmultiply进行单态化,将内联具体的结果表达式,并且恒定的传播会将涉及零参数的表达式转换为no-ops。

现在,将类型级别的零度计算与运行时结果计算分离。这使我可以使多向量类型级别的计算更加抽象和简单,因为它只需要谈论部件的5x5组合,而不是完整的16x16乘法表。 (complexnumber版本太简单了,无法产生任何效果。)

使类型级别的函数尽可能平坦和宽泛。每个子表达式在特征范围中都需要更多子句,因此,较宽和较浅的表达式所需的行数少于较窄和较深的表达式。

让我们看看在复杂乘法的简单设置中这些更改的外观。额外的机制使代码相当大,但它的线性开销适中,因此对于多向量而言,与天真设计的二次增长相比,最终变得更小。这些代码段摘自RustPlayground的完整版本

对于我们的具体输出表达式,有一个特征,它对结果的每个部分都有作用。 (这是普通特征而不是类型级别的功能特征。)

特质CMul A,B。 {fn re(_:A,_:B)->自; fn im(_:A,_:B)->自; }

此特征是针对零和数字的返回类型实现的。对于结果的每个部分分别调用它,并且每次调用可以具有不同的结果类型。调用看起来很简单,因为在其他地方指定了类型:

impl< A,B> CMul< A,B>对于零{fn re(_:A,_:B)->自我{零} fn im(_:A,_:B)->自我{零}}

CMul for Num实现有两件事:将部分消失类型的参数转换为完全水合的Num-everywhere混凝土类型,然后对混凝土类型进行计算。在多向量的情况下,表达式比转换要大得多。

类型ComplexNum = Complex< Num,Num&gt ;; impl< Ar,Ai,Br,Bi> CMul络合物Ar,Ai,络合物Br,Bi。对于Num,其中ComplexNum:来自< Ar,Ai&gt ;, ComplexNum:来自< Br,Bi,Bi,{ )->自我{让Complex(ar,ai)= ComplexNum :: from(a);让Complex(br,bi)= ComplexNum :: from(b); ar * br

......