仓库:https://gitee.com/mrxiao_com/2d_game_3
优化工作概述
这次我们正在进行一些非常有趣的工作,主要是对游戏进行优化。这是首次进行优化,我们正在将一个常规的标量C代码例程转换为内建指令,以便利用AIX 64位处理器的SIMD指令集进行加速。到目前为止,已经通过这些基本的转换工作实现了3倍的性能提升。为了强调这一点,即使在开始深入优化之前,仅仅通过这种方式翻译代码,也能带来显著的性能提升。
今天我们将继续完成这一转换过程,尽量在今天完成这一工作。
回顾昨天的进展
昨天的工作中,处理到了一个非常具体的问题,我们在那时正准备解决。今天我们将从上次停下的地方继续推进。
目前,在运行程序时,虽然通过了所有的单元测试,但在显示内容时,依然出现了黑色的条纹。问题的原因正是这些黑色条纹的出现。
当前问题:黑条
为了进行一些工作,避免修改像素写入的方式,原本用于检查是否应填充的代码被注释掉了。在之前的实现中,每次处理像素时,都会设置一个名为 ShouldFill
的布尔数组,表示该像素是否在需要填充的矩形区域内。
但由于当时还没有处理如何在 SIMD 中实现这一点,所以这部分代码被注释掉了。当前的任务是,只在 ShouldFill
为 true
的像素上进行写入操作。然而,程序运行时,未能进行这种检查,导致一些黑色的像素被写入,这些本不应该被写入。
问题出在 ShouldFill
没有被正确设置。在上面的代码中,如果 ShouldFill
为 false
,就会跳过相应的操作,结果是缓冲区里没有数据,最终写出的就是无效的垃圾数据。因此,需要采取一些方法来保存 ShouldFill
的信息,确保只有需要填充的像素被正确写入。
黑板:将正确的值写入目标
这个过程其实并不复杂,但因为这是全新的内容,之前在 game Hero 中并没有讨论过类似的内容,所以需要确保每个人都理解当前的进度。昨天的工作进展是,已经准备了一个 128 位的 SIMD 寄存器,其中存储了 RGB 和 A 四元组的数据。
目前的问题是,虽然我们已经准备好了一次性写入到目标缓冲区的指令,但目标缓冲区可能并不包含所有应该覆盖的值。具体来说,目标缓冲区中可能只有一些像素值是我们需要更新的。
为了解决这个问题,可以采取的一种方式是,在处理时确保,如果只需要更新两个像素,那么其他两个像素可以填充为有效数据(也就是目标缓冲区原本的数据)。这样,即使我们写入了这两个像素的数据,也不会影响其他像素,因为它们将被写回原来的值。
这种做法是我们首先要考虑的方案,然后可以根据实际情况判断是否有其他更好的方法。在这种情况下,关键是要确保当计算新的值时,虽然不更新其他像素,但我们依然将原始帧缓冲区中的旧值保留在那些未处理的像素位置,从而确保最终的结果是正确的。
对所有像素进行所有操作是可以的
一种思考方式是,我们希望避免对那些不需要处理的像素重复进行所有操作。虽然目前在处理每个像素时,处理一个像素的代价和处理四个像素的代价差不多,但如果尝试将来自不同位置的像素打包并在之后再分散回来,这可能对于四个像素来说并不值得尝试,尽管很难说。出于这个原因,暂时并不会尝试这种方法。
因此,目前的做法是,完全可以对所有像素进行这些操作。唯一的问题是,若对所有像素执行这些操作,到最后我们必须找到一种方法,将所有这些计算得到的值正确地填充回原本在目标缓冲区中的像素值。这意味着需要对所有处理过的值进行操作,确保它们最终能够正确地复现目标缓冲区中的原始内容,但这似乎有点麻烦,甚至感觉有些难度。
另一种选择是,我们可以考虑其他方法来简化这一过程。
黑板:另一种选择:组合旧值/新值
在我们准备写出新值之前,可以考虑使用按位操作来结合这两个值。假设我们已经计算出了四个槽的新的值,并且知道只有两个值是有效的,其他的应该保留旧值。那么,可以使用按位操作将这两个值结合,得到一个最终的向量,其中旧值出现在应为旧值的位置,新值则出现在应为新值的位置。
为了实现这一点,可以使用“或”操作将两个向量结合。如果我们有两个向量,其中包含所有的零,我们可以将它们进行按位“或”运算,这样可以确保中间的位置保持新值,而两端则保留旧值,因为零不会干扰。
接下来的问题是,如何将不需要的新值变为零。在这种情况下,可以使用“与”操作来实现。如果构建一个掩码,将其应用到按位“与”操作中,就可以清除不需要的部分,确保最终的向量只有我们需要的值。
黑板:构建掩码
这看起来像是这样:我们可以构建一个掩码,其中所有应该是旧值的位置都是零,所有应该是新值的位置都是一。通过使用“与”操作对掩码和旧值进行操作,可以清除不需要的部分,然后将结果与新值进行“或”操作,得到最终的向量。
这种方法的关键在于,确保能生成一个正确的掩码,使得掩码的值在需要旧值的位置为零,在需要新值的位置为一。这样就能非常简单地将新值和旧值结合成一个最终的结果。
遮掩无效的新值
可以创建一个名为“MaskOut”的方法,用于通过“与”操作和掩码生成最终结果。首先,使用掩码将原始目标值中的不需要覆盖的部分清除,然后将新计算的像素值中的不需要写入的部分也清除。之后,通过“或”操作将这两个部分合并,得到最终的目标值,最后将其写入目标位置。
为了实现这一过程,需要确保能够正确地计算掩码,并存储原始目标值。这意味着在进行更新操作之前,首先要处理原始数据,并确保每次操作都按照正确的顺序执行。
确保保存原始目标
需要确保每次都能正确处理目标数据,即使是在循环条件中,也不能仅仅在特定条件下进行加载。必须始终加载并更新目标数据。完成这一点后,可以确保在处理过程中原始目标数据能够得到保留,并且新计算的值能够正确地覆盖原有数据。
此外,还需要注意,目前这些数据仍然是直接转换为浮动格式的,并没有进行SIMD化处理。因此,在此过程中,除了处理掩码操作外,还需要对数据格式进行相应的调整。
还没有对加载进行SIMD化,分别处理OriginalDest
为了确保在处理过程中能够准确保留原始目标数据,需要显式加载目标位置的数据。这可以通过将目标数据加载到特定寄存器中实现,类似于之前的操作,只不过这次是加载正在写入的特定位置的值。这样做的目的是能够保存之前的数据,并在后续处理中使用。
目前,虽然数据还没有进行SIMD化处理,但这并不影响目前的操作。此时,将继续保持加载操作的方式,待以后完成SIMD化后,代码可以更新为使用该格式。
WriteMask问题:还没有计算它!
目前,我们面临着一个问题,即写掩码(WriteMask)尚未显式计算。为了解决这个问题,暂时可以像之前处理其他问题一样,使用一个简化的方式来进行近似操作。我们可以将右侧掩码设置为一个虚拟值,比如将其值设为零,意味着不会执行任何写操作。通过这种方式,验证可以看到,确实没有写入任何内容。
如果将右侧掩码设置为全1(即每个32位的掩码都是1),则会恢复到之前的状态,即在所有地方都执行写操作,这是预期的行为。
接下来,需要解决的问题是如何将右侧掩码设置为正确的值。可以通过类似的方式,在后续的代码中使用简化的宏来进行设置。
使用简单的set宏来设置WriteMask
目前,直到找到更好的方式解决这个问题,我们决定将右侧掩码设置为全1。在这种情况下,只有在我们认为需要填充的地方,右侧掩码才会被设置为1。这意味着,只有在需要写入的地方,掩码才会有效,其他地方则不会进行写操作。
game Hero: 一个有点花哨的版本
出现了一个意外的效果,看起来像是某种艺术滤镜被应用到了游戏中,效果非常独特,甚至有些夸张。这个意外的效果让人感到非常惊讶,但也觉得很有趣。现在,似乎是由于使用了浮动点设置所导致的,因此需要临时调整一下,使用整数来处理这个问题。
修复“问题”:为uint设置的Mi宏
这个效果虽然意外,但确实很酷。现在,通过正确的掩码操作,已经能够正常遮蔽掉不需要的值,效果看起来很好,之前的黑色边框问题也解决了。这标志着又一个任务的完成。接下来,还需要提到另外一个问题。
另一个问题:Fabian的舍入模式注释
经过确认,实际上不需要担心广告操作,因为默认的四舍五入模式已经是“最接近值”,因此不需要额外处理。这样就不需要再执行之前的步骤,可以省去这个任务,进而简化了代码。现在,系统运行得更加高效,已经达到了大约110个周期的速度。
还有一些工作要做,最后一个for(I)循环
目前,剩下的工作是完成最后的四个循环。接下来的目标是将一些操作,比如AR、GS和BE的打包和解包,移到外部,利用SIMD实现这一过程。这些操作之前已经做过类似的处理,所以下移这些操作并不会很困难。首先,决定将样本A移到外部,并将其存储在M128寄存器的低位部分,而不是直接作为UN32加载。这个过程将使得我们能够更灵活地处理数据,虽然还不确定是否需要使用strided存储,或者继续使用UN32进行加载。无论如何,四个样本A的处理应该在外部完成,首先进行显式加载。
显式版本的循环展开
在这一阶段,决定采用逐步的方式处理循环,而不是一次性处理所有样本。这样做会生成大量的转换代码,但可以相对简单地完成所有样本的处理。所有样本将按照这种方式处理,每个样本可以独立进行转换。为了方便展示,将这些步骤逐一展示,使得每个细节都能被清晰看到。未来在处理更复杂的汇编任务时,可以根据需要做一些简化,避免一次性跳进复杂的SIMD代码编写,逐步展示每个步骤的好处是让不同学习进度的人都能跟上。
该部分的代码处理涉及从打包纹理中加载数据,这使得操作稍显复杂。尽管一开始看起来直接处理会更简洁,但由于纹理格式的不同,实际加载数据时会遇到一些难度,因此此时的处理方式需要更加谨慎。
检查我们是否还在正常工作:现在每像素不到100个周期
如前所述,在移动某些操作后,循环计数反而减少了,很多情况下已降到低于一百个周期。这看似与直觉相反,因为有些代码从条件语句中移到了常规执行路径中,这意味着它不再仅在条件满足时执行,而是始终执行。尽管如此,处理器和编译器的优化方式以及执行机制有时会导致一些意外的结果,特别是在处理复杂的例程时,这种优化结果可能会出乎意料。因此,尽管这种变化似乎不太直观,但它的效果是明显的。
接下来,考虑目标部分的处理,这可能是最容易开始的部分。已经加载了原始的数据,可以开始着手处理目标部分,初步考虑只处理低位目标,或者先从右掩码着手。
以相同方式处理目标
接下来,继续进行下一步处理,完成当前任务。将更多的步骤移到外部执行,确保过程顺利进行。
移动数据后节省了更多周期
通过将代码移出条件语句,节省了大约十个周期。尽管还没有完全翻译例程,但已经接近目标,周期数已接近五十个。接下来,打算首先处理右掩码的问题。
修复WriteMask的乱象
计算u和v值后,需要确保它们满足某些约束条件,即它们必须大于等于0并且小于等于1。目标是直接计算右掩码,而无需进行标量提取,因为当前代码通过循环从向量中提取各个部分。为了避免这种操作,计划通过使用SSC指令来计算右掩码,从而不需要将其打包到四个单独的通道中。
SSE比较操作
SSC提供了多种比较操作,包括“greater than or equal to”(大于或等于)等指令。这些指令可以比较两个值,如果满足条件,结果会在对应的通道中填充全1,否则填充全0。该操作专门设计用来处理此类比较需求。
黑板:宽操作的比较
使用 SSC 提供的比较操作,可以直接生成右侧掩码。该操作比较每个通道的值,并根据条件生成全 1 或全 0 的掩码。这种机制是为了避免在宽操作中使用条件控制流,因为在宽操作中无法仅部分执行某些操作,必须对整个向量进行处理。通过选择性地应用条件判断,可以计算出每个像素的最终值。这个过程本质上是在计算一个条件语句的两个分支,并在每个通道中根据条件选择执行哪个分支。
emmintrin
是一个包含 128 位 SSE 指令集的内建函数库,用于在多个数据元素上同时执行相同的操作,特别是整数类型的数据。下面是一些常见的 emmintrin
比较操作及其应用示例。
常见的 emmintrin
比较操作
-
_mm_cmpgt_epi32: 比较两个 128 位的整数向量中每个元素是否大于,生成一个掩码结果。
__m128i _mm_cmpgt_epi32(__m128i a, __m128i b);
- 该操作返回一个 128 位的整数向量,每个元素都是 0 或 0xFFFFFFFF(即全 1),表示对应元素是否满足
a > b
的条件。
- 该操作返回一个 128 位的整数向量,每个元素都是 0 或 0xFFFFFFFF(即全 1),表示对应元素是否满足
-
_mm_cmpeq_epi32: 比较两个 128 位的整数向量中每个元素是否相等,生成一个掩码结果。
__m128i _mm_cmpeq_epi32(__m128i a, __m128i b);
- 返回一个 128 位的整数向量,每个元素如果对应位置相等,结果为 0xFFFFFFFF,否则为 0。
-
_mm_max_epi32: 对两个 128 位整数向量中的每一对元素,取较大的值。
__m128i _mm_max_epi32(__m128i a, __m128i b);
- 返回一个新的 128 位整数向量,其中的每个元素为对应位置上两个输入向量的最大值。
-
_mm_min_epi32: 对两个 128 位整数向量中的每一对元素,取较小的值。
__m128i _mm_min_epi32(__m128i a, __m128i b);
- 返回一个新的 128 位整数向量,其中的每个元素为对应位置上两个输入向量的最小值。
-
_mm_and_si128: 对两个 128 位整数向量的元素进行按位与操作,用于生成掩码。
__m128i _mm_and_si128(__m128i a, __m128i b);
- 对两个向量的每一位执行按位与,返回一个新的向量。
应用举例
1. 条件选择
假设需要对两个数据集中的数值执行条件选择操作,比如根据某个条件选择哪个数据集中的数值进行处理。可以利用 emmintrin
提供的比较操作和掩码操作来高效实现。
示例:
#include <emmintrin.h>
#include <stdio.h>
int main() {
// 定义两个 128 位整数向量
__m128i v1 = _mm_set_epi32(10, 20, 30, 40);
__m128i v2 = _mm_set_epi32(5, 25, 35, 45);
// 使用 _mm_cmpgt_epi32 比较 v1 和 v2
__m128i mask = _mm_cmpgt_epi32(v1, v2); // v1 > v2 时为 0xFFFFFFFF, 否则为 0
// 使用 _mm_and_si128 和 _mm_set_epi32 进行条件选择
__m128i result = _mm_and_si128(mask, v1); // 如果 mask 为 0xFFFFFFFF,则选择 v1 中的值,否则选择 0
// 打印结果
int* resultArray = (int*)&result;
for (int i = 0; i < 4; i++) {
printf("%d ", resultArray[i]);
}
return 0;
}
解释:
- 使用
_mm_cmpgt_epi32(v1, v2)
比较向量v1
和v2
中的每一对元素,生成一个掩码mask
,如果v1
的元素大于v2
的元素,则掩码对应位置为 0xFFFFFFFF,否则为 0。 - 使用
_mm_and_si128(mask, v1)
仅选择掩码位置为 0xFFFFFFFF 的元素,其他位置将为 0。
输出:
0 0 0 10
2. 图像处理
在图像处理中,可以使用 emmintrin
的比较操作快速进行像素值的比较与操作,生成掩码并基于条件处理图像像素。
示例:
假设需要对图像中每个像素的亮度值进行比较,并根据某个条件修改亮度。
#include <emmintrin.h>
#include <stdio.h>
int main() {
// 模拟两幅图像的像素值
__m128i image1 = _mm_set_epi32(100, 150, 200, 250);
__m128i image2 = _mm_set_epi32(50, 180, 190, 230);
// 比较图像中的像素值,找出较大的像素值
__m128i result = _mm_max_epi16(image1, image2);
// 打印结果
int* resultArray = (int*)&result;
for (int i = 0; i < 4; i++) {
printf("%d ", resultArray[i]);
}
return 0;
}
解释:
- 使用
_mm_max_epi16(image1, image2)
来选择image1
和image2
中每对像素的最大值。这样可以有效地对图像像素进行比较并选择较亮的像素。
输出:
100 180 200 250
总结
emmintrin
的比较操作提供了高效的向量化处理能力,使得可以在 128 位的多个数据元素上同时执行条件判断操作,从而在图像处理、物理模拟和其他计算密集型应用中大幅提升性能。通过使用这些指令,可以更高效地处理条件控制、掩码生成、最大/最小值选择等操作,减少冗余计算,提高代码执行效率。
使用比较直接生成WriteMask
在这种情况下,条件语句无法使用,需要通过掩码操作来实现。因此,使用了特定的掩码操作来替代传统的条件判断。步骤如下:
-
掩码生成:将比较操作的结果(如大于或等于的结果)直接应用于掩码中。这意味着不再依赖于传统的条件分支,而是使用掩码值来控制哪些部分会执行操作。
-
使用零和一的向量:已经加载了零向量和一向量,然后通过比较操作(例如大于或等于零、小于或等于一)来生成掩码。这些掩码决定了哪些部分的数据会被使用或跳过。
-
使用 SSE 指令:通过利用 SSE 指令集(例如
ssc
指令),可以在不使用传统条件判断的情况下,直接生成这些掩码。这样可以高效地进行并行计算。 -
类型转换:由于操作涉及浮点数,需要在计算完成后将掩码进行类型转换,以确保结果正确并与目标类型匹配。最终,生成的掩码是基于浮点数运算的,可能需要转换为适合的整数类型或其他格式。
通过这种方式,避免了传统的条件判断,改为使用掩码来控制数据流,优化了运算效率。
使用宽操作的工作WriteMask
现在,通过使用操作,完全生成了右掩码,并且运行得非常顺利。这表明,掩码生成的过程是有效的。然而,问题在于,尽管右掩码已成功生成,计算部分仍然存在,因为仍然需要加载纹理。
问题:无法完全去掉if语句…
现在存在一个问题,就是不能完全去除条件语句并始终加载纹理。原因在于,可能会从无效的内存中加载数据。必须确保始终加载有效的值,否则可能会访问到无效的内存。因此,需要在加载时进行有效性检查,确保数据的合法性。
解决方案:限制U和V
为了确保所有的纹理加载都能正确执行,即使在纹理未实际使用的情况下,依然需要对u和v值进行限制(clamp),确保它们始终保持在有效范围内。这样做可以避免加载无效的纹理数据。通过将u和v值限制在0到1之间,任何超出范围的值都已经被处理并映射为无效。因此,通过这种方式可以确保所有需要的像素都被正确计算并写入,而不受范围外的影响。
完全去掉if语句!
通过提前对u和v值进行限制(clamp),可以完全避免在后续操作中选择超出纹理范围的像素。这样一来,就可以确保不会访问无效的纹理区域,并且在进行纹理采样时,所有的像素都会处于有效范围内。
也将纹理获取宽化
通过提前计算纹理的宽度和高度,可以将这些计算步骤变成符号操作。这样,纹理的相关计算将以宽向量的形式进行,这对于后续的纹理坐标处理是非常方便的。这些计算会通过乘法操作进行,可以确保每个像素值都适用于相同的处理。同时,也需要在后续步骤中调整这些计算,特别是在处理纹理边界时。
还没有进行优化,只是在转换为SIMD
为了优化纹理计算,计划将原本逐个像素进行的标量操作改为通过宽向量进行的批量处理。通过将 U
向量与宽度向量相乘,可以一次性处理所有像素,从而避免逐一计算。这样做不仅提升了效率,也简化了代码结构。同时,考虑到纹理的提取和计算过程,计划对 x
和 y
的提取方式进行改进,确保操作更为直接和清晰。对于一些命名不规范的变量,建议进行优化和重命名,以提高代码的可读性和易维护性。
调整纹理获取以使用宽值
为了进一步优化纹理计算,计划引入一个新的转换操作,用于从之前计算中提取相关的值。这将通过提取像素的 x
和 y
坐标,确保能够有效地从宽向量中提取纹理数据。通过这种方式,能确保计算更为高效,并减少重复的操作。
同时,需要注意的是,_mm_cvtps_epi32
操作在执行时可能会引发一些问题,因为它的行为和数据转换方式需要特别关注,以确保不会影响到后续的计算过程。总的来说,这一改进的目标是通过简化和优化纹理提取流程,提高性能,并使代码更加简洁明了。
通过截断转换获取坐标
为了确保在计算过程中能够正确地处理纹理坐标,需要先对值进行截断,而不是四舍五入。为了实现这一点,可以通过先减去 0.5 来确保数值能够落入正确的范围,这样就能达到截断的效果。虽然最初认为无法直接进行截断操作,但通过使用 _mm_cvttps_epi32
指令,实际上可以实现这一功能,而不需要额外的减法操作,这为代码的简化提供了便利。
在完成乘法计算后,将生成纹理的 tx
坐标,然后使用 _mm_cvttps_epi32
来截断该值,进而得到正确的纹理像素索引。这一过程的关键是精确计算纹理的位置,并确保从正确的像素数据中提取信息。这种方法能有效避免误差,并确保纹理映射过程的准确性。
通过减法获取fX和fY
为了处理纹理坐标的分数部分,需要计算 x
和 y
的小数部分,这可以通过减去其截断后的整数值来实现。这一操作会得到纹理坐标的精确小数部分,从而用于精确的纹理采样。
在这个过程中,需要将整数转换为浮动点数,因此需要使用一种将整数向上转换为浮动点数的指令(例如 ep32
),而不是进行简单的类型转换。通过这种转换,能够保持精度,并确保纹理坐标正确计算。
完成这一转换后,操作就变得非常简洁,几乎只需要进行纹理采样的提取,这样可以确保纹理操作正确且高效地完成。
一切正确,低于70个周期
经过前面的工作,纹理采样的计算已经基本完成,并且优化到每个循环减少到不到 70 个周期,这样的效率提升是非常显著的。接下来,尽管大部分主要工作已经完成,仍然有一些被剪切出来的代码片段没有完全处理,尤其是对于宽度的计算。现在,可以开始处理这些尚未完全初始化的部分,因为这些部分不再需要像之前那样以较为繁琐的方式进行初始化,可以使用更简洁的方式来完成后续的工作。
不再需要初始化Texel值
经过优化后,代码已经不再报错,因为之前需要初始化的一些部分,现在通过清理后可以避免再次报错。接着,可以通过直接在行内进行处理,避免了不必要的初始化,进一步简化了代码。所有这些优化让每个像素的处理时间明显降低,现在每个像素的处理周期已经远低于 70 个周期,这显著提高了效率。对于混合和深度的处理也可以直接按这种方式完成。
一切都已SIMD化,除了纹理加载
在继续优化过程中,性能的提升大部分来自于将处理从 SIMB 转换到更高效的方式。目前,除了纹理加载(texel loads)外,几乎所有的操作都已迁移到 SIMD 中。由于暂时没有适用于 SIMD 的纹理提取(fetch),这一部分仍需要继续使用其他方法进行处理。不过,其他操作已经在 SIMD 中完成,接下来只需关注纹理提取及其上转换的部分。目标是将这部分操作也迁移到 SIMD 中,从而进一步提高效率。
黑板:解包颜色数据
在处理像素写入时,目标是从目标数据中提取已打包的像素,这些像素是按某种顺序打包的,如 BG、RA 等。需要将这些打包的像素解包成单独的分量,以便每个分量都能被提取并转化为向量形式。例如,对于蓝色分量(B),通过遮罩操作可以提取出蓝色分量的值,然后将其转换为浮动点值。同样的处理方式也适用于其他颜色分量。这种解包和转换的过程与之前打包过程的操作是逆向的,理论上可以正常工作。
使用掩码和移位提取颜色
要处理目标数据时,可以通过先进行遮罩操作来提取单独的颜色通道。例如,首先对目标数据进行遮罩操作,只保留蓝色通道的数据,然后将其转换为浮动点值。在处理过程中,使用了简单的掩码和位移操作,确保正确提取和转换每个通道的值。在所有通道(如红色、绿色、蓝色、透明度)上,采用相同的操作,只需要根据需要将其移动到正确的位置。此外,通过适当的位移操作,可以确保每个通道的数据位于正确的内存位置。
最后,这些操作以更加简洁、清晰的方式实现,避免了繁琐的过程和多次的提取步骤,从而使代码更加简洁和高效。对于纹理的加载,也面临一些挑战,尤其是在没有直接加载纹理的情况下,需要进行一定的复制或模拟加载操作。
以下是您提到的不同大小的SIMD(单指令多数据)寄存器右移操作函数及其对应的指令的详细说明:
-
_mm_srli_epi16:
- 操作:将一个128位寄存器(
__m128i
)中的每个16位元素右移指定的位数(imm8
)。 - 指令:
psrld
(打包右移双字)。
- 操作:将一个128位寄存器(
-
_mm_srli_epi32:
- 操作:将一个128位寄存器(
__m128i
)中的每个32位元素右移指定的位数(imm8
)。 - 指令:
psrlq
(打包右移四字)。
- 操作:将一个128位寄存器(
-
_mm_srli_epi64:
- 操作:将一个128位寄存器(
__m128i
)中的每个64位元素右移指定的位数(imm8
)。 - 指令:
psrlw
(打包右移字)。
- 操作:将一个128位寄存器(
-
_mm_srli_pi16:
- 操作:将一个64位寄存器(
__m64
)中的每个16位元素右移指定的位数(imm8
)。 - 指令:
psrld
(打包右移双字)。
- 操作:将一个64位寄存器(
-
_mm_srli_pi32:
- 操作:将一个64位寄存器(
__m64
)中的每个32位元素右移指定的位数(imm8
)。 - 指令:
psrldq
(打包右移双字)。
- 操作:将一个64位寄存器(
-
_mm_srli_si128:
- 操作:将一个128位寄存器(
__m128i
)按字节右移指定的位数(imm8
)。 - 指令:
psrlq
(打包右移四字)。
- 操作:将一个128位寄存器(
-
_mm_srli_si64:
- 操作:将一个64位寄存器(
__m64
)中的每个64位元素右移指定的位数(imm8
)。 - 指令:
psrldq
(打包右移双字)。
- 操作:将一个64位寄存器(
这些操作通常用于在SIMD寄存器中对数据进行位级别的操作,广泛应用于高性能的多媒体处理、加密算法等领域。
黑板:样本读取矩阵
在进行纹理处理时,目标是将多个采样值(样本)合并成一个可以高效处理的格式。每个像素都有不同的采样值,比如样本A、样本B、样本C和样本D,每个样本都是对应某个像素的不同纹理样本。这些样本本身来自不同的位置,因此我们需要将它们整理成一个矩阵形式,便于后续的操作。
具体来说,处理时有四个像素(像素0、1、2、3),并且每个像素对应四个样本值(A、B、C、D)。为了高效处理这些像素,目标是将这些样本值打包成一个128位的SIMD寄存器,这样可以像处理单一的像素一样一次性处理所有像素的所有样本。
为了实现这一点,计划将每个样本值依次打包成一个寄存器,每个位置对应一个像素的不同样本值。通过这种方式,能够利用SIMD并行处理的优势,对所有像素的所有样本进行操作,从而提高计算效率。
将样本数据打包到4宽的寄存器中
目标是将样本值打包成紧凑的格式,以便对每个像素进行高效的处理。通过将每个像素的样本值打包到一个128位的SIMD寄存器中,能够实现每个像素所有样本的并行处理。这意味着原先的数组结构将被替换成每个像素的打包寄存器,每个寄存器包含该像素的所有样本。
一旦样本值被打包成128位的寄存器,就可以像处理普通的SIMD值一样对它们进行操作。这种方式可以显著提高处理效率,因为所有操作都可以并行完成,而不需要单独处理每个像素的每个样本。
一些疯狂的emacs宏功夫
通过将样本值转换为打包格式,可以继续对其进行处理,类似于原先的操作。现在,每个样本已经被打包到寄存器中,可以像原始的目标一样进行处理。通过这种方式,处理每个像素样本的效率得到显著提高,且由于并行操作,周期计数持续降低。这种优化使得处理变得更加高效,减少了不必要的计算,并且简化了操作流程。
对于vscode 的多行编辑功能
选中要编辑的内容一顿ctrl+d后修改
按照与目标相同的方式处理Texels
通过将每个纹理样本(如TexelDr,TexelDg,TexelDb,TexelDa等)与之前处理的目标样本进行类似的打包处理,可以大大简化代码。每个样本只需要按照已经准备好的打包格式进行处理,就像之前解包目标一样,这样就能得到一个简洁且高效的代码段。通过删除所有冗余的标量版本代码,可以让每个纹理样本的处理变得更加简洁且高效。最终,所有纹理样本的处理都可以统一简化,减少了复杂的重复代码,并且优化了处理流程。
正常工作的纹理读取,几乎是每像素50周期
经过转换后,处理流程已经优化,成功将代码转换为SIMD格式后,每个像素的处理时间缩短到大约50个周期,这还没有进行其他优化,因此处理效率已经显著提高。现在,大部分操作都已成功简化,只剩下纹理获取部分还未完全优化。虽然可能还有遗漏的地方,但整体效果看起来很不错。
此外,可以尝试进一步简化代码,看看是否可以不再设置某些值。计算了u和v向量后,生成了右侧掩码,并开始思考如何优化这个过程,进一步提高代码的效率。
如果掩码中没有内容怎么办?
如果写掩码(write mask)全为零,意味着没有像素需要写入,这时可以通过在整个处理流程中加入一个条件判断(if语句),避免执行不必要的工作。当确定所有像素都完全在填充区域之外时,就可以跳过这些像素的处理,节省计算资源。
为了实现这一点,可以使用 _mm_movemask_epi8
指令。该指令从每个8位元素的最高有效位生成一个掩码,并将结果存储到标量中。通过对右侧掩码执行 _mm_movemask_epi8
操作,如果某个像素需要填充,掩码中会相应地标记,只有在掩码有值时才执行后续工作。这种方式本可以进一步提高效率,但在实际测试中,并没有显著提升性能。
尽管如此,仍然可以利用 _mm_movemask_epi8
进行标量测试,尤其是在处理大量像素时,可以在需要时跳过不必要的计算。
_mm_movemask_epi8
是一个 SSE(Streaming SIMD Extensions)指令,用于从一个 128 位的整数向量(__m128i
类型)中提取每个字节的最高有效位(MSB)。该指令会将这些最高有效位打包成一个 16 位的整数,返回一个结果,表示每个字节的最高有效位是 1 还是 0。
使用场景
_mm_movemask_epi8
通常用于快速检查一组数据的条件。例如,它可以用来判断多个条件是否满足,或者检查哪些元素符合特定条件。这个操作是高效的,因为它能够在一次 SIMD 操作中处理多个字节。
语法
int _mm_movemask_epi8(__m128i a);
a
是一个__m128i
类型的向量(包含 16 个字节),该函数会提取每个字节的最高有效位,打包为一个 16 位的整数。
返回值
返回一个 int
,该整数的每个二进制位对应于 __m128i
向量中每个字节的最高有效位。如果某个字节的最高有效位为 1,则对应的二进制位为 1,否则为 0。
示例
假设有一个 128 位的 __m128i
向量,包含 16 个字节,调用 _mm_movemask_epi8
后,返回的整数可以通过其二进制表示来查看每个字节的 MSB:
__m128i data = _mm_set_epi8(0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0);
int mask = _mm_movemask_epi8(data);
printf("Mask: %x\n", mask); // 输出: Mask: 8000
在这个例子中,data
向量的第 8 个字节的最高有效位为 1,因此输出的掩码为 0x8000
。
常见应用
- 条件判断:通过检查哪些字节的最高有效位为 1,能够在处理大量数据时快速判断哪些元素符合特定条件。
- 早期退出:可以用于减少不必要的计算,例如,在处理像素、矩阵、向量等时,避免对不需要处理的数据做进一步的运算。
这种操作的优势在于可以同时处理多个字节,在 SIMD 计算中极大提高效率。
你不能一开始就将X坐标对齐到4像素边界,然后使用对齐加载和存储吗?
确实可以通过将像素边界对齐到预定位置来使用对齐加载和存储操作,这样可以提高内存访问效率。然而,暂时并不打算这样做,原因在于当前仍然有一些问题需要解决。具体来说,宽度和高度的计算不完全正确,导致可能会覆盖数据。因此,必须先正确分配帧缓冲区,并确保内存对齐,以便所有操作能够正常工作。
在完成这些基本设置之后,才会考虑对齐操作。尽管对齐可能不会对当前的处理器产生显著影响,但仍然计划进行测试以确保性能的提升。
你是不是很快就要把这段代码移到地面溅射中了?
代码将很快移植到地面平坦区域。实际上,如果启用了地面分割,它应该已经能在当前情况下正常工作。暂时关闭了地面分割,因为它消耗了太多时间。地面分割在被调用时会占用大量资源,虽然它现在确实在处理地面平坦时被使用,但由于它的计算量较大,目前还没有显著加速,可能无法带来明显的性能提升。
目前的代码处理仍然非常低效,尤其是由于正在进行透视分割,导致填充的工作量巨大。为了解决这个问题,可以考虑增加地面缓冲区的大小,从而减少每帧都需要填充缓存的次数,这样可能会改善内存缓存的使用效率,减轻卡顿现象。
然而,在实现地面分割代码之前,仍然需要做很多优化和调整工作。尽管目前没有启用地面分割,但计划在完成相关的优化后尽快启用。
是不是我感觉经过这次SIMD转换后,每像素周期变得更一致了?
经过这次SIMD转换后,每个像素的周期变得更加一致了。虽然不完全确定这种变化是否可能,但目前看起来是这样的。
我错过了几天,不知道单独使用SSE2的CPU内建指令对你的游戏代码有不好影响吗,还是我们正在面向SSE2?例如,是否应该将所有内容封装到平台特定的文件中,这样更容易支持其他平台?
在游戏代码中,直接使用CPS进行优化(尤其是针对SSC平台)并不是一个好主意,因为在代码优化和抽象层设计之前,还需要确保基础功能的实现。通常,第一次编写例程时,会直接针对目标平台进行开发,然后再考虑移植和优化。如果没有特别的需求或明确的目标,就不应该提前为跨平台做抽象。
在本例中,虽然可以通过宏来替换内建操作以支持其他平台,但提前进行抽象并不会带来额外的好处。至于将代码移植到其他平台的可能性,可能性较小。除非是在其他平台上有特别的需求(比如物理计算等),但对于渲染部分,基本上不需要考虑将其移植到SSC2以外的平台,特别是对于像iPhone、Android或树莓派这类平台,软件渲染的需求并不大。SSC2更多是用来理解如何为软件渲染进行代码优化,并深入了解整个渲染流程,但实际操作中,渲染代码不太可能会被移植到其他平台。
对于没有指定吞吐量的内建指令,这是什么意思?
当提到没有明确指定吞吐量的内建函数(intrinsics)时,通常意味着该内建函数的执行速度或处理能力没有在文档中给出具体的数值。这种情况下,无法准确预期它的性能表现,可能需要通过实际的测试或基准测试来了解其在特定硬件上的表现。
不同类型的内建函数(如数学运算、向量操作等)可能在不同的处理器架构上有不同的吞吐量,甚至在相同架构上也可能受到其他因素(如内存访问模式、指令流水线等)的影响。因此,在没有明确吞吐量的情况下,开发人员可能需要进行更多的性能优化和实验,以确保程序能够高效运行。
如果不先加载目标,直接跳过而用掩码写入,例如 _mm_maskmoveu_si128,是否会更快?
有一个问题是,是否跳过加载目标,直接使用带有掩码的操作(如 _mm_maskmoveu_si128
),这样做是否会更快。通常要回答这个问题,需要实际进行尝试。
对于这种掩码移动操作,它会根据掩码条件有选择地将元素从一个数组存储到内存中,未满足掩码的元素不会被存储。而且,掩码移动操作在非对齐的情况下似乎并没有惩罚,意味着即使目标地址没有对齐,它也能正常工作。
为了测试这种方法,首先需要看看掩码移动的实现方式和它的效果。通过实际测试,发现使用掩码移动后的性能实际上比不使用它慢了三倍,尽管原本的预期是掩码移动会更快。
虽然这个结果有些令人困惑,且目前无法解释为什么使用掩码移动会更慢,但在这个简单的案例中,不使用掩码移动显然要更快,甚至是三倍的速度差异。
是否应该在我们所有的程序中都使用SIMD来进行所有数学运算?
在所有程序中对所有数学操作使用四倍宽度(SIMD)操作的问题是复杂的。虽然在某些情况下,SIMD 确实能带来好处,尤其是在能够同时处理四个操作的场景下,但并不是所有情况都适合。
首先,如果程序的计算不能并行执行四个操作,那么使用 SIMD 可能不会带来任何显著的提升。通常,只有在计算可以同时处理多个数据(如四个数据)时,才会从四倍宽度的操作中获得性能提升。但并不是所有代码都能设置为并行执行四个操作,很多时候数据处理无法达到这种程度。
此外,使用 SIMD 编译器内建指令可能会导致代码的复杂性增加,尤其是在需要对数据进行重新格式化、转换等操作时,这样的工作量可能得不偿失。而且,即使使用四倍宽度操作能够提升性能,实际的性能提升也可能有限。因此,最好还是针对那些确定能够从中获益的代码进行优化,而不是盲目地将所有操作都转换为四倍宽度操作。
吞吐量为1的内建指令示例:_mm_cmpgt_ps
关于没有吞吐量(throughput)的指令,问题的讨论主要集中在 `_mm_cmpgt_ps 指令上。分析过程中,虽然没有立即找到关于吞吐量的明确说明,但通过查看指令的文档,发现该指令可能与浮点数比较操作相关,具体为“compare pack single precision floating point values”。
指令文档显示,吞吐量的设置为2,但这个信息并不显著,因此无法确认是否确实是吞吐量为2。为了更准确地理解,可能需要深入研究更多的技术文档或者与更熟悉该指令的专家进行交流。
在分析过程中,也注意到了一些关于指令实现的细节,例如一些指令可能通过软件模拟来处理大于或大于等于的比较操作,或者采用反向关系进行实现。因此,吞吐量的问题可能与这些实现方式的差异有关。
grumpygiant Agner Fog说吞吐量是1
有观点认为吞吐量是1,这个说法似乎也合理。但也存在不确定性,因为在某些文献中提到的吞吐量值为“-”,这让人感到困惑。甚至有地方将“PS”列为指令,这也让人更加不解。若能得到Intel方面的解释,可能有助于更清晰地了解情况。最终,大家的讨论似乎到了一个阶段,无法进一步推进。
应该是以前文档没更新
[什么是延迟与吞吐量?]
延迟是指从开始到结束执行某个指令所需的总时间。而吞吐量则是指在没有中断的情况下,能够在一定时间内完成多少个指令执行,吞吐量通常因为指令之间的重叠而提高。
组织的最终目标通常是希望将延迟降低到一个特定的阈值以下,从而提高整体效率。
优化的最终目标是什么,是要达到某个阈值,还是仅仅完成转换?
目前的优化目标是将所有内容转换完成,虽然还没有做到这一点,但预计很快就能实现。我们的计划是完成转换后,估算每个像素所需的周期数。得出这个数字后,接下来会尝试尽可能接近理论值,看看能达到什么程度。大家的进展都还不错,讨论也到了一个阶段。
SIMD对变量的大小有影响吗,比如32位与64位的区别?
变量的大小确实很重要,原因在于它决定了每次操作中能处理多少数据。例如,在不同的架构中,寄存器的大小不同:SCC寄存器是128位,AVX是256位,而AVX-512是512位。这意味着每个寄存器可以存储的数据量是由寄存器的位数与数据类型的大小决定的。
如果使用32位数据,每次操作中可以处理多个数据项:SCC可以处理4个,AVX可以处理8个,AVX-512可以处理16个。而如果使用64位数据,每次操作只能处理更少的项:SCC处理2个,AVX处理4个,AVX-512处理8个。
另外,某些操作在64位下可能比32位更耗时。例如,64位的除法操作可能比32位更慢。因此,选择合适的数据类型非常重要,通常建议使用尽可能小的数据类型,这样可以减少操作的成本并提高效率。
SSE代码有没有做任何缓存预取或提示相关的操作?
目前还没有进行缓存预取或提示等优化工作。实际上,直到现在我们还没有涉及到内存部分,所做的工作仅仅是对例程进行翻译。我们还没有进行广播、对齐等优化,也没有进行非临时存储操作(尽管在这种情况下,可能不需要使用非临时存储)。
目前的工作主要集中在代码转换阶段,对于优化的影响还不确定。缓存预取可能会有所帮助,但需要通过测试才能确定效果。现在的执行速度相对较快,每个像素的时间大约为50个周期,总的例程执行大约需要200个周期。由于200个周期的循环没有太多时间可以用来进行内存等待,因此很难预见缓存预取能带来显著提升。但如果幸运的话,可能在等待期间有些操作可以进行缓存预取,从而节省部分时间。
我们能不能使用半精度浮点数而不是单精度浮点数,因为只有255个离散值不需要那么高的精度?
由于当前的硬件架构(例如SCC)不支持半精度浮点数(half float),所以不能直接使用半精度浮点数进行运算。SCC不支持半精度浮点数的乘法等操作,因此不能使用它们。虽然可以尝试自己实现半精度浮点数的支持,但这可能比直接使用16位定点数更加复杂且耗时。
如果要实现类似功能,16位定点数可能是一个更合适的选择,因为它比32位浮点数更容易实现,而且16位定点数可能在性能上更有优势,尽管这仍然难以确定。由于当前的例程中数学运算的数量不多,可能通过巧妙的设计,利用16位定点数能够提升效率,从而能够同时处理更多像素。
如果能够将操作保持在相似的数量级,同时使用16位定点数,这将大大提高处理能力,每次可以处理更多像素,这是一个非常值得探索的方向。
法线贴图代码会转换为SIMD吗?
计划是最终将法线贴图代码转换为SIMD,但目前还需要明确如何具体实现。我们会在搞清楚其工作原理之后,着手进行转换。