ChatGPT解决这个技术问题 Extra ChatGPT

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

我有一个具有时间关键 ISR 的嵌入式应用程序,它需要遍历一个大小为 256 的数组(最好是 1024,但 256 是最小值)并检查一个值是否与数组内容匹配。在这种情况下,bool 将设置为 true。

微控制器是NXP 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;
     }
}

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

有没有办法以不同的方式将值存储在数组中?如果您可以对它们进行排序,那么二进制搜索肯定会更快。如果要存储和搜索的数据在一定范围内,它们可以用位图等表示。
@BitBank:您会惊讶于在过去三年中编译器的改进程度。 ARM 特别是对编译器非常友好。而且我知道 GCC 上的 ARM 可以发出加载多条指令(至少从 2009 年开始)
很棒的问题,人们忘记了在现实世界中存在性能很重要的案例。太多次这样的问题都是用“just use stl”来回答的
标题“...遍历数组”具有误导性,因为您实际上只是在搜索给定值。遍历数组意味着要对每个条目进行一些操作。如果成本可以通过多次搜索分摊,那么排序确实是一种独立于语言实现问题的有效方法。
您确定不能简单地使用二进制搜索或哈希表吗?对 256 个项目进行二分搜索 == 8 次比较。哈希表 == 平均 1 次跳跃(如果您有完美的哈希,则为 1 次跳跃 max)。只有在 1) 有一个不错的搜索算法(O(1)O(logN),与 O(N) 相比),并且 2) 您已将其分析为瓶颈之后,您才应该诉诸汇编优化。

B
BitBank

在性能至关重要的情况下,与手动调整的汇编语言相比,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 代码。这是 GCC 产生的:

.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 可能会给出类似的结果。


@LưuVĩnhPhúc - 这通常是正确的,但严格的 ISR 是最大的例外之一,因为您通常比编译器了解更多。
魔鬼的拥护者:有没有量化证据表明这段代码更快?
@BitBank:这还不够好。你必须用证据来支持你的主张。
我多年前就吸取了教训。我为 Pentium 上的图形例程制作了一个惊人的优化内部循环,最佳地使用了 U 和 V 管道。将其降低到每个循环 6 个时钟周期(计算和测量),我为自己感到非常自豪。当我针对用 C 编写的相同内容对其进行测试时,C 更快。我再也没有写过另一行英特尔汇编程序。
“评论中的怀疑论者认为我的经历是轶事/毫无价值,需要证据。”不要过度消极地看待他们的评论。展示证明只会让你的好答案变得更好。
A
Alon Gubkin

有一个优化它的技巧(我在一次工作面试中被问到这个):

如果数组中的最后一个条目包含您要查找的值,则返回 true

将您要查找的值写入数组的最后一个条目

迭代数组,直到遇到要查找的值

如果在数组的最后一个条目之前遇到过,则返回 true

返回假

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;
}

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


@ratchetfreak:OP 没有提供有关如何、在何处以及何时分配和初始化该数组的任何详细信息,因此我给出了一个不依赖于此的答案。
数组在 RAM 中,但不允许写入。
很好,但数组不再是 const,这使得它不是线程安全的。似乎要付出高昂的代价。
@EOF:问题中曾在哪里提到过 const
@barakmanos:如果我将一个数组和一个值传递给您,并询问您该值是否在数组中,我通常不会假设您会修改数组。最初的问题既没有提到 const 也没有提到线程,但我认为提到这个警告是公平的。
M
Mike Dunlavey

保持表格排序,并使用 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 的幂,您最多可以添加一个比较,最多可以添加两倍的存储空间。

** 如果您不习惯用概率来思考,那么每个决策点都有一个,它是您通过执行它学到的平均信息。对于 >= 测试,每个分支的概率约为 0.5,-log2(0.5) 为 1,这意味着如果您选择一个分支,您将学习 1 位,如果您选择另一个分支,您将学习 1 位,平均值就是你在每个分支上学到的知识乘以该分支的概率的总和。所以 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 位。 大约百分之一。该测试无法承受它的重量!


