编写高性能F#代码

2021-02-06 19:47:07

尽管这篇文章是针对F#.NET开发人员的,但它介绍了从硬件体系结构到总体.NET运行时和JIT编译器优化的更广泛的概念。不应感到意外-优化应用程序性能需要我们了解高级代码与硬件上实际发生的事情之间的关系。

普遍认为,F#代码必须比等效的C#代码慢。这种观点大多是错误的,但是有一些道理。通常,比较不会在两种语言中使用等效的代码,并且F#通常具有更高的层次和声明性。 "惯用语" F#代码无法始终与.NET虚拟机配合使用。在.NET平台上编写高级,声明性和快速的代码不是一件容易的事。

在下面的示例中,我们将使用一些通用工具来帮助我们更好地了解F#代码的性质:

Sharplab使我们可以轻松检查给定F#代码段的生成的JIT中间表示形式,汇编或什至等效的C#代码(有时是近似的,因为并非所有IL习惯用C#都可以表示)。对于汇编代码,通常需要对参数进行一些额外的处理才能生成代码,因为SharpLab有时无法自省F#核心库代码。

使用对象布局检查器,我们可以看到结构和类将如何在内存中实际表示。

BenchmarkDotNet是编写微型基准测试的非常流行的库。我们将使用它来显示代码的堆分配和执行时间。

在开始执行优化代码的任何工作之前,对执行中的应用程序进行正确的分析是至关重要的-毫无意义地将最后可能的CPU周期从执行了0.1%的时间的功能中剔除掉。

请记住,对于大多数日常业务应用程序而言,解决性能问题的第一种方法是减少明显的错误(例如,用一个替换多个I / O请求,编写更有效的数据库查询等)。如果不是这种情况,那么令人满意的解决方案的下一步就是简单地编写命令式代码-以便更轻松地为编译器而不是人员进行推理-或选择更适合的数据结构。这在F#中尤其普遍,在F#中,我们可以观察到&t列表之类的类型的普遍使用(提示:如果您正在寻找要使用的集合并希望获得良好的性能,则F#列表几乎永远不是一个好答案) 。在这里,我们将更深入,进入与预制数据类型和算法竞争的领域。

.NET运行时用来获得最高性能的一项重要性能提升,它通常是通过使用值类型而在竞争最终性能方面优于其他托管虚拟机(如JVM)。因此,如果我们要快速发展,我们首先需要了解它们的工作方式。

.NET结构表示类型,这些类型不会在托管内存堆上单独分配,而是内联在包含范围内(在字段的情况下为类的实例,在变量中为线程堆栈等)。这意味着通常在高分配方案中它们更便宜且更易于访问。

从历史上看,在使用值类型时,F#代码的前景并不乐观。如今,我们得到了诸如struct tuples-struct(' a *' b)之类的东西,不幸的是,尽管实际上在使用tuple时它们应该是首选,但它们在F#中并未得到广泛使用。并且[< Struct ]属性,该属性可用于记录和有区别的联合(我们稍后会再介绍给他们),使用它们变得更加可行。

但这并不一定意味着用值类型替换所有引用类型将使我们的代码神奇地运行得更快。实际上,这可能恰恰相反。为什么?想象一下,当我们想要传递记录作为参数时会发生什么。怎么做?通常将对象传递给函数是通过复制对该对象的引用来实现的,该引用是4B或8B,具体取决于我们的操作系统是x86还是x64,因此完全适合标准CPU寄存器。

