仓库:https://gitee.com/mrxiao_com/2d_game_3
打开程序,查看我们在性能方面的进展
这段内容主要介绍了优化代码以利用处理器中的SIMD(单指令多数据)向量单元的基本概念。具体流程如下:
- 讲解了SIMD的基本原理,如何通过SIMD优化代码,使其能够更高效地执行。
- 讨论了如何使用SIMD以及如何在代码中应用它,以提高处理效率。
- 提到会在黑板上讲解相关的基础知识,包括SIMD的定义和使用方法。
- 接下来会进行一些代码实践,让参与者能够更好地理解SIMD的工作原理。
接着,展示了当前进展,讲解了已经完成的渲染函数优化。为了优化性能,所有的渲染操作被扁平化,移除了函数调用,使得所有操作一目了然。通过这种方式,可以更好地观察和分析每个操作,进而进行优化。
最后,提到接下来会继续讨论关于SIMD的内容,并开始在代码中实际应用这些优化。
黑板:x64上的SIMD
内容主要介绍了SIMD(单指令多数据)在不同处理器上的应用,特别是在x64和ARM处理器(如NEON)的实现。具体内容如下:
-
SIMD的概念:SIMD指的是"单指令多数据",即处理器在解码每条指令时,能够同时对多个数据进行相同的操作。这使得处理器能够在一次操作中处理多个数据点,提高计算效率。
-
典型操作 :在C语言中,常见的操作是类似
b = a + a
的表达式。在传统的处理器操作中,这通常通过两条指令来完成,其中一条将a的值加载到寄存器b,另一条将a和b相加并将结果存储回b寄存器。 -
SIMD的工作方式 :在SIMD中,操作并不是仅针对单个数据进行,而是同时对多个数据进行处理。举例来说,
b = a + a
并不是处理一个单一的a值,而是同时对多个a值执行相同的加法操作。这些数据被组织成"宽度"(比如4宽或8宽),每次处理的都是多个数据。 -
操作单位:这些操作通常被称为"lane",即每次操作都会在这些"lane"上同时进行。例如,SIMD指令可以同时对四个数据进行加法操作,这四个数据会并行处理,所有操作都使用相同的指令。
在SIMD(单指令多数据)架构中,lane 是指处理器中并行执行指令的独立数据处理通道或单元。每个lane处理一个数据元素,而多个lane可以并行处理多个数据元素,从而加速计算过程。
例如,如果一个SIMD指令在处理四个数据元素(例如4个整数)时,处理器可能会将这些数据元素分配到四个不同的lane,每个lane独立处理一个数据元素。所有的lane同时执行相同的操作,但每个lane操作的数据不同。
在不同的SIMD架构中,lane的数量可能会有所不同:
- 在x86架构中的SSE指令集,每个SIMD指令通常支持4个数据元素(即4个lane)。
- 在AVX指令集中,可能会有更宽的SIMD指令集,比如AVX2支持8个lane。
- ARM的NEON架构和其它SIMD扩展也有类似的概念,不同的架构和处理器可能有不同数量的lane。
通过并行处理,SIMD能显著提高数据密集型任务(如图形渲染、科学计算等)的性能。
- 不同的SIMD实现:不同的处理器有不同的SIMD实现。例如,x64使用SSE(Streaming SIMD Extensions),而ARM处理器则使用NEON。SIMD的宽度也有不同,最常见的是4宽,最近的一些处理器支持8宽,甚至16宽的操作,随着技术的进步,SIMD的宽度不断增大。
总结来说,SIMD通过在硬件层面上并行处理多个数据点,极大提高了计算性能。不同的硬件平台使用不同的SIMD技术,但核心原理是相同的------通过单条指令同时操作多个数据。

