MIT软件性能工程——Homework-3-Vectorization

ELecmark VIP

作业要求:

Write-up1: 分析汇编代码

在汇编代码中,编译器设置起始索引为 -65536 ($-65536) 并在每次内存访问时对其进行增加,而不是从0开始并使用小的正偏移量。这种做法可能有几个原因:

内存对齐和访问优化

编译器可能会选择这样的索引方式来确保内存访问对齐,特别是在进行SIMD操作时,正确的对齐可以显著提高性能。通过从-65536开始,编译器可能在某种程度上试图优化循环内部对于数组的迭代访问,使其更加适合于向量化操作。

循环绕回

此方法可能涉及到处理循环边界的问题,特别是当数组长度正好不是向量大小的整数倍时。编译器可能使用这种方式来简化循环结束后的边界检查逻辑,通过在计算偏移时自动“绕回”数组起始位置。

指令优化

使用这种特定的起始偏移可能有助于简化某些处理器指令,编译器可能利用这种方法来减少某些指令的使用,或者是优化指令的执行路径。

总结而言,这种汇编代码生成方式可能是编译器为了优化性能,根据底层硬件的特定特性和向量化的需求而做出的特定决策。

Write-up 2: 修正代码以使用AVX2寄存器进行对齐移动

在使用AVX2指令集时,注意到代码中使用的是 vmovdqu 指令,这是一个用于非对齐内存访问的指令。虽然这使得代码在处理非对齐数据时更为灵活,但使用对齐的内存访问指令(如 vmovdqa)通常能提供更好的性能,因为对齐的数据访问可以减少内存访问延迟并增加数据吞吐量。

为了确保数组 ab 被正确对齐以利用AVX2的对齐指令,可以在函数参数中声明这些数组是对齐的。这可以通过在C语言中使用 __attribute__((aligned(32))) 来实现,或者在函数调用中使用 _builtin_assume_aligned 内建函数来保证对齐,如下所示:

1
2
3
4
5
6
7
8
9
10
11
#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架构是最优的。

Write-up 3: 解释编译器生成截然不同的汇编代码的原因

在这个例子中,观察到编译器在处理两个版本的 test 函数时生成了截然不同的汇编代码。初步版本中的代码并没有被有效地向量化,而修改后的版本则实现了完全向量化,使用了 movdqapmaxub 指令。这种差异主要由以下几个因素造成:

条件分支的影响

  • 在初始的代码中,使用了 if 语句来判断并赋值,这引入了条件分支。在很多情况下,条件分支可以阻碍向量化,因为向量化操作要求在多个数据上同时执行相同的操作。条件分支会使得每个元素可能需要不同的操作,这与向量化的要求相冲突。
  • 修改后的代码使用了条件操作符(三元运算符),这种表达方式使得编译器能够更容易地推断出可以使用单个向量指令(如 pmaxub)来同时处理多个元素。

内存对齐与访问模式

  • 使用 __builtin_assume_aligned 明确告诉编译器,输入数组 ab 都按照16字节对齐。这个信息允许编译器使用对齐的内存访问指令(如 movdqa),这些指令比非对齐的内存访问指令(如 movdqu)更快更高效。
  • 对齐的内存访问减少了处理器在执行内存读写时可能遇到的延迟和复杂性,从而提高了代码的执行速度和效率。

向量化指令的选择

  • 使用 pmaxub(Packed Maximum Unsigned Byte)指令允许编译器生成一个单一的向量化循环,该循环在每次迭代中处理多个数组元素。这显著提高了循环的效率,因为它减少了迭代次数并且更好地利用了CPU的向量处理能力。

编译器优化策略

  • 编译器的内部优化策略也可能影响向量化的结果。在有明确内存对齐和简化操作(如使用三元运算符代替条件分支)的情况下,编译器更有可能实施向量化优化。

