快速查找C数组中是否存在值?

时间:2014-09-04 09:31:54

标签: c optimization assembly embedded arm

我有一个具有时间要求严格的ISR的嵌入式应用程序需要迭代256个数组(最好是1024,但最小值为256),并检查一个值是否与数组内容匹配。如果是这种情况,bool将设置为true。

微控制器是恩智浦LPC4357,ARM Cortex M4内核,编译器是GCC。我已经结合优化级别2(3更慢)并将功能放在RAM而不是闪存中。我还使用指针算法和for循环,它进行向下计数而不是向上计数(检查i!=0是否比检查i<256更快)。总而言之,我的最终持续时间为12.5μs,必须大幅度降低才能实现。这是我现在使用的(伪)代码:

uint32_t i;
uint32_t *array_ptr = &theArray[0];
uint32_t compareVal = 0x1234ABCD;
bool validFlag = false;

for (i=256; i!=0; i--)
{
    if (compareVal == *array_ptr++)
    {
         validFlag = true;
         break;
     }
}

这样做绝对最快的方法是什么?允许使用内联汇编。其他“不太优雅”的技巧也是允许的。

15 个答案:

答案 0 :(得分:104)

在性能至关重要的情况下,与使用手动调整的汇编语言相比,C编译器很可能不会产生最快的代码。我倾向于采取阻力最小的路径 - 对于像这样的小程序,我只是编写asm代码并且很清楚它将执行多少个循环。您可能能够使用C代码并让编译器生成良好的输出,但最终可能会浪费大量时间来调整输出。编译器(特别是来自Microsoft)在过去几年中已经走过了漫长的道路,但它们仍然没有你们之间的编译器那么聪明,因为你正在研究你的具体情况,而不仅仅是一般情况。编译器可能不会使用可以加快这一速度的某些指令(例如LDM),并且它不可能足够聪明地展开循环。这是一种方法,它结合了我在评论中提到的3个想法:循环展开,缓存预取和使用多个加载(ldm)指令。每个数组元素的指令周期数约为3个时钟,但这并未考虑存储器延迟。

操作原理: ARM的CPU设计在一个时钟周期内执行大多数指令,但指令在流水线中执行。 C编译器将尝试通过在其间交错其他指令来消除流水线延迟。当呈现与原始C代码类似的紧密循环时,编译器将很难隐藏延迟,因为必须立即比较从内存中读取的值。我的下面的代码在2组4个寄存器之间交替,以显着减少存储器本身和获取数据的流水线的延迟。通常,在处理大型数据集时,如果您的代码没有使用大部分或全部可用的寄存器,那么您将无法获得最佳性能。