类型A()=类[< DefaultValue>] val可变x:int [< DefaultValue>] val可变y:int [< DefaultValue>] val可变z:int枚举打印(值:' t) = System.Console.WriteLine(value.ToString())let a = A()// sub rsp,0x28 // mov rcx,0x7ff91b23d1a8 //调用0x00007ff9730aade0;在堆打印上分配A // mov rdx,rax;复制对a的引用到堆栈// mov rcx,0x7ff91b23d5f8 //调用_.print [[System .__ Canon,System.Private.CoreLib]](System .__ Canon)

现在,如果我们正在使用结构呢?对于引用类型,我们将对象的引用复制到堆栈上-由于引用只是单个地址,因此它始终可以装入寄存器并可以在单个操作中完成。对于值类型,我们将复制整个值。如果它们不适合注册,我们将必须分多个步骤复制它们。

类型B = struct [< DefaultValue>] val可变x:int [< DefaultValue>] val可变y:int [< DefaultValue>] val可变z:int端点b = B()// sub rsp,0x38 / / xor eax,eax;零场b.x // xor ecx,ecx;零场b.y // xor edx,edx;零场b.zprint b // lea r8,[rsp + 0x28] // mov [r8],ecx;将字段b.x复制到堆栈// mov [r8 + 4],eax;将字段b.y复制到堆栈// mov [r8 + 8],edx;将字段b.z复制到堆栈// lea rcx,[rsp + 0x28] //调用_.print [[_ + B,_]](B)

每个步骤都是一条机器指令,需要花费一些时间来执行。但是,有时.NET可以优化指针大小的寄存器,而不仅仅是现代机器中可用的寄存器。我们还有一个特殊用途的SIMD(单指令多数据),它更大并且可以使用,只要传递的数据完全适合它们即可。

类型C = struct [< DefaultValue>] val可变x:int [< DefaultValue>] val可变y:int [< DefaultValue>] val可变z:int [< DefaultValue>] val可变zz:int end let c = C()// sub rsp,0x48 // xor eax,eax;零寄存器// mov [rsp + 0x38],rax;初始化字段b.x和b.y以及零寄存器// mov [rsp + 0x40],rax;初始化字段b.z和b.zz以及归零的寄存器打印c // vmovupd xmm0,[rsp + 0x38];使用SIMD寄存器将所有4个字段一起复制到堆栈上// vmovupd [rsp + 0x28],xmm0 // lea rcx,[rsp + 0x28] //调用_.print [[_ + C,_]](C)

.NET中还有另一件事,它使我们可以解决传递结构的效率低下的问题,即所谓的by-ref参数。其中有3种类型,分别使用&t inref,< outref和&#39bybyref进行标记:

let print(value:' t inref)= ... let c = C()print& c // lea rcx,[rsp + 0x28];将结构头的地址复制到堆栈// //调用_.print [[_ + C,_]](C ByRef)

' ref实际上是Ref" a>的别名。类,因此在堆上分配并通过引用传递。通常,在F#中很少使用此类(除了编写示例性惯用代码)。

一个byref等效于C#ref参数标记-这意味着我们正在将引用(内存地址)传递给对象或结构。它期望将其初始化,并可用于更改基础值的内容。因此,F#要求使用mutable关键字声明作为byref传递的字段和变量。

' outref等效于C#out参数标记-必须始终在函数主体的末尾对其进行初始化。这听起来有些棘手,因为F#并未明确提出该要求。如果我们没有在任何代码分支中进行分配,则F#编译器将为我们简单地使用默认值对其进行初始化(就像使用Unchecked.defaultof< _>一样),这有时可能会导致空引用异常。

' inref是其中最年轻的,并且等效于参数中的C#-在不必为该参数的参数传递的C#结构中必须对其进行标记的情况下,F#始终需要标记通过ref进行传递(使用&传递的参数的前缀),以byref / inref / outref标记的任何参数。 inref基本上是一种针对上面所见内容的优化技术-它允许我们仅使用其内存地址将结构传递给函数,而无需复制整个结构内容。另外,inref表示该参数被视为只读参数,因此无法在函数体内进行修改。 .NET JIT在某些情况下可以利用此信息来减少安全检查的次数,从而减少要执行的指令的数量。

虽然在编写针对复杂值类型的代码时通常使用by-ref参数是个好主意,但它有一些限制。

一种是使用by-ref参数传递的参数不能被闭包/ lambdas /匿名函数捕获,这阻止了它们在更抽象的代码中使用:

// WRONG!let doSomething(a:int inref)= [1..10] |> List.map(fun x-> x + a)// // a被闭包捕获,这是编译错误// RIGHTlet doSomething(a:int inref)=让可变结果= []将x = 10降至1执行结果<-(x + a)::结果

即使对于常见的内联函数(例如),也存在问题。管道运算符|>。这就是价格,我们必须为速度付出代价(至少目前如此)。

第二个问题是,当前by-ref参数不能参与构建嵌套函数(无论它们是否从外部作用域中的函数中获取值)。这在使用诸如尾递归循环模式的情况下再次使它们使用起来非常不方便:

// WRONG!let doSomething(a:' a)=让rec循环n(x:' a inref)= //如果n = 0则编译该嵌套函数,然后()else循环(n-1)& x循环100& a // RIGHTlet rec循环n(x:' a inref)=如果n = 0则()else循环(n-1)& x let doSomething (a:' a)=循环100& a

在讨论by-ref参数时,.NET(和最新的F#)使我们能够做更多的事情-我们可以定义所谓的by-ref结构和只读结构:

[< Struct; IsByRefLike; IsReadOnly>]类型BufWriter< a> = // //由于BufWriter是by-ref结构,因此它可以具有by-ref类型作为字段// //否则将导致编译错误[< DefaultValue>] val缓冲区:ReadOnlySpan<' a> ///默认情况下,F#记录和已区分的并集标记为IsReadOnly [< Struct; IsByRefLike>]型BufWriter< a> = {缓冲区:ReadOnlySpan< a> }

[< IsReadOnly>]属性让我们将给定结构定义为只读。出于明显的原因,这也意味着相应的数据类型不能在其中包含任何可变字段。

它只是一种轻微的优化技术-有时.NET JIT编译器必须保证不会修改结构内容。为此,即使使用inref参数将其传递到函数中,它也会保守地复制该结构。如果已用[< IsReadOnly>]属性标记struct,则编译器可以跳过此步骤并避免构建防御性副本。你可以在这里读更多关于它的内容。

[< IsByRefLike>]是另一个属性。我们谈论了很多有关使用内存位置地址而不是进行深拷贝的值类型的传递。使用该属性标记struct基本上是说"我一直想通过引用传递此值。当然,这带有严格的限制:不能将其装箱(移至托管堆),因此,它决不能被闭包,实现接口捕获,也不能用作类或其他非按引用结构中的字段。

就F#而言,这基本上意味着此类结构主要用于在函数体内立即执行的代码,而无需计算表达式或其他间接方式。这通常使它们有资格进入我们代码中的热路径,在这些热路径中,预计会占用大量CPU且不欢迎分配,例如:

for .. in循环-实际上,许多现代的.NET结构都有GetEnumerator的特殊变体,该变体不分配任何内存,而是作为by-ref结构实现的。 F#还了解这种模式-实际上,您可以为您的集合定义自定义GetEnumerator():MyEnumerator方法,而MyEnumerator-甚至可以是引用结构-具有两个方法:Current:' item和MoveNext:unit-&gt ; bool,F#将自动了解如何在循环中使用它。您可以在此处看到它的示例实现-它是持久性矢量数据类型实现的一部分,类似于FSharpX持久性矢量,但是它的速度提高了4.5倍,并且在循环中执行时不会在堆上分配任何内容。

字节共享操作周围的上下文数据。与解析/格式化有关的所有事物都可以利用该技术来优化速度并减少分配。它也用于各种使用I / O的驱动程序中。

虽然我们在这里使用显式的类/结构类型定义,但是从.NET运行时的角度来看,记录和结构记录的内存布局是完全相同的(对于区别联合,这是不同的,但是我们很快会介绍一下) )。

值得注意的另一点是.NET对类的数据大小有自己的假设。我们来看一个例子:

类型A = {x:int; y:int}类型B = {x:int; y:整数; z:int}

您如何看待A和B的大小?天真的,我们可以假设B实例比A类型的实例大4个字节。但是,这并不总是正确的。让我们检查两个类的内存布局:

如您所见,这两个类均以16字节的对象标头和vtable指针开头:这是每个类(和盒装结构)所必需的。它们使诸如方法重写或锁定对象之类的事情成为可能。然后,我们得到了实际的类内容:如果是A,则2 * sizeof(int)= 8个字节;如果是B,则3 * sizeof(int)= 12个字节。如果是B,您还可以看到4个额外的填充字节。它来自哪里?

在管理堆大小时,.NET GC / allocator进行了一些简化。即,它分配的内存块是标准指针大小的乘积,在32位OS中为4字节,在64位OS中为8字节。因此,在实例化对象时,GC将始终为它们分配足够的空间以封装所有字段并使其适合4/8个字节的上限:由于当今大多数服务器都使用64位,因此我们重新谈论16 + 8字节,16 + 16字节,16 + 24字节等的存储桶。

有趣的是,此填充要求与非托管结构(仅由其他值类型组成的值类型)无关。如果我们将记录B修改为结构:

,我们将看到它仅占用12个字节。如果考虑到对象标头,则该空间比基于类的记录少2.5个以上的空间,并且没有堆分配,因此以后不需要GC。请记住,将引用类型(例如字符串)添加为struct字段将导致其再次添加填充。在这种情况下,节省空间是由于缺少对象标头/ vtable指针。

现在,如有必要,我们还可以手动将填充应用于结构。虽然在。 Java您需要添加多余的额外字段来做到这一点,在.NET中,我们可以提示运行时有关预期的结构大小:

StructLayout具有许多有用的属性,即它为手动定义类型中每个记录字段的位置打开了大门。它还公开了Size属性,我们可以使用该属性手动说明结构的预期大小-在这种情况下,创建结构时,运行时将显式添加额外的字节用于填充。但是,我们需要它做什么呢?我们很快就会回答。

我们需要更深入一点,并进入硬件领域。初级程序员经常被教导将计算机内存视为单个同质块。这是一个方便的谎言,尤其是因为语言-即使像C这样的低级语言-都很少公开任何基元来对其进行操作。从计算机体系结构类中,您可以了解到内存被分为几层-从RAM到L1-L3缓存。

事实是,对L1的访问时间可能比对主存储器(RAM)的访问时间快几十倍。因此,当驻留在主存储器中的数据将由CPU使用时,它将首先加载到高速缓存中。硬件在这里下了一点赌注:假设一起使用的大多数数据彼此之间紧密地位于主内存中。因此,它不只是加载单个对象引用-它甚至不知道它是什么-而是随后的整个数据块,即所谓的缓存行。在现代硬件上,高速缓存行通常为64个字节长。

我们之所以讨论struct这么久的原因之一是,像A []这样的实体集合的行为有很大不同,这取决于A是类还是结构:

如果A是一个类,则意味着A []仅包含对对象的引用,这些对象的实际内容可能位于完全不同的内存位置。鉴于.NET GC的性质,当在不同线程上创建它们时,您可以确定它们不会放置在一起。这意味着在遍历它们时,您可能需要从内存中的不同位置多次加载它们。

如果A是一个结构,则A []将包含A的内联值,并且它们的所有内容依次相邻存储。

关于高速缓存行的一件事是,在代码的微基准测试期间可能导致误导的结果。考虑简单的操作,例如列表值的总和:intList |> List.sum。让我们运行两次并检查结果:

在这两种情况下,我们都在谈论预分配列表上完全相同的代码(因此列表初始化不是基准测试的一部分),但是第二个示例执行时间几乎要长3倍。那么,发生了什么变化?设置测试用例时,我在列表的追加节点之间添加了对象的额外分配,如下所示:

caseA<-[1..1024] //用于i的TestA的数据集= 1024降到1个操作caseB a.x有趣)成员this.Item index = input。[index] type ContainerB(input:Point3D [])= let x = input |> Array.map(fun a-> a.x)令y =输入|> Array.map(fun a-> a.y)令z =输入|> Array.map(fun a-> a.z)成员this.SumX = Array.sum x成员this.Item index = {x = x。[index]; y = y。[index]; z = z。[index]}

现在,根据我们更关心哪种操作(访问单个元素或计算X坐标的总和),一个或另一种实现将更具意义。如果我们研究数据库的世界,这种方法甚至更加普遍-OLTP数据库(面向标准事务性工作负载)和OLAP数据库(面向分析数据处理)之间的性能差异的巨大贡献源于:行与按列。

PS:在ContainerB的情况下,我们可以以矢量化的形式添加更好的优化技术,我们将在博客文章的后面进一步介绍。

现在,下一件事是L1-L2缓存位于c

......