总结来说,通过简化条件逻辑,并明确告诉编译器关于数据对齐的信息,能够帮助编译器生成更高效的向量化代码。这突显了在编写高性能代码时,对编译器的指导和代码的表达方式需要精心设计,以便充分利用现代处理器的向量化能力。

Write-up 4: 分析为什么汇编代码没有使用向量寄存器,并探讨向量化是否会更快

在例子3中,test 函数实现了一个简单的数组赋值操作,其中数组 a 的每个元素被设置为数组 b 中下一个元素的值。具体代码如下:

1
2
3
for (i = 0; i < SIZE; i++) {
a[i] = b[i + 1];
}

这段代码的特点是每次循环都涉及到对数组 b 的下一个元素的访问,这种“滑动窗口”类型的数据访问模式可能是阻碍向量化的原因。

为什么汇编中没有使用向量寄存器?

  1. 数据依赖性

    • 在这个循环中,每个 a[i] 的计算依赖于 b 数组的连续元素 (b[i+1])。这种数据依赖关系导致了数据访问的“依赖滞后”,这可能阻止编译器使用向量寄存器进行并行处理,因为向量化需要数据之间相对独立,以便同时处理多个数据。
  2. 内存对齐问题

    • 对于有效的向量化,通常需要数据严格对齐到特定的字节界限(如16字节或32字节对齐)。在本例中,b[i+1] 的访问模式打破了可能的对齐界限,可能使得编译器难以应用标准的向量化策略。
  3. 向量化的复杂性

    • 尽管现代编译器能够自动识别并向量化多种循环模式,但“滑动窗口”类型的操作增加了向量化的复杂性,特别是当窗口移动跨越多个数据块时。这可能需要更复杂的预取和数据重组策略,编译器可能认为标准的向量化不足以带来性能上的收益。

如果进行了向量化,会更快吗?

  • 潜在的速度提升

    • 如果能够有效地向量化这种类型的循环,理论上可以提高执行速度,因为向量化可以利用现代CPU的SIMD(单指令多数据)能力,同时处理多个数组元素,显著减少总的处理时间。
  • 实际的向量化挑战

    • 实现这种类型的向量化可能需要手动干预或使用特定的编译器指令,例如通过使用内置函数重新安排数据或显式地使用SIMD指令集编程。
    • 同时,需要考虑数据的预取和重组,以确保内存访问的连续性和对齐,这些都可能增加实现的复杂度。

总结来说,尽管向量化这类循环在理论上可能提高性能,但实际上实现向量化可能需要克服数据依赖和对齐等技术障碍。在这种情况下,编译器可能决定不进行向量化,因为它可能无法保证向量化后的性能收益足以抵消实现的复杂性。如果确实需要优化这种循环,可能需要更深入地探索编译器的向量化选项或手动优化代码。

Write-up 5: 检查汇编并验证是否正确向量化

在示例4中,首先使用不带 -ffast-math 标志的编译指令编译 test 函数,这是一个简单的累加浮点数组的循环。初次编译结果显示,代码没有被向量化,并且使用了 addsd 指令,这是单个双精度浮点数的标量加法指令。

未向量化的原因:

浮点加法具有一些特殊性,特别是涉及到浮点数的关联性和可交换性时。由于浮点数计算的特殊规则(如IEEE浮点标准中的舍入模式和对特殊值的处理),编译器默认情况下不会假设浮点加法操作是关联的。这意味着编译器不会自动重组这些操作以利用SIMD指令,因为这可能改变计算结果。

使用 -ffast-math 的影响:

添加 -ffast-math 编译标志可以放宽对浮点数精确行为的限制,允许编译器假设加法是关联的并可以重排序操作。这样,编译器可以自由地使用SIMD指令来加速计算,因为它不需要严格遵守IEEE浮点标准的所有规则。

重新编译后,看到汇编代码中出现了如 addpdvaddpd 等向量化指令,这些指令可以同时处理多个浮点数,提高了代码的执行效率。

使用和不使用 -ffast-math 的比较:

运行编译后的程序时,注意到使用和不使用 -ffast-math 标志的输出结果可能有细微差别。这些差别通常源于浮点计算的不同舍入行为和精确度要求的放宽。

  • **使用 -ffast-math**:会产生更快的执行时间,因为编译器可以使用SIMD优化。结果与严格按照IEEE标准执行的结果略有不同,因为发生了计算顺序的改变或是舍入行为的变化。

  • **不使用 -ffast-math**:执行更慢,但结果会严格遵守IEEE浮点标凈,对于需要高度数值精确的应用来说,这是必须的。

结论:

在决定是否使用 -ffast-math 时,需要权衡性能提升和数值精确度之间的关系。对于对性能要求极高但对精确度要求不是非常严格的应用,启用 -ffast-math 是有益的。然而,对于科学计算和精确金融计算等领域,最好还是保持默认的严格浮点行为,以保证结果的准确性。

Write-up 6: 向量化代码与非向量化代码的性能比较及使用 -mavx2 的影响

性能提升的测量

要了解向量化带来的性能提升,可以通过对比向量化前后的执行时间来衡量。在实验中,通过在AWS的特定环境下运行 loop 程序,并使用不同的编译标志来激活或关闭向量化,可以得到关于向量化效果的直观认识。

  1. 编译并运行向量化与非向量化的代码

    • 使用 make clean; make 编译并运行基础代码以获取未向量化的执行时间。
    • 使用 make clean; make VECTORIZE=1 编译并运行向量化代码,使用 -mavx 标志。
    • 使用 make clean; make VECTORIZE=1 AVX2=1 再次编译并运行,这次使用 -mavx2 标志以启用AVX2指令集,这提供了更大的向量寄存器。
  2. 测量并记录执行时间

    • 在每种情况下,记录程序的执行时间。需要多次运行以获取更稳定的结果,并取中位数作为最终结果。

性能提升分析

  • **基础向量化 (-mavx)**:

    • 向量化通常可以显著提高数据并行循环的执行速度。对于简单的数组操作,如数组加法,使用基础的AVX指令集向量化已经提供了显著的加速,2倍左右的性能提升。
  • **进一步使用 -mavx2**:

    • 启用 -mavx2 后,因为AVX2支持更宽的向量操作(256位),所以会观察到进一步的性能提升。这表现为总体上更高的加速比,从2倍左右提高到3倍左右。

推断AWS运行环境的向量寄存器宽度

  • 默认向量寄存器宽度

    • 如果未使用 -mavx2 标志时观察到了显著的加速,这暗示默认的向量寄存器是128位宽(标准AVX指令)。
  • AVX2向量寄存器宽度

    • 使用 -mavx2 标志并观察到额外的性能提升,暗示AVX2向量寄存器宽度是256位。

结论

这些实验显示,向量化能显著提高数据并行任务的性能,尤其是在使用现代指令集如AVX2时。进一步分析这些数据,可以了解默认的向量寄存器和AVX2寄存器的宽度及其对性能的具体影响。此外,了解和选择合适的编译器标志对于充分利用现代硬件的向量处理能力至关重要。

Write-up 7: 比较向量化开启与未开启时的汇编代码

为了深入了解代码的向量化表现,可以直接查看编译器生成的汇编代码。通过分析这些代码,可以明确看到哪些指令被用于执行向量化操作,以及这些操作是如何被优化的。

步骤和方法

  1. 生成汇编代码

    • 使用 make ASSEMBLE=1 VECTORIZE=1 命令生成启用向量化的汇编代码。
    • 使用 make ASSEMBLE=1 VECTORIZE=0 生成未启用向量化的汇编代码。
  2. 检查汇编代码

    • 打开生成的 loop.s 文件,查找与向量化操作相关的指令。
  3. 向量化指令对比

    • 在启用向量化的代码中,寻找负责向量加法操作的指令。
    • 再次编译代码,这次使用 AVX2=1 标志,查找与此设置相关的向量加法指令。