我真的很喜欢这个解决方案。如果值的位置是敏感信息,则可以将其修改为以固定数量的周期运行以避免基于时间的取证。
@OregonTrail:基于时间的取证?有趣的问题,但悲伤的评论。
您会在加密库中看到类似这样的展开循环,以防止定时攻击en.wikipedia.org/wiki/Timing_attack。这是一个很好的例子 github.com/jedisct1/libsodium/blob/… 在这种情况下,我们防止攻击者猜测字符串的长度。通常,攻击者会获取数百万个函数调用样本来执行定时攻击。
@OregonTrail:我支持您基于时间的评论。我不止一次不得不编写以固定周期数执行的加密代码,以避免将信息泄露给基于时间的攻击。
C
Community

您正在寻求优化算法的帮助,这可能会将您推向汇编程序。但是你的算法(线性搜索)不是那么聪明,所以你应该考虑改变你的算法。例如:

完美的哈希函数

二分查找

完善的哈希函数

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

保持哈希函数相当快。

最小化 n。你能得到的最小值是 256(最小的完美哈希函数),但这可能很难实现,具体取决于数据。

请注意,对于高效的散列函数,n 通常是 2 的幂,相当于低位的按位掩码(AND 运算)。示例哈希函数:

输入字节的 CRC,以 n 为模。

((x << i) ^ (x >> j) ^ (x << k) ^ ...) % n (根据需要选择尽可能多的 i、j、k、...,左移或右移)

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

然后在您的中断程序中,输入 x:

将 x 散列到索引 i(在 0..n 范围内) 在表中查找条目 i 并查看它是否包含值 x。

这将比线性搜索 256 或 1024 个值快得多。

我已经 written some Python code 找到了合理的散列函数。

二进制搜索

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


感谢那。二分搜索选项是我选择的选项。另请参阅第一篇文章中的早期评论。在不使用汇编的情况下,这可以很好地解决问题。
实际上,在尝试优化代码(例如使用汇编或其他技巧)之前,您可能应该看看是否可以降低算法复杂性。通常,降低算法复杂度比尝试缩短几个周期但保持相同的算法复杂度更有效。
一个流行的概念是,要找到一个有效的哈希例程需要花费太多精力,因此“最佳实践”是二进制搜索。但有时,“最佳实践”还不够好。假设您在数据包的标头到达(但不是其有效负载)的那一刻即时路由网络流量:使用二进制搜索会使您的产品变得非常慢。嵌入式产品通常具有这样的约束和要求,例如在 x86 执行环境中的“最佳实践”是嵌入式中的“轻松出路”。
I
Ira Baxter

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

通常,特定键集的完美散列函数相对容易计算;您不希望它冗长而复杂,因为这会争夺时间,也许最好花在进行多次探测上。

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

另一种想法:如果您的处理器非常快,那么从完美哈希中对内存的一次探测可能会主导执行时间。如果处理器不是很快,那么 k>1 探针可能是实用的。


Cortex-M 的速度远非极快。
事实上,在这种情况下,他根本不需要任何哈希表。他只想知道某个键是否在集合中,他不想将其映射到一个值。因此,如果完美的散列函数将每个 32 位值映射到 0 或 1 就足够了,其中“1”可以定义为“在集合中”。
好点,如果他能得到一个完美的哈希生成器来产生这样的映射。但是,那将是“一个极其密集的集合”;我相信他可以找到一个完美的哈希生成器来做到这一点。如果在集合中,他可能最好尝试获得一个完美的散列,该散列产生一些常数 K,如果不在集合中,则产生除 K 之外的任何值。我怀疑即使是后者也很难获得完美的哈希值。
@DavidOngaro table[PerfectHash(value)] == value 如果值在集合中,则产生 1,如果不在集合中,则产生 0,并且有众所周知的方法来产生 PerfectHash 函数(例如,参见 burtleburtle.net/bob/hash/perfect.html)。试图找到一个哈希函数,将集合中的所有值直接映射为 1,并将集合中没有的所有值映射为 0,这是一项鲁莽的任务。
@DavidOngaro:一个完美的散列函数有很多“误报”,也就是说,不在集合中的值与集合中的值具有相同的散列。所以你必须有一个表,由哈希值索引,包含“in-the-set”输入值。因此,要验证任何给定的输入值,您 (a) 对其进行哈希处理; (b) 使用哈希值进行查表; (c) 检查表中的条目是否与输入值匹配。
j
jpa

