游戏引擎学习第117天

server/2025/2/24 14:22:14/

仓库:https://gitee.com/mrxiao_com/2d_game_3

加载代码并考虑优化

今天的内容主要集中在游戏开发中的性能优化部分,特别是SIMD(单指令多数据)优化。在前一周,已经完成了一些基本的优化,使得代码运行速度提高了大约三倍,尽管还没有完成所有计划中的优化部分。接下来将进一步处理更复杂的部分,特别是在SIMD优化方面,目标是将更多的代码逻辑转化为SIMD操作,从而提高性能。

目前,已经完成了对大部分数学逻辑的SIMD优化,但在某些部分,特别是图像像素写入部分,仍然没有进行SIMD优化。该部分代码使用了单线程的处理方式,速度较慢,未来将尝试将其转化为SIMD处理,从而提升效率。

通过优化,将计算从单个像素的处理转变为同时处理多个像素,这种优化方式虽然看似简单,但可以显著提高性能。优化的核心思想是通过SIMD指令集来并行处理数据,从而加快运算速度,而不是通过减少计算步骤或优化算法本身。虽然这种方式是将代码从标量C代码转化为SIMD指令,但已经取得了显著的加速效果。

今天的任务是集中在尚未进行SIMD优化的部分,特别是像素写入的环节。目标是将这些环节也转化为SIMD操作,从而避免在性能上出现瓶颈。

game_render_group.cpp: 注释掉 if(ShouldFill[I])

首先,为了简化问题,决定暂时去掉一部分内容,以便能够专注于解决其中的一个方面。通过注释掉“ShouldFill”部分,可以确保即使绘制操作超出预定区域,仍然会将像素绘制到缓冲区。这样做的效果是,如果操作区域的边界溢出,可能会看到黑条出现在本不应该触碰的地方。

这个问题暂时不需要关心,稍后会重新加入并修复。现在,重点是解决如何在非标量的情况下进行像素写入。当前的做法是循环四次,每次手动构建一个像素。目标是避免这种重复的操作,寻找一种更高效的方式来一次性完成像素写入,而不需要单独处理每个像素。

在这里插入图片描述

在这里插入图片描述

Blackboard: 交错四个 SIMD 值

在处理 C++ 代码时,尤其是当涉及到数据格式转换和内存布局时,尤其需要关注如何将宽数据(如浮点数)转换为不同的格式并适应图像处理的需求。以下是一些重要的要点和策略:

1. 宽数据到内存格式的转换

我们从一个浮点数数组开始,这个数组由 128 位组成,通常包含四个 32 位浮动点数。每个浮动点数对应一个像素的颜色成分(红、绿、蓝、透明度)。为了将这些浮动点数存储到内存中,需要进行格式转换。目标格式是将每个浮动点数从 32 位转换为 8 位。

  • 在转换时,虽然每个值的位数减小了(从 32 位降到 8 位),但总的数据量保持不变。关键是如何调整数据的排列顺序。

2. 内存布局的差异

数据在内存中的布局与原始的数据结构有所不同。内存中的存储顺序是按颜色分离的,每个颜色成分(红、绿、蓝、透明度)按字节序列依次排列。比如,内存中存储的顺序可能是 R0, G0, B0, A0, R1, G1, B1, A1 等等,其中每个值为 8 位(即 1 字节)。

这种存储布局与处理过程中所需的格式有所不同,因此需要进行重新排列。目标是将原始的浮动点数数据(每个颜色成分为 32 位)转换为 8 位的格式,并确保正确的内存布局。

3. 如何加载和存储数据

数据加载的方式通常会遵循小端序(little-endian)原则,即数据按从低字节到高字节的顺序存储。举例来说,如果从内存中读取数据,首先会读取最低有效字节,然后再读取其他字节。

在此基础上,我们需要将四个颜色成分的数据(即红色、绿色、蓝色和透明度)转换成新的顺序。具体来说,我们可以将每个颜色成分分解成多个 8 位的元素,并将这些元素放入正确的寄存器中。通过这种方法,我们可以避免重复的存储操作。

4. 格式转换的挑战

问题的关键在于,如何将四个分别存储不同颜色成分的寄存器,转换成一个符合内存布局要求的格式。目标是将这些数据转换为“交错排列”的格式,即每四个相邻的像素数据按 R, G, B, A 的顺序交错存储。

这种转换过程的核心是要“提取”每个寄存器中的颜色成分,然后重新排列它们。通过适当的循环和数据搬移,可以将原本分离存储的颜色成分合并为交错排列的格式。

5. 实际解决方案

为了实现这种格式转换,可以将每个颜色通道(红、绿、蓝、透明度)提取出来,然后分别进行处理。每个通道的数据可以从其原始的浮动点数格式转换为 8 位整数,并存储到适当的位置。

具体操作包括:

  • 从每个 SIMD 寄存器中提取红色、绿色、蓝色和透明度的数据。
  • 将这些数据按顺序放入一个新的寄存器中,确保它们按照正确的顺序(如 R0, G0, B0, A0)存储。
  • 通过这样的方式,数据就能被正确地格式化,并按照期望的内存布局写入。

6. 性能与效率

这种转换过程的性能和效率至关重要,特别是在图形处理和视频渲染等领域。通过精确控制数据的读取、转换和写入方式,可以最大化处理速度,同时避免内存开销和不必要的数据复制操作。

总结

在进行数据格式转换时,特别是从 32 位浮动点数到 8 位整数的转换过程中,最大的挑战在于如何处理数据的排列顺序。通过将数据从宽格式转换为交错的内存格式,可以确保数据能够正确地存储和访问,同时保持处理的效率。

每次git提交太频繁当保存这个文件时候就去打开git commit 方便提交git

1. 安装Git Commit Message Editor 插件

在这里插入图片描述

2. 每次保存这个文件就去执行vscode的命令gitCommitMessageEditor.editor.command.openEditor

在这里插入图片描述

在这里插入图片描述

Blackboard: 确定我们需要的顺序

在处理内存数据时,尤其是在图形渲染中,需要关注数据的顺序和格式化。特别是当数据被加载到寄存器中时,内存的顺序和最终的寄存器布局可能会有所不同。以下是一些关键点:

1. 小端架构的内存顺序

在小端架构(little-endian)中,数据会按照从低字节到高字节的顺序存储和读取。这样,当我们将数据加载到寄存器时,它的排列顺序也会受到影响。例如,寄存器中存储的图像数据通常是按照特定的内存顺序排列的,这可能与我们希望的显示格式不同。

2. Windows帧缓冲区的存储顺序

在Windows中,帧缓冲区的存储顺序与OpenGL纹理格式有所不同。特别是在Windows平台,颜色的排列顺序是BGRA(即蓝色、绿色、红色和透明度),而不是标准的RGBA。这是因为Windows系统中的帧缓冲区采用了不同的颜色通道顺序。

  • OpenGL纹理格式: 通常为 RGBA 格式(红色、绿色、蓝色和透明度)。
  • Windows帧缓冲区格式: 颜色顺序为 BGRA(蓝色、绿色、红色和透明度),这与标准的OpenGL格式有所不同。

3. 需要的格式转换

目标是将数据转换为Windows帧缓冲区格式,即BGRA的排列顺序,而不是OpenGL中常见的RGBA格式。为了实现这一点,我们需要确保将颜色分量正确地按BGRA的顺序存储到寄存器中。

例如,假设我们有四个颜色通道(红、绿、蓝、透明度),我们希望将它们存储在寄存器中的顺序为 B, G, R, A。具体来说,我们需要将每个寄存器中的颜色成分按照BGRA顺序排列:

B G R A
B G R A
B G R A
B G R A

这样,最终在寄存器中的数据顺序会是 BGRA BGRA BGRA BGRA,符合Windows帧缓冲区的存储格式。