; r0 = count, r1 = source ptr, r2 = comparison value

   stmfd sp!,{r4-r11}   ; save non-volatile registers
   mov r3,r0,LSR #3     ; loop count = total count / 8
   pld [r1,#128]
   ldmia r1!,{r4-r7}    ; pre load first set
loop_top:
   pld [r1,#128]
   ldmia r1!,{r8-r11}   ; pre load second set
   cmp r4,r2            ; search for match
   cmpne r5,r2          ; use conditional execution to avoid extra branch instructions
   cmpne r6,r2
   cmpne r7,r2
   beq found_it
   ldmia r1!,{r4-r7}    ; use 2 sets of registers to hide load delays
   cmp r8,r2
   cmpne r9,r2
   cmpne r10,r2
   cmpne r11,r2
   beq found_it
   subs r3,r3,#1        ; decrement loop count
   bne loop_top
   mov r0,#0            ; return value = false (not found)
   ldmia sp!,{r4-r11}   ; restore non-volatile registers
   bx lr                ; return
found_it:
   mov r0,#1            ; return true
   ldmia sp!,{r4-r11}
   bx lr

<强>更新 评论中有很多怀疑者认为我的经历是轶事/无价值,需要证据。我使用GCC 4.8(来自Android NDK 9C)通过优化-O2生成以下输出(所有优化都打开包括循环展开)。我编译了上面问题中提出的原始C代码。以下是海湾合作委员会制作的内容:

.L9: cmp r3, r0
     beq .L8
.L3: ldr r2, [r3, #4]!
     cmp r2, r1
     bne .L9
     mov r0, #1
.L2: add sp, sp, #1024
     bx  lr
.L8: mov r0, #0
     b .L2

GCC的输出不仅不会展开循环,而且还会在LDR之后的档位上浪费时钟。每个阵列元素至少需要8个时钟。它很好地使用地址来知道何时退出循环,但是编译器能够做的所有神奇事物都无法在此代码中找到。我还没有在目标平台上运行代码(我不拥有代码),但是对ARM代码性能有经验的人都可以看到我的代码更快。

更新2: 我给了Microsoft的Visual Studio 2013 SP2一个机会,可以更好地使用代码。它能够使用NEON指令来矢量化我的数组初始化,但是由OP编写的线性值搜索类似于GCC生成的(我重命名了标签以使其更具可读性):

loop_top:
   ldr  r3,[r1],#4  
   cmp  r3,r2  
   beq  true_exit
   subs r0,r0,#1 
   bne  loop_top
false_exit: xxx
   bx   lr
true_exit: xxx
   bx   lr

正如我所说,我并不拥有OP的确切硬件,但我将测试3种不同版本的nVidia Tegra 3和Tegra 4的性能,并很快在此发布结果。

更新3: 我在Tegra 3和Tegra 4(Surface RT,Surface RT 2)上运行了我的代码和Microsoft编译的ARM代码。我运行了1000000次迭代的循环,无法找到匹配项,因此所有内容都在缓存中,并且很容易衡量。

             My Code       MS Code
Surface RT    297ns         562ns
Surface RT 2  172ns         296ns  

在这两种情况下,我的代码运行速度几乎快两倍。大多数现代ARM CPU可能会产生类似的结果。

答案 1 :(得分:87)

有一个优化它的技巧(我曾在工作面试时问过这个问题):

  • 如果数组中的最后一个条目包含您要查找的值,则返回true
  • 将您要查找的值写入数组的最后一个条目
  • 迭代数组,直到遇到您正在寻找的值
  • 如果您在数组中的最后一个条目之前遇到过它,则返回true
  • 返回false

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    uint32_t x = theArray[SIZE-1];
    if (x == compareVal)
        return true;
    theArray[SIZE-1] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    theArray[SIZE-1] = x;
    return i != SIZE-1;
}

每次迭代产生一个分支,而不是每次迭代产生两个分支。


<强>更新

如果您允许将数组分配给SIZE+1,那么您可以摆脱“最后一个条目交换”部分:

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t i;
    theArray[SIZE] = compareVal;
    for (i = 0; theArray[i] != compareVal; i++);
    return i != SIZE;
}

您还可以使用以下代码删除theArray[i]中嵌入的其他算术:

bool check(uint32_t theArray[], uint32_t compareVal)
{
    uint32_t *arrayPtr;
    theArray[SIZE] = compareVal;
    for (arrayPtr = theArray; *arrayPtr != compareVal; arrayPtr++);
    return arrayPtr != theArray+SIZE;
}

如果编译器尚未应用它,那么此函数肯定会这样做。另一方面,它可能会使优化器更难以展开循环,因此您必须在生成的汇编代码中验证...

答案 2 :(得分:62)

您正在寻求优化算法的帮助,这可能会促使您进入汇编程序。但是你的算法(线性搜索)并不那么聪明,所以你应该考虑改变你的算法。 E.g:

完美哈希函数

如果您的256个“有效”值是静态的并且在编译时已知,那么您可以使用perfect hash function。您需要找到一个哈希函数,将您的输入值映射到范围为0 .. n 的值,其中没有冲突表示您关心的所有有效值。也就是说,没有两个“有效”值散列到相同的输出值。在搜索好的哈希函数时,您的目标是:

  • 保持哈希函数合理快速。
  • 最小化 n 。您可以获得的最小值是256(最小完美散列函数),但这可能很难实现,具体取决于数据。

注意对于有效的散列函数, n 通常是2的幂,这相当于低位的按位掩码(AND操作)。散列函数示例:

  • 输入字节的CRC,模数 n
  • ((x << i) ^ (x >> j) ^ (x << k) ^ ...) % n(根据需要选择ijk,...,左移或右移)

然后您创建一个 n 条目的固定表,其中哈希将输入值映射到索引 i 到表中。对于有效值,表条目 i 包含有效值。对于所有其他表条目,请确保索引 i 的每个条目都包含一些其他无效值,该值不会散列到 i

然后在你的中断例程中,输入 x

  1. 哈希 x 索引 i (范围为0..n)
  2. 在表格中查找条目 i ,看它是否包含值 x
  3. 这比256或1024值的线性搜索要快得多。

    written some Python code找到合理的哈希函数。

    二进制搜索

    如果您对256个“有效”值的数组进行排序,则可以执行binary search,而不是线性搜索。这意味着您应该能够仅以8个步骤(log2(256))搜索256个条目表,或者以10个步骤搜索1024个条目表。同样,这将比256或1024值的线性搜索快得多。

答案 3 :(得分:60)

保持表格按顺序排列,并使用Bentley展开的二元搜索:

i = 0;
if (key >= a[i+512]) i += 512;
if (key >= a[i+256]) i += 256;
if (key >= a[i+128]) i += 128;
if (key >= a[i+ 64]) i +=  64;
if (key >= a[i+ 32]) i +=  32;
if (key >= a[i+ 16]) i +=  16;
if (key >= a[i+  8]) i +=   8;
if (key >= a[i+  4]) i +=   4;
if (key >= a[i+  2]) i +=   2;
if (key >= a[i+  1]) i +=   1;
return (key == a[i]);

重点是,

  • 如果你知道桌子有多大,那么你就知道会有多少次迭代,所以你可以完全展开它。
  • 然后,在每次迭代中都没有针对==情况的点测试,因为除了最后一次迭代之外,该情况的概率太低,无法证明花时间测试它。**
  • 最后,通过将表格扩展为2的幂,您最多可以添加一个比较,最多可以添加两个存储。

**如果你不习惯在概率方面进行思考,那么每个决策点都有一个 entropy ,这是你通过执行它所学到的平均信息。 对于>=测试,每个分支的概率大约为0.5,-log2(0.5)为1,这意味着如果你拿一个分支你学习1位,如果你拿另一个分支你学习一个bit,平均值只是你在每个分支上学到的总和乘以该分支的概率。 所以1*0.5 + 1*0.5 = 1,所以>=测试的熵是1.因为你有10位需要学习,所以需要10个分支。 这就是它快速的原因!

另一方面,如果您的第一次测试是if (key == a[i+512)怎么办?为真的概率是1/1024,而假的概率是1023/1024。所以,如果它是真的,你将学习所有10位! 但如果它是假的,你学习-log2(1023/1024)= .00141位,几乎没有! 因此,您从该测试中获得的平均金额为10/1024 + .00141*1023/1024 = .0098 + .00141 = .0112位。 大约百分之一。 那个测试没有承受它的重量!

答案 4 :(得分:16)

如果事先知道表中的常量集,则可以使用perfect hashing来确保只对表进行一次访问。完美哈希确定哈希函数 将每个有趣的键映射到一个唯一的插槽(该表并不总是密集的,但您可以决定您可以负担得起的表的密集程度,使用较少密集的表通常会导致更简单的散列函数)。

通常,特定键组的完美哈希函数相对容易计算;你不希望它变得冗长而复杂,因为竞争时间或许更好地花费多次探测。

完美散列是一种“最大探测”方案。可以概括这个想法,认为人们应该将计算哈希码的简单性与制作k个探测器所需的时间进行交易。毕竟,目标是“查找最少的总时间”,而不是最少的探测或最简单的哈希函数。但是,我从未见过任何人构建k-probes-max哈希算法。我怀疑一个人可以做到,但这可能是研究。

另一个想法:如果你的处理器非常快,那么从一个完美的哈希到内存的探测可能会占据执行时间。如果处理器不是很快,那么k> 1探针可能是实用的。

答案 5 :(得分:14)

使用哈希集。它将给出O(1)查找时间。

以下代码假定您可以将值0保留为“空”值,即不会出现在实际数据中。 对于不是这种情况的情况,可以扩展解决方案。

#define HASH(x) (((x >> 16) ^ x) & 1023)
#define HASH_LEN 1024
uint32_t my_hash[HASH_LEN];

int lookup(uint32_t value)
{
    int i = HASH(value);
    while (my_hash[i] != 0 && my_hash[i] != value) i = (i + 1) % HASH_LEN;
    return i;
}

void store(uint32_t value)
{
    int i = lookup(value);
    if (my_hash[i] == 0)
       my_hash[i] = value;
}

bool contains(uint32_t value)
{
    return (my_hash[lookup(value)] == value);
}

在此示例实现中,查找时间通常非常低,但在最坏的情况下可以达到存储的条目数。对于实时应用程序,您还可以考虑使用二叉树的实现,这将具有更可预测的查找时间。

答案 6 :(得分:9)

在这种情况下,调查Bloom filters可能是值得的。他们能够快速确定一个值不存在,这是一件好事,因为大多数2 ^ 32个可能的值不在1024个元素数组中。但是,有些误报需要额外检查。

由于您的表显然是静态的,因此您可以确定Bloom过滤器存在哪些误报,并将它们放在完美的哈希值中。

答案 7 :(得分:8)

假设您的处理器运行在204 MHz,这似乎是LPC4357的最大值,并且假设您的时序结果反映了平均情况(遍历数组的一半),我们得到:

  • CPU频率:204 MHz
  • 周期:4.9 ns
  • 周期持续时间:12.5μs/ 4.9 ns = 2551周期
  • 每次迭代的周期:2551/128 = 19.9

因此,您的搜索循环每次迭代花费大约20个周期。这听起来不太糟糕,但我想为了让它更快,你需要看一下装配。

我建议删除索引并使用指针比较,然后制作所有指针const

bool arrayContains(const uint32_t *array, size_t length)
{
  const uint32_t * const end = array + length;
  while(array != end)
  {
    if(*array++ == 0x1234ABCD)
      return true;
  }
  return false;
}

至少值得测试。

答案 8 :(得分:6)

其他人建议重组你的表,在最后添加一个标记值,或者对其进行排序以提供二进制搜索。

你说“我也使用指针算法和for循环,它会向下计数而不是向上计算(检查i != 0是否比检查i < 256更快)。”

我的第一个建议是:摆脱指针算术和向下计数。像

这样的东西
for (i=0; i<256; i++)
{
    if (compareVal == the_array[i])
    {
       [...]
    }
}

往往是编译器的惯用。循环是惯用的,并且循环变量上的数组的索引是惯用的。使用指针算法和指针进行操作将倾向于混淆编译器的习惯用法并使其生成与所写内容相关的代码,而不是编译器编写者认为最好的代码一般任务的课程

例如,上面的代码可能被编译成一个循环,从-256-255运行到零,索引&the_array[256]。可能是在有效C中甚至无法表达的东西,但与您生成的机器的架构相匹配。

所以微优化。您只是将扳手投入优化器的工作中。如果你想要聪明,可以处理数据结构和算法,但不要微观优化他们的表达。如果不是当前的编译器/体系结构,那么它会回来咬你,然后再下一步。

特别是使用指针算法而不是数组和索引对于编译器完全了解对齐,存储位置,别名注意事项和其他内容以及以最适合机器架构的方式进行强度降低等优化是有害的。

答案 9 :(得分:3)

如果您可以使用可用内存量来容纳值的域,那么,最快的解决方案是将数组表示为位数组:

bool theArray[MAX_VALUE]; // of which 1024 values are true, the rest false
uint32_t compareVal = 0x1234ABCD;
bool validFlag = theArray[compareVal];

修改

我对批评者的数量感到震惊。这个帖子的标题是“如何快速查找C数组中是否存在值?”我将坚持我的答案,因为它恰好回答了这个问题。我可以说它具有最快速的哈希函数(因为地址===值)。我已经阅读了这些评论,我知道明显的警告。毫无疑问,这些警告限制了可用于解决的问题的范围,但是,对于它确实解决的那些问题,它可以非常有效地解决。

不要直接拒绝这个答案,而应将其视为可以通过使用哈希函数来实现速度和性能之间更好平衡的最佳起点。

答案 10 :(得分:3)

可以在这里使用矢量化,因为它通常在memchr的实现中。您使用以下算法:

  1. 创建一个重复的查询掩码,其长度等于操作系统的位数(64位,32位等)。在64位系统上,您将重复32位查询两次。

  2. 将列表一次处理为多个数据的列表,只需将列表转换为更大数据类型的列表并拉出值即可。对于每个块,使用掩码对其进行异或,然后使用0b0111 ... 1进行异或,然后加1,然后使用&amp;掩码为0b1000 ... 0重复。如果结果为0,则肯定不匹配。否则,可能(通常很有可能)是一个匹配,所以正常搜索块。

  3. 示例实施:https://sourceware.org/cgi-bin/cvsweb.cgi/src/newlib/libc/string/memchr.c?rev=1.3&content-type=text/x-cvsweb-markup&cvsroot=src

答案 11 :(得分:1)

确保指令(“伪代码”)和数据(“ theArray”)位于单独的(RAM)存储器中,以便充分利用CM4 Harvard体系结构的潜力。在用户手册中:

enter image description here

  

为优化CPU性能,ARM Cortex-M4具有三条总线,分别用于指令(代码)(I)访问,数据(D)访问和系统(S)访问。当指令和数据保存在单独的存储器中时,则可以在一个周期内并行进行代码和数据访问。当代码和数据保存在同一内存中时,加载或存储数据的指令可能需要两个周期。

答案 12 :(得分:0)

如果我的答案已经回答,我很抱歉 - 只是我是一个懒惰的读者。感觉你自由地downvote然后))