汇编指令的识别

  • 不启用AVX2时的向量加法指令(基于SSE2指令集):

    • 通常使用 addpdaddps 指令进行双精度或单精度的向量加法。
    • 示例指令:addpd %xmm1, %xmm0
    • 这指令将 %xmm1 寄存器中的数据加到 %xmm0 寄存器中的数据。
  • 启用AVX2时的向量加法指令

    • 在AVX2指令集中,可以使用 vaddpdvaddps 指令进行更宽向量寄存器的加法操作。
    • 示例指令:vaddpd %ymm1, %ymm0, %ymm0
    • 这指令将 %ymm1 中的数据与 %ymm0 中的数据相加,结果存回 %ymm0

性能和优化

  • 分析向量化的效果

    • 观察启用和未启用向量化时汇编代码的差异,特别是循环结构和操作的表示。
    • 检查是否有额外的优化,如循环展开或特定的数据预取指令,这些在启用向量化时出现。
  • 改善汇编代码的可读性

    • 考虑在Makefile中移除 -g-gdwarf-3 标志,以减少调试符号,使汇编代码更加简洁。
    • 使用 -fno-unroll-loops 标志有助于更清晰地观察循环的向量化情况,因为这可以防止编译器自动展开循环。

结论

通过比较不同编译标志下的汇编输出,可以具体了解编译器如何处理向量化指令,以及这些指令对程序性能的潜在影响。理解这些基础可以帮助开发者优化代码,并充分利用现代处理器的向量处理能力。

Write-up 8: 使用 OP 宏实验不同的运算符

在本实验中,通过更改数据并行循环中的运算符来测试不同类型的向量运算。在这个实验中,运算符由一个宏 __OP__ 定义,能够轻松地更换使用的运算符,并观察其对向量化的影响。

操作符的修改和初始化问题

  1. 初始化问题
    • 由于数组 B 被初始化为全零,使用 /% 运算符时会导致除零错误。为解决这个问题,可以在初始化数组 B 时填充非零值,例如使用 1 来避免除零错误。
1
2
3
for (size_t i = 0; i < SIZE; i++) {
b[i] = 1; // 设置为非零值以避免除零错误
}
  1. 改变运算符
    • 修改数据并行循环中使用的运算符,例如尝试使用加 (+), 减 (-), 乘 (*), 除 (/), 和位移 (<<, >>) 等运算。

向量化的观察

  • 不同运算符的向量化表现
    • 大多数基本算术运算(加、减、乘、除)都应该能够在启用 VECTORIZE=1 AVX2=1 时被成功向量化,因为现代向量处理单元支持这些操作的硬件实现。
    • 对于位移运算符如 <<>>,向量化可能更复杂,因为它们通常需要不同的硬件支持。

汇编代码分析

  • VECTORIZE=1 的位移运算

    • 在只启用 VECTORIZE=1 时,编译器可能使用标准的向量位移指令(如 pslldpsrlq 等)处理整数数组的位移。
    • 示例指令:pslld xmm0, 1(将 xmm0 寄存器中的值左移1位)。
  • VECTORIZE=1 AVX2=1 的位移运算

    • 启用 AVX2=1 后,编译器可使用更宽的向量寄存器(如 ymm),并可能使用相应的AVX2位移指令(如 vpslld ymm0, ymm1, imm8)。
    • 示例指令:vpslld ymm0, ymm1, 1(将 ymm1 寄存器中的值左移1位,并存入 ymm0)。

结论

实验表明,大多数基本算术运算符可以成功向量化,而位移运算符的向量化可能取决于具体的编译器实现和目标硬件的支持。了解和选择合适的编译器标志和硬件指令集对充分利用向量处理能力至关重要。此外,适当的数组初始化也是避免运行时错误的关键。

Write-up 9: 比较不同数据类型对向量化性能的影响