4. 内存中的实际数据布局

在内存中,数据的顺序会受到平台架构的影响。虽然我们将数据从32位浮动点数转换为8位整数并存储,但实际的内存布局和寄存器中数据的排列顺序仍然需要进行调整。这种调整的目标是确保数据能够正确地按照所需格式存储和读取。

为了在内存中正确地存储和访问这些数据,必须进行适当的格式化。特别是将原本按RGBA排列的颜色通道,转化为BGRA格式,从而确保正确的内存写入。

5. 结论

最终的目标是将数据从32位浮动点数转换为8位整数后,按照正确的内存顺序(BGRA)存储。这个过程不仅涉及格式转换,还包括调整颜色通道的排列顺序,以确保在不同平台或不同图形API之间的兼容性。

game_render_group.cpp: 写出我们希望最终得到的 SIMD 寄存器名称

要完成当前的工作,首先需要将已经混合的 RGB 和 Alpha 值按照目标顺序整理成适当的格式。目标是将混合后的 RGB 和 Alpha 按照一定顺序重新排列为以下格式:

  • RGBA0
  • RGBA1
  • RGBA2
  • RGBA3

这个步骤是关键的,但完成后,剩下的操作会相对简单,不需要太多的额外工作。主要是将当前的数据按照正确的顺序整理好,之后就可以顺利完成剩下的操作了。

一旦这一部分完成,后续的工作会变得更加清晰,剩下的只是做一些必要的步骤来确保所有内容按照预期处理完毕。
在这里插入图片描述

Internet: Intel Intrinsics 指南

在当前的代码中,使用了 sscssc2,这些在处理过程中非常方便。而需要注意的是,有一组函数被命名为 _mm_unpack,这个命名有些特殊,因为它们的作用是将数据解包(解开并重新排列)。这些解包函数会将数据按照一定规则展开,特别是将数据的字节数扩展。

举个例子,假如用一个零向量或者零寄存器来解包数据,解包过程会扩展数据,使其占用更多的字节。以 _mm_unpackhi_epi32 为例,这个函数的作用是将数据解包,而 unpack high 通常会有一个对应的 unpack low 函数。

这种解包操作会使得数据按预定的方式进行扩展,并且分成高位和低位两个部分,分别处理。这些函数帮助将数据处理得更加灵活,适应不同的格式和操作需求。
在这里插入图片描述

_mm_unpackhi_epi32 是一个 SIMD(单指令多数据)指令,用于在 Intel 的 AVX 或 SSE 指令集下处理 32 位整数。其主要作用是将两个 128 位的寄存器中的高 64 位元素(每个寄存器包含 4 个 32 位整数)解包并交替地合并成一个新的寄存器。

具体来说,_mm_unpackhi_epi32 做了以下几件事情:

1. 输入

假设有两个 128 位的寄存器(ab),每个寄存器包含 4 个 32 位整数:

  • a = [a0, a1, a2, a3]
  • b = [b0, b1, b2, b3]

2. 操作

该指令将从这两个寄存器中分别提取出高 2 个 32 位整数,然后将它们按顺序交替合并:

  • a2a3(来自寄存器 a 的高 32 位整数)和
  • b2b3(来自寄存器 b 的高 32 位整数)

3. 结果

最终会得到一个新的 128 位寄存器,其中:

  • 第一个 32 位元素是 a2
  • 第二个 32 位元素是 a3
  • 第三个 32 位元素是 b2
  • 第四个 32 位元素是 b3

也就是:

  • result = [a2, a3, b2, b3]

4. 用途

_mm_unpackhi_epi32 常用于解包操作,在一些需要将多个寄存器中的数据合并并重新排列的场景中非常有用。它通常与其他类似的解包指令(如 _mm_unpacklo_epi32)一起使用,用于处理数据重排、矢量化计算和多媒体处理等任务。

示例:

__m128i a = _mm_set_epi32(1, 2, 3, 4); // a = [4, 3, 2, 1]
__m128i b = _mm_set_epi32(5, 6, 7, 8); // b = [8, 7, 6, 5]
__m128i result = _mm_unpackhi_epi32(a, b); // result = [2, 1, 6, 5]

在这个例子中,_mm_unpackhi_epi32 会将 ab 的高 32 位整数(即 a2, a3, b2, b3)交替合并,结果是 [2, 1, 6, 5]

Blackboard: __mm_unpackhi_epi32 和 __mm_unpacklo_epi32

unpack highunpack low 是 SIMD 指令中的解包操作,它们主要用于将数据从一个寄存器中解包并重新排列。它们的工作原理是将寄存器中的 32 位整数分为高位和低位,并对其进行交替排列。具体来说:

1. 解包的操作

假设有两个寄存器(rg),每个寄存器中包含 4 个 32 位整数,如下所示:

  • r = [r0, r1, r2, r3]
  • g = [g0, g1, g2, g3]

2. unpack low 的操作

  • 当使用 unpack low 时,它会从这两个寄存器中取出低位部分的数据并交替排列:

    • r0 放入目标寄存器的第一个槽位
    • g0 放入第二个槽位
    • r1 放入第三个槽位
    • g1 放入第四个槽位

    结果为:[r0, g0, r1, g1]

3. unpack high 的操作

  • 当使用 unpack high 时,它会从这两个寄存器中取出高位部分的数据并交替排列:

    • r2 放入目标寄存器的第一个槽位
    • g2 放入第二个槽位
    • r3 放入第三个槽位
    • g3 放入第四个槽位

    结果为:[r2, g2, r3, g3]

4. 低位和高位的选择

  • unpack lowunpack high 之所以有两个版本,主要是因为每个寄存器只能容纳 4 个 32 位值,因此,低位和高位操作会选择不同的 4 个数据来进行解包。
  • unpack low 处理的是前 4 个元素(低位),而 unpack high 处理的是后 4 个元素(高位)。

5. 解包过程的目的

  • 这些操作的目的是将寄存器中的数据按照特定的方式重新排列成所需格式,常用于图形处理、数据传输等需要按特定顺序处理数据的场景。
  • 这种交替排列的操作能有效地将多个 32 位数据打散,进行有效的组合和重新排列,从而提高处理效率。

6. 总结

unpack lowunpack high 提供了对寄存器数据的不同解包方式,帮助完成复杂的数据重排任务。通过选择低位或高位进行交替组合,能够实现数据的插入、扩展和排列,达到需要的格式或结构。

Blackboard: 使用这些操作生成我们需要的内容

在处理数据时,使用解包操作(unpack)来实现交错排列(interleave)。由于每次解包只能处理两个元素,因此需要多个解包操作来实现目标结果。

操作步骤:

  1. 目标交错排列

    • 我们的目标是将数据交错排列成特定格式,例如将 gb 交错成一对一对的形式。这需要用到解包操作。
  2. 解包过程

    • 假设有两个寄存器,一个存储 g 3 g 2 g 1 g 0 g_3g_2g_1g_0 g3g2g1g0,另一个存储 b 3 b 2 b 1 b 0 b_3b_2b_1b_0 b3b2b1b0,它们分别包含多个值。
    • 解包低位(unpack low):将 gb 中的前两个元素交替排列,生成 b 0 g 0 b 1 g 1 b_0g_0b_1g_1 b0g0b1g1 的格式。
    • 解包高位(unpack high):处理后续的元素,生成 b 2 g 2 b 3 g 3 b_2g_2b_3g_3 b2g2b3g3 的格式。
  3. 交错排列

    • 使用这种解包方式,可以成功地交错并排列需要的数据,比如 bg
  4. 处理多个数据

    • 也可以使用类似的操作将 ar 数据进行解包和交错排列。通过这样的方式,最终目标是将 a, b, g, r 数据交错排列成需要的格式,例如 a0, b0, g0, r0, a1, b1, g1, r1
  5. 遇到的挑战

    • 如果只是简单地解包两个寄存器的低位和高位,它们的排列顺序可能无法满足所有需求。例如,想要交错排列 bg 时,可能会在合并结果时得到不希望的格式。
  6. 解决方案

    • 可以通过重新排列解包的顺序来解决这个问题。比如,先将 br 混合,再将 ga 混合。通过调整混合的顺序,使用同样宽度的解包指令,就能实现期望的交错排列。
  7. 总结

    • 通过合理选择解包的顺序,可以在保持相同指令宽度的情况下完成复杂的数据交错操作。虽然不一定每次都需要这样做,但理解这些解包操作和交错排列的机制很重要,尤其是在处理较复杂的操作时,这样的灵活性可以帮助实现预期效果。

game_render_group.cpp: 按寄存器顺序命名寄存器

在这里插入图片描述

Internet: 仔细检查解包操作的参数顺序

在进行解包操作时,必须仔细确认每个参数的顺序,因为在这些操作中,参数的顺序可能会导致一些困惑。具体来说,需要检查哪个数据进入低位槽。

步骤说明:

  1. 确认解包操作

    • 使用 _mm_unpacklo_epi32_mm_unpackhi_epi32 指令时,需要确保理解哪个数据应该放入低位槽(low slot)和高位槽(high slot)。例如,unpack low 指令将数据的低32位解包,而 unpack high 则处理高32位。
  2. 参数顺序检查

    • 确保传入的源寄存器顺序正确。以 _mm_unpacklo_epi32 为例,需要确认哪一部分数据进入低位槽。如果源寄存器为 source1,那么数据的低32位将来自 source1,即 source1 的前32位会被解包到低位槽中。
  3. 选择数据源

    • 需要明确在进行低位解包时,数据源应该是 b,即在此示例中,b 应该是源寄存器 source1 中的低32位数据。

通过这种方式,确保了在进行数据解包时,能够正确地将数据按预期格式处理,避免顺序错误导致的问题。
在这里插入图片描述

game_render_group.cpp: 开始填充寄存器

在编写代码时,可以按照预期的方式进行操作并逐步验证每个步骤的正确性。这是一个合理的调试过程,确保代码按预期运行。

步骤说明:

  1. 逐步验证代码

    • 在编写代码时,可以逐步执行并验证每一步的结果。这可以帮助确认每个操作是否正确,尤其是在涉及复杂的解包操作时。
  2. 初始化并调试

    • 在初步实施时,可以将变量值归零并确保代码逻辑没有问题。这样做有助于清除潜在的错误源,确保后续的操作能够顺利进行。

通过这种方式,能够逐步调试和验证代码,确保每个环节的正确性,避免出现错误并及时修正。
在这里插入图片描述

Internet: 牢记在 __m128 和 __m128i 之间切换时要注意

在处理优化时,需要注意的一个关键点是 __m128 类型的使用。最初认为 __m128 高低位的不同仅是类型上的问题,但实际上,这种差异实际上是编译器优化的提示。它指导编译器在选择指令时做出更高效的决策,尤其是在整数与浮点数运算之间的切换上。不同类型的运算会使用不同的硬件单元,且在某些架构下,跨类型的切换可能会导致性能下降,因为可能会浪费一些周期。

因此,虽然在许多情况下,跨类型的切换影响微乎其微,但在性能要求高的情况下,关注这些细节可能会带来一定的优化。尤其是在某些特定的硬件架构上,这种切换的成本可能不容忽视。所以,虽然这在大多数开发任务中不一定是问题,但如果对性能有严格要求时,了解和注意这一点会是一个有益的优化策略。
在这里插入图片描述

game_render_group.cpp: 将 Blended 值从浮点数转换为整数

需要做的事情是更改变量的类型。具体来说,需要使用 cast pscast si 进行类型转换。cast ps 将浮点数转换为整数,cast si 将整数转换为浮点数,这两个操作在大多数情况下是免费的,不会带来显著的性能损失。因此,可以根据需要自由地在浮点数和整数指令之间切换。

这样做的目的是确保在需要整数运算时使用整数指令,而在需要浮点运算时使用浮点指令,从而优化性能。通过适当的类型转换,代码在处理不同的数据类型时能够保持效率,同时避免不必要的计算开销。
在这里插入图片描述

在这里插入图片描述

使用结构化艺术来帮助我们查看正在发生的事情

首先,设置结构化的调试方法,通过插入类似放射性染料的方式,便于观察寄存器中的变化。通过将一些寄存器的值进行覆盖,使用 set ps 来模拟不同的操作,目的是为了使寄存器内容在处理过程中更加直观易懂。这样做类似于医学中用染料追踪物质流动的方式,确保在寄存器交换时能够清晰地看到各个值。

为了进一步简化观察过程,可以使用十六进制的值填充寄存器,这样做有助于直观地查看结果,并且通过这种方式模拟各种寄存器的内容(如 r0, r1, r2, r3 等)。通过设置这些值,使得调试过程中的寄存器操作变得更加清晰可见。

接下来,使用调试工具(如断点)来暂停程序,以便查看寄存器的内容,确保每个操作的效果是预期的。这样做可以避免编译器自动优化影响调试过程,从而确保代码中的每一步都能够被检查和验证。
在这里插入图片描述

调试器: 观察我们的艺术如何被重新排列

首先,通过在调试过程中加载并观察寄存器的值,能够更清晰地查看各个寄存器的内容。在此过程中,寄存器的值被显示在调试器的监视窗口中,通过检查寄存器的值,可以更好地理解和验证寄存器中存储的数据是否符合预期。为了方便观察,使用了十六进制的表示方法,并且通过查看128位或32位的值,可以更加直观地看到寄存器中的数据。

通过在调试器中观察这些寄存器的值,可以发现每个寄存器中的数据表示的不同像素值(例如第一个像素、第二个像素等)。为了更清楚地展示这些值,还使用了结构化的方法来确保能够准确地理解寄存器中的内容。

在调试过程中,遇到了内存顺序和寄存器顺序的不同表示方式,这就要求在查看数据时要小心,确保正确理解数据的排列顺序。尽管如此,最终成功地通过结构化调试方法验证了所期望的值已经正确地存储在寄存器中,确保了操作的准确性。

通过这种方法,尤其是在初次接触这类操作时,能够更加清楚地看到每个寄存器中存储的具体值,从而避免由于随机浮动值而导致的困惑,确保调试过程中能够精确地跟踪数据的变化。
在这里插入图片描述

game_render_group.cpp: 生成我们需要的其余像素值

首先,使用解包操作对每个像素进行处理,并通过解包低位像素来获取像素值。通过这种方式,可以逐个处理并获得每个像素的具体值,例如第一个像素和第二个像素。在这个过程中,首先解包低位数据,然后通过解包高位来处理剩余的像素值。

通过对像素值进行逐一解包,最终得到了正确的打包像素值。然而,这些像素值仍然是32位浮动点数格式,需要进一步处理以符合所需格式。接下来,解包高位数据,通过这种方式,可以从低位像素值转换为高位像素值,完成后再进行相同的操作以处理所有像素。

尽管这种操作相对繁琐且复杂,但其本质上是使用一对低位和高位解包指令处理32位值。为了生成四个像素,需要执行八次解包操作,这样才能完成正确的像素生成。处理这些数据时,确实需要注意顺序和细节,尤其是在转换数据格式时,可能会遇到一些不便和困难。

最终,处理完成的像素值按预期方式输出,但它们仍然是32位浮动点格式,仍需要进一步调整。这一过程虽然繁琐,但展示了如何通过低位和高位解包操作将数据转换为最终所需的格式。
在这里插入图片描述

在这里插入图片描述

将 32 位浮动点数值转换为 8 位整数