1)你可以删除计数器'i' - 只需比较指针,即

for (ptr = &the_array[0]; ptr < the_array+1024; ptr++)
{
    if (compareVal == *ptr)
    {
       break;
    }
}
... compare ptr and the_array+1024 here - you do not need validFlag at all.
但是,所有这些都不会带来任何显着的改进,这种优化可能是由编译器本身实现的。

2)正如其他答案已经提到的,几乎所有现代CPU都是基于RISC的,例如ARM。据我所知,即使是现代的英特尔X86 CPU也在内部使用RISC内核(从X86开始编译)。 RISC的主要优化是流水线优化(以及英特尔和其他CPU),最大限度地减少了代码跳转。一种类型的这种优化(可能是主要的)是“循环回滚”。它非常愚蠢,高效,甚至英特尔编译器都可以做到这一点AFAIK。它看起来像:

if (compareVal == the_array[0]) { validFlag = true; goto end_of_compare; }
if (compareVal == the_array[1]) { validFlag = true; goto end_of_compare; }
...and so on...
end_of_compare:

这种方式的优化是管道在最坏的情况下不会中断(如果数组中没有compareVal),所以它尽可能快(当然不计算算法优化,如哈希表,排序数组和等等,在其他答案中提到,根据数组大小可能会给出更好的结果。循环回滚方法也可以在那里应用。我在这里写的是关于我认为我没有在其他人看到的)

