传统的数据结构优化方法包括结构拆分、字段重排序和指针内联等,这些方法通常需要全局改动程序中的数据结构类型,同时修改所有相关引用。这些优化方法通常依赖于工具进行插桩、追踪或采样,从而创建模型来指导数据的变换。而编译器通常无法自动验证这些变换是否合法,因此往往需要手动检查和手动优化。
本文提出了一种名为RebaseDL的方法,通过静态分析找出局部代码区域中可以进行数据布局变换的机会,以改善数据访问的局部性。这种局部变换(局部应用于代码区域,而不是全局)减少了代价高昂的性能剖析过程,并简化了合法性验证。RebaseDL不仅支持传统的结构优化,还能发现一些无需改变结构类型的优化机会,比如数据打包变换。
这个分析工具基于LLVM实现,并在SPEC CPU基准测试套件中找到了多个可变换的机会。通过应用这些变换,部分代码区域的运行速度提高了最高达1.34倍。
说明:
-
数据结构拼接技术(Data-structure splicing techniques):这是指对数据的存储方式进行重新组织,以便计算机更快地访问和处理数据。这种技术可以包含以下几种具体方法:
- 结构拆分(structure splitting):把一个大的数据结构拆分成几个较小的部分,从而更高效地利用内存。
- 字段重排序(field reordering):改变数据结构中各个字段(例如变量)的存储顺序,以减少缓存中的数据冲突,从而加快访问速度。
- 指针内联(pointer inlining):直接把指针引用的内容嵌入数据结构中,从而减少间接访问的步骤,提升性能。
-
缓存(cache):缓存是一种快速内存,它存储了计算机最近使用或常用的数据。当计算机需要某个数据时,如果它已经在缓存中,就能更快地读取到,而不需要从更慢的主存(内存)中取数据。
-
地址转换缓冲区(TLB,Translation Look-aside Buffer):TLB 是一种特殊的缓存,用来加速内存地址的转换。简单来说,TLB 帮助计算机更快地找到需要的数据在内存中的位置。
-
全局变换(globally transformed):指的是对整个程序中使用的数据结构进行统一的修改。全局变换意味着所有地方的数据都要一起修改,工作量大且复杂。
-
插桩(instrumentation):插桩是一种方法,在程序运行过程中加入一些代码,记录程序的行为。这些“插桩代码”可以帮助开发者分析程序的执行情况。
-
追踪(tracing):记录程序在运行时的行为,通常是为了找出程序的瓶颈或错误。
-
采样(sampling):通过对程序执行情况的采样,获取部分性能数据,来推断整个程序的性能表现。采样的好处是不会对程序性能有太大的影响。
-
手动检查和手动变换(manual inspection and manual transformation):传统方法需要人手来检查代码的合法性,或者手动对代码进行优化,这个过程费时费力。
-
数据布局变换(data layout transformation):这是一种改变数据在内存中的组织方式的技术。通过重新排列数据,可以让程序更高效地访问内存,减少等待时间。
-
局部(locally)与全局(globally):局部变换意味着只在程序中的某些代码区域应用变换,而不是整个程序都做修改。这样可以更灵活地进行优化,减少不必要的工作。
-
数据重用(data reuse):这是指程序中多次使用相同的数据。如果同一份数据能多次被使用,那么通过合理组织这些数据,可以减少内存访问的次数,提高程序性能。
-
数据打包(data packing):将多个小的数据组合在一起,形成一个更紧凑的结构。这种方法可以让数据在内存中更紧凑地存储,减少内存浪费,提高数据访问效率。
-
静态分析(static analysis):这是在不运行程序的情况下,检查代码的工具或方法,用于找出代码中的潜在优化机会或问题。
-
LLVM:这是一个编译器的基础设施工具,可以帮助开发者实现各种代码优化和编译技术。RebaseDL就是基于LLVM实现的一个工具。
-
SPEC CPU 基准测试套件(SPEC CPU benchmark suite):这是一个用于评测计算机性能的标准测试程序集,广泛用于比较不同计算机或不同优化方法的性能表现。
1. 结构拆分(Structure Splitting)
解决的问题:大数据结构的内存浪费和缓存效率低。
问题背景:
- 当一个大数据结构中包含多个字段时,如果这些字段不经常一起被访问,就会导致不必要的内存浪费。例如,一个结构体包含十个字段,而某段代码每次只使用其中一个字段,这样每次加载这个结构体时就会浪费大量内存,导致缓存中有很多无用数据。
- 由于缓存的工作方式是按缓存行(通常是固定大小的内存块)来加载数据的,如果一个大结构被分散到多个缓存行,就会增加内存访问的延迟。
如何解决:
- 将大数据结构拆分成多个较小的部分,使得每个部分只包含那些经常一起被访问的字段。
- 这样一来,程序在访问这些字段时,可以减少加载到缓存中的无用数据,从而提高缓存的命中率,减少内存浪费。
有效原因:
- 更小的数据结构可以被更高效地加载到缓存中,因为缓存的容量有限,把不相关的数据分开可以让缓存中存放更多有用的数据,从而减少从主存加载的频率。
2. 字段重排序(Field Reordering)
解决的问题:缓存行分散和伪共享(False Sharing)。
问题背景:
- 数据结构中的字段在内存中是按顺序存储的,当这些字段的访问模式不匹配它们的存储顺序时,会导致缓存行被不必要地频繁加载和替换。
- 如果程序中经常一起访问的字段在内存中距离较远,就会导致多次加载和替换缓存行,增加了缓存未命中的几率。
- 伪共享问题是指,当多个线程访问不同但位于同一缓存行的数据时,由于缓存行的共享,会导致不必要的缓存一致性流量,从而降低程序的性能。
如何解决:
- 通过改变字段的存储顺序,使得那些经常一起被访问的字段在内存中相邻存储。
- 这样可以让这些字段更容易被一次性加载到同一个缓存行中,从而减少缓存未命中的频率。
有效原因:
- 把经常一起使用的字段放在一起,能有效提高缓存行的利用率,从而减少内存访问的开销。
- 解决伪共享问题时,通过重排序把需要不同线程访问的数据分开存储,这样可以减少缓存一致性协议带来的额外开销,提高多线程性能。
3. 指针内联(Pointer Inlining)
解决的问题:间接访问和缓存未命中。
问题背景:
- 在使用链表或其他通过指针连接的数据结构时,指针的存在会导致间接访问,即程序需要首先读取指针,然后根据指针找到数据。
- 这种间接访问会导致缓存命中率降低,因为每次都需要加载指针指向的内存位置。如果这些内存位置分散得比较远,访问的延迟就会更高。
如何解决:
- 指针内联是将指针所指向的数据直接嵌入到当前的数据结构中,而不是通过单独的内存块来存储。
- 这样一来,程序在访问数据时就不再需要通过指针间接访问,减少了一次内存跳转。
有效原因:
- 通过内联指针,相关的数据被放在一起,可以在一次内存访问中全部加载到缓存中,从而提高缓存的利用率和访问效率。
- 减少了指针跳转带来的性能开销,尤其是在数据被分散存储的情况下,内联能极大地减少缓存未命中和内存访问延迟。
总结
这些技术主要是为了优化内存访问模式,特别是提高缓存的利用效率,从而减少内存访问的延迟,提高程序的性能。具体地说:
- 结构拆分:通过将大结构拆分成小块,减少不必要的数据加载,提升缓存利用率。
- 字段重排序:通过调整字段顺序,将经常一起访问的数据放在一起,减少缓存未命中和伪共享问题。
- 指针内联:通过将指针指向的数据内联化,减少指针间接访问和内存跳转的次数,提高缓存利用率。
图中的内容展示了结构拆分(structure splitting)的一个示例,目的是为了提高缓存的利用率。具体解释如下:
-
左边的图表示一个名为
f1_neuron
的数据结构,其中包含多个字段,例如P
、W
、X
、V
等(其他字段没有显示完整)。在内存中,f1_neuron
中所有字段被放在一起存储,而在缓存行中,每次需要加载整个结构的所有字段,导致缓存的浪费。 -
右边的图显示了一种经过结构拆分后的情况:原结构体被拆分为两个新的类型,其中一个只包含字段
P
。图中可以看到,拆分后的数据结构f1_neuron_split
只包含字段P
,并且这些字段紧密地排列在多个缓存行中。
拆分的好处:
- 在这种情况下,缓存行只包含字段
P
,也就是说,如果程序在运行过程中频繁访问字段P
,它可以一次性把所有需要的数据加载到缓存中,而不会加载不必要的数据(例如其他字段)。 - 这样可以提高缓存的命中率,减少缓存未命中和内存访问的延迟。
主要内容和涉及的技术
-
数据布局变换的概念:
- 数据布局变换是一种优化技术,目的是将程序中的数据重新排列,以便更好地利用缓存,提高程序的运行速度。
- 常见的变换方式包括缓存感知数据布局(cache-conscious data placement)和数据打包(data packing),这两者都是解决如何更高效地存储和访问数据的问题,但它们在数学上是非常复杂的,被称为NP困难问题(意味着在大多数情况下找到最佳解决方案非常难)。
-
常见的数据优化技术:
- 结构拆分(structure splitting):把一个大的数据结构分成两个或多个较小的部分,便于更有效地利用内存。
- 字段重排序(field reordering):改变数据结构中字段的存储顺序,使得常用的数据更紧密排列,从而减少缓存未命中。
- 数组重组(array regrouping):对数组元素进行重新分组,以便它们在内存中更紧密地排列在一起。
- 指针内联(pointer inlining):将指针引用的数据直接嵌入结构中,减少访问延迟。
- 这些技术被统称为结构拼接(structure splicing)。
-
结构拆分的示例:
- 引言中提到了图1(即你之前发的图)展示了如何通过结构拆分把一个结构体中的字段拆分为两个新类型的例子。通过结合访问频率分析,结构拆分可以把经常访问的字段(称为热字段)与不常访问的字段(冷字段)分开存储。还提到了亲和性分析,用来找出经常一起被访问的字段,并将它们分组在一起。
-
传统变换的挑战:
- 尽管有大量研究致力于数据布局变换,但这些技术在生产编译器中的应用还是很有限。主要有以下三个原因:
- 全局变换的限制:传统的优化通常在整个程序范围内进行,试图找到一个全局最优的数据布局,但由于不同代码区域的数据访问模式不同,全局最优布局并不总是存在。
- 昂贵的分析工具:这些优化需要对程序的运行行为有深入的了解,比如数据访问的频率和亲和性。获得这些信息通常需要使用插桩(instrumentation)或追踪(tracing),这些技术会显著增加运行时的开销,在真实应用中难以实现。
- 合法性验证困难:编译器需要证明变换是合法的,尤其是在像 C 这样的类型不安全语言中,这些验证工作通常无法自动完成,必须依赖手动检查,非常耗费时间和精力。
- 尽管有大量研究致力于数据布局变换,但这些技术在生产编译器中的应用还是很有限。主要有以下三个原因:
-
区域性数据布局变换:
- 本文提出的中心思想是基于代码区域(region-based)的数据布局变换,即只对代码中某些特定区域进行变换。这种方式可以简化合法性验证,因为变换只需要对目标区域是合法的,而不是整个程序。
- 区域性方法也避免了运行时分析的开销,因为可以通过静态分析直接确定合适的变换。
-
RebaseDL 工具:
- 本文提出了一种新的分析工具叫RebaseDL,用来识别适合应用区域性结构拆分和字段重排序的代码区域,并确保变换是合法且有利于性能提升。
- RebaseDL 能够找到数据重用的代码区域,从而克服由于数据复制带来的开销。它也能识别一些数据打包的机会,而不仅限于对数据结构类型进行优化。
- RebaseDL 基于 LLVM 实现,这是一个现代编译器框架,可以方便地在各种应用中推广使用。
-
实验结果:
- 作者对 RebaseDL 进行了评估,结果显示在 SPEC CPU 基准测试中找到了许多可以优化的机会。通过这些区域性变换,部分程序的速度提升了最高 1.34 倍,这表明这些方法在实际应用中有很大的潜力。
基于代码区域(region-based)的数据布局变换是指仅对程序中的某些特定区域进行数据组织的优化,而不是对整个程序的所有数据结构进行全局性变换。这种方法针对程序中一些局部性很强的代码区域来优化数据布局,从而提高这些区域的性能。这种区域性变换有几个核心要点:
1. 什么是“代码区域”
- 代码区域可以理解为程序中的某一部分代码,这部分代码可能是一个函数、一个循环或者某段逻辑。
- 每个代码区域有其独特的数据访问模式,也就是说,程序在不同的区域可能会频繁访问不同的数据。
- 例如,一个函数可能只使用数据结构的一部分字段,或者在某个循环内频繁访问某些变量。
2. 为什么采用基于区域的变换?
传统的数据布局变换通常是在全局范围内进行,即尝试对整个程序的所有数据结构进行统一的优化,但这存在一些问题:
- 访问模式不同:不同区域的数据访问模式可能差别很大。例如,一个区域中可能频繁访问结构体的某个字段,而其他区域却几乎不使用这个字段。如果采用全局变换,会很难找到对所有区域都有效的数据布局。
- 全局优化的成本高:为了实现全局的最佳布局,编译器需要全面地了解整个程序的运行情况,包括所有变量的访问频率和亲和性,这需要复杂的运行时分析(如插桩和追踪),而且难以保证其优化是全局最优的。
基于代码区域的数据布局变换通过只关注某些特定的代码区域,避免了这些问题,优势包括:
- 更加灵活:可以为不同的区域采用不同的优化策略。例如,如果一个区域中字段
P
经常被访问,而另一个区域则频繁访问字段Q
,那么可以为每个区域单独进行优化,从而获得更好的性能。 - 减少分析开销:区域性变换只需要分析特定代码段的行为,而不是整个程序,因此可以避免昂贵的全局运行时分析。
3. 基于区域的变换是怎么做的?
- 首先,编译器或工具会对程序进行静态分析,找出哪些代码区域有改进空间。例如,可以分析程序中某些函数或循环,看看它们是否频繁访问某些数据。
- 接着,针对这些区域进行数据布局的变换,比如将结构体拆分,把经常一起被访问的数据放在一个结构体里,或改变字段的顺序以提高缓存的利用率。
- 通过这样做,编译器可以更好地利用缓存,提高这些区域的数据访问速度,从而提升程序性能。
4. 基于区域的好处
- 简化合法性验证:只需确保变换对目标代码区域是合法的,而不需要在全局范围内验证变换是否合法。例如,在某个函数中进行字段拆分后,只需要修改和验证这个函数中的引用,而不需要考虑整个程序其他部分。
- 避免全局冲突:在全局变换中,某个变换可能对程序的某些部分有利,而对其他部分不利,这样就需要权衡取舍。而区域变换可以对每个区域进行单独优化,避免了这种冲突。
举个例子
假设有一个大型程序,其中包含一个结构体 f1_neuron
,里面有多个字段,比如 P
、Q
、R
等。
- 在某个函数 A 中,程序主要使用字段
P
,而几乎不访问Q
和R
。 - 在另一个函数 B 中,程序则主要使用
Q
和R
。
在这种情况下,全局变换可能很难找到一个最优的方案,因为对 P
的优化可能会损害对 Q
和 R
的访问性能。基于代码区域的变换则可以:
- 在函数 A 中对
f1_neuron
进行结构拆分,只保留字段P
,使它们在缓存中紧密存放,减少不必要的数据加载。 - 在函数 B 中,则对
f1_neuron
进行其他优化,比如把Q
和R
排列在一起。
Definitions and Notation
1. 静态单赋值(Static Single Assignment, SSA)
- **SSA(静态单赋值)**是一种中间表示形式,用于编译器中,它要求每个变量只被赋值一次。这意味着,每个变量在程序中的每个地方都有一个唯一的名字,从而可以更容易地进行优化分析。
生词解释:
- 静态单赋值(SSA):编译器中的一种表示方式,每个变量只被赋值一次。
- 中间表示(IR, Intermediate Representation):代码的抽象表示形式,用于编译器优化。
2. 自然循环和控制流图(CFG, Control Flow Graph)
- 自然循环是指程序中的循环结构,比如
for
或while
循环,它们由控制流图(CFG)的一部分节点组成。 - **控制流图(CFG)**是程序的一个图形表示,其中每个节点代表一个基本块(代码片段),边代表代码执行的可能路径。
- 每个循环都有一个头节点(header node),它支配(dominate)了循环中的所有其他节点。这意味着,无论如何执行循环,都会先执行这个头节点。
生词解释:
- 自然循环:程序中的循环结构,如
for
和while
。 - 控制流图(CFG, Control Flow Graph):程序的控制流程图,每个节点代表代码片段。
- 头节点(header node):循环的入口,所有循环中的节点都必须经过这个节点。
3. 中间表示(IR, Intermediate Representation)指令和不透明指针
- **IR(中间表示)**是编译器在优化和生成机器码之前对程序的一种抽象描述。
- **不透明指针(opaque pointers)**是指在 IR 中指向内存的指针,但它们的具体类型没有明确标注。指针所指向的元素类型是通过对指针的使用方式来推断的。
- 当访问聚合数据结构(例如结构体或数组)中的某个字段时,通常通过给基地址加上偏移量来找到具体位置。这些偏移量可以是简单的一次加法,或者是多次加偏移量,形成偏移链(offset chain)。
生词解释:
- 不透明指针(opaque pointers):指向内存的指针,但没有明确说明其类型。
- 聚合数据结构:包含多个字段的数据结构,比如结构体和数组。
- 偏移量(offset):访问内存地址时的偏移位置。
- 偏移链(offset chain):多个偏移量的组合,用于访问复杂的数据结构。
4. 内存分配和指针
- 静态数组定义和**动态分配函数(如 malloc)**都是分配一块连续的内存空间,大小由输入参数决定。
- 指针引用内存地址,通常会随着偏移操作继续引用同一个内存范围。例如,如果你用一个指针加上一个偏移量,你希望它仍然指向同一个内存块中的不同位置。
- 区分那些访问不同内存范围的指令是困难的,因为编译器需要知道这些指令是否在访问相同或不同的数据块。这对于精确分析和确定哪些数据元素适合新的数据布局至关重要。
生词解释:
- 静态数组定义:定义时直接分配固定大小的数组。
- 动态分配函数(malloc):在运行时分配内存,用于动态大小的数据。
- 内存范围(memory range):内存中的一块连续区域。
- 指针(pointer):用于引用内存地址的变量。
5. 最小基址和别名分析
- **最小基地址(minimal base address)**是一个内存访问所基于的地址,它是对某个内存块进行最大偏移操作得到的最初地址。
- **最大偏移链(maximal offset chain)**指的是对一个地址执行的所有可发现的偏移操作的完整链条。
- 最小基地址不一定是内存分配的起始字节,而可能是通过加载指令或作为函数参数得到的。
- 在编译时,通过一个指针变量进行内存访问,最小基地址就是这个指针变量。
- **别名分析(alias analysis)**是编译器用来判断两个指针是否引用同一块内存的分析方法。通过别名分析和最小基地址,可以区分不同的内存范围,从而确定哪些内存访问是独立的,哪些可能会重叠。
生词解释:
- 最小基地址(minimal base address):内存访问中基于的最初地址。
- 最大偏移链(maximal offset chain):访问内存时涉及的所有偏移操作的组合。
- 别名分析(alias analysis):分析两个指针是否引用同一块内存。
6. 数据布局变换的候选区域
- 数据布局变换的候选是用**代码区域和内存范围对(代码区域 ! 和内存范围 ")**来表示的。这表示在特定的代码区域内对特定的内存范围进行数据布局变换。
- 目标代码区域主要是**单入口单出口(SESE)**的循环,即循环的执行只能从一个入口进入和一个出口退出。
- 目标循环可以有多个出口,但这些出口中的大部分必须导致程序终止(比如通过断言失败)。这种类型的出口用**不可达指令(unreachable instruction)**来表示。
- 之所以选择循环作为目标区域,是因为循环通常占用了程序的大部分计算时间,而且循环内经常出现数据重用的情况,因此在这些地方进行数据布局优化可以带来显著的性能提升。
生词解释:
- 单入口单出口(SESE, Single-Entry Single-Exit):代码块只有一个入口和一个出口。
- 不可达指令(unreachable instruction):用于标识程序中不可达的地方,通常是程序终止的结果。
- 数据重用(data reuse):程序中对相同数据的多次使用,通常在循环中出现。
手段
具体介绍区域性结构拆分、字段重排序和数据打包这些优化手段。
1. 结构拆分和字段重排序
想象你有一个大抽屉,里面杂乱地放着很多不同类型的东西,比如文具、工具、和书籍。如果你在不同情况下只需要用到某一类物品,整个大抽屉就显得很不方便。结构拆分(structure splitting) 就相当于把这个大抽屉分成几个小盒子,分别装文具、工具和书籍,这样你在需要某类物品时,只需打开特定的小盒子。
而字段重排序(field reordering) 则类似于在盒子里对物品重新摆放,比如把你最常用的笔放在最上面,把不常用的剪刀放在角落里,这样每次你打开盒子时,都能最方便地拿到你最常用的东西。这样做不仅让你更快找到需要的东西,也不会浪费太多时间在翻找上。
2. 区域性结构拆分(Region-Based Transformation)
传统的优化方式像是对整个屋子都做整理,把所有物品的存放位置一次性优化,但每个房间的使用方式不一样,这样的整理方式可能会效率低、且复杂。而区域性结构拆分更像是针对每个房间分别进行整理。例如在书房你只把文具盒子放在桌子上,而在厨房里则把调料放在灶台附近。这样可以让每个房间的物品都摆放得合理且使用方便。
3. 复制数据到新位置
在进行这些优化时,我们可能需要把原来的数据复制到新的位置,这就像你在整理房间时,需要把某些物品从一个大抽屉移到新的分隔盒子里,以便更好地使用。在程序中,也需要做类似的“复制工作”来把旧的数据放到新的更合理的位置。
4. 数据打包(Data Packing)
有时,我们不是在优化某个大的数据结构,而是对一些单独的数据(比如独立的变量)进行紧凑排列,这被称为数据打包。这就像把散落在房间各处的小物件——比如钥匙、硬币和发夹——集中放到一个小盒子里,这样你每次找这些小东西时,都不用在不同地方来回找。
举个例子:在循环中优化
想象你有一个程序,这个程序每天要在厨房做菜(一个循环)。而你在做菜的过程中,可能需要各种调料,但最常用的是盐、酱油和油。如果这些调料分散在不同的柜子里,你每次做菜都需要不停地找。为了节省时间,你可以把这些最常用的调料放在灶台旁边的一个小架子上(即结构拆分),并且按照你常用的顺序来摆放(即字段重排序)。
如果在做菜过程中,偶尔需要用到一些其他的调料,你可以在下班后再把那些偶尔用的调料放回原来的位置(即把数据从新内存位置复制回去)。这种方式有效减少了做菜的时间(程序的执行时间),因为大部分常用的东西已经摆在最方便的位置了。
更专业的说明
1. 结构拆分(Structure Splitting)和字段重排序(Field Reordering)
想象你有一个数据库表,表中有很多字段(例如用户表包含:名字、年龄、地址、邮箱等)。在某些查询中,比如用来验证用户登录时,你可能只需要使用“名字”和“邮箱”,而不需要其他信息。
结构拆分在数据库中就像是把原来的用户表拆分成几个小表:
- 一个表专门存储用户登录信息(名字、邮箱),另一个表存储其他信息(比如地址、年龄等)。
- 在代码中,结构拆分是把大的数据结构(类或结构体)分成多个小的结构体,专注于只包含那些在特定场景下会被访问的字段。
对于代码来说,字段重排序类似于调整数据库表中字段的存储顺序,以便高效访问。
- 比如你常用的字段放在前面,数据库在读取这些字段时就能更快地找到它们。
- 在代码中,这是通过重新安排数据结构中的字段顺序,确保那些常被访问的数据可以在内存中紧密地排列在一起,这样可以提升缓存的利用效率。
2. 区域性结构拆分(Region-Based Structure Splitting)
区域性优化的思路在数据库和代码中可以这样理解:
- 在数据库中,不是对整个表结构进行全局优化,而是对特定的查询进行针对性优化。
- 例如,针对用户登录功能,优化登录信息相关的字段,而对于用户详情展示,则优化展示页面需要的数据。这种方法类似于创建专用的索引,帮助提高特定查询的速度。
- 在代码中,区域性结构拆分是只对某些代码区域(例如某个特定函数或循环)进行数据结构优化。
- 比如在某个函数内,只访问某个大结构体的一个字段,那么可以拆分出一个只包含该字段的结构体,只在这个函数中使用,从而减少内存访问开销。
这种优化方式能够避免为了全局改动数据结构而增加的复杂性和成本,特别是在不同的代码区域对同一个数据结构有不同的访问模式时。
3. 复制数据到新位置(Copying Data to New Location)
在数据库操作中,复制数据可以类比为创建一个临时表,用于缓存某些数据以提高特定操作的效率:
- 例如,在做一些复杂的统计计算时,可能会先把需要的数据复制到一个临时表中进行运算,再将结果写回到原表中。这可以减少原始表的锁定时间,提高数据库的并发性。
- 在代码中,这就相当于分配一个新的内存位置,将原数据结构中某些字段复制到新的位置进行处理。这样做是为了在优化后的数据结构中进行操作,而不会影响原始数据。
4. 数据打包(Data Packing)
在数据库中,数据打包类似于创建一个包含紧凑信息的视图或表,以便更快速地访问数据:
- 比如你有一个订单表,表中有很多字段,但某些统计任务只需要一小部分字段(比如订单金额和日期)。这时可以创建一个只包含这些字段的紧凑表,来更快速地处理统计任务。
- 在代码中,数据打包就是将分散在内存不同位置的数据紧密地放在一起,使得它们可以一次性地加载到缓存中,提高访问效率。这可以减少内存访问的次数,特别是对某些独立数据频繁访问时。
5. 举个例子:在循环中优化
假设你有一个电商系统的数据库,你想统计用户的购买次数(这个操作相当于一个循环),每次都需要用户的基本信息和订单数据。如果这些数据分散在不同的表中,每次查询都需要做很多次跨表的查找,这会耗费很多时间。
为了优化这个过程,你可以创建一个专门用于统计的紧凑表,包含用户 ID 和购买次数,这样在统计时就不需要不断地查多个表。
- 这就像在代码中的循环里,只把必要的数据字段拆分出来进行操作,以减少内存的访问量,提升处理速度。
专业说明:
数据布局变换(Data Layout Transformation)
数据布局变换的目标是通过重新组织数据结构,提升程序在运行时的性能。这项工作特别关注基于代码区域的结构拆分(region-based structure splitting)和字段重排序(field reordering),这两种技术可以优化数据在内存中的存储方式,使得内存访问更加有效,从而提升程序的执行速度。
以下是具体的数据布局变换的过程,包括了如何找到变换的候选区域,以及每个步骤的详细说明。
1. 数据布局变换候选的识别
在进行数据布局变换之前,首先需要识别出合适的候选代码区域和内存范围对(代码区域 ! 和内存范围 ")。在这个组合中,内存范围 " 包含结构类型的元素,代码区域 ! 是一个目标循环,这些区域是代码中最常耗时且经常出现数据重用的部分。
-
代码区域:在这项工作中,代码区域主要指**单入口单出口(SESE, Single-Entry Single-Exit)**的循环。这种代码区域非常适合进行优化,因为它们具有明确的边界,执行路径比较固定。
-
内存范围:内存范围通常由一个指针引用,在编译时,通过**别名分析(alias analysis)和最小基地址(minimal base address)**来确定。别名分析用于判断不同指针是否引用相同的内存区域,以确保优化是安全的。
2. 结构拆分和字段重排序的步骤
对于内存范围 " 和目标循环 !,结构拆分和字段重排序的具体步骤如下:
-
创建新的结构类型:首先创建一个新的结构类型 "′,该类型只包含在目标循环 ! 中被访问的字段。这样可以有效地减少无用字段的访问,使内存更加紧凑,提升缓存的利用率。
-
字段重排序:对 "′ 中的字段进行重排序,以使字段的存储顺序与它们在目标循环中的访问顺序一致。这样做是为了让访问顺序和内存中的存储顺序尽可能地匹配,减少缓存未命中(cache miss)。
-
内存分配和数据复制:
- 在目标循环的头节点之前,分配新的内存范围 "′,该内存将用于存储类型为 "′ 的元素。
- 插入一个复制循环(copy loop),将内存范围 " 中的数据复制到 "′ 中,以便接下来的操作能够基于新的内存布局。
-
替换访问指令:将循环 ! 内所有对内存范围 " 的访问指令替换为访问内存范围 "′ 中对应元素的指令。这样,目标循环在运行时将只访问优化后的数据结构。
-
数据写回和内存释放:
- 在循环结束后,如果对 "′ 中的数据有写操作,则插入一个复制循环,将修改过的数据从 "′ 复制回 ",确保其他地方的数据一致性。
- 必要时,释放 "′ 的内存,避免内存泄漏。
3. 数据打包(Data Packing)
除了结构拆分和字段重排序,这项工作还可以应用于非聚合类型(non-aggregate types),这被称为数据打包(data packing)。对于非聚合类型,优化过程类似于结构拆分,但省略了创建新结构类型和字段重排序的步骤,因为非聚合类型在逻辑上已经紧凑。
4. 分割模式
在结构拆分的过程中,可以选择不同的分割模式:
- 普通拆分模式:将目标循环中所需的字段提取出来,形成一个新的结构体。
- 最大拆分模式(maximal splitting):将结构体的所有字段完全分离,类似于将一个包含多个字段的结构体数组转化为每个字段单独存储的多个数组。这种方式可以使访问特定字段的操作更加高效,因为所有相同类型的数据会集中存放,减少内存的随机访问。
5. 运行示例(Running Example)
原文中通过一个来自 SPEC CPU2000 基准测试 179.art 的代码片段作为例子:
- 该代码中有一个名为
f1_layer
的指针,指向结构类型f1_neuron
。 - 在目标循环
loopC9
中,程序主要访问f1_neuron
的某个字段P
。 - 经过变换后,新的结构类型
f1_neuron_split
只包含字段P
,并且新内存范围f1_layer_split
在循环中被使用。
这种变换有效减少了内存中不必要的数据,优化了程序在循环中对内存的访问,提高了性能。
总结
数据布局变换的主要目的是通过重新组织内存中的数据结构,提升数据访问的局部性和效率。通过基于代码区域的结构拆分和字段重排序,可以对代码中最常耗时的部分进行针对性的优化,使得缓存的利用率提升,程序运行速度加快。以下是关键要点:
- 区域性结构拆分只在特定代码区域中进行数据布局优化,避免全局修改带来的复杂性。
- 字段重排序使得数据在内存中的排列顺序更符合访问的顺序,减少缓存未命中。
- 数据打包是对非聚合类型进行紧凑排列,减少内存使用和访问延迟。