接下来需要讨论的是如何将32位浮动点数转换为8位整数。在这个过程中,可以通过加上0.5和乘以255来实现转换,这些操作是我们熟悉的,也已经在代码中做过类似的转换。

为了将浮动点数转换为整数,可以使用转换指令,如convert ps,它可以将32位浮动点数转换为32位整数,并将结果存储到目标位置。这就是我们需要的功能,即将浮动点数转为整数,而不是简单的直接存储浮动点值。

具体来说,通过调用convert ps指令,我们可以将每四个浮动点数一次性转换为整数,这个操作需要四条指令才能完成。指令的作用是将每个浮动点数转换为对应的整数,最终得到我们需要的8位整数值。这种转换方法通过使用特定的指令集来实现,可以确保我们快速而准确地将数据从浮动点数格式转换为整数格式。
__m128i _mm_cvtps_epi32 (__m128 a)
在这里插入图片描述

在这里插入图片描述

设置已知的四舍五入模式

问题在于,当进行转换时,使用的是哪种舍入模式?是使用截断(truncation),还是舍入到最接近的值(round to nearest)?如果使用的是舍入到最接近的值,就不需要加0.5;如果使用的是截断模式,就需要加0.5。因此,了解舍入模式非常重要。

然而,遗憾的是,无法直接知道使用的舍入模式,除非手动设置。这是因为这些操作所使用的SSE寄存器实际上有一个模式,可以设置为不同的舍入方式。为了处理这个问题,可以设置一个已知的舍入模式,并暂时假设它按照所需的方式进行舍入,稍后再回到这个问题进行处理。

Blackboard: 使用这 32 位寄存器中的 8 位

当将值转换为整数格式后,面临另一个问题,即这些整数的宽度为32位,但实际上只需要使用其中的8位。为了处理这个问题,需要将这些整数以更有用的方式组织起来。

已经知道这些整数会被限制在0到1之间,且已经进行了“最小最大”限制,所以可以确定这些整数的值范围。由于每个整数的宽度为32位,但只使用最低的8位,因此可以通过将这些8位移动到适当的位置,来确保只使用其中的8位。

具体来说,若这些整数按顺序排列,可以通过将它们的8位右移,确保最终结果位于正确的位置,然后通过按位“或”操作将这些寄存器结合在一起,从而获得正确的值。这类似于先前所讨论的通过“位移”和“与”操作来组织数据的过程,可以使用相同的操作来处理宽寄存器。

game_render_group.cpp: 对这些值进行按位或操作和位移

在处理整数时,可以通过按位操作将不同的颜色通道值(如红色、绿色、蓝色、透明度)合并在一起。然而,问题出现在如何将这些值进行位移,因为需要将它们按正确的顺序放置在一个最终的像素值中,但缺乏位移的操作。

解决这个问题的方式是通过位操作(bit manipulation)。具体而言,可以使用位移操作,将每个颜色通道的值移到正确的位置。通过这种方式,可以将每个通道的8位数据移到适当的位置,确保它们合并成一个最终的值。位移操作是针对整数类型进行的,因此它适用于32位整数,这也是此时处理的整数类型。

为了实现这一点,可以选择不同类型的位移操作,这些操作的宽度(即移动的位数)由具体需求决定。通过这些位移操作,可以将每个颜色通道的值从原始位置移动到目标位置,从而形成最终的输出像素值。
_mm_or_si128 是一种 SIMD (Single Instruction, Multiple Data) 操作,用于按位或(bitwise OR)两个 128 位的整数向量。它是 Intel 的 SSE2 指令集中的一个函数,用来执行 128 位的按位或操作。

函数原型:

__m128i _mm_or_si128(__m128i a, __m128i b);
  • ab:这两个参数是 128 位的整数向量(__m128i 类型),可以包含多个整数值(例如,4 个 32 位整数,8 个 16 位整数等)。
  • 返回值:函数返回按位或运算后的结果,也是一个 128 位的整数向量。

例子

假设我们有两个 128 位整数向量 ab,它们分别包含 4 个 32 位整数(每个 32 位整数占用 4 字节,总共 16 字节)。使用 _mm_or_si128 后,返回的结果会包含对应位置的整数进行按位或运算后的值。

#include <emmintrin.h> // 引入 SSE2 库__m128i a = _mm_set_epi32(0x0, 0xF0F0F0F0, 0x12345678, 0x87654321);
__m128i b = _mm_set_epi32(0xA5A5A5A5, 0x0F0F0F0F, 0xF1F1F1F1, 0x22222222);// 执行按位或操作
__m128i result = _mm_or_si128(a, b);

按位或操作

对于 32 位整数,按位或操作会比较每个整数对应位置上的二进制位:

  • 如果两个位其中一个为 1,则结果为 1。
  • 如果两个位都是 0,则结果为 0。

例如:

  • 0x0 | 0xA5A5A5A5 = 0xA5A5A5A5
  • 0xF0F0F0F0 | 0x0F0F0F0F = 0xF0F0F0FF
  • 0x12345678 | 0xF1F1F1F1 = 0xF333F7F1
  • 0x87654321 | 0x22222222 = 0xA7756733

总结

_mm_or_si128 使得可以高效地对 128 位整数向量执行按位或操作,通常用于处理图形、纹理、颜色或其他位操作密集型任务。这种操作常见于图形渲染、图像处理、加密算法等领域。
在这里插入图片描述

Blackboard: 位移操作如何工作

在进行位移操作时,有一些关键的概念需要理解。首先,位移操作有不同的宽度(例如 32 位或 16 位),这会影响位移操作的结果。比如,当执行 32 位的左移操作时,位移的位会一直移动直到超出指定宽度的边界,然后被移除,不会再进入更高的位数。相反,如果使用 16 位宽度进行操作,则位移操作会在第 16 位时停止,超出的部分会被剪切掉。

对于左移操作,通常使用的是逻辑左移,也就是说,移出的位会被丢弃,空出的位会被填充为 0。右移操作则有两种类型:逻辑右移和算术右移。逻辑右移的操作会将高位补充为 0,而算术右移则会根据符号位(即最高位)填充。例如,如果最高位是 1,则填充 1,保持原有的符号。

算术右移的主要目的是处理带符号整数,尤其是负数。通过这种方式,右移操作可以保持负数的符号一致,使得负数在进行除法近似时能正确处理。对于负数的二进制表示,算术右移确保高位不会因为逻辑右移而被填充为 0,从而保持负数的正确符号。

在某些情况下,位移操作被用于执行除法操作,特别是当我们希望通过移位来模拟除以 2 的运算时,算术右移就显得特别重要,因为它能够确保负数在除法运算中的符号不变。

算术左移、算术右移、逻辑左移和逻辑右移的主要区别在于位移时如何处理符号位和填充的方式。以下是每种操作的具体描述及示例:

1. 算术左移 (Arithmetic Left Shift)

  • 操作:左移操作时,符号位保持不变,空出的位置填充 0。实际上,算术左移与逻辑左移在处理时是相同的。

  • 示例

    • 假设我们有一个 8 位整数 01011010(即 90)。
    • 左移 1 位后变为 10110100(即 180)。
    01011010 (90)
    << 1
    --------
    10110100 (180)
    

2. 算术右移 (Arithmetic Right Shift)

  • 操作:右移时,符号位(即最左边的位)保持不变。移出的位被丢弃,空出的位置会根据符号位填充。

    • 如果符号位是 0(正数),则填充 0
    • 如果符号位是 1(负数),则填充 1,保持负号。
  • 示例

    • 假设我们有一个 8 位整数 11111010(即 -6,补码表示)。
    • 右移 1 位后变为 11111101(即 -3,补码表示)。
    11111010 (-6)
    >> 1
    --------
    11111101 (-3)
    

    如果是正数,举个例子:

    • 假设我们有一个 8 位整数 01011010(即 90)。
    • 右移 1 位后变为 00101101(即 45)。
    01011010 (90)
    >> 1
    --------
    00101101 (45)
    

