在数据段中执行x86指令会有性能惩罚吗?

5
我有一个简单的程序,首先将一些本地x86指令写入已声明的缓冲区,然后设置函数指针到这个缓冲区并进行调用。然而,当该缓冲区分配在堆栈上时(而不是在堆中或全局数据区中),我发现存在严重的性能惩罚。我验证了数据缓冲区中指令序列的起始位置位于16字节边界上(我假设这是CPU所需的(或希望))。我不知道为什么执行指令的位置会影响程序,但在下面的程序中,“GOOD”在我的双核工作站上执行4秒,“BAD”需要6分钟左右。是否存在某种对齐/指令缓存/预测问题?我的VTune评估许可证已经结束,所以我甚至无法对此进行分析 :( 谢谢。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>

typedef int (*funcPtrType)(int, int);

int foo(int a, int b) { return a + b; }

void main()
{
  // Instructions in buf are identical to what the compiler generated for "foo".
  char buf[201] = {0x55,
                   0x8b, 0xec,
                   0x8b, 0x45, 0x08,
                   0x03, 0x45, 0x0c,
                   0x5D,
                   0xc3
                  };

  int i;

  funcPtrType ptr;

#ifdef GOOD
  char* heapBuf = (char*)malloc(200);
  printf("Addr of heap buf: %x\n", &heapBuf[0]);
  memcpy(heapBuf, buf, 200);
  ptr = (funcPtrType)(&heapBuf[0]);
#else // BAD
  printf("Addr of local buf: %x\n", &buf[0]);
  ptr = (funcPtrType)(&buf[0]);
#endif

  for (i=0; i < 1000000000; i++)
    ptr(1,2);
}

运行结果如下:

$ cl -DGOOD ne3.cpp
Microsoft (R) 32位 C/C++ 优化编译器版本 11.00.7022 for 80x86
版权所有 (C) Microsoft Corp 1984-1997。保留所有权利。

ne3.cpp
Microsoft (R) 32 位增量链接程序版本 5.10.7303
版权所有 (C) Microsoft Corp 1992-1997。保留所有权利。

/out:ne3.exe
ne3.obj
$ time ./ne3
堆缓冲区地址:410eb0

真实时间 0m 4.33s
用户时间 0m 4.31s
系统时间 0m 0.01s
$
$
$ cl ne3.cpp
Microsoft (R) 32位 C/C++ 优化编译器版本 11.00.7022 for 80x86
版权所有 (C) Microsoft Corp 1984-1997。保留所有权利。

ne3.cpp
Microsoft (R) 32 位增量链接程序版本 5.10.7303
版权所有 (C) Microsoft Corp 1992-1997。保留所有权利。

/out:ne3.exe
ne3.obj
$ time ./ne3
本地缓冲区地址:12feb0

真实时间 6m41.19s
用户时间 6m40.46s
系统时间 0m 0.03s
$

谢谢。

  • Shasank

顺便检查一下生成的代码。虽然看起来不太可能,但谁知道编译器是否在某个路径上进行了优化……并逐步通过几个循环逐步操作。 “这是唯一确定的方法。” - DigitalRoss
2个回答

3

安全的堆栈保护?

猜测你可能遇到了基于MMU的堆栈保护方案。许多安全漏洞都是基于有意的缓冲区溢出,这会将可执行代码注入到堆栈中。对抗这些漏洞的一种方法是使用非可执行堆栈。这将导致陷入操作系统,我想在那里操作系统或一些病毒软件可能会做一些事情。

负指令高速缓存一致性交互?

另一个可能性是同时使用代码和数据访问相邻地址会破坏CPU缓存策略。我相信x86实现了一个基本自动的代码/数据一致性模型,这很可能会导致任何内存写入时大量相邻的缓存指令无效。你不能通过更改程序来避免使用堆栈(显然可以移动动态代码)来解决这个问题,因为堆栈总是由机器代码写入的,例如每当推送过程调用的参数或返回地址时。

现在的CPU相对于DRAM甚至外层缓存环来说都非常快,所以任何破坏内部缓存环的东西都会非常严重,而其实现可能涉及CPU实现内部的微小陷阱,然后在硬件中进行“循环”以使缓存失效。这不是英特尔或AMD担心速度的问题,因为对于大多数程序来说,它永远不会发生,当它发生时,通常只会在加载程序后发生一次。


真是个好思路,伙计们。所以,为了防止这种情况发生(注意,根据你们的说法,即使指令存储在堆中,这也可能发生),指令应该位于一个数据缓冲区的中间,该缓冲区在两个方向上都有“cache-line-size”字节的间隔,这样对相邻数据的写入就不会污染包含我的指令的缓存行了?再次感谢你们迅速的回应。- Shasank - Shasank
我不能确定。在8086上运行的软件不需要担心缓存,因此出于向后兼容性的考虑,英特尔决定在硬件中进行大量的缓存管理,这是很复杂的。我相信有一些专家对此非常了解,但对于像你和我这样的人来说:测试代码,看看它的表现如何! - Artelius
好的,请查看我在你问题下面的评论..你肯定想要单步调试堆和栈版本,以确保你认为发生的事情确实发生了。仅仅将写操作与代码隔开一个缓存块可能不够好,一致性粒度可能是页面大小或设计者想要的任何东西。一个有趣的想法是在栈上分配一些相当大的东西..几个页面。看看距离4k字节或40k字节是否有帮助... - DigitalRoss

2
我的猜测是,由于你的堆栈上也有变量i,当你在for循环中改变i时,你会破坏代码所在的同一缓存行。将代码放在缓冲区的中间某个位置(并可能扩大缓冲区),以使其与其他堆栈变量分离开来。
还要注意,堆栈上执行指令通常是安全漏洞(如缓冲区溢出)被利用的标志。
因此,操作系统通常配置为禁止这种行为。病毒扫描器也可能采取措施防范此类行为。也许你的程序每次尝试访问该堆栈页面时都会通过安全检查(虽然在这种情况下我预计sys时间字段会更大)。
如果你想“正式”地使一个内存页可执行,你应该考虑使用VirtualProtect()

谢谢Artelius。我在DigitalRoss的回答下添加了一条评论。- Shasank - Shasank
如果您想要检查这个问题,您可以将大量数据放置在bufi之间(在声明bufi的位置之间使用类似于char junk[10000]的东西),以确保它们不在同一个高速缓存行中。这样,您就不会遇到自修改代码,这对性能来说实在是非常糟糕的。 - Nathan Fellman
要注意编译器不会将其优化掉 :P - Artelius

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接