指针上的指针 - 性能损失的原因

pointers on pointers - reason for performance penalty

我回答了 ,并注意到我认为编译器的一种奇怪行为。

我首先编写了这个程序(作为我在那里回答的一部分):

class Vector {
private:
  double** ptr;


public:

  Vector(double** _ptr): ptr(_ptr) {}

  inline double& operator[](const int iIndex) const {
    return *ptr[iIndex];
  }
};

extern "C" int test(const double a);

int main() {
    double a[2] = { 1.0, 2.0 };
    Vector va((double**) &a);

    double a1 = va[0];
    test(a1);

    double a2 = va[0];
    test(a2);
}

编译时生成两条加载指令:

clang -O3 -S -emit-llvm main.cpp -o main.ll

这可以在 llvm-IR 中看到(也可以在程序集中看到):

    define i32 @main() #0 {
    entry:
      %a.sroa.0.0.copyload = load double*, double** bitcast ([2 x double]* @_ZZ4mainE1a to double**), align 16
      %0 = load double, double* %a.sroa.0.0.copyload, align 8, !tbaa !2
      %call1 = tail call i32 @test(double %0)
      %1 = load double, double* %a.sroa.0.0.copyload, align 8, !tbaa !2
      %call3 = tail call i32 @test(double %1)
      ret i32 0
    }

我希望只有一个加载指令,因为没有调用对内存有副作用的函数,而且我没有 link 这个对象有副作用。实际上,在阅读程序时,我只希望调用两次

test(1.0);

因为我的数组在内存中是常量,所有内容都可以正确内联。

为了保险起见,我将双指针替换为简单指针:

class Vector {
private:
  double* ptr;

public:
  Vector(double* _ptr): ptr(_ptr) {}

  inline double& operator[](const int iIndex) const {
    return ptr[iIndex];
  }
};

extern "C" int test(const double a);

int main() {
    double a[2] = { 1.0, 2.0 };
    Vector va(a);

    double a1 = va[0];
    test(a1);

    double a2 = va[0];
    test(a2);
}

用同一行编译,我得到了预期的结果:

define i32 @main() #0 {
entry:
  %call1 = tail call i32 @test(double 1.000000e+00)
  %call3 = tail call i32 @test(double 1.000000e+00)
  ret i32 0
}

看起来优化得更好:)

因此我的问题是:

什么原因阻止编译器对第一个代码示例执行相同的内联?那是双指针吗?

在您的第二个代码中,编译器尝试访问:

va.ptr[0]

编译器可以推导出va.ptr&a[0]相同,并且由于amain的非易失性局部变量,它也知道您不修改 a[0]test 没有 "access" 到 a),因此它可以将您的代码简化为对具有常量值的 test 的简单调用.

然而,在您的第一个代码中,编译器知道它正在尝试访问:

*(((double**)&a)[index])

虽然 ((double**)&a)[index] 可能由编译器推断(这是一个依赖于编译器的值),但您将获得一个指向地址的指针,例如 0x3ff0000000000000 (在我的计算机上)。上面的表达式然后试图做的是访问存储在这个地址的值,但是这个值可以被 test,甚至被其他东西修改——编译器没有理由假设这个地址的值确实第一次访问和第二次访问之间没有变化。

请注意,如果您使用 double (*)[2] 而不是 double**,您将获得与第二个代码相同的输出,并且您的代码将是格式良好的。


你的第一个代码基本上等同于:

extern "C" int test(const double a);

int main() {
    double a[2] = { 1.0, 2.0 };
    double **pp = (double**)&a;
    double *p = pp[0];

    double a1 = *p;
    test(a1);

    double a2 = *p;
    test(a2);
}

您将使用命令行进行相同的反汇编。

假设一个架构有 4 个字节 double 和指针,你在执行时得到这样的东西:

0x7fff4f40 0x3f800000 # 1.0
0x7fff4f44 0x40000000 # 2.0

由于 adouble 的数组,&a 可能会衰减为 double (*)[2] "with a value of" 0x7fff4f40.

现在,您正在将 &a 转换为 double**,因此您将得到一个值为 0x7fff4f40double **pp。从这里,您可以使用 pp[0] 检索 double *p,因为在我假设的体系结构中指针也是 4 个字节,您将得到 0x3f800000

太好了,所以编译器可能会对此进行优化,基本上它可以创建这样的东西:

double *p = (double*) 0x3f800000;

double a1 = *p;
test(a1);

double a2 = *p;
test(a2);

知道百万美元的问题是:地址0x3f80000是什么?好吧,没有人知道,甚至是编译器。此地址的值可能随时通过调用 test() 甚至外部源进行修改。

我不是 double 和指针类型的大小限制方面的专家,但让我们假设一个假设的架构,其中 sizeof(double*) > 2 * sizeof(double),编译器甚至无法推断出 p 因为您将尝试访问 a.

之外的值

错误在这些行中:

double a[2] = { 1.0, 2.0 };
Vector<double> va((double**) &a);

a 是两个双精度数组。它 衰减 double *,但 &a 而不是 double **数组和指针不是同一种动物.

事实上你有以下内容:(void *) a == (void *) &a因为数组的地址是它的第一个元素的地址。

如果你想构建一个指向指针的指针,你必须明确地创建一个真正的指针:

double a[2] = { 1.0, 2.0 };
double *pt = a; // or &(a[0]) ...
Vector<double> va((double**) &pt);