在本实验中,会探索改变数据类型对内存需求和向量包装能力的影响,特别是在使用加法运算符时,对比使用 uint64_t, uint32_t, uint16_t, 和 uint8_t 数据类型的向量化代码与非向量化代码之间的性能差异。

实验设置

  • 数据类型更改:改变数组A、B、C的数据类型,分别从 uint64_tuint8_t
  • 运算符设置:设置运算符 OP 为加法 +
  • 编译和运行:分别为每种数据类型编译和运行代码,一次不启用向量化,一次启用AVX2向量化。
  • 性能测量:记录每种情况下的执行时间,并计算向量化代码相对于非向量化代码的加速比。

性能分析

  1. **uint64_t**:

    • 非向量化代码表现较差,因为每次操作的数据量较大。
    • 向量化代码(尤其是AVX2)显示出一定的加速,但由于寄存器宽度限制,每个寄存器只能包含较少的元素。
  2. **uint32_t**:

    • 这是较常见的选择,向量化可以较好地加速,因为一个256位的AVX2寄存器可以包含8个 uint32_t 元素。
  3. **uint16_t**:

    • 内存需求更小,每个向量寄存器可以包含更多元素(例如,16个元素),这导致更高的加速比。
  4. **uint8_t**:

    • 每个向量寄存器可以包含更多的元素(如32个),这提供最高的加速比,因为更多的操作可以并行执行。

性能提升

  • 向量化代码相比于非向量化代码

    • 对于所有数据类型,向量化通常都会带来性能提升。特别是当数据类型较小(如 uint16_tuint8_t)时,加速比更为显著,因为可以在每个向量操作中处理更多元素。
  • AVX2向量化代码相比于非向量化代码

    • 使用AVX2时,加速比通常会进一步提高,因为AVX2支持更宽的向量操作,可以一次处理更多的数据。

结论

数据类型对于向量化的性能影响显著。选择较小的数据类型可以减少内存需求并增加向量寄存器中可以包装的元素数量,从而提高性能。在设计和优化向量化应用程序时,应仔细考虑数据类型的选择以最大化性能。

Write-up 10: 测试使用 uint64_tuint8_t 类型的向量乘法性能

在此实验中探索使用不同数据类型(特别是 uint64_tuint8_t)对于向量化代码在执行乘法操作时的性能影响。乘法操作相对于加法需要更多的时钟周期,这会影响向量化带来的性能提升。

实验设置

  • 数据类型uint64_tuint8_t
  • 操作符:乘法 (*)。
  • 编译和测试
    • 对于 uint64_tuint8_t,分别编译并运行使用乘法的向量化和非向量化代码。
    • 使用 VECTORIZE=1VECTORIZE=1 AVX2=1 标志进行编译。

性能分析

  1. uint64_t 数据类型

    • 非向量化代码:执行较慢,因为每次操作处理的数据量较大,且乘法操作相对耗时。
    • 向量化代码(AVX2):虽然向量化可以提供一定程度的加速,但由于 uint64_t 数据类型的元素宽度较大,每个向量寄存器能处理的元素数量较少,限制了向量化的效益。
  2. uint8_t 数据类型

    • 非向量化代码:相对较慢,但由于处理的数据单元较小,所需的时钟周期较少。
    • 向量化代码(AVX2):由于 uint8_t 类型允许单个向量寄存器包含更多元素(例如AVX2的32个元素),向量化提供了显著的加速。这种情况下,向量化乘法可以极大提升性能,因为可以同时处理更多的数据。

性能提升

  • 对于 uint64_t 类型,向量化的性能提升相对有限,只有轻微的改善,1.5倍左右的加速。
  • 对于 uint8_t 类型,由于可以在一个操作中处理更多元素,加速比会更高,达到了4倍左右。

结论

向量化在执行数据并行操作时能显著提高性能,特别是当操作单元较小时(如 uint8_t)。选择更小的数据类型可以增加向量寄存器中的元素数量,从而提高每次向量操作的效率。对于乘法这样的高成本操作,向量化的好处尤其明显。然而,对于较大的数据类型(如 uint64_t),尽管向量化能带来性能改善,其提升幅度有限,特别是当每个向量寄存器能处理的元素数量较少时。