黑板:我们如何使用SIMD?
在使用SIMD(单指令多数据)时,核心思想是通过并行处理多个数据来提高效率。通常,这意味着要识别那些能够同时对多个数据执行相同操作的代码路径。一个常见的应用场景是图形渲染,其中每个像素的处理步骤是相同的,因此可以利用SIMD一次处理多个像素。
具体来说,可以通过调整代码中的循环结构,将原本逐个处理的数据(例如处理每个像素的操作)改为每次处理多个数据元素。比如,通常的循环可能是逐个像素处理,但使用SIMD时,可以改为一次处理四个像素。这样,代码就可以通过一次操作处理多个数据点,从而提高效率。
例如,在渲染循环中,原本的代码可能会按单个像素逐步处理,通过x
的值从x_min
到x_max
递增,逐个像素进行操作。使用SIMD时,可以将这个增量改为每次加4,这样就能在一个循环迭代中同时处理四个像素。这种做法通过SIMD指令将多个数据并行处理,从而提高性能。
简而言之,SIMD的基本思想是将原本依次进行的操作批量化,通过一次性操作多个数据元素来提升计算效率。
黑板:CPU vs GPU 帧缓冲区
在将代码转换为SIMD(单指令多数据)风格时,通常会考虑如何组织内存以提高效率,特别是像素数据的存储方式。在原始的实现中,像素通常是按顺序存储的,例如每行像素的RGB值按顺序排列,这样会导致每次加载和操作时需要处理一些不必要的像素,浪费了计算资源。
为了提高效率,可以使用"内存重排"技术,将内存中连续的四个像素组织成一个块,这样在每次加载四个像素时,SIMD指令可以同时处理四个相关像素,而不会有额外的未使用像素被计算。例如,内存中的像素可以被重新安排成一个二维的排列方式,使得每个SIMD元素表示一个4个像素的数据块。这种方式能够减少计算时的无效像素处理,避免浪费计算资源,尤其在处理较大的数据时,内存的组织方式显得尤为重要。
通过这种方式,内存的访问模式变得更加高效,不仅减少了每次处理时浪费的像素,还提高了数据处理的局部性和效率。对于较大的SIMD指令集(如宽度为16的指令集),如果不做适当的内存组织,就会浪费更多的计算资源,因此通过合理地重排内存布局,可以减少外部像素浪费,提升性能。
总之,通过优化像素数据的存储方式,可以最大限度地减少SIMD操作时的无效计算,提升处理效率。这一调整不仅仅是循环本身的改动,而是需要在内存管理层面进行相应的调整。
黑板:"SOA" vs "AOS"
上面讨论了结构体数组(Structure of Arrays, SOA)和数组结构体(Array of Structures, AOS)的差异。在C语言中,通常会使用数组结构体的方式来组织数据,例如定义一个包含RGB和Alpha值的结构体,并将多个结构体组成一个数组。这种方式虽然在编程时很直观,但在处理SIMD(单指令多数据)时并不高效。
在SIMD中,我们更希望对相同类型的数据进行并行处理,因此需要将同一类型的元素放在一起,以便同时加载和操作这些数据。例如,操作四个像素的RGBA值时,SIMD寄存器期望四个相同类型的元素(例如四个R、四个G等)连续存储。如果使用数组结构体(AOS)存储数据,内存中的数据布局是按结构体的形式存储的,这会导致在加载数据时,需要先将不同结构体中的R、G、B、A值提取到各自的寄存器中,这样就会浪费额外的指令来进行数据的重新排列。
与此相对的,结构体数组(SOA)将每种数据类型(如R、G、B、A)分别存储在不同的数组中。这种方式使得每个数组都包含连续的相同类型的元素,在加载数据时,SIMD寄存器能够直接读取并处理相同类型的数据,避免了额外的加载和排列操作,从而提高了效率。
因此,结构体数组(SOA)的方式更适合SIMD操作,因为它能够以最优的方式将数据布局组织成适合并行处理的格式。这样,在处理多个像素或颜色值时,SIMD指令可以更高效地处理数据,减少了无效计算和内存访问,从而提升性能。
黑板:这些内容是如何实际工作的
SIMD(单指令多数据)操作的基本原理与普通的处理器指令操作非常相似,主要的区别在于寄存器的宽度和操作的数据量。
在普通的处理器操作中,寄存器通常是64位宽,操作也往往涉及对这些寄存器中的数据进行加法、乘法等基本运算。比如,某个寄存器中的数据可以加载64位内存并进行操作,或者加载一个32位整数并操作其低32位。在此过程中,运算指令可能是简单的加法操作(例如将寄存器中的值加到自己上)。
而在SIMD中,寄存器的宽度变得更大,通常是128位,这意味着每个寄存器可以同时存储多个数据。例如,SIMD寄存器可以存储4个32位的浮点数或8个16位的有符号整数。当数据加载到SIMD寄存器中时,处理器会读取128位的数据,并将其存储到寄存器中。
SIMD操作的指令不仅包括基本的运算操作(如加法、减法、乘法等),还包括如何处理这些数据的附加信息。举例来说,如果一个指令是浮点数加法操作,SIMD寄存器将按浮点数格式对寄存器中的4个浮点数进行加法运算。如果指令要求的是对8个16位有符号整数进行加法,处理器会按每个整数16位的格式进行处理。
因此,SIMD的运算过程与普通指令非常相似,关键区别在于寄存器的宽度和一次性处理的数据量。程序员在编写SIMD代码时,不需要特别关心底层的细节,基本上就是将数据加载到寄存器,进行数学运算,然后将结果存回内存。
简而言之,SIMD的关键在于如何高效地将数据加载到更宽的寄存器中并进行处理,这对提升计算效率非常重要。
黑板:NEON上的跨步加载
在讨论结构体数组(Array of Structures)与数组结构体(Structure of Arrays)时,特别是在SIMD(单指令多数据)操作中,关键差异是数据在内存中的排列方式。不同的硬件架构对这些结构的支持也有所不同,尤其是在加载数据时的处理方式。
例如,Intel架构没有支持跨越内存块加载数据的功能,这使得在处理类似RGB颜色这样的数据时,存在一定的困难。理想情况下,如果数据是以"结构体数组"的格式存储,每个元素包含完整的颜色数据(如RGBA),在进行SIMD加载时,处理器会逐个加载这些数据并进行操作,但Intel的处理器无法直接加载非连续的内存块,因此必须进行数据重排。这是因为Intel处理器的SIMD指令只能加载连续的内存块。
相比之下,像ARM的Neon架构可以支持以"步长"加载的方式,允许加载非连续的内存块。举例来说,如果数据是按RGBA排列的,每个颜色数据是四个浮点数(16字节),Neon可以通过设定步长加载每四个字节,从而简化了操作。然而,在Intel的架构下,如果数据是以结构体数组的形式存储,那么每次加载数据时,必须将它们重新排列到正确的位置,通常需要使用"解包"和"打包"指令进行数据重组。这些操作虽然有帮助,但效率不高,且在编程时需要额外的工作。
总之,结构体数组和数组结构体的选择,尤其是在SIMD操作中,直接影响到数据的加载和处理效率。在Intel的架构上,使用"结构体数组"的方式处理这些数据时,必须进行额外的步骤以确保数据在寄存器中的排列顺序正确,这也是为什么Intel倾向于使用"数组结构体"的方式,因为这种方式可以减少数据加载后的重排操作。
build.bat:关闭编译器优化
这里讨论的是编译程序的过程。首先,关闭优化选项,以便查看未经过优化的代码。这样可以直观地看到处理器和编译器在没有进行优化时的实际行为和代码结构。这个步骤是为了展示编译后的代码样子,而不会引入任何额外的优化技术。通过这样做,可以确保在没有任何优化干扰的情况下,直接查看到编译器和处理器的原始处理方式。
互联网:Intel内建函数指南
在这段内容中,讨论了如何使用Intel的SIMD指令。首先提到的是,编程时记住每个指令的助记符非常困难,尤其是对于一些复杂的指令。为了方便使用,介绍了Intel的一个工具,它允许开发者查看可以在特定处理器上使用的指令。这个工具特别有用,可以让开发者只关注目标处理器支持的指令,从而避免查阅复杂的架构手册。
工具中的指令被称为"内建函数"(intrinsics),它们并不是汇编语言,而是通过C语言提供指令的具体要求。这些内建函数是Intel为开发者提供的,可以帮助编译器生成相应的汇编指令,使得C程序员能够指定他们想要执行的SIMD操作。尽管这些内建函数让C程序员可以以接近汇编的方式编程,但并不需要手动管理寄存器分配。
此外,提到的"_m64"和"_m128"类型是与SIMD寄存器匹配的数据类型,尽管这些类型之间的区别并不重要,处理器实际上并不在乎这些类型的细微差别。对于整数操作和浮点操作,它们的区分主要是出于某种伪类型系统的设计,尽管这被认为是非常烦人的。
总结来说,Intel的内建函数和SIMD指令为开发者提供了一个高效的方式,来在C语言中执行低级的SIMD操作,尽管这种方式有时让人感到不必要的复杂。
game_render_group.cpp:初始化一些__m128寄存器,并使用SIMD内联函数对它们进行4宽度的操作
介绍了如何使用SIMD内建函数初始化和操作SIMD寄存器的值。首先,使用带有_m128
的类型,可以定义一个SIMD寄存器,并将其初始化为常量值。例如,可以将一个浮动值1.0赋值给该寄存器,并通过内建函数_mm_set_ps
将这个标量值复制到寄存器的所有四个"lane"中,确保每个"lane"的值都等于1.0。接着,可以进行诸如加法等操作,例如将两个寄存器中的值相加。
编译时,这些内建函数应该能正确地工作,前提是编译环境已经包含了必要的头文件,例如xmm.h
或类似的文件,这些文件定义了SIMD操作所需的指令集和类型。值得注意的是,虽然Microsoft Visual Studio并不支持这些Intel特定的内建函数,但它们在使用LLVM时是兼容的,能够支持相同的操作。
在实际的编译过程中,代码成功编译并执行,可以继续进入其他功能的开发,如图形渲染函数的实现。通过这些SIMD操作,可以大大提高计算效率,尤其是在处理大量并行数据时。
vscode调试器:进入反汇编并查看SIMD寄存器
在反汇编的过程中,可以观察到 SIMD 寄存器的使用情况。最初的寄存器是由 SSE 引入的,而后来随着处理器的扩展,添加了更多的寄存器。操作系统和调试工具允许我们单独控制这些寄存器,尽管在某些情况下,可能需要调整显示以更清晰地查看它们。
在该示例中,显示了浮点数值是如何被映射到这些寄存器中的。例如,xmm0
存储了四个浮点值,这些值来自于编译期间加载的常量值。程序加载了常量值 1.0f
到 xmm0
中,并将其存储到栈中,然后又从栈中将相同的值读取回来。这看似无意义的操作实际上是调试过程中常见的行为,通常是为了便于查看和调试。
接着,程序加载了 2.0f
到另一个寄存器,并重复了同样的无用步骤。这种现象是调试模式下常见的"冗余"操作,通常在发布版本中不会出现这些额外的栈操作。最终,xmm0
中的值被用于进行加法运算,将另一个值加载到寄存器中,并通过内存操作直接与其进行运算,产生了结果。
在汇编中,通常会使用 movaps
指令将数据加载到寄存器中,然后通过 addps
等操作进行加法运算。此时,结果会直接存储在寄存器中,而不需要通过内存中间变量。值得注意的是,Intel 处理器支持直接操作内存中的数据,而不必将数据先加载到寄存器中。
在该示例中,如果不考虑调试过程中的冗余操作,最终的代码可以简化为两条指令:一条用于将常量值加载到寄存器,另一条用于直接对内存中的数据进行加法运算。这种优化方法是针对没有调试需求时的代码设计,而在调试模式下,可能会看到更多的中间步骤。
综上所述,虽然调试模式下会显示一些额外的、无关紧要的操作,但理解基本的汇编指令以及它们在处理器中的执行方式,有助于开发者优化和调试程序。在发布版本中,去除不必要的栈操作,可以使代码更加简洁高效。








