效率:数组与指针

时间:2010-02-21 11:56:33

标签: c arrays pointers performance memory-access

通过指针进行内存访问比通过数组进行内存访问更有效。我正在学习C,上述内容在K& R中有说明。具体来说他们说

  

通过数组下标可以实现的任何操作也可以使用指针完成。指针版本通常会更快

我使用visual C ++解组了以下代码。(我是一个686处理器。我已禁用所有优化。)

int a[10], *p = a, temp;

void foo()
{
    temp = a[0];
    temp = *p;
}

令我惊讶的是,我发现通过指针的内存访问需要通过数组对内存访问所采用的两条指令。以下是相应的代码。

; 5    : temp = a[0];

    mov eax, DWORD PTR _a
    mov DWORD PTR _temp, eax

; 6    : temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx

请帮我理解。我在这里想念的是什么?


正如许多答案和评论所指出的,我使用了编译时常量作为数组索引,从而使得通过数组访问变得更容易。下面是汇编代码,其中变量作为索引。我现在有相同数量的指令通过指针和数组进行访问。我更广泛的问题仍然很好。通过指针进行内存访问并不会使自己更有效率。

; 7    :        temp = a[i];

    mov eax, DWORD PTR _i
    mov ecx, DWORD PTR _a[eax*4]
    mov DWORD PTR _temp, ecx

; 8    : 
; 9    :    
; 10   :        temp = *p;

    mov eax, DWORD PTR _p
    mov ecx, DWORD PTR [eax]
    mov DWORD PTR _temp, ecx

14 个答案:

答案 0 :(得分:70)

  

通过指针进行内存访问比通过数组进行内存访问更有效。

过去编译器是相对愚蠢的野兽时可能也是如此。您只需要在高优化模式下查看gcc的一些代码输出,就可以知道它不再是真的。其中一些代码很难理解,但是一旦你这样做,它的亮度就很明显了。

一个不错的编译器会为指针访问和数组访问生成相同的代码,你可能不应该担心这个级别的性能。编写编译器的人比我们凡人更了解他们的目标架构。在优化代码(算法选择等)时更专注于宏观层面,并相信您的工具制造者能够完成他们的工作。


事实上,我很惊讶编译器没有优化整个

temp = a[0];

一行不存在,因为temp在下一行中覆盖了不同的值,而a并未标记为volatile

我记得很久以前的一个城市神话,关于最新的VAX Fortran编译器(显示我的年龄)的基准测试,其表现优于其竞争对手几个数量级。

原来,编译器发现基准计算的结果没有在任何地方使用,因此它将整个计算循环优化为遗忘。因此,运行速度得到了实质性的改善。


更新:优化代码在您的特定情况下更有效的原因是您找到位置的方式。 a将位于链接/加载时决定的固定位置,并且同时修复对它的引用。因此,a[0]a[any constant]将位于固定的位置。

由于同样的原因,p本身也会在固定的位置。 但是 *pp的内容)是可变的,因此需要额外的查找才能找到正确的内存位置。

您可能会发现将另一个变量x设置为0(不是const)并使用a[x]也会引入额外的计算。


在你的一条评论中,你说:

  