Write-up 11: 分析使用 uint64_t 类型的AVX2向量化乘法与加法代码的性能

在这个实验中,使用 awsrun perf record 工具来收集使用 uint64_t 类型的AVX2向量化乘法操作的性能数据,然后通过 aws-perf-report 工具来分析哪些操作占用了最多的执行时间。比较乘法操作与加法操作在向量化环境中的性能差异。

步骤和方法

  1. 收集性能数据

    • 对于乘法 (*) 操作,设置 __OP__ 为乘法并使用 awsrun perf record 来执行程序,收集性能数据。
    • 对于加法 (+) 操作,将 __OP__ 改回加法并重复性能数据收集过程。
  2. 分析性能报告

    • 使用 aws-perf-report 来分析收集到的性能数据,特别注意向量乘法与向量加法指令的执行时间占比。

性能分析

  • 向量乘法 (*) 性能分析

    • 向量化的乘法操作会消耗较多的CPU时间,因为向量乘法相对于加法需要更多的时钟周期。
    • 检查性能报告,观察是否有大量时间被消耗在 vpmulld(向量乘法)或其他相关向量乘法指令上。
  • 向量加法 (+) 性能分析

    • 向量加法操作通常需要较少的时钟周期,因此相比于向量乘法,其在性能报告中占用的时间比例较低。
    • 分析性能报告,确认向量加法指令如 vpadd 在总执行时间中的占比。

实验结果和结论

  • 向量乘法的时间占比

    • 如果发现向量乘法操作并没有占用大部分时间,可能的原因包括内存访问延迟或数据依赖导致的CPU空闲等。
    • 分析是否有其他系统层面的操作(如内存加载)消耗了大量时间,这可能影响了整体的性能表现。
  • 向量加法的时间占比

    • 比较向量加法与乘法的性能数据,通常应观察到加法操作在性能报告中占用的时间比例较低,因为加法操作更快。
  • 性能优化的见解

    • 这种比较有助于理解不同向量操作的性能影响,为优化提供方向,尤其是在处理涉及复杂数据操作的应用时。
    • 如果乘法操作占用过多时间,考虑优化算法或调整数据结构和访问模式,以减少性能瓶颈。

这项分析能够揭示在使用高级向量扩展时,不同算术操作在性能上的实际差异,从而为进一步的性能优化提供依据。

Write-up 12: 对于运行时确定的循环边界的向量化性能分析

在之前的实验中,循环的界限 N 是预先定义为1024的固定值,这使得编译器能够在编译时进行优化。改为在运行时通过命令行参数设置 N,将增加编译器处理向量化的复杂性,因为它不能再做出关于循环迭代次数的静态假设。

实验设置

  • 修改代码:将循环界限 N 的定义改为运行时决定:
    1
    2
    3
    4
    int main(int argc, char *argv[]) {
    int N = atoi(argv[1]);
    ...
    }
  • 编译和运行:对不同的 N 值(例如256, 512, 1024, 2048等)分别进行非向量化、AVX2向量化和非AVX2向量化的编译和测试。

性能分析

  • 向量化与非向量化的比较

    • 对于较小的 N 值,向量化代码不会显著优于非向量化代码,因为启动和管理向量操作的开销占据了较大比例。
    • 随着 N 值的增加,向量化代码的性能优势应该更加明显,因为长循环更能够利用向量处理的并行性。
  • AVX2与非AVX2向量化的比较

    • 使用AVX2指令集的向量化代码应该在所有 N 值下都显示出比普通向量化更好的性能,尤其是在 N 较大时,因为AVX2支持更宽的向量操作,可以处理更多数据。

