在汇编代码中,编译器设置起始索引为 -65536 ($-65536) 并在每次内存访问时对其进行增加,而不是从0开始并使用小的正偏移量。这种做法可能有几个原因:
$-65536
编译器可能会选择这样的索引方式来确保内存访问对齐,特别是在进行SIMD操作时,正确的对齐可以显著提高性能。通过从-65536开始,编译器可能在某种程度上试图优化循环内部对于数组的迭代访问,使其更加适合于向量化操作。
此方法可能涉及到处理循环边界的问题,特别是当数组长度正好不是向量大小的整数倍时。编译器可能使用这种方式来简化循环结束后的边界检查逻辑,通过在计算偏移时自动“绕回”数组起始位置。
使用这种特定的起始偏移可能有助于简化某些处理器指令,编译器可能利用这种方法来减少某些指令的使用,或者是优化指令的执行路径。
总结而言,这种汇编代码生成方式可能是编译器为了优化性能,根据底层硬件的特定特性和向量化的需求而做出的特定决策。
在使用AVX2指令集时,注意到代码中使用的是 vmovdqu 指令,这是一个用于非对齐内存访问的指令。虽然这使得代码在处理非对齐数据时更为灵活,但使用对齐的内存访问指令(如 vmovdqa)通常能提供更好的性能,因为对齐的数据访问可以减少内存访问延迟并增加数据吞吐量。
vmovdqu
vmovdqa
为了确保数组 a 和 b 被正确对齐以利用AVX2的对齐指令,可以在函数参数中声明这些数组是对齐的。这可以通过在C语言中使用 __attribute__((aligned(32))) 来实现,或者在函数调用中使用 _builtin_assume_aligned 内建函数来保证对齐,如下所示:
a
b
__attribute__((aligned(32)))
_builtin_assume_aligned
1234567891011
#include <stdint.h>#include <stdlib.h>#define SIZE (1L << 16)void test(uint8_t * __attribute__((aligned(32))) a, uint8_t * __attribute__((aligned(32))) b) { uint64_t i; for (i = 0; i < SIZE; i++) { a[i] += b[i]; }}
在编译时,确保使用 -mavx2 选项以启用AVX2指令集。这样修改后,编译器应该会使用 vmovdqa 指令而不是 vmovdqu,从而提高代码的性能。这些对齐指令确保每次内存访问都是在32字节边界上进行,这对AVX2架构是最优的。
-mavx2
在这个例子中,观察到编译器在处理两个版本的 test 函数时生成了截然不同的汇编代码。初步版本中的代码并没有被有效地向量化,而修改后的版本则实现了完全向量化,使用了 movdqa 和 pmaxub 指令。这种差异主要由以下几个因素造成:
test
movdqa
pmaxub
if
__builtin_assume_aligned
movdqu
总结来说,通过简化条件逻辑,并明确告诉编译器关于数据对齐的信息,能够帮助编译器生成更高效的向量化代码。这突显了在编写高性能代码时,对编译器的指导和代码的表达方式需要精心设计,以便充分利用现代处理器的向量化能力。
在例子3中,test 函数实现了一个简单的数组赋值操作,其中数组 a 的每个元素被设置为数组 b 中下一个元素的值。具体代码如下:
123
for (i = 0; i < SIZE; i++) { a[i] = b[i + 1];}
这段代码的特点是每次循环都涉及到对数组 b 的下一个元素的访问,这种“滑动窗口”类型的数据访问模式可能是阻碍向量化的原因。
数据依赖性:
a[i]
b[i+1]
内存对齐问题:
向量化的复杂性:
潜在的速度提升:
实际的向量化挑战:
总结来说,尽管向量化这类循环在理论上可能提高性能,但实际上实现向量化可能需要克服数据依赖和对齐等技术障碍。在这种情况下,编译器可能决定不进行向量化,因为它可能无法保证向量化后的性能收益足以抵消实现的复杂性。如果确实需要优化这种循环,可能需要更深入地探索编译器的向量化选项或手动优化代码。
在示例4中,首先使用不带 -ffast-math 标志的编译指令编译 test 函数,这是一个简单的累加浮点数组的循环。初次编译结果显示,代码没有被向量化,并且使用了 addsd 指令,这是单个双精度浮点数的标量加法指令。
-ffast-math
addsd
浮点加法具有一些特殊性,特别是涉及到浮点数的关联性和可交换性时。由于浮点数计算的特殊规则(如IEEE浮点标准中的舍入模式和对特殊值的处理),编译器默认情况下不会假设浮点加法操作是关联的。这意味着编译器不会自动重组这些操作以利用SIMD指令,因为这可能改变计算结果。
添加 -ffast-math 编译标志可以放宽对浮点数精确行为的限制,允许编译器假设加法是关联的并可以重排序操作。这样,编译器可以自由地使用SIMD指令来加速计算,因为它不需要严格遵守IEEE浮点标准的所有规则。
重新编译后,看到汇编代码中出现了如 addpd 或 vaddpd 等向量化指令,这些指令可以同时处理多个浮点数,提高了代码的执行效率。
addpd
vaddpd
运行编译后的程序时,注意到使用和不使用 -ffast-math 标志的输出结果可能有细微差别。这些差别通常源于浮点计算的不同舍入行为和精确度要求的放宽。
**使用 -ffast-math**:会产生更快的执行时间,因为编译器可以使用SIMD优化。结果与严格按照IEEE标准执行的结果略有不同,因为发生了计算顺序的改变或是舍入行为的变化。
**不使用 -ffast-math**:执行更慢,但结果会严格遵守IEEE浮点标凈,对于需要高度数值精确的应用来说,这是必须的。
在决定是否使用 -ffast-math 时,需要权衡性能提升和数值精确度之间的关系。对于对性能要求极高但对精确度要求不是非常严格的应用,启用 -ffast-math 是有益的。然而,对于科学计算和精确金融计算等领域,最好还是保持默认的严格浮点行为,以保证结果的准确性。
要了解向量化带来的性能提升,可以通过对比向量化前后的执行时间来衡量。在实验中,通过在AWS的特定环境下运行 loop 程序,并使用不同的编译标志来激活或关闭向量化,可以得到关于向量化效果的直观认识。
loop
编译并运行向量化与非向量化的代码:
make clean; make
make clean; make VECTORIZE=1
-mavx
make clean; make VECTORIZE=1 AVX2=1
测量并记录执行时间:
**基础向量化 (-mavx)**:
**进一步使用 -mavx2**:
默认向量寄存器宽度:
AVX2向量寄存器宽度:
这些实验显示,向量化能显著提高数据并行任务的性能,尤其是在使用现代指令集如AVX2时。进一步分析这些数据,可以了解默认的向量寄存器和AVX2寄存器的宽度及其对性能的具体影响。此外,了解和选择合适的编译器标志对于充分利用现代硬件的向量处理能力至关重要。
为了深入了解代码的向量化表现,可以直接查看编译器生成的汇编代码。通过分析这些代码,可以明确看到哪些指令被用于执行向量化操作,以及这些操作是如何被优化的。
生成汇编代码:
make ASSEMBLE=1 VECTORIZE=1
make ASSEMBLE=1 VECTORIZE=0
检查汇编代码:
loop.s
向量化指令对比:
AVX2=1
不启用AVX2时的向量加法指令(基于SSE2指令集):
addps
addpd %xmm1, %xmm0
%xmm1
%xmm0
启用AVX2时的向量加法指令:
vaddps
vaddpd %ymm1, %ymm0, %ymm0
%ymm1
%ymm0
分析向量化的效果:
改善汇编代码的可读性:
-g
-gdwarf-3
-fno-unroll-loops
通过比较不同编译标志下的汇编输出,可以具体了解编译器如何处理向量化指令,以及这些指令对程序性能的潜在影响。理解这些基础可以帮助开发者优化代码,并充分利用现代处理器的向量处理能力。
在本实验中,通过更改数据并行循环中的运算符来测试不同类型的向量运算。在这个实验中,运算符由一个宏 __OP__ 定义,能够轻松地更换使用的运算符,并观察其对向量化的影响。
__OP__
/
%
1
for (size_t i = 0; i < SIZE; i++) { b[i] = 1; // 设置为非零值以避免除零错误}
+
-
*
<<
>>
VECTORIZE=1 AVX2=1
VECTORIZE=1 的位移运算:
VECTORIZE=1
pslld
psrlq
pslld xmm0, 1
xmm0
VECTORIZE=1 AVX2=1 的位移运算:
ymm
vpslld ymm0, ymm1, imm8
vpslld ymm0, ymm1, 1
ymm1
ymm0
实验表明,大多数基本算术运算符可以成功向量化,而位移运算符的向量化可能取决于具体的编译器实现和目标硬件的支持。了解和选择合适的编译器标志和硬件指令集对充分利用向量处理能力至关重要。此外,适当的数组初始化也是避免运行时错误的关键。
在本实验中,会探索改变数据类型对内存需求和向量包装能力的影响,特别是在使用加法运算符时,对比使用 uint64_t, uint32_t, uint16_t, 和 uint8_t 数据类型的向量化代码与非向量化代码之间的性能差异。
uint64_t
uint32_t
uint16_t
uint8_t
OP
**uint64_t**:
**uint32_t**:
**uint16_t**:
**uint8_t**:
向量化代码相比于非向量化代码:
AVX2向量化代码相比于非向量化代码:
数据类型对于向量化的性能影响显著。选择较小的数据类型可以减少内存需求并增加向量寄存器中可以包装的元素数量,从而提高性能。在设计和优化向量化应用程序时,应仔细考虑数据类型的选择以最大化性能。
在此实验中探索使用不同数据类型(特别是 uint64_t 和 uint8_t)对于向量化代码在执行乘法操作时的性能影响。乘法操作相对于加法需要更多的时钟周期,这会影响向量化带来的性能提升。
uint64_t 数据类型:
uint8_t 数据类型:
向量化在执行数据并行操作时能显著提高性能,特别是当操作单元较小时(如 uint8_t)。选择更小的数据类型可以增加向量寄存器中的元素数量,从而提高每次向量操作的效率。对于乘法这样的高成本操作,向量化的好处尤其明显。然而,对于较大的数据类型(如 uint64_t),尽管向量化能带来性能改善,其提升幅度有限,特别是当每个向量寄存器能处理的元素数量较少时。
在这个实验中,使用 awsrun perf record 工具来收集使用 uint64_t 类型的AVX2向量化乘法操作的性能数据,然后通过 aws-perf-report 工具来分析哪些操作占用了最多的执行时间。比较乘法操作与加法操作在向量化环境中的性能差异。
awsrun perf record
aws-perf-report
收集性能数据:
分析性能报告:
向量乘法 (*) 性能分析:
vpmulld
向量加法 (+) 性能分析:
vpadd
向量乘法的时间占比:
向量加法的时间占比:
性能优化的见解:
这项分析能够揭示在使用高级向量扩展时,不同算术操作在性能上的实际差异,从而为进一步的性能优化提供依据。
在之前的实验中,循环的界限 N 是预先定义为1024的固定值,这使得编译器能够在编译时进行优化。改为在运行时通过命令行参数设置 N,将增加编译器处理向量化的复杂性,因为它不能再做出关于循环迭代次数的静态假设。
N
1234
int main(int argc, char *argv[]) { int N = atoi(argv[1]); ...}
向量化与非向量化的比较:
AVX2与非AVX2向量化的比较:
N = 1024
改变 N 的定义使其在运行时确定,表明了编译器在处理不确定循环边界时的行为。虽然向量化在大多数情况下都能提供性能提升,但其效果受到循环长度的影响。实验结果显示,当循环边界较大时,向量化(尤其是使用AVX2)带来的性能提升更为显著。这强调了在设计向量化代码时,考虑数据规模和选择合适的编译器优化标志的重要性。
在此实验中,调整数组遍历的步长(stride),并观察编译器是否能够向量化这样的循环。步长不为1意味着每次迭代跳过一个或多个元素,这可能影响编译器的向量化决策。
__TYPE__
for (j = 0; j < N; j += 2) { C[j] = A[j] + B[j];}
编译器的向量化决策:
为什么可能不向量化:
调整循环的步长可以是一个重要的性能优化方向,尤其是在处理步长非1的数据访问模式时。然而,开发者需要仔细考虑步长变化对向量化的可能影响,并通过实验验证预期的优化效果。如果向量化未带来预期的性能提升,可能需要探索其他优化策略或调整算法结构以适应硬件的特性。
#pragma clang loop
为了强制编译器向量化一个步长不为1的循环,用 #pragma clang loop 指令,这是 Clang 提供的一种语言扩展,用于控制循环的优化。这允许开发者对编译器的循环优化行为进行更细致的控制,尤其是在向量化方面。
修改代码:在步长为2的循环前添加 #pragma clang loop vectorize(enable) 来尝试强制向量化该循环。
#pragma clang loop vectorize(enable)
#pragma clang loop vectorize(enable)for (j = 0; j < N; j += 2) { C[j] = A[j] + B[j];}
编译与测试:
vectorize_width
基本向量化测试:
**修改 vectorize_width**:
使用AVX2:
通过使用 #pragma clang loop 指令,能够更精细地控制编译器的向量化行为,特别是在处理具有复杂访问模式的循环时。在合适的配置下,向量化代码相比于非向量化代码确实能够提供显著的性能提升。这种控制手段为性能优化提供了更多的可能性,尤其是在需要手动调整编译器行为以适应特定算法的情况下。
在本实验中,通过汇编代码来理解编译器是如何向量化数组求和(reduction)操作的。向量化的求和操作对于理解数据归约在现代处理器上的效率至关重要。
修改代码:实现一个简单的数组求和循环,确保该循环是向量化的重点。
int total = 0;for (int j = 0; j < N; j++) { total += A[j];}
编译并生成汇编代码:
make ASSEMBLE=1
向量化操作的实现:
vpaddd
具体的汇编指令:
paddd xmm0, xmm1
vpaddd ymm0, ymm0, ymm1
循环尾部处理:
归约操作的并行化:
编译器优化的限制:
汇编代码分析揭示了编译器如何有效地实现向量化数组求和操作。通过并行处理多个数组元素,向量化显著提高了性能。然而,为了完全理解和优化这类操作,开发者需要注意编译器如何处理向量化操作的细节,包括数据加载、并行执行和循环尾部处理。这些知识对于编写高效的向量化代码至关重要。