使用C/C++在同一个可执行文件中实现不同的优化(普通、SSE、AVX)

7
我正在为我的3D计算开发优化,现在有以下版本:
- 一个使用标准C语言库的“普通”版本, - 一个使用预处理器#define USE_SSE编译的SSE优化版本, - 一个使用预处理器#define USE_AVX编译的AVX优化版本。
是否可以在不编译不同可执行文件的情况下切换这3个版本(例如,拥有不同的库文件并动态加载“正确”的库文件,不知道是否应该使用inline函数来实现)?我也会考虑在软件中进行此类切换时的性能。

1
不提及平台?有些平台即使你知道那些指令永远不会被调用,也会拒绝运行使用AVX的代码。有些平台具有ifunc,在运行时选择多个实现之间进行选择。有些平台会在依赖于能力的路径中查找共享库。 - Marc Glisse
3个回答

7

这个问题有几种解决方案。

其中一种基于C++,你需要创建多个类 - 通常情况下,你会实现一个接口类,并使用工厂函数来给你正确类的对象。

例如:

class Matrix
{
   virtual void Multiply(Matrix &result, Matrix& a, Matrix &b) = 0;
   ... 
};

class MatrixPlain : public Matrix
{
   void Multiply(Matrix &result, Matrix& a, Matrix &b);

};


void MatrixPlain::Multiply(...)
{
   ... implementation goes here...
}

class MatrixSSE: public Matrix
{
   void Multiply(Matrix &result, Matrix& a, Matrix &b);
}

void MatrixSSE::Multiply(...)
{
   ... implementation goes here...
}

... same thing for AVX... 

Matrix* factory()
{
    switch(type_of_math)
    {
       case PlainMath: 
          return new MatrixPlain;

       case SSEMath:
          return new MatrixSSE;

       case AVXMath:
          return new MatrixAVX;

       default:
          cerr << "Error, unknown type of math..." << endl;
          return NULL;
    }
}

或者,如上所建议的,您可以使用具有共同接口的共享库,并动态加载正确的库。

当然,如果您将矩阵基类实现为您的“普通”类,则可以进行逐步细化并仅实现您实际发现有益的部分,并依赖于基类来实现性能不高的函数。

编辑: 您谈论内联,我认为如果是这种情况,您正在查看错误的函数级别。您需要相当大的函数,在相当多的数据上执行某些操作。否则,您所有的努力都将花费在将数据准备成正确格式上,然后执行少量计算指令,然后将数据放回到内存中。

我还会考虑如何存储数据。您是否存储了具有X、Y、Z、W数组集的集合,还是将大量X、大量Y、大量Z和大量W分别存储在单独的数组中[假设我们正在进行三维计算]?根据您的计算方式,您可能会发现采用一种或另一种方式将为您带来最大的好处。

我曾经做过一些SSE和3DNow!优化,几年前,“诀窍”通常更多地与如何存储数据有关,以便您可以轻松地一次性获取正确类型的数据“捆绑”。如果您将数据存储在错误的方式中,那么您将浪费大量时间“扫描数据”(将数据从一种存储方式移动到另一种)。


这种方法的问题在于,您无法为不同的架构编译和优化不同的函数。如果所有内容都使用 -march=i7 编译,即使是 C 版本也只能在 i7 上运行;如果使用 -march=i686 进行编译,则可以在过去 15 年内构建的每台机器上运行,但某些内部函数(如 SSE/AVX)将不可用,并且优化器仅使用 SSE/AVX 版本中可用指令的子集。 - Gunther Piez
因此,请在单独的源文件中构建代码。虽然我发现,如果您真的想以非常好的方式利用SSE / AVX指令,您将需要使用内联汇编。编译器通常不会做得那么聪明。 - Mats Petersson

6
一种方法是实现三个符合相同接口的库。使用动态库,您只需交换库文件,可执行文件将使用找到的任何内容。例如在Windows上,您可以编译三个DLL:
  • PlainImpl.dll
  • SSEImpl.dll
  • AVXImpl.dll
然后使可执行文件链接到Impl.dll。现在只需将三个特定的DLL之一放入与.exe相同的目录中,将其重命名为Impl.dll,它将使用该版本。相同的原则基本上应适用于类UNIX操作系统。
下一步是以编程方式加载库,这可能是最灵活的方法,但它是特定于操作系统的,并需要一些额外的工作(如打开库,获取函数指针等)。
编辑:当然,您也可以根据某些参数/配置文件设置等,在运行时实现该函数三次并选择其中一个,如其他答案中所述。

在Linux上,通过Implib.so工具,可以自动化加载库和获取函数指针的样板工作。 - yugr

0

当然可以。

最好的方法是编写完成整个任务的函数,并在运行时从中选择。这样做是可行的,但并不是最优解:

typedef enum
{
    calc_type_invalid = 0,
    calc_type_plain,
    calc_type_sse,
    calc_type_avx,
    calc_type_max // not a valid value
} calc_type;

void do_my_calculation(float const *input, float *output, size_t len, calc_type ct)
{
    float f;
    size_t i;

    for (i = 0; i < len; ++i)
    {
        switch (ct)
        {
            case calc_type_plain:
                // plain calculation here
                break;
            case calc_type_sse:
                // SSE calculation here
                break;
            case calc_type_avx:
                // AVX calculation here
                break;
            default:
                fprintf(stderr, "internal error, unexpected calc_type %d", ct);
                exit(1);
                break
        }
    }
}

在每次循环中,代码都会执行一个switch语句,这只是额外的开销。理论上,一个非常聪明的编译器可以为您修复它,但最好自己修复。

相反,编写三个单独的函数,一个用于普通,一个用于SSE,一个用于AVX。然后在运行时决定要运行哪一个。

对于奖励分数,在“调试”构建中,使用SSE和普通计算结果,并断言结果足够接近以获得信心。编写普通版本,不是为了速度,而是为了正确性;然后使用其结果验证您的优化版本是否得到正确答案。

传奇人物John Carmack推荐后一种方法;他称之为“并行实现”。阅读他的文章了解更多信息。

所以我建议您先编写普通版本。然后,回过头来使用SSE或AVX加速重新编写应用程序的部分,并确保加速版本给出正确的答案。(有时,普通版本可能存在加速版本没有的错误。拥有两个版本并进行比较可以帮助发现任何一个版本中的错误。)


4
如果您正在考虑优化,我怀疑您不希望在循环内执行此类检查... - Alex Chamberlain
是的,你最好将循环放在每个switch分支调用的函数内部。 - lethal-guitar
2
甚至更好的是,有一个接口类,使用这3种优化进行扩展和实现...多态开关。 - Alex Chamberlain

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