按照你的建议做了导致通过数组的内存访问的3条指令(获取索引,获取数组元素的值,存储在temp中)。但我仍然无法看到效率。 : - (

我对此的回应是,你很可能不会看到使用指针的效率。现代编译器不仅可以确定数组操作和指针操作可以转换为相同的底层机器代码。

事实上,如果没有启用优化,指针代码可以更少效率。请考虑以下翻译:

int *pa, i, a[10];

for (i = 0; i < 10; i++)
    a[i] = 100;
/*
    movl    $0, -16(%ebp)              ; this is i, init to 0
L2:
    cmpl    $9, -16(%ebp)              ; from 0 to 9
    jg      L3
    movl    -16(%ebp), %eax            ; load i into register
    movl    $100, -72(%ebp,%eax,4)     ; store 100 based on array/i
    leal    -16(%ebp), %eax            ; get address of i
    incl    (%eax)                     ; increment
    jmp     L2                         ; and loop
L3:
*/

for (pa = a; pa < a + 10; pa++)
    *pa = 100;
/*
    leal    -72(%ebp), %eax
    movl    %eax, -12(%ebp)            ; this is pa, init to &a[0]
L5:
    leal    -72(%ebp), %eax
    addl    $40, %eax
    cmpl    -12(%ebp), %eax            ; is pa at &(a[10])
    jbe     L6                         ; yes, stop
    movl    -12(%ebp), %eax            ; get pa
    movl    $100, (%eax)               ; store 100
    leal    -12(%ebp), %eax            ; get pa
    addl    $4, (%eax)                 ; add 4 (sizeof int)
    jmp     L5                         ; loop around
L6:
*/

从该示例中,您实际上可以看到指针示例更长,不必要地。它会pa多次加载%eax而不会发生变化,并且会在%eaxpa之间交替&(a[10])。这里的默认优化基本上都没有。

当您切换到优化级别2时,您获得的代码是:

    xorl    %eax, %eax
L5:
    movl    $100, %edx
    movl    %edx, -56(%ebp,%eax,4)
    incl    %eax
    cmpl    $9, %eax
    jle     L5

表示阵列版本,并且:

    leal    -56(%ebp), %eax
    leal    -16(%ebp), %edx
    jmp     L14
L16:
    movl    $100, (%eax)
    addl    $4, %eax
L14:
    cmpl    %eax, %edx
    ja      L16

指针版本。

我不打算在这里对时钟周期进行分析(因为它太多了,我基本上都很懒)但我会指出一件事。在汇编程序指令方面,两个版本的代码没有太大差别,并且考虑到现代CPU实际运行的速度,除非你正在做数十亿,否则你不会注意到差异操作。我总是倾向于编写代码以便于阅读,而只是担心性能会成为一个问题。

顺便说一下,你引用的那句话:

  5.3指针和数组:指针版本通常会更快,但至少对于没有经验的人来说,更难以立即掌握。

可以追溯到最早版本的K&amp; R,包括我1978年的古老版本,其中的功能仍在编写:

getint(pn)
int *pn;
{
    ...
}

从那时起,编译器已经走了很长的路。

答案 1 :(得分:11)

如果您正在编写嵌入式平台,则可以快速了解指针方法比使用索引要快得多。

struct bar a[10], *p;

void foo()
{
    int i;

    // slow loop
    for (i = 0; i < 10; ++i)
        printf( a[i].value);

    // faster loop
    for (p = a; p < &a[10]; ++p)
        printf( p->value);
}

慢循环必须每次计算一个+(i * sizeof(结构条)),而第二个只需要每次都将sizeof(结构条)添加到p。乘法运算比许多处理器上的加法使用更多的时钟周期。

如果你在循环中多次引用[i],你真的开始看到改进了。有些编译器不会缓存该地址,因此可能会在循环内多次重新计算。

尝试更新样本以使用结构并引用多个元素。

答案 2 :(得分:8)

在第一种情况下,编译器直接知道数组的地址(也是第一个元素的地址)并访问它。在第二种情况下,他知道指针的地址并读取指针的值,该值指向该存储器位置。这实际上是一个额外的间接,所以这里的速度可能会慢一些。

答案 3 :(得分:7)

速度是循环获得的,最重要的是。使用数组时,您将使用递增的计数器。要计算位置,系统将此计数器与数组元素的大小相乘,然后添加第一个元素的地址以获取地址。 使用指针,你需要做的就是转到下一个元素是使用元素的大小增加当前指针以获得下一个元素,假设所有元素在内存中彼此相邻。

因此,指针运算在执行循环时会少一些计算。此外,使用指向右侧元素的指针比使用数组中的索引更快。

然而,现代开发正逐渐摆脱许多指针操作。处理器越来越快,数组比指针更容易管理。此外,数组往往会减少代码中的错误数量。 Array将允许索引检查,确保您不访问数组外的数据。

答案 4 :(得分:7)

正如paxdiablo所说,任何新编译器都会使它们非常相似。

更重要的是,我看到数组比指针更快的情况。这是在使用向量运算的DSP处理器上。

在这种情况下,使用数组类似于使用 restrict 指针。因为通过使用两个数组,编译器 - 明确地 - 知道它们不指向相同的位置。 但是如果你处理2指针,编译器可能会认为它们指向相同的位置并且会跳过管道衬里。

例如:

int a[10],b[10],c[10];
int *pa=a, *pb=b, *pc=c;
int i;

// fill a and b.
fill_arrays(a,b);

// set c[i] = a[i]+b[i];
for (i = 0; i<10; i++)
{
   c[i] = a[i] + b[i];
}

// set *pc++ = *pa++ + *pb++;
for (i = 0; i<10; i++)
{
   *pc++ = *pa++ + *pb++;
}

在案例1中,编译器将很容易进行添加a和b的管道连接,并将值存储到c。

在第2种情况下,编译器不会管道,因为他可能在保存到C时覆盖a或b。

答案 5 :(得分:7)

指针自然表达简单的归纳变量,而下标本质上需要更复杂的编译器优化


在许多情况下,只使用下标表达式需要在问题中添加额外的图层。一个递增下标 i 的循环可以作为状态机,而表达式 a [i] 在技术上每次使用时都需要乘以每个元素的大小并添加到基地址。

为了将该访问模式转换为使用指针,编译器必须分析整个循环并确定,例如,正在访问每个元素。然后,编译器可以将下标乘以元素大小的多个实例替换为前一个循环值的简单增量。此流程结合了名为common subexpression eliminationinduction variable strength reduction.

的优化

使用指针编写时,整个优化过程不是必需的,因为程序员通常只是逐步完成数组。

有时编译器可以进行优化,有时却不能。近年来,手头有一个复杂的编译器比较常见,因此基于指针的代码并不总是更快

因为arrrays通常必须是连续的,所以指针的另一个优点是创建递增分配的复合结构。

答案 6 :(得分:3)

这是一个非常古老的问题,已经得到了回答,因此我不需要回答!但是,我没有注意到一个简单的答案所以提供一个。

答案:间接访问(指针/数组)“可能”添加一个额外的指令来加载(基本)地址,但之后的所有访问(如果指向struct的数组/成员的情况下的元素)应该是只有一条指令,因为它只是添加了已加载的(基址)地址的偏移量。因此,在某种程度上它将与直接访问一样好。因此,在大多数情况下,通过数组/指针进行访问是等效的,元素访问也可以直接访问变量。

实施例。如果我有一个包含10个元素的数组(或指针)或一个包含10个成员的结构(通过指向结构的指针访问),并且我正在访问一个元素/成员,那么一个可能的附加指令在开头只需要一次。在此之后,所有元素/成员访问应该只是一条指令。

答案 7 :(得分:2)

你在这里得到了很好的答案,但是既然你正在学习,那么值得指出的是,那个级别的效率很少被人注意到。

当您调整程序以获得最佳性能时,您应该至少同样关注在程序结构中查找和修复较大的问题。修复后,低级优化可以进一步改变。

Here's an example of how this can be done.

答案 8 :(得分:2)

指针以前比数组更快。当然,当C语言被设计时,指针的速度要快得多。但是现在,优化器通常可以比使用指针更好地优化数组,因为数组更受限制。

现代处理器的指令集也被设计用于帮助优化阵列访问。

所以最重要的是,这些天数组通常更快,特别是在带有索引变量的循环中使用时。

当然,您仍然希望使用指针来处理链接列表之类的东西,但是现在通过数组而不是使用索引变量来指针的旧时优化现在很可能是一种不优化。

答案 9 :(得分:1)

“指针版本通常会更快”意味着在大多数情况下,编译器更容易生成具有指针(只需要解除引用)的更有效的代码,而不是具有数组和下标(这意味着编译器需要从数组的开头移位地址)。但是,使用现代处理器和优化编译器,典型情况下的数组访问并不比指针访问慢。

特别是在您的情况下,您需要打开优化,以获得相同的结果。

答案 10 :(得分:1)

由于0被定义为常量,因此[0]也是常量,编译器知道它在编译时的位置。在“正常”情况下,编译器必须从base + offset计算元素地址(根据元素大小缩放offset)。

OTOH,p是一个变量,间接需要额外的移动。

一般来说,无论如何,数组索引在内部被处理为指针算术,所以我不确定K&amp; R试图做出什么。

答案 11 :(得分:1)

由于大多数人已经给出了详细的答案,我只是举一个直观的例子。如果以更大的比例使用数组和指针,则使用指针的效率将更加显着。例如,如果要对大型long int数据集进行排序,方法是将其排序为多个子集,然后合并它们。

import { AngularFireAuth } from 'angularfire2/auth'; ... export class EditObjectiveComponent implements OnInit { constructor(private afAuth: AngularFireAuth) { } ngOnInit() { this.afAuth.authState .map(x => x.uid) .subscribe(x => console.log(x)) } }

对于2017年的每日8G ram机器,我们可以将long int * testData = calloc(N, sizeof(long int));设置为400000000,这意味着您将为此原始数据集使用大约1.5G内存。如果您使用的是N,则可以使用

快速分隔数据
MPI

您可以简单地将MPI_Scatterv(testData, partitionLength, partitionIndex, MPI_LONG, MPI_IN_PLACE, N/number_of_thread, MPI_LONG, 0, MPI_COMM_WORLD); 视为将paritionLength存储为每个相同部分的长度的指针,并将N/number_of_thread视为指针,该指针会逐渐存储N / number_of_threads凝视索引。假设你有一个4核CPU,你只用4个线程分开你的工作。 partitionIndex肯定会通过参考快速完成这项工作。但是如果你正在使用数组,那么这个例程必须在数组上运行指针算法以首先找到分区点。哪个不如指针那么直接。此外,合并分区数据集时,您可能希望使用MPI来加速。您需要一个临时空间来存储四个已排序的数据集。在这里,如果使用指针,则只需存储4个地址。但是,如果使用数组,它将存储4个完整的子数组,这样效率不高。有时,如果您没有使用K-way merge来确保程序是线程安全的,MPI_Barrier甚至可能会抱怨您的内存实现不好。我有一台32G机器,通过数组方法和指针方法在8个线程上对4亿个长值进行排序,相应地得到了​​11.054980s和13.182739s。如果我将大小增加到1000000000,如果我使用数组,我的排序程序将无法成功执行。这就是为什么许多人对除了C中的标量之外的每个数据结构都使用指针。

答案 12 :(得分:0)

我对ptr有点惊讶比数组讨论更快,其中不是这种情况的证据最初是由Abhijith的asm代码给出的。

mov eax,dord ptr _a; //直接从地址加载_a

<强> VS

mov eax,dword ptr _p; //将地址/ p的值加载到eax

mov ecx,dword ptr [eax]; //使用加载的地址来访问值并输入ecx

一个数组代表一个固定的地址,所以cpu可以直接访问它,而不是ptr需要取消引用它才能让cpu访问它!

第二批代码不是comareable,因为必须对数组偏移进行计算,为了对ptr执行此操作,您还需要至少1/2个指令!

编译器在编译期间可以推断的任何内容(固定地址,偏移等)是高性能代码的关键。 比较迭代代码并分配给变量:

<强>阵列:

2791:tmp = buf_ai [l];

mov eax, DWORD PTR _l$[ebp]
mov ecx, DWORD PTR _buf_ai$[ebp+eax*4]
mov DWORD PTR _tmp$[ebp], ecx

<强> VS

<强> PTR

2796:tmp2 = * p;

mov eax, DWORD PTR _p$[ebp]
mov ecx, DWORD PTR [eax]
mov DWORD PTR _tmp2$[ebp], ecx

<强>加

2801:++ p;

mov eax, DWORD PTR _p$[ebp]
add eax, 4
mov DWORD PTR _p$[ebp], eax

首先是ptr加载地址,而不是使用它与数组使用地址同时获取价值!

最好的问候

答案 13 :(得分:0)

数组与指针的效率:向量化的案例

如果您正在使用像 gcc 这样的编译器,那么在点上使用数组以从 auto-vectorization 的增益中获利是很有意义的:

<块引用>

基本块矢量化,又名 SLP,由标志 -ftree-slp-vectorize 启用,并且需要与循环矢量化相同的平台相关标志。基本块 SLP 在 -O3 和 -ftree-vectorize 启用时默认启用。


不可向量化循环

当前无法向量化的循环示例:

示例 1:不可数循环:


while (*p != NULL) {
  *q++ = *p++;
}

矢量化循环

“feature”表示示例展示的矢量化能力。

示例 1:

int a[256], b[256], c[256];
foo () {
  int i;

  for (i=0; i<256; i++){
    a[i] = b[i] + c[i];
  }
}

底线

虽然很多人会告诉你指针或数组更好,但最好的是,一如既往:

  • 使用尽可能最好的标志编译您的代码
  • 使用 compiler explorer
  • 检查生成的字节码
  • 最后基准实际运行速度。