3. 逻辑左移 (Logical Left Shift)

  • 操作:左移时,符号位不受影响,空出的位置填充 0。与算术左移相同,都是填充 0

  • 示例

    • 假设我们有一个 8 位整数 01011010(即 90)。
    • 逻辑左移 1 位后变为 10110100(即 180)。
    01011010 (90)
    << 1
    --------
    10110100 (180)
    

4. 逻辑右移 (Logical Right Shift)

  • 操作:右移时,符号位不被特别处理,空出的位置总是填充 0。即使是负数,符号位也会被替换成 0,这与算术右移不同,算术右移会保留符号位。

  • 示例

    • 假设我们有一个 8 位整数 11111010(即 -6,补码表示)。
    • 逻辑右移 1 位后变为 01111101(即 125,注意符号发生变化)。
    11111010 (-6)
    >> 1 (logical)
    -----------
    01111101 (125)
    

    如果是正数,举个例子:

    • 假设我们有一个 8 位整数 01011010(即 90)。
    • 逻辑右移 1 位后变为 00101101(即 45)。
    01011010 (90)
    >> 1 (logical)
    -----------
    00101101 (45)
    

总结:

  • 算术左移逻辑左移在填充空位时没有区别,都是填充 0
  • 算术右移在右移时会保留符号位,确保负数保持负号(填充符号位),而逻辑右移则不考虑符号位,直接填充 0
  • 算术右移更适用于带符号整数,逻辑右移适用于无符号整数。

emmintrin 是一个 Intel 的 SSE2 (Streaming SIMD Extensions 2) 指令集的头文件,它提供了对 128 位寄存器进行操作的指令,包括移位操作。移位操作可以作用于 128 位的整数数据,可以同时对多个整数进行移位操作,这对于提高并行处理能力非常重要。

emmintrin 中的移位操作

SSE2 中的移位操作包括逻辑移位和算术移位。它们使用的操作指令有:

  1. _mm_slli_si128 - 左移指令(算术或逻辑左移,具体取决于数据类型)。
  2. _mm_srli_si128 - 右移指令(逻辑右移)。
  3. _mm_srai_si128 - 算术右移指令。

移位操作示例

以下是如何使用 emmintrin 中的移位操作。

1. _mm_slli_si128 - 左移操作(逻辑左移)

该指令将 128 位的整数左移一个指定的位数,其他位置用零填充。

示例:

#include <emmintrin.h>
#include <iostream>int main() {__m128i data = _mm_set_epi32(0x00000002, 0x00000001, 0x00000004, 0x00000003);  // 设置一个 128 位整数向量__m128i result = _mm_slli_si128(data, 4);  // 将数据左移 4 字节int* ptr1 = (int*)&result;int* ptr2 = (int*)&data;std::cout << std::hex << ptr2[0] << " " << ptr2[1] << " " << ptr2[2] << " " << ptr2[3] << std::endl;std::cout << std::hex << ptr1[0] << " " << ptr1[1] << " " << ptr1[2] << " " << ptr1[3] << std::endl;return 0;
}

输出

3 4 1 2
0 3 4 1

在这里插入图片描述

2. _mm_srli_si128 - 逻辑右移操作

该指令将 128 位的整数右移一个指定的位数,其他位置用零填充。

示例:

#include <emmintrin.h>
#include <iostream>int main() {__m128i data = _mm_set_epi32(0x00000002, 0x00000001, 0x00000004, 0x00000003);  // 设置一个 128 位整数向量__m128i result = _mm_srli_si128(data, 4);  // 将数据右移 4 字节int* ptr1 = (int*)&result;int* ptr2 = (int*)&data;std::cout << std::hex << ptr2[0] << " " << ptr2[1] << " " << ptr2[2] << " " << ptr2[3] << std::endl;std::cout << std::hex << ptr1[0] << " " << ptr1[1] << " " << ptr1[2] << " " << ptr1[3] << std::endl;return 0;
}

输出

3 4 1 2
4 1 2 0

在这里插入图片描述

3. _mm_srai_si128 - 算术右移操作

该指令将 128 位的整数算术右移,并根据符号位(最高位)填充空位。

示例:

#include <emmintrin.h>
#include <iostream>int main() {__m128i data =_mm_set_epi32(0x80000002, 0x00000001, 0x80000004, 0x00000003);  // 设置一个包含负数的向量__m128i result = _mm_srai_epi32(data, 4);                           // 算术右移 4 字节int* ptr1 = (int*)&result;int* ptr2 = (int*)&data;std::cout << std::hex << ptr2[0] << " " << ptr2[1] << " " << ptr2[2] << " " << ptr2[3]<< std::endl;std::cout << std::hex << ptr1[0] << " " << ptr1[1] << " " << ptr1[2] << " " << ptr1[3]<< std::endl;return 0;
}

在这里插入图片描述

输出

3 80000004 1 80000002
0 f8000000 0 f8000000

解释:这里进行了算术右移操作,负数的高位(符号位)被填充为 1,使得移位后的数据保持为负值。

总结

  • _mm_slli_si128:左移指令,逻辑左移,填充零。
  • _mm_srli_si128:逻辑右移指令,填充零。
  • _mm_srai_si128:算术右移指令,符号位填充。

这些操作非常适合用于处理 SIMD 向量中的数据,尤其是在需要并行处理多个数据时。

为了测试上面的代码添加了一个test 的目录用于测试代码

在这里插入图片描述

game_render_group.cpp: 实现这些位移

为了将这些操作对齐,关键是通过进行 32 位宽的移位操作,并按照 8、16 或 24 位进行上移。这样,移位的方式与之前的代码完全一致。

首先,使用 shift left logical immediate 进行固定值的移位,而 shift left logical 则是根据另一个寄存器的值来进行移位。此时,我们使用 shift left logical immediate,它会直接以固定的值进行移位。例如,首先对某个值进行 24 位的左移。

需要注意的是,对于 ANDOR 操作,它们并不关心宽度,因为这些操作不会改变位的顺序或进行位的移动。因此,在这些操作中,宽度并不是重点,通常 OR 操作的助记符就是统一的,不需要明确指定宽度。

在进行位移时,通常会对不同的颜色分量(如 G、B、R)进行不同的位移操作。比如,G 通常会进行 8 位的上移,R 则是 16 位上移,而其它的处理方式与此一致。总的来说,这些操作主要集中在通过位移操作将不同颜色的分量对齐。
在这里插入图片描述

调试器: 查看 Out 值

在这里插入图片描述

在这段过程中,首先讨论了输出值(out value)的问题。尝试查看“输入r”和其他相关值时,发现它们并未按预期设置。此时,决定将结构化数据(structured art)上移,以便在转换后能够覆盖这些值。接下来,尝试通过给每个“r”和“g”输入正确的值来修正问题。

在运行过程中,通过调试检查输入值,确认它们符合预期。然而,当查看最终输出值时,发现得到的结果并不正确,尽管“b”值是正确的,其他值却存在问题。

分析后,怀疑可能使用了错误的操作方式,考虑到某种移位操作和结果存储的问题。检查后确认移位操作本身没有问题,最后仍不确定问题的根源。
在这里插入图片描述

game_render_group.cpp: 分离这些值

在这段过程中,决定进一步查看实际的值,以便找出问题的根源。为了做到这一点,首先将常规操作拆解开来,并确认这些步骤接近完成。接着,设定了对各个变量进行移位操作,并将移位后的“r”值、"b"值、"g"值等提取出来,确保这些值按预期设置。通过检查这些值,期望能够发现异常,并进行必要的调整,最终运行这些操作以验证结果。
在这里插入图片描述