cpp
00007FFDED1F81CF movaps xmm0,xmmword ptr [__xmm@3f8000003f8000003f8000003f800000 (07FFDED217F20h)]
; 将存储的1.0f值加载到xmm0寄存器中,1.0f在内存中的表示为0x3f800000,重复四次表示一个4元素的__m128。
00007FFDED1F81D6 movaps xmmword ptr [rbp+10C0h],xmm0
; 将xmm0寄存器中的值存储到栈中的地址[rbp+10C0h]。
00007FFDED1F81DD movaps xmm0,xmmword ptr [rbp+10C0h]
; 从栈中的地址[rbp+10C0h]加载值到xmm0寄存器中。
00007FFDED1F81E4 movaps xmmword ptr [ValueA],xmm0
; 将xmm0寄存器中的值存储到变量ValueA中。
00007FFDED1F81E8 movaps xmm0,xmmword ptr [__xmm@40000000400000004000000040000000 (07FFDED217F40h)]
; 将存储的2.0f值加载到xmm0寄存器中,2.0f在内存中的表示为0x40000000,重复四次表示一个4元素的__m128。
00007FFDED1F81EF movaps xmmword ptr [rbp+10F0h],xmm0
; 将xmm0寄存器中的值存储到栈中的地址[rbp+10F0h]。
00007FFDED1F81F6 movaps xmm0,xmmword ptr [rbp+10F0h]
; 从栈中的地址[rbp+10F0h]加载值到xmm0寄存器中。
00007FFDED1F81FD movaps xmmword ptr [ValueB],xmm0
; 将xmm0寄存器中的值存储到变量ValueB中。
00007FFDED1F8201 movaps xmm0,xmmword ptr [ValueA]
; 将变量ValueA中的值加载到xmm0寄存器中。
00007FFDED1F8205 addps xmm0,xmmword ptr [ValueB]
; 将xmm0寄存器中的值与ValueB中的值相加,并将结果存储回xmm0寄存器中。
00007FFDED1F8209 movaps xmmword ptr [rbp+1120h],xmm0
; 将xmm0寄存器中的值存储到栈中的地址[rbp+1120h]。
00007FFDED1F8210 movaps xmm0,xmmword ptr [rbp+1120h]
; 从栈中的地址[rbp+1120h]加载值到xmm0寄存器中。
00007FFDED1F8217 movaps xmmword ptr [Sum],xmm0
; 将xmm0寄存器中的值存储到变量Sum中。
game_render_group.cpp:设置寄存器中的四个不同值
上面内容提到了一些关于如何设置和操作多个值的示例。首先,展示了如何通过设置一个包含四个值的内建结构来实现目标。接着,通过一个例子演示了如何给这些值设置具体的数字,比如100、1000等。
调试器:查看寄存器中的不同值,并注意它们加载的顺序
处理器在执行某些操作时的具体过程,特别是涉及SIMD(单指令多数据)指令的使用。首先,提到通过将一个值加载到xmm寄存器中,然后执行一系列计算操作。在这个过程中,有一个值得注意的点:寄存器中值的顺序与预期的不同,Intel的指令集规定寄存器的加载顺序是反向的,即最显著的字节会首先加载,而不是最不显著的字节。尽管这种反向加载的行为可能会让人困惑,但实际上,它符合Intel的规范。
接下来,讲解了如何进行内存中的加法操作,加载的四个值分别加起来,得到了四个计算结果。这里的关键在于,使用SIMD指令(如mm_add_ps)进行向量加法是非常直接和简单的操作,主要的难点通常不是指令本身,而是在于如何管理和转换数据格式。正确组织数据的结构,以避免浪费时间进行不必要的操作,是使用SIMD编程时的一个重要挑战。
总体来说,虽然执行操作的指令很简单,但数据结构的设计和优化常常是更复杂的部分。确保数据能够高效地传递到需要的格式,避免在计算过程中浪费时间,才是SIMD编程中的真正难点。