性能变化与 N = 1024 的比较

  • N 较小(如256或以下)时,相对于 N = 1024,向量化带来的性能提升不那么显著。这是因为向量化的启动和管理成本在小规模数据上的相对影响更大。
  • N 较大(如2048或以上)时,向量化代码的性能提升应该比 N = 1024 时更加明显,因为较长的循环能更好地利用处理器的向量化能力,减少了相对开销。

结论

改变 N 的定义使其在运行时确定,表明了编译器在处理不确定循环边界时的行为。虽然向量化在大多数情况下都能提供性能提升,但其效果受到循环长度的影响。实验结果显示,当循环边界较大时,向量化(尤其是使用AVX2)带来的性能提升更为显著。这强调了在设计向量化代码时,考虑数据规模和选择合适的编译器优化标志的重要性。

Write-up 13: 探索步长不为1时的向量化情况

在此实验中,调整数组遍历的步长(stride),并观察编译器是否能够向量化这样的循环。步长不为1意味着每次迭代跳过一个或多个元素,这可能影响编译器的向量化决策。

实验设置

  • 数据类型和操作:设置 __TYPE__uint32_t__OP__ 设置为加法 (+)。
  • 修改循环结构:将内部循环修改为步长为2的循环:
    1
    2
    3
    for (j = 0; j < N; j += 2) {
    C[j] = A[j] + B[j];
    }

编译和运行

  • 使用相应的编译标志进行编译,检查编译器是否能够向量化这种带有特定步长的循环。

向量化的观察与分析

  • 编译器的向量化决策

    • 对于步长为2的循环,编译器不会选择向量化。这是因为非连续的内存访问模式降低了数据的局部性,从而影响了向量化的效率。
    • 向量单位虽然可以支持不同的步长,但在步长增大时,需要额外的指令来处理间隔较大的元素加载,这导致性能不如步长为1时优秀。
  • 为什么可能不向量化

    • 内存访问效率:步长为2可能导致内存访问不连续,使得处理器缓存利用率下降,进而影响性能。
    • 复杂度增加:处理不连续数据需要额外的逻辑和指令,如间隔加载和额外的数据对齐处理,增加了编译器的实现复杂度。
    • 硬件优化限制:尽管现代硬件支持多种步长,但对于较大的步长,硬件加速可能不如连续访问显著。

实验结果

  • 在实际测试中,发现即使硬件和编译器支持向量化步长不为1的循环,性能提升也不如连续访问的情况显著。这一结果应通过检查生成的汇编代码中是否包含向量指令以及这些指令的性能指标来确认。

结论

调整循环的步长可以是一个重要的性能优化方向,尤其是在处理步长非1的数据访问模式时。然而,开发者需要仔细考虑步长变化对向量化的可能影响,并通过实验验证预期的优化效果。如果向量化未带来预期的性能提升,可能需要探索其他优化策略或调整算法结构以适应硬件的特性。

Write-up 14: 使用 #pragma clang loop 指令优化向量化

为了强制编译器向量化一个步长不为1的循环,用 #pragma clang loop 指令,这是 Clang 提供的一种语言扩展,用于控制循环的优化。这允许开发者对编译器的循环优化行为进行更细致的控制,尤其是在向量化方面。

实验设置

  • 修改代码:在步长为2的循环前添加 #pragma clang loop vectorize(enable) 来尝试强制向量化该循环。

    1
    2
    3
    4
    #pragma clang loop vectorize(enable)
    for (j = 0; j < N; j += 2) {
    C[j] = A[j] + B[j];
    }
  • 编译与测试

    • 分别编译并运行未使用AVX2和使用AVX2的向量化代码,记录并比较执行时间。
    • 尝试修改 vectorize_width 参数,比如设置为2,来看看对性能的影响。

性能分析

  • 基本向量化测试

    • 使用基本的 #pragma clang loop vectorize(enable) 指令会看到一定的性能提升,因为编译器被提示尽可能向量化该循环。
  • **修改 vectorize_width**:

    • vectorize_width 设置为2会进一步影响性能,因为这指示编译器在向量化时使用更小的向量宽度。这对于步长为2的循环更为合适。
  • 使用AVX2

    • 启用AVX2向量化应该会看到更显著的性能提升,因为AVX2支持更宽的向量操作,可以更有效地处理数据。