game_render_group.cpp: 修复测试用例

在进行调试时,发现问题的根源在于期望的值范围是从 0 到 255,但输入数据并没有按照这个范围进行预处理,因此导致测试结果不准确。问题的出现是由于数据未经过预期的归零处理,导致了错误的输出。

在意识到这一点后,决定重新进行测试,并确保数据已经被正确地限制到 8 位范围内。通过对数据进行适当的修正后,得到了更合理的结果。这也表明,管道中的数据处理步骤是有效的,且在经过适当的值限制后,得到了预期的结果。
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

调试器: 检查我们的内容

通过前面的操作,可以看到,处理数据时不再需要像处理浮点数那样进行复杂的交错操作。如果数据已经在寄存器中正确地排列好,只需通过简单的移位操作,就可以将这些小值调整到正确的位置。这使得操作变得更加直接和高效。

具体来说,当不需要对数据进行浮点交错处理时,可以通过移位操作将数据对齐,并通过按位“或”操作合并各个通道的值。这样,就能够以非常简洁的方式实现数据的处理。这种方法的效率比浮点数处理要高得多,且实现方式几乎与原来的处理方式一致,只不过在这一步骤中,数据已经预先处理好了,直接通过移位来对齐寄存器中的数据即可。

game_render_group.cpp: 将 Out 写入像素

通过去除一些不必要的代码后,可以看到,程序能够直接将处理后的值写入像素中。将处理结果作为一个 _mm128 类型的值直接赋给像素,能够按预期将数据打包并写出。这种做法简化了原本的操作,并且不会影响程序的性能,处理速度没有显著变化,程序仍然能够高效地运行。因此,最终得到了与最初相同的结果,同时优化了代码结构,避免了不必要的复杂操作。

调试器: 注意我们正在进行非对齐写入 我的好像没遇到这个问题

在调试过程中,遇到了由于尝试向未对齐的地址写入数据而导致的崩溃。问题出现在Intel处理器上,向未对齐的地址写数据会触发崩溃。通过验证发现,如果确保地址对齐,就不会发生崩溃;而如果允许对未对齐的地址写入数据,则会导致程序崩溃。尽管这是一个需要关注的点,但目前并不打算深入讨论这一问题,而是将其作为后续讨论的内容。在测试代码阶段,尝试确保操作在对齐的情况下进行,以避免崩溃。

Blackboard: 对齐

在Intel处理器中,数据写入有对齐和未对齐的两种方式。每个处理器都有特定的要求,例如,当数据宽度为128位时,它要求写入操作必须发生在16字节对齐的内存边界上。对齐意味着数据写入应从内存的起始位置开始,每16字节为一个单位,确保底部的4位指针为零(因为16是2的4次方)。如果写入操作未对齐,即写入位置不是16字节的倍数,就会触发异常。

尽管某些处理器在执行宽写操作时要求数据对齐,Intel处理器提供了写入未对齐内存的能力,并且可以通过使用特定的内建函数来强制执行这种操作。因此,可以通过这些内建函数来允许写入未对齐的内存地址,以避免触发异常。

game_render_group.cpp: 使用 _mm_storeu_si128 强制编译器使用(非对齐)mov 指令

在处理器中,对于数据存储操作,有一些特定的指令可以处理未对齐的内存写入。例如,_mm_storeu_si128 就是一个不需要内存对齐的指令。使用这个指令时,可以直接将数据存储到内存中,而不需要强制要求内存地址的对齐。这与传统的存储指令不同,后者要求数据按照特定的内存边界(如16字节对齐)来存储。

在实现中,使用 _mm_storeu_si128 内建函数可以避免未对齐内存写入引起的异常,同时保证数据能够正确存储。此举提高了效率,并且避免了需要手动处理内存对齐的问题。

通过使用这种指令,代码运行时间有所缩短,性能得到了提升。例如,在原先的实现中,代码执行的周期数为120周期,而采用了 _mm_storeu_si128 后,周期数减少到了接近110周期,这表明性能有所改善。
在这里插入图片描述

_mm_storeu_si128 是一个 SSE(Streaming SIMD Extensions)指令集的内建函数,它用于将 128 位(16 字节)数据存储到内存中。其作用是将存储在 __m128i 类型寄存器中的数据写入到指定的内存地址。与标准的 _mm_store_si128 不同,_mm_storeu_si128 不要求目标内存地址对齐,因此它可以用于任何地址,而不仅仅是要求 16 字节对齐的内存地址。

函数原型

void _mm_storeu_si128(__m128i* mem_address, __m128i a);

参数:

  • mem_address:目标内存地址,数据将被存储到此地址。该地址不要求是 16 字节对齐的,可以是任意地址。
  • a:一个 __m128i 类型的值,包含要存储的数据。

主要特点:

  1. 不要求内存对齐:通常,SSE 指令要求数据按 16 字节对齐(比如 _mm_store_si128),但是 _mm_storeu_si128 允许写入到任何内存地址,无论其是否对齐。这样可以简化代码,不需要手动管理内存对齐。

  2. 用于 128 位数据:该指令用于处理 128 位(即 16 字节)的数据,因此它适用于 __m128i 类型的变量,通常用于存储整数数据。

示例:

#include <emmintrin.h>  // SSE2
#include <iostream>int main() {__m128i data = _mm_set_epi32(1, 2, 3, 4);  // 创建一个 __m128i 类型的向量__m128i* p = (__m128i*) malloc(sizeof(__m128i));  // 分配内存_mm_storeu_si128(p, data);  // 将 data 存储到 p 指向的内存// 打印存储的值int* ptr = (int*)p;std::cout << "Stored values: " << ptr[0] << " " << ptr[1] << " " << ptr[2] << " " << ptr[3] << std::endl;free(p);  // 释放内存return 0;
}

总结:

_mm_storeu_si128 允许将 128 位的数据存储到内存中,而不需要对目标内存地址进行 16 字节对齐的要求。它在需要处理未对齐数据时非常有用,尤其是在动态分配内存或使用不对齐的缓冲区时,能够避免因内存对齐问题而导致的崩溃或性能下降。

在这里插入图片描述

总结并展望未来

在这一阶段,完成了之前的一些优化。通过去除底部的循环,现在不再需要进行循环处理了。直接在寄存器中构建需要的 128 位数据,然后直接将其写入输出。这个过程的开销并不大,处理过程中总共使用了 10 条指令,其中 7 条指令用于准备数据,3 条指令用于将数据压缩到打包格式。

这种方法不仅简化了原本的代码,还大大提高了效率。通过这种方式,处理每个像素的速度非常快,尽管需要进行一些准备工作,最终依然比之前的处理方式更高效。

操作会重新排序以减少操作数和加载/存储吗?

在优化过程中,编译器通常会做得比较好,能够自动调整指令的顺序以减少加载和存储的次数。不过,是否能够通过调整操作的顺序来进一步减少加载和存储,取决于具体情况。如果是算法层面的问题,也许可以进行调整,但如果只是单纯地调整汇编指令的顺序,通常效果不大。

因为编译器有足够的信息来优化指令的调度,通常会自动选择合适的顺序。除非编译器优化得非常差,否则重排指令顺序的效果有限。另外,处理器本身也会进行指令重排,它有一个指令窗口,会在执行时重新排序指令,因此,程序中指令的顺序和实际执行顺序可能不同。

因此,在调整指令顺序时要考虑这些因素,重排的效果通常有限,处理器和编译器会在大多数情况下自行做出优化。

你正在按 or(or(or(r, g), b), a) 计算 Out。是否可以按这样做:or(or(r, g), or(b, a)),这样前两个 or 操作就不会相互依赖?

