【发布时间】:2011-02-08 23:45:35
【问题描述】:
在 C 中,访问数组索引更快还是通过指针访问更快? 我的意思是更快,哪个需要更少的时钟周期。 该数组不是常量数组。
【问题讨论】:
-
如果你有一个指向元素确切位置的指针,那么指针可能会更快,因为索引必须根据基地址计算元素的地址。
标签: c optimization assembly
在 C 中,访问数组索引更快还是通过指针访问更快? 我的意思是更快,哪个需要更少的时钟周期。 该数组不是常量数组。
【问题讨论】:
标签: c optimization assembly
哪一个更快完全取决于系统,但两者在功能上是等价的,如果一个真的更快,我会感到非常惊讶。也就是代码
myArr[index]
完全等价于
*(&myArr[0] + index)
同样,写作
*ptr
相当于写
ptr[0]
大多数编译器都足够聪明,可以解决这个问题,所以如果一个编译器比另一个更快,我会感到惊讶。
不过,更重要的是,您可能不应该太担心这一点。在一切正常之后再担心优化。如果您发现数组访问确实让您感到沮丧,那么请考虑寻找更快的替代方案。否则,不要担心;除非您迫切需要优化,否则拥有干净、可读、可维护的代码比拥有优化的代码更有价值。
【讨论】:
templatetypedef 总结了它。为他的回应添加一些支持。以这些示例函数为例:
无符号整数 fun1 ( 无符号整数 *x ) { 无符号整数 ra,rb; rb=0; for(ra=0;ra现在 gcc 产生了这个:
00000000 乐趣1: 0: e52d4004 推 {r4} ; (str r4,[sp,#-4]!) 4: e1a03000 移动 r3, r0 8: e2804efa 添加 r4, r0, #4000 ; 0xfa0 c: e3a00000 mov r0, #0 10: e1a02003 移动 r2, r3 14:e492c004 ldr ip,[r2],#4 18:e5931004 ldr r1,[r3,#4] 1c: e2823004 添加 r3, r2, #4 20: e080000c 添加 r0, r0, ip 24: e1530004 cmp r3, r4 28: e0800001 加 r0, r0, r1 2c:1afffff7 bne 10 30: e49d4004 弹出 {r4} ; (ldr r4, [sp], #4) 34:e12fff1e bx lr 00000038 乐趣2: 38:e3a03000 移动 r3,#0 3c: e1a02003 移动 r2, r3 40: e790c003 ldr ip, [r0, r3] 44: e2833004 添加 r3, r3, #4 48:e7901003 ldr r1,[r0,r3] 4c: e2833004 添加 r3, r3, #4 50: e082200c 添加 r2, r2, ip 54:e3530efa cmp r3,#4000; 0xfa0 58: e0822001 添加 r2, r2, r1 5c:1afffff7 bne 40 60: e1a00002 移动 r0, r2 64:e12fff1e bx lr代码不同,但我对错失优化机会感到惊讶。
Clang/llvm 产生了这个:
00000000 乐趣1: 0:e3a01000 移动 r1,#0 4: e3a02ffa 移动 r2, #1000 ; 0x3e8 8: e1a03001 移动 r3, r1 c: e2522001 subs r2, r2, #1 10:e490c004 ldr ip,[r0],#4 14: e08c3003 添加 r3, ip, r3 18: e2c11000 sbc r1, r1, #0 1c: e182c001 orr ip, r2, r1 20:e35c0000 cmp ip,#0 24:1afffff8 bne c 28: e1a00003 移动 r0, r3 2c: e12fff1e bx lr 00000030 乐趣2: 30:e3a01000 移动 r1,#0 34: e3a02ffa 移动 r2, #1000 ; 0x3e8 38: e1a03001 移动 r3, r1 3c: e2522001 潜艇 r2, r2, #1 40:e490c004 ldr ip,[r0],#4 44: e08c3003 添加 r3, ip, r3 48: e2c11000 sbc r1, r1, #0 4c: e182c001 orr ip, r2, r1 50:e35c0000 cmp ip,#0 54:1afffff8 bne 3c 58: e1a00003 移动 r0, r3 5c: e12fff1e bx lr您可能会注意到编译器生成了完全相同的代码、指针或偏移量。通过更改编译器,我比更改指针与数组索引更好。我认为 llvm 可以做得更好一些,我需要进一步研究这一点以了解我的代码做了什么导致这种情况。
编辑:
我希望编译器至少使用支持指针的 ldr rd,[rs],#4 指令,并希望编译器会看到它可以破坏数组地址,从而将其视为指针而不是而不是一个数组的偏移量(并使用上面的指令,这基本上是 clang/llvm 所做的)。或者,如果它做了数组的事情,它将使用 ldr rd,[rm,rn] 指令。基本上是希望其中一个编译器能够生成以下解决方案之一:
乐趣: 移动 r1,#0 mov r2,#1000 funa_loop: ldr r3,[r0],#4 添加 r1,r1,r3 潜艇 r2,r2,#1 bne funa_loop 移动 r0,r1 bx lr 乐趣: 移动 r1,#0 移动 r2,#0 funb_loop: ldr r3,[r0,r2] 添加 r1,r1,r3 添加 r2,r2,#4 cmp r2,#0x4000 bne funb_loop 移动 r0,r1 bx lr 功能: 移动 r1,#0 mov r2,#4000 潜艇 r2,r2,#4 函数循环: beq func_done ldr r3,[r0,r2] 添加 r1,r1,r3 潜艇 r2,r2,#4 b func_loop func_done: 移动 r0,r1 bx lr没有到达那里,但已经很接近了。这是一个有趣的练习。注意以上都是 ARM 汇编程序。
一般来说,(不是我特定的 C 代码示例,也不一定是 ARM),一些流行的架构会从基于寄存器的地址 (ldr r0,[r1]) 加载和使用寄存器加载index/offset (ldr r0,[r1,r2]) 其中地址是两个寄存器的总和。理想情况下,一个寄存器是数组的基地址,第二个是索引/偏移量。前者从寄存器加载适合指针,后者适合数组。如果您的 C 程序不打算更改或移动指针或索引,那么在这两种情况下,这意味着计算静态地址,然后使用正常加载,数组和指针都应该产生相同的指令。对于更改指针/索引的更有趣的情况。
指针 ldr r0,[r1] ... 添加 r1,r1,一些数字 数组索引 ldr r0,[r1,r2] ... 添加 r2,r2,一些数字(根据需要将 load 替换为 store,将 add 替换为 sub)
某些架构没有三寄存器寄存器索引指令,因此您必须执行类似的操作
数组索引: 移动 r2,r1 ... ldr r0,[r2] ... 添加 r2,r2,一些数字或者取决于编译器,它可能会变得非常糟糕,尤其是如果您编译进行调试或没有优化,并且假设您没有添加三个寄存器
数组索引: 移动 r2,#0 ... 移动 r3,r1 添加 r3,r2 ldr r4,[r3] ... 添加 r2,一些数字所以这两种方法很可能是相等的。正如在 ARM 上看到的那样,它可以将两个(在立即数限制内)指针指令合并为一个,从而加快速度。数组索引解决方案会消耗更多的寄存器,并且取决于架构中可用寄存器的数量,这会促使您更快、更频繁地将寄存器交换到堆栈中(比使用指针时更频繁),从而使您的速度更慢。如果您不介意破坏基地址,那么最重要的是指针解决方案可能从性能角度为您提供优势。它与您的代码和编译器有很大关系。对我来说,它的可读性开始发挥作用,我觉得数组更容易阅读和遵循,其次我是否需要保留该指针以释放 malloc 或再次遍历该内存等。如果是这样,我可能会使用一个数组一个索引,如果它是一次性传递并且我不关心破坏基地址,我将使用一个指针。正如您在上面看到的编译器生成的代码,如果性能很关键,那么无论如何都要在汇编器中手动编写解决方案(基于建议的方法,让编译器先尝试)。
【讨论】:
在我接触过的每个编译器上,简单的索引操作都会编译成相同的机器代码。为了可读性,通常建议按索引。
涉及指针访问与数组索引的不同逻辑的更复杂的情况需要逐个检查。如果您有疑问,请像往常一样分析您的代码。
【讨论】:
您的问题没有有意义的答案。语言级别的操作没有与之相关的特定“速度”。它们本身不能“更快”或“更慢”。
只有 CPU 指令可以更快或更慢,并且只有 CPU 指令可以消耗 CPU 周期。为了以某种方式将这种“速度”概念从 CPU 指令带回到语言级别的操作 [这些 CPU 指令是从中生成的],在一般情况下,您需要了解上下文。之所以如此,是因为相同的语言级操作可以在不同的上下文中生成完全不同的 CPU 指令(更不用说它还可能取决于编译器设置等)
换句话说,发布实际代码。作为一个抽象的无上下文问题,它根本没有意义。
【讨论】:
在最低级别,这些操作大多倾向于编译成相同的东西。如果您真的感兴趣,您应该让您的 C 编译器生成汇编输出(例如使用 gcc -S),以便您可以检查,尤其是因为它至少取决于:
您会发现,即使存在 差异(这是值得怀疑的),这种程度的微优化也大多不值得您为此付出努力。您最好进行宏观优化,例如改进算法,因为这样可以提供更多的投资回报。
在这些影响可能很小的情况下,我总是针对可读性进行优化。
【讨论】:
明确消除常见的子表达式可能对您有用。如果您使用的是 x86 或 RISC 架构和优化器质量,可能会有所不同。
当我编写一个必须遍历数组或索引结构的例程时,我会计算一个指向数组/结构成员基址的指针并使用它来寻址。基本情况
struct SOMETHING list[100];
int find_something (...)
{
int i;
i=0;
while (i<(sizeof(list)/sizeof(struct SOMETHING)))
{
if (list[i].active && list[i].last_access+60<current_time) return i;
++i;
}
return -1;
}
可以细化为(即帮助编译器生成更好的代码):
int find_something (...)
{
int i;
struct SOMETHING *pList;
i=0;
while (i<(sizeof(list)/sizeof(struct SOMETHING)))
{
pList=&list[i];
if (pList->active && pList->last_access+60<current_time) return i;
++i;
}
return -1;
}
这只是为了说明,代码的简单性可能会隐式生成指针,但如果例程更复杂,情况可能并非如此。使用“列表[i]”。在第一个示例中,您将运行(在 x86 上)编译器没有足够的寄存器来生成和存储地址一次的风险(RISC haha),而是为每个引用生成它。对于 x86 情况,需要一个局部变量来存储指针,除非明确指示,否则很少有编译器会创建堆栈变量。在 RISC 上,编译器有很多可供使用的寄存器,并且通常会决定每次迭代都创建(并保留)一次指针是值得的。
循环可以进一步细化:
pList=list;
i=0;
while (i<(sizeof(list)/sizeof(struct SOMETHING)))
{
if (pList->active && pList->last_access+60<current_time) return i;
pList+=1;
++i;
}
这种构造没有任何地址计算开销。 “pList+=1”(其他人可能更喜欢“++pList”)会导致将一个常量值(等于单个行/成员的大小)添加到 pList。
还有:
pList=list;
pEndList=&list[sizeof(list)/sizeof(struct SOMETHING)];
while (pList!=pEndList)
{
if (pList->active && pList->last_access+60<current_time) return pList-list;
pList+=1;
}
这消除了索引增量,并将其替换为循环外的一乘和循环内的一除(在返回构造中只执行一次)。
现在,在所有你不优化的人开始尖叫血腥谋杀之前,我的观点是,可接受的构造取决于它们所在函数的大小和复杂性。我可能不会在一个 300 行的函数中考虑这个结构,它足够复杂,但在上述情况下?如果搜索是整体处理的重要组成部分?如果加速足够大?
那为什么不呢?优点和缺点。它总是有利有弊。充分利用它们。绝对的?很少(如果有的话)。
【讨论】:
一样。都是 O(1),时钟时间可以忽略不计。你基本上是在访问内存地址。
【讨论】:
a[i] 编译为一个将指针增加1 i 倍的循环会怎样?这将使它成为 O(n)。如果你想学究气...
通过索引访问数组时,实际上是在执行两个操作:加法(将索引添加到数组基址),然后是内存访问(实际读取或写入结果地址的内容)。我想当您谈论“通过指针访问”时,您的意思是您已经拥有指向目标元素的指针。因此,从逻辑上讲,使用指针可以节省“加法”部分,因此应该更快,或者至少不会变慢。
不过……
作为一个粗略的估计,在现代计算机中,内存访问比添加要昂贵得多(尤其是如果它从缓存中掉出来),因此差异(如果有的话)将是微小的。在某些架构(例如 x86 或 PowerPC)上,加法和内存访问可以组合成一个操作码。情况也会有所不同,具体取决于数组地址是否为编译时常量(即数组不是常量数据,而是声明为全局变量,vs 使用malloc() 获得的块)。使用数组可以帮助编译器找到更好的代码,关于泛型指针(特别是在使用 restrict 关键字时)。上下文有很大的影响(例如,当时有多少空闲寄存器?)。
所以:
【讨论】: