【问题标题】:Accessing array values via pointer arithmetic vs. subscripting in C通过指针算术访问数组值与 C 中的下标
【发布时间】:2010-09-18 23:44:23
【问题描述】:

我一直在读到,在 C 语言中,使用指针算法通常比使用下标访问数组更快。即使使用现代(据说是优化的)编译器也是如此吗?

如果是这样,当我开始从学习 C 转向 Objective-C 和 Mac 上的 Cocoa 时,情况是否仍然如此?

在 C 和 Objective-C 中,数组访问的首选编码风格是什么?哪个被认为(各自语言的专业人士)更清晰、更“正确”(因为没有更好的术语)?

【问题讨论】:

    标签: objective-c c arrays pointers pointer-arithmetic


    【解决方案1】:

    您需要了解此声明背后的原因。你有没有问过自己为什么它更快?让我们比较一些代码:

    int i;
    int a[20];
    
    // Init all values to zero
    memset(a, 0, sizeof(a));
    for (i = 0; i < 20; i++) {
        printf("Value of %d is %d\n", i, a[i]);
    }
    

    它们都为零,真是令人惊讶:-P 问题是,a[i] 实际上在低级机器代码中是什么意思?这意味着

    1. 在内存中取a的地址。

    2. i 乘以a 的单个项目的大小添加到该地址(int 通常为四个字节)。

    3. 从该地址获取值。

    因此,每次从a 获取值时,a 的基地址都会与i 乘以四的结果相加。如果你只是取消引用一个指针,步骤 1. 和 2. 不需要执行,只需要执行步骤 3。

    考虑下面的代码。

    int i;
    int a[20];
    int * b;
    
    memset(a, 0, sizeof(a));
    b = a;
    for (i = 0; i < 20; i++) {
        printf("Value of %d is %d\n", i, *b);
        b++;
    }
    

    这段代码可能更快...但即使是这样,差异也很小。为什么会更快? "*b" 与上面的第 3 步相同。但是“b++”和第1步和第2步是不一样的。“b++”会将指针增加4。

    (对新手很重要:运行++ 上一个指针不会增加 指针在内存中的一个字节!它会 将指针增加尽可能多的字节 在内存中,因为它指向的数据是 在尺寸方面。它指向 intint 在我的机器上是四个字节,所以 b++ b 增加 4!)

    好的,但为什么会更快呢?因为向指针添加四比将 i 乘以四并将其添加到指针要快。在任何一种情况下你都有一个加法,但在第二种情况下,你没有乘法(你避免了一次乘法所需的 CPU 时间)。考虑到现代 CPU 的速度,即使数组是 1 个 mio 元素,我想知道您是否真的可以对差异进行基准测试。

    现代编译器可以将任何一个优化为同样快,您可以通过查看它产生的汇编输出来检查这一点。为此,您可以将“-S”选项(大写 S)传递给 GCC。

    这是第一个 C 代码的代码(已使用优化级别 -Os,这意味着优化代码大小和速度,但不要进行会显着增加代码大小的速度优化,与 -O2 不同-O3):

    _main:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %edi
        pushl   %esi
        pushl   %ebx
        subl    $108, %esp
        call    ___i686.get_pc_thunk.bx
    "L00000000001$pb":
        leal    -104(%ebp), %eax
        movl    $80, 8(%esp)
        movl    $0, 4(%esp)
        movl    %eax, (%esp)
        call    L_memset$stub
        xorl    %esi, %esi
        leal    LC0-"L00000000001$pb"(%ebx), %edi
    L2:
        movl    -104(%ebp,%esi,4), %eax
        movl    %eax, 8(%esp)
        movl    %esi, 4(%esp)
        movl    %edi, (%esp)
        call    L_printf$stub
        addl    $1, %esi
        cmpl    $20, %esi
        jne L2
        addl    $108, %esp
        popl    %ebx
        popl    %esi
        popl    %edi
        popl    %ebp
        ret
    

    与第二个代码相同:

    _main:
        pushl   %ebp
        movl    %esp, %ebp
        pushl   %edi
        pushl   %esi
        pushl   %ebx
        subl    $124, %esp
        call    ___i686.get_pc_thunk.bx
    "L00000000001$pb":
        leal    -104(%ebp), %eax
        movl    %eax, -108(%ebp)
        movl    $80, 8(%esp)
        movl    $0, 4(%esp)
        movl    %eax, (%esp)
        call    L_memset$stub
        xorl    %esi, %esi
        leal    LC0-"L00000000001$pb"(%ebx), %edi
    L2:
        movl    -108(%ebp), %edx
        movl    (%edx,%esi,4), %eax
        movl    %eax, 8(%esp)
        movl    %esi, 4(%esp)
        movl    %edi, (%esp)
        call    L_printf$stub
        addl    $1, %esi
        cmpl    $20, %esi
        jne L2
        addl    $124, %esp
        popl    %ebx
        popl    %esi
        popl    %edi
        popl    %ebp
        ret
    

    嗯,这是不同的,这是肯定的。 104 和 108 的数字差异来自变量b(在第一个代码中,堆栈上少了一个变量,现在我们多了一个,改变了堆栈地址)。 for循环中真正的代码区别是

    movl    -104(%ebp,%esi,4), %eax
    

    相比

    movl    -108(%ebp), %edx
    movl    (%edx,%esi,4), %eax
    

    实际上,在我看来,第一种方法似乎更快(!),因为它发出一个 CPU 机器代码来执行所有工作(CPU 为我们完成所有工作),而不是有两个机器代码。另一方面,下面的两个汇编命令的运行时间可能比上面的要短。

    作为结束语,我会说取决于您的编译器和 CPU 功能(CPU 提供什么命令以何种方式访问​​内存),结果可能是任何一种方式。任何一个都可能更快/更慢。除非您将自己完全限制在一个编译器(也意味着一个版本)和一个特定的 CPU 上,否则您无法确定。由于 CPU 可以在单个汇编命令中执行越来越多的操作(很久以前,编译器确实必须手动获取地址,将 i 乘以 4,然后在获取值之前将两者相加),这些陈述曾经是绝对正确的古往今来,如今越来越成问题。还有谁知道 CPU 在内部是如何工作的?上面我比较了一个汇编指令和另外两个。

    我可以看到指令的数量不同,并且此类指令所需的时间也可能不同。此外,这些指令在它们的机器表示中需要多少内存(毕竟它们需要从内存传输到 CPU 缓存)是不同的。但是,现代 CPU 不会按照您提供指令的方式执行指令。他们将大指令(通常称为 CISC)拆分为小的子指令(通常称为 RISC),这也使他们能够更好地优化程序流程以提高内部速度。事实上,下面的第一条指令和另外两条指令可能会产生相同的子指令集,在这种情况下,无论如何都没有可测量的速度差异。

    关于 Objective-C,它只是带有扩展的 C。因此,所有适用于 C 的东西都适用于 Objective-C,就指针和数组而言也是如此。另一方面,如果您使用对象(例如,NSArrayNSMutableArray),这是一个完全不同的野兽。但是在这种情况下,无论如何您都必须使用方法访问这些数组,没有可供选择的指针/数组访问。

    【讨论】:

    • Memset() 在数组变量上也可以正常工作。毕竟只是一个指针。
    【解决方案2】:

    "使用指针算术一般是 比数组下标快 访问”

    不。无论哪种方式都是相同的操作。下标是将(元素大小 * 索引)添加到数组起始地址的语法糖。

    也就是说,当迭代数组中的元素时,每次通过循环获取指向第一个元素的指针并增加它通常比每次从循环变量中计算当前元素的位置要快一些。 (虽然这在现实生活中的应用程序中很重要。首先检查你的算法,过早的优化是万恶之源,等等)

    【讨论】:

    • 除了编译器经常会转为 (i = 0; i
    【解决方案3】:

    这可能有点离题(抱歉),因为它没有回答您关于执行速度的问题,但您应该考虑到过早的优化是万恶之源 (Knuth)。在我看来,特别是在(重新)学习语言时,一定要以最容易阅读的方式编写它。 然后,如果您的程序运行正确,请考虑优化速度。 无论如何,大多数情况下你的代码都足够快。

    【讨论】:

    • 在实践中,我同意你的看法。但是,我正在寻找的是来自使用特定语言的专业人士的更明智的建议,为什么我可能会关心以一种方式而不是另一种方式做某事,以及可能产生的下游后果......
    • 不仅如此,编译器比您更了解 CPU,因此它可以做出更好的决定,通常如果您在每次循环迭代中引用数组中的多个元素,计算地址可能会更快即时,特别是如果您将索引用于索引之外。 (并且在 x86 上,您有同时执行索引和加载的指令......)基本上把它留给编译器,除非它是一个性能关键循环(并且您进行了分析,所以您知道它是正确的)然后尝试两种方式并再次配置文件以查看哪个更快。
    【解决方案4】:

    Mecki 有一个很好的解释。根据我的经验,索引与指针之间通常很重要的一件事是循环中的其他代码。示例:

    #include <stdio.h>
    #include <stdlib.h>
    #include <time.h>
    #include <iostream>
    
    using namespace std;
    
    typedef int64_t int64;
    static int64 nsTime() {
      struct timespec tp;
      clock_gettime(CLOCK_REALTIME, &tp);
      return tp.tv_sec*(int64)1000000000 + tp.tv_nsec;
    }
    
    typedef int T;
    size_t const N = 1024*1024*128;
    T data[N];
    
    int main(int, char**) {
      cout << "starting\n";
    
      {
        int64 const a = nsTime();
        int sum = 0;
        for (size_t i=0; i<N; i++) {
          sum += data[i];
        }
        int64 const b = nsTime();
        cout << "Simple loop (indexed): " << (b-a)/1e9 << "\n";
      }
    
      {
        int64 const a = nsTime();
        int sum = 0;
        T *d = data;
        for (size_t i=0; i<N; i++) {
          sum += *d++;
        }
        int64 const b = nsTime();
        cout << "Simple loop (pointer): " << (b-a)/1e9 << "\n";
      }
    
      {
        int64 const a = nsTime();
        int sum = 0;
        for (size_t i=0; i<N; i++) {
          int a = sum+3;
          int b = 4-sum;
          int c = sum+5;
          sum += data[i] + a - b + c;
        }
        int64 const b = nsTime();
        cout << "Loop that uses more ALUs (indexed): " << (b-a)/1e9 << "\n";
      }
    
      {
        int64 const a = nsTime();
        int sum = 0;
        T *d = data;
        for (size_t i=0; i<N; i++) {
          int a = sum+3;
          int b = 4-sum;
          int c = sum+5;
          sum += *d++ + a - b + c;
        }
        int64 const b = nsTime();
        cout << "Loop that uses more ALUs (pointer): " << (b-a)/1e9 << "\n";
      }
    }
    

    在基于 Core 2 的快速系统(g++ 4.1.2、x64)上,时间安排如下:

    简单循环(索引):0.400842 简单循环(指针):0.380633 使用更多 ALU(索引)的循环:0.768398 使用更多 ALU(指针)的循环:0.777886

    有时索引更快,有时指针算法更快。这取决于 CPU 和编译器如何将循环执行流水线化。

    【讨论】:

      【解决方案5】:

      如果您正在处理数组类型的数据,我会说使用下标可以使代码更具可读性。在今天的机器上(尤其是对于像这样简单的东西),可读的代码更为重要。

      现在,如果您正在显式处理 malloc() 处理的一大块数据,并且您想在该数据中获取一个指针,例如音频文件头中的 20 个字节,那么我认为地址算术更清楚地表达了什么你正在尝试做。

      我不确定这方面的编译器优化,但即使下标速度较慢,它最多也只能慢几个时钟周期。当您可以从清晰的思路中获得更多收益时,这几乎算不了什么。

      编辑:根据其他一些回复,下标只是一个句法元素,对性能没有影响,就像我想的那样。在这种情况下,请务必使用您试图通过访问指针指向的块内的数据来表达的任何上下文。

      【讨论】:

        【解决方案6】:

        请记住,即使使用超标量 cpu 等查看机器代码,执行速度也很难预测

        • 乱序执行
        • 流水线
        • 分支预测
        • 超线程
        • ...

        这不仅仅是计算机器指令,甚至不仅仅是计算时钟周期。 在真正需要的情况下进行测量似乎更容易。即使计算给定程序的正确循环计数并非不可能(我们必须在大学里这样做),但这并不有趣,而且很难做到正确。 旁注:在多线程/多处理器环境中正确测量也很困难。

        【讨论】:

          【解决方案7】:
          char p1[ ] = "12345";
          char* p2 = "12345";
          
          char *ch = p1[ 3 ]; /* 4 */
          ch = *(p2 + 3); /* 4 */
          

          C 标准没有说明哪个更快。在可观察的行为上是相同的,编译器可以按照它想要的任何方式来实现它。很多时候它甚至根本不会读取内存。

          通常,除非您指定编译器、版本、体系结构和编译选项,否则您无法说出哪个“更快”。即使这样,优化也将取决于周围的环境。

          因此,一般建议是使用任何代码更清晰、更简单的代码。使用 array[ i ] 提供了一些工具来尝试查找索引超出范围的条件,因此如果您使用的是数组,最好只处理它们。

          如果它很重要 - 查看编译器生成的汇编程序。但请记住,它可能会随着您更改围绕它的代码而改变。

          【讨论】:

          • C 或 C++ 标准都没有说哪个更快,因为标准不是实现。 C 或 C++ 的特定实现负责最终程序的执行性能。在您的示例中,使用索引运算符执行 else 更多,但指针算术 (stackoverflow.com/questions/2124935/c-strings-confusion/…) 以及当今大多数(如果不是所有)编译器都会生成相同的代码。
          【解决方案8】:

          不,使用指针算术并不是更快,而且很可能更慢,因为优化编译器可能会使用英特尔处理器上的 LEA(加载有效地址)等指令或其他处理器上的类似指令进行指针算术,这比 add 或 add/mul 更快.它的优点是一次做几件事而不影响标志,而且计算也需要一个周期。顺便说一句,以下来自 GCC 手册。所以-Os 主要不是针对速度进行优化。

          我也完全同意 themarko。首先尝试编写干净、可读和可重用的代码,然后考虑优化并使用一些 profiling 工具来找到瓶颈。大多数时候,性能问题是与 I/O 相关的,或者是一些糟糕的算法或一些你必须寻找的错误。 Knuth 是男人 ;-)

          我突然想到,你会用结构数组做什么。如果你想做指针运算,那么你肯定应该为结构的每个成员做。听起来是不是有点矫枉过正?是的,这当然是矫枉过正,也为掩盖错误打开了一扇大门。

          -Os 优化大小。 Os 启用通常不会增加代码大小的所有 O2 优化。它还执行旨在减少代码大小的进一步优化。

          【讨论】:

            【解决方案9】:

            这不是真的。它与下标运算符一样快。在 Objective-C 中,您可以像在 C 中一样使用数组,也可以在面向对象风格中使用数组,其中面向对象风格要慢得多,因为由于调用的动态特性,它在每次调用中都会进行一些操作。

            【讨论】:

            • 在大多数情况下可以从更好的算法中找到最大的性能优化。不在语言或低级优化中。
            【解决方案10】:

            速度上不太可能有任何差异。

            使用数组运算符 [] 可能是首选,因为在 C++ 中,您可以对其他容器(例如向量)使用相同的语法。

            【讨论】:

              【解决方案11】:

              我已经为几个 AAA 标题进行 C++/汇编优化 10 年了,我可以说在我从事的特定平台/编译器上,指针算法 产生了相当大的差异。

              作为一个透视的例子,我能够在我们的粒子生成器中创建一个非常紧凑的循环,速度提高 40%,方法是用指针算法替换所有数组访问,这让我的同事完全不相信。我从我的一位老师那里听说过它是一个很好的技巧,但我认为它不会对我们今天拥有的编译器/CPU 产生影响。我错了;)

              必须指出,许多控制台ARM 处理器不具备现代CISC CPU 的所有可爱功能,而且编译器有时会有点不稳定。

              【讨论】:

                猜你喜欢
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                • 2023-01-30
                • 1970-01-01
                • 2016-06-19
                • 1970-01-01
                • 1970-01-01
                • 1970-01-01
                相关资源
                最近更新 更多