game_render_group.cpp:将Square函数转换为乘法
上面的内容举了一个简单的例子,说明如何将常规的计算操作转化为SIMD操作。首先,提到一个处理RGB到线性亮度的转换公式,原始的代码中包含了多个乘法操作。为了简化,去掉了其中的平方函数,剩下的就是一系列的乘法运算。通过这个例子,可以看到原始操作的结构非常简单,主要是乘法。
接下来,讨论了如何将这些乘法操作转化为SIMD指令。虽然具体的SIMD转化过程没有展开详细说明,但可以预见,这样的转化相对直观,因为本质上只是将一系列的标量乘法操作转化为SIMD指令中的向量乘法操作。这种方式将大大加快计算速度,因为SIMD允许在一个指令周期内同时处理多个数据。
修复循环,使其按4个像素的批次处理
如何调整循环结构以便批量处理像素,从而为SIMD优化做准备。首先,提到要把像素按四个一组批量处理,因此需要修改循环结构,使得每次循环能够跳过四个像素,而不是一个。开始时,代码通过逐个像素处理并尝试批量加载像素,但遇到了一个问题:因为每次只前进一个像素,导致处理效果有些偏差,看起来就像是一种"门窗效应"。之后,尝试通过修改循环,将每次步进四个像素,但依然出现了预期之外的效果。
接着,问题被归结为x坐标的处理方式不正确,导致每次处理的像素在x轴方向上没有正确增加,结果在图像上出现了不连续的效果。为了解决这个问题,最终的调整方法是把实际的x值单独计算出来,确保每次增加四个像素的同时,x坐标也正确更新。这一修改使得图像显示恢复正常,但仍然是一个简化的版本,目标是为了后续进一步优化和处理。这一过程主要是为了重新组织代码结构,以便更好地适应SIMD的批量处理方式。