此优化的第二部分是该数组项由直接地址获取(在编译阶段计算,确保使用静态数组),并且不需要额外的ADD操作来从数组的基地址计算指针。此优化可能没有显着影响,因为AFAIK ARM体系结构具有加速阵列寻址的特殊功能。但无论如何,最好直接知道你在C代码中做得最好,对吗?

循环回滚可能看起来很麻烦,因为浪费了ROM(是的,如果你的主板支持这个功能,你确实把它放到RAM的快速部分),但实际上它是基于RISC概念的公平的速度支付。这只是计算优化的一般要点 - 为了速度而牺牲空间,反之亦然,这取决于您的要求。

如果您认为1024个元素的数组的回滚对于您的情况而言是太大的牺牲,您可以考虑“部分回滚”,例如将数组分成两部分,每部分512个项目,或4x256,依此类推。< / p>

3)现代CPU通常支持SIMD操作,例如ARM NEON指令集 - 它允许并行执行相同的操作。坦率地说,我不记得它是否适合比较操作,但我觉得它可能是,你应该检查一下。谷歌搜索显示可能还有一些技巧,以获得最大速度,请参阅https://stackoverflow.com/a/5734019/1028256

我希望它可以给你一些新的想法。

答案 13 :(得分:0)

这更像是一个附录,而不是一个答案。