使用哈希集。它将给 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);
}

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


这取决于此查找必须执行多少次才能有效。
呃,查找可以在数组的末尾运行。而且这种线性散列具有很高的冲突率——你不可能得到 O(1)。好的哈希集不是这样实现的。
@JimBalter 是的,不是完美的代码。更像是总体思路;可能只是指向现有的哈希集代码。但考虑到这是一个中断服务例程,证明查找不是非常复杂的代码可能很有用。
你应该修复它,让它环绕我。
一个完美的散列函数的要点是它只做一次探测。时期。
P
Peter Mortensen

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

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


P
Peter Mortensen

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

CPU频率:204兆赫

周期: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;
}

这至少值得测试。


-1,ARM 有一个索引地址模式,所以这是没有意义的。至于制作指针 const,GCC 已经发现它不会改变。 const 也不添加任何内容。
@MSalters 好的,我没有用生成的代码进行验证,关键是要表达一些使它在 C 级别更简单的东西,我认为只是管理指针而不是指针和索引 is更简单。我只是不同意“const 没有添加任何东西”:它非常清楚地告诉读者该值不会改变。这是很棒的信息。
这是深度嵌入的代码;迄今为止的优化包括将代码从闪存移动到 RAM。然而,它仍然需要更快。在这一点上,可读性不是目标。
@MSalters“ARM 有一个索引地址模式,所以这是没有意义的”——好吧,如果你完全没抓住重点...... OP 写道“我也使用指针算术和 for 循环”。 unwind 并没有用指针替换索引,他只是消除了索引变量,因此在每次循环迭代时都会额外减去。但是 OP 是明智的(与许多回答和评论的人不同)并最终进行了二进制搜索。
G
Grijesh Chauhan

其他人建议重新组织您的表,在末尾添加一个标记值,或对其进行排序以提供二进制搜索。

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

我的第一个建议是:摆脱指针算术和递减计数。像这样的东西

for (i=0; i<256; i++)
{
    if (compareVal == the_array[i])
    {
       [...]
    }
}

对编译器来说往往是惯用的。循环是惯用的,循环变量上的数组索引是惯用的。使用指针算术和指针将倾向于混淆编译器的习语,并使其生成与您编写的内容相关的代码,而不是编译器编写者决定作为一般任务的最佳课程的代码。

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

所以不要微优化。您只是将扳手扔进优化器的工作中。如果您想变得聪明,请研究数据结构和算法,但不要对其表达进行微优化。它会回来咬你,如果不是在当前的编译器/架构上,那么在下一个。

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


指针上的循环在 C 中是惯用的,良好的优化编译器可以像处理索引一样处理它们。但这整件事没有实际意义,因为 OP 最终进行了二进制搜索。
m
meisel

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

创建重复查询的掩码,长度等于操作系统的位数(64 位、32 位等)。在 64 位系统上,您将重复 32 位查询两次。一次将列表处理为多条数据的列表,只需将列表转换为更大数据类型的列表并提取值即可。对于每个块,将其与掩码异或,然后与 0b0111...1 异或,然后加 1,然后 & 与 0b1000...0 的掩码重复。如果结果为 0,则肯定不匹配。否则,可能(通常以非常高的概率)匹配,因此正常搜索块。

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