运行游戏并注意到我们正在覆盖边界
现在代码已经回到正常绘制的状态,处理的像素按四个一组进行批量处理。然而,出现了一个小的视觉问题,即屏幕的右侧出现了边界重叠的情况。具体来说,右侧的像素被覆盖了,这导致了屏幕上的内容出现了"溢出"现象,尤其是在处理屏幕最右侧的像素时,由于每次处理四个像素,导致处理到最后一列时会覆盖到下一行的像素,形成了环绕效果。
这个问题的根本原因在于每次循环步进四个像素,而在屏幕的最右边,如果剩余的像素数少于四个,就会超出边界。虽然这个问题在目前阶段不太重要,主要是为了演示和优化批量处理的结构,因此暂时没有对这个溢出问题做处理。最终的目标是继续讨论如何通过这种批量处理方式实现优化。
game_render_group.cpp:临时裁剪缓冲区
为了防止在屏幕的右侧出现覆盖问题,采取了一种简单的剪裁方法。在这种方法中,通过最大值操作来限制绘制的区域,确保不会超出预定的边界。具体来说,使用了WidthMax
和HeightMax
来处理缓冲区大小,暂时假设缓冲区比实际小。接下来,通过减去三个像素的方式,确保在绘制时不会覆盖到屏幕之外的区域,从而避免了写入超出边界的内存。
这个剪裁操作是为了避免不必要的内存覆盖和调试时出现混乱,确保绘制的内容不会在屏幕之外渲染,同时保持一定的边距,避免出现无法预料的行为。这样做的目的是为了优化代码,防止覆盖问题的发生。

将内存加载部分与计算部分分开
现在,讨论一下SIMD操作的部分。在执行四个操作时,目标是能够同时处理四个元素。为了简化过程,暂时忽略一些复杂的部分,如加载数据等。接下来的步骤是将代码分成多个循环,逐步执行。首先,可以通过将PIndex
部分提取出来,确保操作是按四个一组进行的,然后继续处理其他操作。
这样做的目的是将代码分为多个阶段,便于逐步理解和调试。虽然通常会直接以SIMD的方式编写代码,这种分步操作有助于更清晰地展示每个转换过程。具体来说,通过分离内存加载和实际计算部分,可以先执行计算部分,确保核心操作不会受数据加载的干扰。最后一步是进行内存写回,并处理相关的数据。
通过这种方式,计算部分保持简单,专注于执行核心操作,同时把内存管理的复杂部分留到后面单独处理。
在循环之前声明数组
代码的编写过程中,首先创建了一些存储变量,用来处理加载的数据。数据包括Texel值和Dest值,它们被存储在一个灵活的四浮点数组中(例如:k, g, b, a)。接下来,为了将代码模块化并优化,进行了调整,将代码分成了多个部分,这样在后续处理时可以逐步优化每个模块,最终实现并行化。
接着,代码被改造为通过循环结构来加载各个数据部分。在这个过程中,还特别处理了是否需要填充的标志,只有在需要填充时,才会加载数据。通过设置"ShouldFill"标志,确保只有满足条件的数据才会被处理。
此外,代码的结构已经进行了转换,使其能够在多个小的部分中依次运行,以便后续优化。这种转化对于非高级程序员来说并不是必须的,但它有助于为后续的优化打好基础。
在处理过程中,还调整了相关的计算,以便正确处理每个元素。最后,代码的输出部分被配置为输出混合后的结果,同时也进行了一些打包设置,最终完成了整个流程的编写。
运行并注意到我们(几乎)回到了起点
在调试过程中,发现了一个问题,尽管代码已经回到了预期的状态,但在某些情况下,仍然出现了像素化的现象,这很难理解。为了解决这个问题,推测可能是某个步骤没有正确执行,特别是涉及到宽度的处理。经过检查,发现问题出在宽度的应用上,可能在某些地方没有正确地进行宽度的设置。因此,需要仔细检查这些操作,确保它们按照预期执行,并解决导致像素化的原因。


game_render_group.cpp:逐步走查例程

从正确的位置加载像素
问题的根源是没有正确加载像素,导致数据没有从正确的位置获取。为了解决这个问题,需要加载四个不同的目标像素,并遍历每个目标像素。在遍历完成后,逐个写回每个目标像素。为了使代码更加规范,还修改了命名,使其看起来更合适。通过这些修改,现在实现了基于像素的正确处理。