在优化代码时,首先讨论了通过调整 OR 操作的顺序是否能提高性能。提议的方式是先进行与 g 的 OR 运算,再进行与 ba 的 OR 运算,这样前两个 OR 操作不再相互依赖。初步猜测这种顺序可能会更好,但为了验证这一点,进行了调试并查看了实际执行的汇编代码。

在调试过程中,发现代码已经对这些操作进行了优化,并且不再按原先的顺序执行这些 OR 操作。实际上,编译器或处理器可能已经重排了这些操作,导致它们的顺序与预期有所不同。从调试信息来看,优化后的代码已经大大改变了原来的操作顺序,并且并不完全按照最初的设计进行操作。甚至发现,某些操作已经被优化掉或重新排序,以提高执行效率。

这些观察表明,虽然手动调整操作顺序可能会有一定的效果,但编译器和处理器本身已经进行了优化,通常无需手动干预。此外,调试过程中还发现一些其他操作(如移位和加载纹素)之间的依赖关系,进一步验证了优化的复杂性和自动化处理的优势。

game_render_group.cpp: 按 mmozeiko 的建议进行修改

虽然按照建议的方式写可能会更好,确保编译器能理解这些操作不需要按顺序执行,但实际上,编译器可能已经知道这一点,因此可能不会生成比原来更有实质性差异的代码。调试过程中,观察到两个 OR 操作被一起发出了,编译器没有觉得有必要将它们分开执行。

在进一步的分析中,发现编译器实际上并没有将两个 OR 操作一起执行,而是先执行了第一个 OR 操作,然后执行第二个,再执行最终的合并操作。这表明,尽管代码看起来需要顺序执行,编译器可能会根据实际情况调整操作顺序,优化执行效率。

对于使用内建函数(intrinsics)时,容易产生将其视作汇编语言编程的错误。实际上,编译器会决定如何优化代码,写出的汇编代码与实际执行的汇编代码可能并不完全一致。因此,调整代码顺序并不一定会带来性能提升,尽管它打破了看似串行依赖的操作。

需要记住的是,尽管改变操作顺序在理论上可能有助于性能,但编译器和处理器的自动优化可能会产生不同的结果。因此,在编写代码时,必须理解这些优化的实际效果,而不能仅凭表面分析做出结论。在真实的汇编编程中,手动优化顺序可能会产生不同的效果,但在现代编译器和处理器优化下,结果往往并不会有明显差异。

是否需要从 32 位浮点数开始?是否存在不需要类型转换的进一步优化?

在优化过程中,首先讨论了是否需要使用 32 位浮点数进行计算。此时的目标是避免不必要的类型转换,特别是避免浮点与整数之间的转换。实际上,优化过程中并没有涉及到显式的类型转换,而是采用了浮点与整数之间的“转换”操作,即分开处理浮点和整数。

接着提出了一个想法:是否可以直接使用整数进行计算。根据目前的操作内容,考虑将这个计算过程完全使用整数进行处理是有可能的,尽管这样做可能不会带来性能上的提升,因为在使用整数时,需要不断引入移位操作。这一操作虽然是可行的,但可能会增加计算的复杂度,因此不一定会比使用浮点数计算更快。

总结而言,虽然可以尝试用整数来处理这些运算,但这需要平衡移位等操作带来的额外计算开销,且可能无法显著提升性能。

Blackboard: 乘法浮点数与乘法整数的区别

在讨论整数与浮点数的运算时,首先提到浮点数的乘法 float * float = float 是直接的,而整数的乘法 integer * integer = integer 在处理过程中则更为复杂。具体来说,浮点数的计算过程会自动调整结果,确保它在合理的范围内,而整数的乘法没有这种自动调整机制。

例如,当进行浮点数乘法时,结果会被限制在 0 到 1 之间,这个过程是由浮点单元自动完成的,而在整数计算中没有类似的机制。因此,整数乘法可能会产生更大的值,这就需要进行额外的移位操作来避免溢出或精度丢失。这意味着整数计算需要更多的步骤和资源,这样的操作变得非常昂贵。

为了避免这些额外的移位操作,通常的做法是将计算转换为浮点数进行处理,然后再根据需要转换回整数。这样,浮点数的计算方式通常比固定点整数计算更加高效,因为浮点数运算自动处理了范围和精度问题,避免了额外的开销。

同样适用于纹理双线性加法

讨论中提到,当多个元素通过线性加法结合时,结果可以被视为相同的保护器。也就是说,将这些元素进行加法操作时,结果会按一定的线性关系进行叠加,确保最终的值符合预期的范围或规则。这种方法通常用于保持操作的简洁性和效率,同时避免过多复杂的计算过程。

game_render_group.cpp: 实现 mmozeiko 的建议

在讨论过程中,提到了一种通过线性加法组合纹理的操作,类似于前面提到的 OR 操作的优化。尝试通过不同的方式排列加法操作,但这可能导致阅读和理解上的困难,因为涉及的操作较多。为了解决这个问题,介绍了另一种方法,即使用乘法和加法来组合,最终达到相同的效果。

在调整操作顺序时,发现了一些语法错误,可能是因为不小心删除了某些部分。修复错误后,发现这种排列的操作确实和之前的操作相同,但并没有显著改变结果。尽管如此,仍然不确定这种变化对实际性能的影响,因为编译器可能已经优化了这些操作,并且不必严格按照顺序执行。

最终,尽管操作顺序有所不同,但对性能的提升似乎并不明显。即便如此,最终结果的评估还是要通过实际测试来验证,看看这种优化是否对最终的执行速度产生了实际影响。
在这里插入图片描述

为什么为 SIMD 操作(如 mmSquare 等)创建宏,而不是创建函数?

选择使用宏而不是函数来处理操作,主要是为了消除任何不确定性。宏是直接的文本替换,可以确保编译器不会出错,而函数则可能会让编译器产生一些混淆或不确定的行为。因此,使用宏可以确保操作的稳定性和准确性,不依赖编译器的优化或判断。

这些内建函数在其他操作系统或编译器上是否相同,只要使用的是 Intel 架构?

这些内建函数在使用英特尔架构的操作系统和编译器上是相同的,前提是采用相同的架构和工具链。唯一不同的是用于测试的宏部分。在 Windows 的 Visual Studio 中,使用的是特定的宏和联合体来访问组件,而在 LLVM 中则没有使用这些联合体。然而,代码已经被编写成兼容两种平台的方式,通过将其强制转换为指针来访问成员。因此,当前的代码应该能在任何英特尔架构的系统上正常工作,无论是 GCC 还是 LLVM,都能正确运行。

为什么说非对齐存储是麻烦的?据我所知,从 Ivy Bridge 开始,最新的 Intel CPU(至少)对非对齐加载/存储的开销并不大(差异小于 5%)

关于在线存储的性能,过去有些处理器的在线存储操作确实非常昂贵,这使得它们在优化时需要特别关注。尽管现在的差异可能低于5%,但是在优化中,即便是5%的差距也不能忽视。不同处理器在执行这些操作时的性能差异很大,某些处理器可能根本不支持在线存储,或者其开销极高,因此需要特别小心。

在编写优化代码时,通常希望确保数据对齐,因为这可以提高缓存效率。数据不对齐可能导致性能下降,尤其是当数据不按照16字节边界对齐时,可能影响到缓存的有效性,从而造成额外的性能损失。因此,一般来说,保持数据对齐是一个好的做法,可以带来更好的性能。

然而,如果经过测试发现,填充或溢出对齐的开销比在线存储的代价更大,那么在特定情况下,使用非对齐存储也许会带来更好的性能。最终,是否使用对齐存储需要根据具体情况进行测试和验证,以确保其对性能有正面影响。

对 Intel 处理器上的 __m128 元素进行标量访问是否仍然很慢?