S
Stephen Quan

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

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

编辑

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

与其直接拒绝这个答案,不如将其视为最佳起点,您可以通过使用散列函数在此基础上进行改进,以在速度和性能之间取得更好的平衡。


这如何获得 4 票?问题表明它是Cortex M4。这个东西有 136 KB RAM,而不是 262.144 KB。
令人吃惊的是,有多少人对明显错误的答案投了赞成票,因为回答者只见树木不见森林。对于 OP 的最大情况 O(log n) << O(n)。
当有更好的解决方案可用时,我对烧掉大量内存的程序员感到非常暴躁。每隔 5 年,我的 PC 似乎就会出现内存不足的情况,而 5 年前这个数量已经很多了。
这些天@CraigMcQueen 孩子们。浪费内存。离谱!回到我的时代,我们有 1 MiB 的内存和 16 位的字长。 /s
严厉的批评者是怎么回事? OP 明确指出速度对于这部分代码绝对至关重要,StephenQuan 已经提到了“荒谬的内存量”。
C
Community

如果我的答案已经被回答,我很抱歉 - 只是我是一个懒惰的读者。那么请随意投反对票))

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 的主要优化是流水线优化(对于 Intel 和其他 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 op 来从数组的基地址计算指针。这种优化可能没有显着效果,因为 AFAIK ARM 架构具有加速阵列寻址的特殊功能。但无论如何,最好知道你直接在 C 代码中做了所有最好的事情,对吧?

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

如果您认为 1024 个元素的数组的回滚对您的情况来说牺牲太大,您可以考虑“部分回滚”,例如将数组分成 2 部分,每部分 512 项,或 4x256,等等。

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

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


OP 绕过了所有专注于优化线性循环的愚蠢答案,而是对数组进行了预排序并进行了二进制搜索。
@Jim,显然应该首先进行这种优化。在某些用例中,例如当您没有时间对数组进行排序时,“愚蠢”的答案可能看起来并不那么愚蠢。或者,如果您获得的速度还不够
“很明显,这种优化应该首先进行”——显然不是那些努力开发线性解决方案的人。 “你没有时间对数组进行排序”——我不知道那是什么意思。 “或者,如果你得到的速度无论如何都不够”——呃,如果二分搜索的速度“不够”,那么优化线性搜索不会改善它。现在我完成了这个主题。
@JimBalter,如果我遇到像 OP 这样的问题,我当然会考虑使用二进制搜索之类的算法。我只是想不到 OP 还没有考虑过。 “您没有时间对数组进行排序”意味着对数组进行排序需要时间。如果您需要对每个输入数据集执行此操作,则可能需要比线性循环更长的时间。 “或者,如果您获得的速度仍然不够”意味着以下 - 上面的优化提示可用于加速二进制搜索代码或任何其他内容
P
Peter Mortensen

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

过去我也遇到过类似的情况,但我的数组在相当多的搜索中保持不变。

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

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

它在 Java 中,但非常简单:

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

因此,在进行二进制搜索之前,我检查了 binaryfilter:

// 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 ...

}

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


f
francek

确保指令(“伪代码”)和数据(“theArray”)位于单独的 (RAM) 存储器中,以便充分利用 CM4 哈佛架构。从用户手册:

https://i.stack.imgur.com/uqKk4.png

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

按照这个指南,我观察到了大约 30% 的速度增加(在我的例子中是 FFT 计算)。


有趣的是,Cortex-M7 具有可选的指令/数据缓存,但在此之前绝对没有。 en.wikipedia.org/wiki/ARM_Cortex-M#Silicon_customization
C
Community

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

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

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

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

如果条目数组跟踪条目被访问的次数,它可以在现实生活中进一步调整。如果在计算索引之前对条目数组从访问次数最多到最少进行排序,那么它将通过一次比较找到最常出现的值。