过去我有一个类似的案例,但我的数组在相当多的搜索中都是不变的。

在其中一半中,搜索的值不在数组中。然后我意识到我可以在进行任何搜索之前应用“过滤器”。

此“过滤器”只是一个简单的整数,计算 ONCE 并在每次搜索中使用。

它是用Java编写的,但它非常简单:

binaryfilter = 0;
for (int i = 0; i < array.length; i++)
{
    // just apply "Binary OR Operator" over values.
    binaryfilter = binaryfilter | array[i];
}

所以,在进行二进制搜索之前,我检查了二进制过滤器:

// Check binaryfilter vs value with a "Binary AND Operator"
if ((binaryfilter & valuetosearch) != valuetosearch)
{
    // valuetosearch is not in the array!
    return false;
}
else
{
    // valuetosearch MAYBE in the array, so let's check it out
    // ... do binary search stuff ...

}

你可以使用'更好'的哈希算法,但这可以非常快,特别是对于大数字。 可能这可以为你节省更多的周期。

答案 14 :(得分:0)

我是哈希的忠实粉丝。问题当然是找到一种快速且使用最少内存的高效算法(特别是在嵌入式处理器上)。

如果事先知道可能出现的值,您可以创建一个程序,该程序通过大量算法运行,以找到最佳算法 - 或者更确切地说,是数据的最佳参数。

我创建了这样一个程序,您可以在this post中阅读并获得一些非常快速的结果。 16000个条目大致转换为2 ^ 14或平均14个比较以使用二分搜索找到该值。我明确地针对非常快速的查找 - 平均找到&lt; = 1.5查找中的值 - 这导致更大的RAM要求。我相信,通过更保守的平均值(比如&lt; = 3),可以节省大量内存。相比之下,对256或1024个条目进行二进制搜索的平均情况将导致平均比较次数分别为8和10。

我的平均查找需要大约60个周期(在具有intel i5的笔记本电脑上)和通用算法(利用变量的一个除法)和40-45个具有专用的循环(可能利用乘法)。这应该转换为MCU上的亚微秒查找时间,当然取决于它执行的时钟频率。

如果条目数组跟踪访问条目的次数,则可以进一步调整现实生活。如果在计算indeces之前将条目数组从大多数访问到最少访问,那么它将通过单个比较找到最常出现的值。