当使用 Numpy 对象从纯 C 切换到 C 时,看似无意义的运行时间会增加

Seemingly nonsensical runtime increases when switching from pure C to C with Numpy objects

简介

我正在尝试在 C 中的一维数组上实现一些数字运算(以后:独立) 并作为用 C 编写的 Numpy 模块(以后:模块)同时。由于我需要对数组做的只是比较选定的元素,我可以使用抽象层来访问数组,因此我可以对 standalone模块.

现在,我预计 模块 会稍微慢一些,因为使用 descr->f->compare 比较未知类型的 Numpy 数组的元素需要额外的函数调用和类似的,因此比已知类型的 C 数组的类似操作成本更高。但是,在查看分析器 (Valgrind) 的输出时,我发现 module 中与 Python 方法没有明显联系的行的运行时间有所增加。如果可能的话,我想了解并避免这种情况。

最小示例

不幸的是,我的最小示例非常冗长。请注意,由于示例减少,Python 变体不再是真正的模块。

# include <stdlib.h>
# include <stdio.h>

# ifdef PYTHON
    # include <Python.h>
    # include <numpy/arrayobject.h>

    // Define Array creation and access routines for Python.

    typedef PyArrayObject * Array;

    static inline char diff_sign (Array T, int i, int j)
    {
        return T->descr->f->compare ( PyArray_GETPTR1(T,i), PyArray_GETPTR1(T,j), T );
    }

    Array create_array (int n)
    {
        npy_intp dims[1] = {n};
        Array T = (Array) PyArray_SimpleNew (1, dims, NPY_DOUBLE);
        for (int i=0; i<n; i++)
            {* (double *) PyArray_GETPTR1(T,i) = i;} // Line A
        return T;
    }
#endif

# ifdef STANDALONE
    // Define Array creation and access routines for standalone C.

    typedef double * Array;

    static inline char diff_sign (Array T, int i, int j)
    {
        return (T[i]>T[j]) - (T[i]<T[j]);
    }

    Array create_array (int n)
    {
        Array T = malloc (n*sizeof(double));
        for (int i=0; i<n; i++) {T[i] = i;} // Line B
        return T;
    }
# endif

int main()
{
    # ifdef PYTHON
        Py_Initialize();
        import_array();
    # endif

    // avoids that the compiler knows the values of certain variables at runtime.
    int volatile blur = 0;

    int n = 1000;
    Array T = create_array (n);

    # ifdef PYTHON
        for (int i=0; i<n; i++)
            {* (double *) PyArray_GETPTR1(T,i) = i;} // Line C
    # endif
    # ifdef STANDALONE
        for (int i=0; i<n; i++) {T[i] = i;} // Line D
    #endif

    int a = 333 + blur;
    int b = 444 + blur;
    int c = 555 + blur;
    int e = 666 + blur;
    int f = 777 + blur;
    int g =   1 + blur;
    int h =   n + blur;

    // Line E                           standa.  module
    for (int i=h; i>0; i--)             // 4000  8998
    {
        int d = c;
        do c = (c+a)%b;                 // 4000  5000
        while (c>n-1);                  // 2000  2000

        if (c==e) f*=2;                 // 3000  3000
        if ( diff_sign(T,c,d)==g ) f++; // 5000  5000
    }

    printf("%i\n", f);
}

我用以下两个命令编译了它:

gcc source.c -o standalone -O3 -g -std=c11 -D STANDALONE
gcc source.c -o module -O3 -g -std=c11 -D PYTHON -lpython2.7 -I/usr/include/python2.7

更改为 -O2 不会更改以下内容;将编译器更改为 Clang 确实会改变最小示例,但不会改变我实际代码中的现象。

分析结果

有趣的事情发生在 E 行之后,我给出了分析器报告的在这些行中花费的总运行时间作为源代码中的注释:尽管与我是否编译为 standalone 没有直接关系module, 这些行的运行时间差异很大。特别是,在我的实际应用中,模块中那些行所花费的额外时间占模块总运行时间的四分之一。

更奇怪的是,如果我删除 C 行(和 D)——这在示例中是多余的,因为数组的值已经在 A 行(和 B)中设置(相同的值)——,循环头中花费的运行时间从 8998 减少到 6002(其他报告的运行时间没有变化)。如果我将 int n = 1000; 更改为 int n = 1000 + blur;,即如果我使 n 未知编译时间,也会发生同样的事情。

这对我来说意义不大,因为它对运行时有相关影响,所以我想避免它。

问题

解释 callgrind 配置文件时必须非常小心。 Callgrind 为您提供指令提取计数,即指令数。这与现代 cpus 的实际性能无关,因为指令可能具有不同的延迟和吞吐量,并且可以由具有适当能力的 cpus 重新排序。

此外,您还在此处将指令提取与调试符号关联的行相匹配。那些不完全对应,例如模块代码将寄存器副本和 nop 指令(与以下划分相比,在 运行 时间方面基本上是免费的)与源代码的循环行相关联,而独立模块将其与上面的行相关联. 在 kcachegrind 中使用 --dump-instr=yes 时,您可以在机器代码选项卡中看到它。 这与两种变体可用的不同寄存器有关,因为不同数量的函数调用意味着将内容溢出到堆栈上。

让我们看一下模循环,看看是否存在显着的运行时间差异:

模块:

  400b58:       42 8d 04 3b             lea    (%rbx,%r15,1),%eax
  400b5c:       99                      cltd   
  400b5d:       41 f7 fe                idiv   %r14d
  400b60:       81 fa e7 03 00 00       cmp    [=10=]x3e7,%edx
  400b66:       89 d3                   mov    %edx,%ebx
  400b68:       7f ee                   jg     400b58 <main+0x1b8>

独立的:

  4005f0:       8d 04 32                lea    (%rdx,%rsi,1),%eax
  4005f3:       99                      cltd   
  4005f4:       f7 f9                   idiv   %ecx
  4005f6:       81 fa e7 03 00 00       cmp    [=11=]x3e7,%edx
  4005fc:       7f f2                   jg     4005f0 <main+0x140>

不同之处在于一个寄存器到一个寄存器副本 mov %edx,%ebx(可能再次由于较早的函数调用导致不同的寄存器压力造成)这是 cpu 中可用的最便宜的操作之一,大概在 1 -2 个周期和良好的吞吐量,因此它对实际的墙时间应该没有可测量的影响。 idiv 指令是最昂贵的部分,它应该在 20 个周期左右,吞吐量很低。所以这里的取指令数是非常误导的。

进行此类详细分析的更好工具是像 perf record/report 这样的采样分析器。当您 运行 足够长时,您将能够挑出花费大量时间的指令,尽管实际上高样本计数也不会直接与慢速指令匹配,因为 cpu 可能与较慢的指令并行执行较晚的独立指令。