实验结果

  • 在不同的 vectorize_width 和是否使用AVX2的条件下,性能提升会有所不同。通常,使用AVX2的向量化代码相比于非向量化代码会有更明显的加速。
  • 如果适当调整 vectorize_width,比如设置为与数据访问模式更匹配的值,会进一步优化性能。

最佳配置

  • 最佳配置会根据具体的循环内容和数据访问模式而变化。在一些情况下,适当地使用 #pragma clang loop 指令并调整相关参数可以显著提高向量化代码的性能。

结论

通过使用 #pragma clang loop 指令,能够更精细地控制编译器的向量化行为,特别是在处理具有复杂访问模式的循环时。在合适的配置下,向量化代码相比于非向量化代码确实能够提供显著的性能提升。这种控制手段为性能优化提供了更多的可能性,尤其是在需要手动调整编译器行为以适应特定算法的情况下。

Write-up 15: 分析数组求和向量化的汇编实现

在本实验中,通过汇编代码来理解编译器是如何向量化数组求和(reduction)操作的。向量化的求和操作对于理解数据归约在现代处理器上的效率至关重要。

实验设置

  • 修改代码:实现一个简单的数组求和循环,确保该循环是向量化的重点。

    1
    2
    3
    4
    int total = 0;
    for (int j = 0; j < N; j++) {
    total += A[j];
    }
  • 编译并生成汇编代码

    • 使用 make ASSEMBLE=1 编译代码,以生成详细的汇编代码文件,从而能够详细查看向量化实现。

汇编代码分析

  • 向量化操作的实现

    • 在生成的汇编代码中,编译器通常会使用如 vpaddd(向量化整数加法)等指令来实现数组的求和。
    • 编译器会将数组元素加载到多个向量寄存器中,然后在这些寄存器上执行并行加法操作。
  • 具体的汇编指令

    • 例如,编译器使用 movdqa 指令加载数据到 SIMD 寄存器(如 xmm0),然后使用 paddd xmm0, xmm1 将两个寄存器中的数据相加,实现部分和的累加。
    • 对于更高位宽的指令集(如AVX2),会看到使用 vpaddd ymm0, ymm0, ymm1 这样的指令,这可以同时处理更多数据。
  • 循环尾部处理

    • 在处理不完全填充向量寄存器的数据时,编译器还会生成处理循环尾部(即数组大小不是向量宽度整数倍部分)的代码。
    • 这通常涉及到在循环结束时对剩余元素进行标量处理,以确保所有元素都被正确求和。

性能优化的观察

  • 归约操作的并行化

    • 通过将多次独立的加法操作合并到单个向量指令中,归约操作的向量化显著提高了处理速度。
    • 向量化归约减少了执行的指令数量,并提高了数据通过率。
  • 编译器优化的限制

    • 虽然向量化可以显著加快归约操作的速度,但编译器在实现这一过程时需要插入额外的逻辑来处理边界情况,这些逻辑会稍微影响性能。

结论

汇编代码分析揭示了编译器如何有效地实现向量化数组求和操作。通过并行处理多个数组元素,向量化显著提高了性能。然而,为了完全理解和优化这类操作,开发者需要注意编译器如何处理向量化操作的细节,包括数据加载、并行执行和循环尾部处理。这些知识对于编写高效的向量化代码至关重要。

  • Title: MIT软件性能工程——Homework-3-Vectorization
  • Author: ELecmark
  • Created at : 2024-04-22 20:09:38
  • Updated at : 2024-04-22 23:58:25
  • Link: https://elecmark.github.io/2024/04/22/MIT软件性能工程——Homework-3-Vectorization/
  • License: This work is licensed under CC BY-NC-SA 4.0.
Comments