从 C++ 到 MMX 的高性能计算






4.81/5 (50投票s)
2003 年 7 月 31 日
9分钟阅读

192995

2019
通过使用硬件加速将您的应用程序性能提升到最佳。
引言
本文首先讨论了一些程序员在编写计算密集型图像处理应用程序时常见的算法。然后解释了如何从原生 C++ 代码改进现有代码,到使用 MMX 加速。MMX 指的是英特尔处理器中存在的一项功能,它可以通过并行执行多个数据的整数指令来提升多媒体软件。尽管处理器支持加速,但这并不意味着您的应用程序可以自动利用它。因此,您的应用程序必须手动编写才能利用它。
一旦用户能够利用 MMX 加速,他/她可以通过利用 SSE(流式 SIMD 扩展)进一步提升一步,SSE 具有并行浮点计算功能,而 SSE2 是浮点和整数计算的升级。您必须注意,并非所有硬件都支持所描述的加速。为了缩小范围,我已将此解释分离到另一篇文章中(即将发布)。
现在让我们来看看支持每项功能的 Intel 处理器列表。
- Pentium MMX, Pro, Pentium 2 - MMX
- Pentium 3, Xeon - MMX, SSE
- Pentium 4 及更高版本 - MMX, SSE, SSE2 /HHT
本文内容主要适用于使用 Intel 处理器硬件的开发人员。对于 AMD 用户,MMX 中的一些汇编指令是兼容的!在此之前,需要一些基本的汇编语言知识。我还将假设您对 MFC 文档/视图架构有所了解。
我之所以写这篇文章,是因为我认为有必要强调一些高性能计算知识。特别是对于那些正在开发时间关键型应用程序的开发人员。图像处理是一个很好的演示示例。
在这个可下载的演示中,您至少需要一个带 MMX 的 Pentium 处理器才能看到结果。
背景
在图像处理/3D 图形等领域,性能至关重要。算法花费的时间越少越好,因为可以挤入更多的功能。
在我的示例中,我生成了两张大小为 1024 x 1024 的图像。每张图像大小为一兆字节。为了简单起见,两者都是 8 位灰度图像。我创建合成图像是因为对其他类/对象/DLL 的依赖性最小。图像 1 是一张黑色背景上带有白色垂直线的图像。图像 2 具有灰色的渐变填充。
这两张图像将叠加在一起,创建第三张图像。最终产品将是一张带有渐变填充和垂直线的图像。
我可以在演示中轻松创建性能最佳的算法,但我更想向您展示不同代码之间的区别。我开发了 4 种算法来演示图像叠加。其中 2 种使用 C++,2 种使用汇编语言。对于每种算法,我都设置了一个高分辨率计时器来记录耗时。最终图像将显示在屏幕上(主窗口)。两个源图像显示在右侧的两个迷你窗口中。然后我将在文章后面的部分向您展示不同类型 PC 的结果。
编写算法
为了获得一些基本理解,让我们来看看这段 C++ 代码。
void CMMXDemoDoc::OnRunbasic() { int x=0, y=0, i=0, iGray=0; // Start timing m_el.Begin(); // Add 2 image using direct memory access // Assume both image are same size for (y=0; y<m_iHeight; y++) { for (x=0; x<m_iWidth; x++) { // calc index i = x + y*m_iWidth; // add 2 pixels iGray = m_pImg1[i] + m_pImg2[i]; // keep saturation value if (iGray > 255) iGray = 255; // save to destination image m_pImg[i] = iGray; } } // End Timing m_el.End(); // Force redraw UpdateAllViews(0); }
m_pImg
、m_pImg1
、m_pImg2
都是无符号 char
的一维数组。所有图像大小相同。
有两个 for 循环,外层循环遍历图像中的每一行。内层循环遍历行中的每个像素。对于每个像素,我们需要计算数组中的索引,将图像 1 和图像 2 的像素相加,检查是否饱和(>255),然后将其存储到目标图像中。
这个算法有什么问题?
- 加法运算中使用了大量的算术运算,还有一个乘法运算。乘法比加法慢一点。尝试将加法运算符更改为乘法,并在演示中查看差异。
- 数组索引访问随机内存数据。这会导致一些减速,因为处理器必须将数组索引转换为内存中的实际地址。
- 两级
for
循环导致处理器执行栈指令。因为ecx
(计数器寄存器)经常被压栈和弹栈。这会消耗一些处理器周期。
使用指针算术进行优化
void CMMXDemoDoc::OnRunopt() { // Begin timing m_el.Begin(); // Precalculate the pointers BYTE *pSrc = m_pImg2; BYTE *pSrcEnd = m_pImg2 + m_iSize; BYTE *pDest = m_pImg; BYTE *pDestEnd = m_pImg + m_iSize; int iGray; // Copy from img1 to tmp memcpy(m_pImg, m_pImg1, m_iSize); // loop each pixel and Add while (pSrc < pSrcEnd) { iGray = *pDest + *pSrc; if (iGray > 255) iGray=255; *pDest = iGray; pSrc++; pDest++; } // End Timing m_el.End(); // Force redraw UpdateAllViews(0); }
注意,我将数据复制到了目标图像。由于 memcpy()
是 VC 中一个优化的函数,我可以省去一个指针加法。(memcpy()
仍然需要时间)
上面的代码使用指针访问数据。没有更多可优化的了,因为指针已经存储了数据内存地址。指针在计算完像素后,从一个元素顺序地移动到下一个元素。源指针和目标指针都加 1。
转换为汇编
下面的代码展示了如何用汇编编写算法。函数的执行时间将更加恒定。
int AsmAdd(BYTE *d, BYTE *s, int w, int h)
{
int iCount = w * h;
// we assume all data in the register is not used by others
__asm
{
// Assign pointers to register
mov esi, [s] ; put src addr to esi reg
mov edi, [d] ; put dest addr to edi reg
mov ecx, [iCount] ; put count to ecx reg
codeloop:
mov al, [edi] ; mov a byte of src data to low
; word of eax register
add al, [esi] ; Add 8 bit from dest ptr to al
jc nosave ; jump if carry flag on
mov [edi], al
nosave:
inc esi
inc edi
dec ecx ; decrement count by 1
jnz
codeloop ; jump to codeloop if not 0
}
return 1;
}
利用 MMX
我们的 80x86/Pentium 处理器有 8 个通用寄存器。它们是 eax
、ebx
、ecx
、esi
、edi
、esp
。这些寄存器用于保存逻辑和算术运算的操作数、地址计算的操作数和内存指针。
MMX 技术执行环境
那么,这 8 个额外的 MMX 寄存器能做什么呢?它们不会运行得更快,因为 CPU 的时钟速度固定在特定时间内可以执行的操作量。是的,它们会!如何做到?通过对预加载的字节流执行 MMX 并行指令!
SIMD 执行模型
MMX 技术引入的数据类型
并行执行可以为每条指令节省一些时钟周期。您可以将 8 个 8 位、4 个 16 位、2 个 32 位数据加载到 64 位 MMX 寄存器中。要加载或存储,您可以使用指令 包含 "abcdefgh" 的字符串 通过使用 MMX,只执行了 4 条指令,并且一次处理 8 字节数据。一个没有 MMX 的典型汇编算法需要循环 8 次,每次迭代执行一个移动指令和一个加法。总共至少 16 条指令。现在速度提高了多少?我敢打赌您至少会获得 200% 的时间增益。 在 Visual C++ 6.0 中,利用 MMX 最简单的方法是使用内联汇编器编写汇编代码。上面的代码说明了这一点。MMX 指令集列表可在 MSDN 网站上找到。内联函数 (wrapup function) 在 VC++.NET 中可用。 对于此演示,您至少应该拥有一个带 MMX 及更高版本的 Pentium 处理器,以产生所需的结果。 构建和执行演示非常简单。源代码与一些内联汇编指令(注释非常详细)一起提供。还有一些帮助类和函数。 各种PC平台在1024 x 1024图像上的测试结果。 请注意,速度受多种因素影响。这包括处理器时钟速度、一级和二级缓存内存大小、系统总线速度、DRAM 速度。快速的系统总线和 DRAM 将导致更快的数据传输到核心寄存器集。仅供参考,数据从 DRAM 获取到缓存内存,然后传输到处理器寄存器集。 我最近阅读了 Alex Farber 的《SSE 编程入门》文章,发现他也发布了一个类似的主题。因此,我修改了内容并将其分为两部分。第一部分侧重于 C++ 和 MMX,第二部分描述了使用外部编译器(Netwide 汇编器)实现 SSE/SSE2。Alex 发布的是使用内在函数的 .NET 版本的 SSE 实现。这为我下一篇文章发布 VC 6.0 版本留下了空间。 MMX 开发面向中级程序员。需要一些学习曲线。您必须掌握汇编、Intel 处理器架构、MMX 指令集、汇编优化及其术语。希望本文对您有所帮助! 根据测试结果,我发现我的笔记本电脑,东芝 satellite 2410,如果使用 MMX 指令,似乎是性能最好的。奇怪的是,戴尔 Dimension 台式机配备 2.2 GHZ 处理器,竟然输给了 1.7 GHZ 克隆系统(技嘉主板)。我已在几台戴尔系统上进行了测试并再次确认了这一点。 最后,如果您喜欢这篇文章,请给我投一票哦! 您可以参考以下一些有用的网站。MOV x
,其中 unsigned char mask[8] = {0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01 }
// txt = "abcdefgh"
unsigned char txt[8] = {0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48 }
__asm {
movq mm0, txt ; mm0 = ( 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 )
movq mm1, mask ; mm1 = ( 01 | 01 | 01 | 01 | 01 | 01 | 01 | 01 )
paddusb mm0, mm1 ; mm0 = ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 )
movq txt, mm0 ; mov back the data in register to memory.
emms ; switch back to FPU state.
}
txt[]
现在将变为 "bcdefghi"。执行演示
<CElapsed>
- 使用 QueryPerformanceCounter()
提供高分辨率计时。<CMiniView>
- 为 CDocument
类提供额外的视图。<CreateNewViews()>
展示了如何为典型文档创建额外的视图。<CheckSSEAvail()>
检查您的处理器中是否存在 SSE。您还可以检查处理器中的其他功能,如 MMX 和 HHT。测试结果
数组索引
指针算术
MMX 指令
克隆 Pentium 2, 400 MHZ
46 毫秒
35 毫秒
16 毫秒
克隆 Pentium 3(eb) 600 MHZ
30.6 毫秒
24 毫秒
11 毫秒
宏碁 TravMate 332T, 移动版 Pentium 2 300 MHZ
71 毫秒
66 毫秒
58 毫秒
宏碁 TravMate 270, 移动版 Pentium 4 1.7 GHZ
34 毫秒
20 毫秒
5.9 毫秒
东芝 Sat 2410, 移动版 Pentium 4 1.8 GHZ
22 毫秒
14 毫秒
4.5 毫秒
克隆,Pentium 4 1.7 GHZ
30 毫秒
18 毫秒
5.5 毫秒
戴尔 Dimen 2400, Pentium 4 2.2 GHZ
18.5 毫秒
12.5 毫秒
6 毫秒 关注点
参考