访问128位元素的标量会带来一定的性能下降,因为当只访问单个元素时,相比于一次性处理四个元素,处理速度会降低。具体来说,处理单个元素时,速度大约会比同时处理四个元素慢四倍,这意味着在访问元素时,性能受到显著影响。更进一步,处理器还需要将宽寄存器中的数据移动到常规寄存器,这也会增加额外的开销。因此,单个元素的访问不仅失去了同时处理四个元素带来的速度提升,而且还会受到处理器访问和数据移动的影响,导致整体性能降低。

处理器窗口是 192 条指令

处理器窗口的大小并不是固定的,不同的处理器窗口大小可能会有所不同,因此说窗口有192条指令并不一定准确。此外,微指令的处理也与处理器的具体实现有关,所以在考虑指令的排序时,确实很难进行明确的推理,因为处理器会根据自己的优化策略来执行指令。这意味着,尽管可以做一些假设,但最终的执行顺序通常由处理器决定,而不是我们直接控制的。

我不明白如何通过使用内建的 or 函数来优化

优化的关键在于使用了内建函数(intrinsics),它允许直接操作更宽的数据。通过使用这些内建函数,可以同时处理四个像素,而不是一个像素,从而实现显著的性能提升。我们通过这种方式使处理速度提高了三倍,这正是因为将操作从单个像素扩展到了四个像素。

_mm_cvttps_epi32 始终会截断。是否比调整四舍五入模式更好?

在讨论关于 _mm_cvttps_epi32 的行为时,发现它总是进行截断,而不是四舍五入。如果这个行为是真的,那么使用它比调整舍入模式要更好。通过查阅 Intel 架构手册,确认了 _mm_cvttps_epi32 的确在转换过程中使用截断,而不是舍入。这一发现令人惊讶,因为之前并没有意识到它的这一特性。

_mm_cvttps_epi32_mm_cvtps_epi32 都是 SSE 指令集中的函数,用于将浮点数转换为整数类型(__m128__m128i)。它们的区别主要在于处理浮点数时的舍入方式。

区别:

  1. _mm_cvtps_epi32
    这个函数将单精度浮点数(float)转换为整数(int)。它采用 四舍五入 的方式来处理浮点数值。

  2. _mm_cvttps_epi32
    这个函数同样将单精度浮点数(float)转换为整数(int),但是它采用 截断 的方式来处理浮点数值,即直接去掉小数部分,而不是四舍五入。

举例说明:

假设我们有一个 __m128 类型的变量,其中包含 4 个单精度浮点数:[3.7, -3.2, 2.4, -1.6]

使用 _mm_cvtps_epi32
  • 3.7 -> 四舍五入为 4
  • -3.2 -> 四舍五入为 -3
  • 2.4 -> 四舍五入为 2
  • -1.6 -> 四舍五入为 -2

结果:[4, -3, 2, -2]

使用 _mm_cvttps_epi32
  • 3.7 -> 截断为 3
  • -3.2 -> 截断为 -3
  • 2.4 -> 截断为 2
  • -1.6 -> 截断为 -1

结果:[3, -3, 2, -1]

总结:

  • _mm_cvtps_epi32 会将浮点数四舍五入到最近的整数。
  • _mm_cvttps_epi32 会将浮点数的值截断(去掉小数部分),不进行四舍五入。

game_render_group.cpp: 切换到 _mm_cvttps_epi32

建议被采纳后,认为这是一个非常好的主意,相比原本的计划,效果明显更好。虽然这个改变会增加四条额外的指令,但考虑到这是一个很好的折衷方案,决定继续采用这一方法。尽管有人可能会建议改为调整舍入模式,但暂时决定不这样做,而是保持当前的做法。考虑到目前的方案已经有了明显的改善,之前的慢速情况已经没有了,现在的性能表现非常好。总结来看,当前的改变是成功的,并且不再有其他疑问。

总结

今天的工作接近尾声,剩下的任务明天继续进行。接下来,需要进行填充操作,预计这部分工作不会很难,虽然我们还没有专门讨论过如何思考这部分内容。明天,我们将着重处理将现有内容转换为尽可能接近四宽(4-wide)操作的代码,这是接下来的主要任务。随着这部分工作的推进,我们接近完成,剩余需要转换的部分已经不多。之后,我们将评估代码的运行周期,预计它能在最理想的情况下运行多少周期,接近我们的目标,确保它能在目标分辨率下良好运作。

在这里插入图片描述

在这里插入图片描述


http://www.ppmy.cn/server/170349.html

相关文章

Nginx学习笔记:常用命令端口占用报错解决Nginx核心配置文件解读

Nginx 1. 基础命令1.1 重新加载systemd配置1.2 停止Nginx服务1.3 启动Nginx服务1.4 重启Nginx服务1.5 查看Nginx服务状态1.6 测试配置和重载Nginx 2. 额外命令2.1 启用开机自启2.2 禁用开机自启2.3 强制关闭所有Nginx进程 3. Nginx端口占用解决方案3.1 查找占用端口8090的进程3…

MATLAB中fft函数用法

目录 语法 说明 示例 含噪信号 高斯脉冲 余弦波 正弦波的相位 FFT 的插值 fft函数的功能是对数据进行快速傅里叶变换。 语法 Y fft(X) Y fft(X,n) Y fft(X,n,dim) 说明 ​Y fft(X) 用快速傅里叶变换 (FFT) 算法计算 X 的离散傅里叶变换 (DFT)。 如果 X 是向量&…

06、ElasticStack系列,第六章:elasticsearch设置密码

第六章&#xff1a;Elasticsearch设置密码 一、修改配置文件 ##进入容器 docker exec -it elasticsearch bash##启用认证 vi config/elasticsearch.yml 添加如下内容&#xff1a; http.cors.enabled: true http.cors.allow-origin: "*" http.cors.allow-headers: A…

[QMT量化交易小白入门]-二十五、DeepSeek生成的年化收益率54%的动量因子评分算法(含代码解析)

本专栏主要是介绍QMT的基础用法&#xff0c;常见函数&#xff0c;写策略的方法&#xff0c;也会分享一些量化交易的思路&#xff0c;大概会写100篇左右。 QMT的相关资料较少&#xff0c;在使用过程中不断的摸索&#xff0c;遇到了一些问题&#xff0c;记录下来和大家一起沟通&a…

redis的缓存击穿,雪崩,穿透

缓存击穿&#xff08;Cache Breakdown&#xff09; 指某个热点数据在缓存中过期或失效的瞬间&#xff0c;大量请求直接打到数据库上&#xff0c;导致数据库压力骤增。 原因&#xff1a; 热点数据在缓存中过期。 大量并发请求同时访问该数据。 解决方法&#xff1a; 永不过期…

【Python爬虫(56)】解锁Scrapy超能力:多库集成实战

【Python爬虫】专栏简介&#xff1a;本专栏是 Python 爬虫领域的集大成之作&#xff0c;共 100 章节。从 Python 基础语法、爬虫入门知识讲起&#xff0c;深入探讨反爬虫、多线程、分布式等进阶技术。以大量实例为支撑&#xff0c;覆盖网页、图片、音频等各类数据爬取&#xff…

【Gee】Day7:错误恢复

Day7&#xff1a;错误恢复 今天的任务是&#xff1a; 实现错误处理机制。 panic 在 Golang 中&#xff0c;较为常见的错误处理方式是返回 error&#xff0c;由调用者决定后续如何处理。但如果是无法恢复的错误&#xff0c;可以手动触发 panic&#xff0c;当然如果在程序运行…

大语言模型中的 Token如何理解?

在大语言模型中&#xff0c;Token 是文本处理的基本单元&#xff0c;类似于“文字块”&#xff0c;模型通过将文本分割成Token来理解和生成内容。举一个形象一点的例子&#xff0c;可以理解为 AI 处理文字时的“最小积木块”。就像搭乐高时&#xff0c;每块积木是基础单位一样&…