运行,注意到我们回到了良好的状态,并瞥见未来
明天的计划是继续进行SIMD优化。现在,我们已经将加载功能隔离开,并且将数据处理为四个浮点数,这个步骤实际上非常简单,尤其是在MSNBC的结构定义中,做起来几乎没有难度。
明天的工作将是实现这一部分的SIMD优化,编写核心代码,这一过程对于我们来说已经非常直接,几乎可以作为作业来完成。此外,明天还会回顾一些前期的工作,尤其是如何在程序中展示这部分内容。
总的来说,所有准备工作都已完成,现在只需要执行优化并整合这些功能即可。
使用一个int32的联合体和一个包含4个int8的结构体来解包像素,会比每个像素进行4次移位和掩码操作更快吗?
关于使用联合体(union)将一个32位整数与四个或八个元素的结构体进行解包的问题,考虑是否比通过四次移位和每个像素的操作更高效。实际操作中并没有采用这种方式,至少在需要关注性能的时候没有这么做。虽然理论上可以这样尝试,但考虑到编译器的优化能力,可能并没有显著的提升。
有理由怀疑,编译器会根据具体情况自动优化,尤其是在使用现代编译器时,可能会自动将代码转换为更高效的形式,因此即使采用联合体,也许并不会带来太大的性能差异。为了确认这一点,最终还是建议进行实际的性能测试。
我们为什么不通过Y<2和X<2的方式,以块而不是线的形式进行处理呢?
为什么不使用块状加载而是继续使用线性加载时,主要的原因与加载指令的执行方式有关。当发出单一的加载指令时,能够一次加载四个像素点。如果改为块状加载,加载指令需要针对每一行分别发出,因此每次处理时必须执行两次加载指令。
如果使用块状加载,每个块都需要加载多次内存,这会导致更多的内存访问。尽管这种做法可能不会立即导致严重的问题,但它可能不如线性加载高效。更具体地说,当处理每一行时,若采用块状加载,必须执行两次加载指令,而不是一次。
如果决定采用块状加载,可以尝试通过重排帧缓冲区的布局,将其转化为块状格式,在最终的混合处理时再进行转换并输出。这种方法虽然可能提高某些操作的效率,但仍需考虑是否会引发过多的加载指令,因为每次块状加载都需要两次加载指令,这可能会影响性能。
总体来看,虽然块状加载理论上可能带来某些好处,但更多的加载指令和内存访问可能会导致性能瓶颈,因此需要进行实际测试来确定是否值得采用这种方案。
如果我们先计算像素是否应该被填充,然后排队等到处理4个像素时再做计算,这样是否更好?
关于是否在计算像素是否需要填充并排队,等积累到四个像素后再进行计算,这个方法的效果取决于多种因素。虽然这个方法听起来是一个有效的优化,但实际上,它会涉及到大量的指令和额外的计算开销。
这种方法需要额外的步骤来管理像素的打包过程。例如,需要一个计数器来跟踪像素的存储位置,在加载时将像素加载到特定的通道中,并且在完成处理后需要将它们写回到正确的内存地址。这个过程在小范围(如四个像素)时,可能并不会带来显著的好处,但如果像素处理的宽度更大(例如 64 像素宽),则可能会有所改善。
目前的计算中,像素计算本身相对简单,节省的计算量有限,因此采用这种方法带来的节省可能并不大。然而,如果计算更加复杂,例如进行大量复杂的数学运算时,这种优化策略就显得更为重要了,因为减少不必要的计算将能够带来更大的性能提升。
综上所述,是否采用这种方法,主要是看在特定情况下的成本与收益之间的权衡。如果当前的像素计算并不复杂,那么进行像素打包和解包的开销可能不值得。但如果计算涉及更复杂的操作,可能就需要考虑这种优化方式,以提高性能。
之前使用的是Das Keyboard 4,但它坏了,所以他目前使用的是一把他闲置的未知品牌键盘
目前使用的键盘是Das Keyboard 3,曾经是从客厅里换来的。这个键盘原本是别人赠送的,后来对其非常喜欢,决定向赠送者索要。Das Keyboard 3在使用过程中一直没有出现过任何问题,而且手感非常好,特别喜欢这种打字体验。遗憾的是,Das Keyboard 3不再生产了,但仍然非常钟爱这款键盘,认为它的质量和手感都很出色。
抱歉,可能有些跑题:是否可以说,任何使用Java编码的人,默认情况下不会利用SIMD,或者你认为JIT编译器在某些情况下会通过字节码分析自动使用它?
在使用Java编程时,默认情况下并不会直接利用SIMD(单指令多数据)技术。不过,JIT编译器可能会在某些情况下通过分析字节码来智能地利用SIMD指令,尽管这并不是Java编程时最常见的关注点。即使如此,也不排除JIT编译器能够在某些特定情境下自动进行SIMD优化。
同时,考虑到Java已经有多年历史,可能存在某种扩展或库,可以在Java中显式指定SIMD指令。如果没有类似的扩展,这将是非常不可思议的,毕竟现代语言和环境通常会提供这种优化手段。
你在优化时是如何平衡缓存未命中的问题与SIMD优化的?我感觉缓存未命中的问题似乎更重要
在优化性能时,考虑到缓存未命中的问题比使用SIMD优化更为重要。缓存未命中通常与数据结构的组织方式有关,尤其是在进行大规模数据操作时。具体来说,如果数据是按连续的内存块排列的,缓存未命中的问题就会相对较少,因为可以预测下一个要访问的内存位置。而如果数据是随机分布的或涉及指针跳跃,缓存未命中会更加频繁,带来较大的性能损失。
对于处理大量像素的代码,尤其是当处理矩形区域时,内存通常是高度连续的,这有助于减少缓存未命中的可能性。因此,在这种情况下,缓存未命中不太可能成为主要瓶颈。与其关注缓存未命中,优化内存操作的宽度更为重要,尤其是在需要从已知连续位置加载多个数据并进行大量计算时。通过利用宽度操作,可以在一次加载后进行多个操作,避免重复加载,从而提高性能。
总的来说,在这种填充大块已知像素的代码中,缓存未命中的问题可能不像其他类型的代码中那么严重。优化时,更应关注如何有效地利用内存带宽和SIMD技术来提高计算速度。
你会涵盖Morton顺序纹理交换吗?
在渲染过程中,纹理的存储方式和访问模式并不像GPU那样需要特别的优化。对于GPU来说,纹理通常不是以连续像素的形式存储,而是按块分割的,因为GPU需要进行随机访问,尤其是在渲染三角形时,这些三角形只会从纹理中抓取一部分数据。为了提高性能,GPU优化了纹理的存储方式,使得内存访问模式适应这种随机访问。
然而,在当前的渲染需求中,处理的几乎总是整个位图,而不是像GPU那样只处理纹理的一部分。由于内存访问是顺序的且主要是处理整个图像,所以纹理存储的方式并不需要进行复杂的优化。现有的存储方式已经足够,如果需要改进,使用2x2的块来存储纹理也是一种简单有效的方案。因此,相比于GPU的需求,当前的渲染方式并不需要额外的纹理优化。
可能是个新手问题:你有没有遇到过浮点运算问题,有什么好的方法来避免这些问题?
浮点数运算问题是一个常见且实际的问题,尤其是在进行复杂的计算时。避免这些问题的一种方法是尽量避免产生问题的根源。例如,可以通过使用区域概念来确保总是围绕原点进行模拟,而不是在一个大世界(如每个方向100,000单位)的范围内进行浮点运算。这样可以避免精度丢失和浮点数运算中的灾难性取消问题。
此外,即使保持小规模的计算,有时也会遇到浮点运算的问题。遇到这种情况时,解决方法就变得较为复杂。要解决真正的浮点运算问题,必须深入了解浮点数的工作原理,成为浮点数的专家。为了深入理解这一点,建议阅读一些经典的资源,比如《每个计算机科学家都应该知道的浮点值》这本PDF。尽管它可能有些过时,但仍然是一本很好的参考资料,可以帮助理解浮点数运算的问题。
《每个计算机科学家都应该知道的浮点值》
https://docs.oracle.com/cd/E19957-01/806-3568/ncg_goldberg.html
除此之外,还有一些更加深入的书籍,比如由Forman Acton编写的《Real Computing Made Real》以及《Numerical Methods That Work》。这些书籍详细介绍了浮点运算的各种技术和方法,并且包含了很多实际案例和技巧,尤其适合那些想要更深入理解数值计算问题的人。
总的来说,如果能在一开始就避免浮点数运算的问题,那就可以不必过多关注这些细节。但如果遇到了无法避免的浮点数运算问题,就需要深入学习浮点数的计算原理,了解其在CPU上的实际操作机制。通过上述资源,可以帮助理解并解决这些复杂的浮点数问题。
说SSE2是标准的,我猜我会从那里开始
在选择游戏的系统要求时,考虑到目标受众的硬件配置非常重要。Steam硬件调查提供了一个基础的参考,帮助了解玩家的常见配置,尤其是在优化要求较高的游戏(如高端游戏)中尤为重要。对于目标玩家群体,一般可以假设他们使用的是Steam平台上的常见硬件配置。
通过查看硬件调查数据,可以看到当前流行的处理器类型以及玩家普遍拥有的物理CPU数量。对于高端游戏,建议避免要求过高的系统配置,因为这样可能会导致很多玩家无法运行游戏。例如,如果要求使用SSC四核以上的处理器,就会失去大量潜在玩家,因此最好将最低要求设为SSC二代处理器,这样可以确保大多数玩家都能运行游戏。
总的来说,了解Steam硬件调查数据可以帮助做出更合适的系统要求设置,以确保大部分玩家能够体验到游戏。如果设置了过高的硬件要求,可能会限制游戏的玩家基础,尤其是在选择较新的处理器(如SSC四核以上)时,可能会造成不少玩家无法运行游戏的情况。
https://store.steampowered.com/hwsurvey/Steam-Hardware-Software-Survey-Welcome-to-Steam
有没有办法追踪内存如何被存储到缓存中?
要跟踪内存如何存储到缓存中,实际上并没有直接的方法可以做到,因为像Intel这样的公司并没有公开提供能精确模拟处理器并显示缓存使用情况的工具。这些工具通常是公司内部使用的,用来模拟处理器的工作流程,查看缓存是如何被使用的。虽然内部团队可以通过模拟运行处理器,查看缓存的使用情况,但普通用户无法访问这些工具。
如果没有这些工具,获取缓存使用情况的信息也不是完全不可能。处理器本身提供了性能计数器,可以用于获取一些关于缓存使用的统计数据。虽然这些数据无法提供缓存的具体操作(如哪些行被填充、哪些行被驱逐等),但通过分析缓存未命中的次数和缓存命中的次数,通常已经足够提供有用的信息。
例如,处理器会提供L2和L3缓存的未命中次数和命中次数等统计数据,通过这些信息可以大致判断哪些部分的代码可能遇到了缓存未命中的问题。通过这些方法,虽然无法准确追踪每个内存的缓存操作,但可以获得足够的汇总统计数据,帮助理解缓存的表现。
总之,虽然没有方法能够准确看到缓存操作的细节,但使用处理器的性能计数器,获取有关缓存命中和未命中的信息,通常已经能够满足大部分需求。
https://www.intel.com/content/www/us/en/developer/articles/tool/performance-counter-monitor.html
跑题了:你知道JAI是否会有扩展/方法来使用SIMD吗?
在J语言中,确实已经有了一些与SIMD相关的功能。具体来说,J语言已经实现了将"数组的结构"转变为"结构的数组",这种转换可以自动进行SIMD优化。这意味着J语言已经为SIMD提供了一些自动化的支持,能够通过这种结构转换提高性能。
然而,目前J语言并没有直接实现SIMD的低级指令(如SIMD intrinsic),这类指令通常用于更细粒度的优化,因此还没有完全达到这一点。不过,考虑到当前的进展,预计将来可能会加入这些低级的SIMD支持,只是目前还未完成。
你在编程时需要考虑内建指令吗,还是编译器通常会处理这些?这是不是使用GNU和Intel编译器的一个重要区别?
在编程时,通常不需要太多关注SIMD的内在指令,因为现代编译器(如Intel编译器等)通常会自动处理这部分优化。编译器会通过分析代码并利用SIMD指令来优化执行,因此开发者更多的是依赖编译器进行这些优化,而不是手动插入SIMD指令。
关于使用不同的编译器(比如Canoe和Intel编译器),一个明显的区别在于它们对SIMD指令的支持和优化策略有所不同。Intel编译器通常会提供更多的底层优化,尤其是在利用特定硬件特性(如缓存优化和SIMD指令集)时,能有效提高代码的性能。然而,这些优化有时可能会显得"奇怪"或不那么直观,因为Intel的库和工具可能会做一些不太容易理解的操作。
为了优化性能,开发者通常会使用一些工具来分析缓存缺失等问题,通过这些数据来进一步调整代码,看看缓存失效的原因。这类数据对于优化程序性能至关重要,尤其是在涉及到内存管理和硬件级优化时。
我认为他基本是在问,编译器自动生成SIMD指令的能力如何?
(Automatic vectorization)
编译器在自动生成SIMD指令方面的能力并不十分高效。尽管编译器通常会尝试将标量操作转化为向量化操作(即自动向量化),但这一过程通常需要开发者提供一定的帮助才能达到最佳效果。自动向量化的过程主要是将标量代码转换为操作宽数据的代码(如处理四个或八个元素的向量),这一过程虽然被编译器支持,但通常在实际应用中效果有限。
在实际的编程过程中,编译器在自动向量化方面并不总是能做得很好。通常,若代码未按SIMD要求结构化,编译器很难进行有效的优化。即使编译器能生成向量化的指令,问题常常出在数据结构的组织和数据的加载与存储方式上,而这些是编译器最难优化的部分。虽然像MM_ADD_PS这样的SIMD指令的编写并不复杂,但真正的挑战在于如何高效地组织数据和进行拆解、重新打包等操作。
总的来说,SIMD编程的真正难点并不在于编写那些SIMD指令,而在于如何正确地组织和布局数据,使得向量化能发挥最大效益。因此,开发者通常需要手动优化数据结构和代码,而不是完全依赖编译器自动生成SIMD指令。
https://llvm.org/docs/Vectorizers.html
https://www.intel.com/content/dam/develop/external/us/en/documents/31848-compilerautovectorizationguide.pdf
**自动向量化(Auto-Vectorization)**是编译器的一种优化技术,旨在将标量(即单个数据元素)操作转化为向量化(处理多个数据元素)的操作,以提高程序在支持SIMD(单指令多数据)架构上的性能。简单来说,自动向量化让编译器将传统的逐个处理数据的循环转换为一次处理多个数据的操作,从而利用CPU的向量指令集(例如AVX、SSE等)加速计算。
工作原理:
-
标量操作转换为向量操作:在没有自动向量化的情况下,程序中的循环可能是逐一处理数据(如一个一个加法运算)。自动向量化会识别出这些可以并行处理的操作,并将它们转换为一次操作多个数据的向量化指令。
-
向量化指令:自动向量化依赖于CPU的SIMD指令集,向量化指令可以在一个时钟周期内并行执行多个数据项的操作。比如,使用SSE或AVX指令集,编译器可以将原本的四个标量加法操作转换为一次处理四个数据的SIMD指令。
-
数据对齐:为了有效执行向量化操作,数据通常需要按特定的对齐方式存储,以便CPU能够高效地访问和处理。
适用场景:
自动向量化通常适用于那些循环体中包含简单的数学计算(如加法、乘法等),并且可以并行化的代码。编译器会扫描这些循环,判断是否可以进行向量化。如果编译器能成功识别并优化代码,性能就能得到显著提升,尤其是在数据密集型的应用程序中。
限制和挑战:
- 数据依赖性:如果循环中存在数据依赖(比如某次迭代依赖于前一次迭代的结果),编译器可能无法向量化该循环。
- 编译器限制:并不是所有的循环都可以通过自动向量化优化,特别是那些较复杂的控制流或者需要条件判断的循环。
- 数据布局:为了使自动向量化有效,数据需要按照特定的方式组织和对齐,否则可能无法利用向量化指令。
总结:
自动向量化是编译器自动进行的一种优化,使得程序能够利用SIMD指令集并行处理多个数据元素,从而提高计算效率。虽然它能自动化处理一些常见的向量化操作,但对于复杂的数据依赖和特定的数据布局,开发者通常需要手动优化代码以获得最佳性能。
你是否需要考虑指令缓存?还是它足够大?
通常情况下,对于轻量级优化(比如常见的优化方法),不需要特别考虑指令缓存(Instruction Cache)。但如果进行非常深入的性能优化,尤其是在核心性能优化领域,就需要考虑指令缓存的影响。指令缓存的大小可能影响某些例程的性能,因为较大的例程可能会因为缓存未命中而变得比小的例程更慢,尽管它们的计算可能更高效。
在实际情况中,有时较大的例程在计算上可能更高效,但因为指令缓存的限制,执行速度可能反而更慢。对于一般的优化来说,这种问题通常不需要过多考虑,因为它通常不会对整体性能造成重大影响。但对于极致性能优化,特别是在需要最大化效率的情况下,这个问题是需要认真考虑的。
内联指令和并行处理是如何协同工作的?每个CPU都有处理内联指令的寄存器吗?如果有,我们能否通过并行计算来提高像素渲染的数量?
在并行处理的上下文中,通常情况下,每个处理器的寄存器是独立的,它们能够在各自的核心上执行指令。这意味着,使用单一处理器上的指令集(例如SIMD)来并行处理数据时,可以将相同的操作应用到其他处理器上,并且每个处理器可以独立执行这些操作。
因此,如果要提高渲染性能或计算能力,可以利用多核处理器并行处理不同的区域或任务。具体来说,可以将屏幕或计算区域分成多个部分,每个处理器负责处理一个部分,这样可以显著提高效率并更好地利用硬件资源。这种方法在进行多线程优化时尤其有效。
总结
讲解者结束了这一节内容,并感谢大家的参与。他表示,明天将开始编写实际的例程,进行SIMD优化,之前已经了解了SIMD的工作原理以及如何重构例程。接下来将加入一些内联指令,逐步实现简单的代码。预计明天会完成这部分内容,但一些涉及加载和存储的复杂前后处理将需要下一周才能继续深入。