【问题标题】:Micro-optimizing a linear search loop over a huge array with OpenMP: can't break on a hit使用 OpenMP 对巨大数组上的线性搜索循环进行微优化:命中时不会中断
【发布时间】:2026-01-05 16:15:01
【问题描述】:

我有一个循环大约需要 90% 到 99% 的程序时间。它读取了一个巨大的 LUT,并且这个循环被执行了 > 100,000 次,所以它值得一些优化。

编辑:

LUT(实际上有各种组成 LUT 的数组)由 ptrdiff_tunsigned __int128 的数组组成。由于算法(尤其是 128 位的),它们必须那么宽。 T_RDY 是唯一的 bool 数组。

编辑:

LUT 存储过去用于尝试解决无效问题的组合。它们之间没有关系(我还可以看到),所以我没有看到更合适的搜索模式。

循环的单线程版本是:

k   = false;
for (ptrdiff_t i = 0; i < T_IND; i++) {
        if (T_RDY[i] && !(~T_RWS[i] & M_RWS) && ((T_NUM[i] + P_LVL) <= P_LEN)) {
                k = true;
                break;
        }
}

通过使用 OpenMP 的这段代码,我将 4 核处理器中的时间缩短了 2 倍到 3 倍:

k   = false;
#pragma omp parallel for shared(k)
for (ptrdiff_t i = 0; i < T_IND; i++) {
        if (k)
                continue;
        if (T_RDY[i] && !(~T_RWS[i] & M_RWS) && ((T_NUM[i] + P_LVL) <= P_LEN))
                k = true;
}

编辑:

关于所用数据的信息:

#define DIM_MAX     128

#define P_LEN       prb_lvl[0]
#define P_LVL       prb_lvl[1]

#define M_RWS       prb_mtx_rws[prb_lvl[1]]

#define T_RWS       prb_tab
#define T_NUM       prb_tab_num
#define T_RDY       prb_tab_rdy
#define T_IND       prb_tab_ind


extern  ptrdiff_t   prb_lvl [2];

extern  uint128_t   prb_mtx_rws [DIM_MAX];

extern  uint128_t   prb_tab [10000000];
extern  ptrdiff_t   prb_tab_num [10000000];
extern  bool        prb_tab_rdy [10000000];
extern  ptrdiff_t   prb_tab_ind;

但是,事实上我没有得到大约 10 的改进。 4x 意味着它引入了开销,我猜它是从 2x 到 1.5x。部分开销是不可避免的(创建和销毁线程),但由于 OpenMP 不允许来自并行循环的 break 并且我在每次迭代中添加了 if 和如果可能的话,我想摆脱它。

还有其他可以应用的优化吗?也许改用 pthreads。

我应该麻烦编辑一些程序集吗?

我正在使用 GCC 9 和 -O3 -flto(以及其他)。

编辑:

CPU:i7-5775C

但我计划使用其他具有更多内核的 x64 CPU。

【问题讨论】:

  • 评论不用于扩展讨论;这个对话是moved to chat
  • 删除断点可以是:for (...; !k &amp;&amp; ...;...)... ... 用于断点的条件。然后你可以使用 k= ... 而不是整个 if 语句。
  • 请提供minimal reproducible example 以及具体的性能测量结果。
  • 它使用了一个查找表(如 1 中),但实际上它使用了 3。它们的大小都相同,可能可以组合成一个查找表桌子。你甚至可以做一个位表,这样你就可以通过比较 0 或 -1(全部为假或全部为真)来一次检查 64 个
  • 假设 M_RWSP_LVLP_LEN 在搜索执行期间不应该更改,您可以将这些外部值加载到局部变量中以确保它们不会每个循环都不断地重新加载。如果您知道P_LEN - P_LVL 不会不足或溢出,那么您可以预先计算并在比较中使用它。

标签: c loops pthreads openmp micro-optimization


【解决方案1】:

您可以将 k 合并为位表,然后一次进行 64 次比较。如果主表中的条目发生更改,请重新计算位表中的该位。

如果不同的查询使用不同的 M_RWSP_LVL 或其他东西,那么您需要单独的缓存用于单独的搜索输入。或者,如果您在更改之间执行多个查询,则为它们的当前值重建缓存。但希望不是这样,否则全大写的名称会产生误导。

设置k为位表

#define KSZ (10000000/64 + !!(10000000 % 63))
static uint64_t k[KSZ];

void init_k(void){
  // We can split this up to minimize cache misses, see below
  for (size_t i;i<10000000;++i)
    k[i/64] |= (uint64_t)((!!T_RDY[i]) & (!(~T_RWS[i] & M_RWS)) &((T_NUM[i] + P_LVL) <= P_LEN) ) << (i&63);
}

您可以通过搜索一个非零的 64 位块,然后使用位扫描来查找该块中的位,从而找到 k 中的位索引:

size_t k2index(void){
  size_t i;
  for (i=0; i<KSZ;++i)
    if (k[i]) break;
  return 64 * i + __builtin_ctzll(k[i]);
}

您可能希望拆分数据读取,以便获得顺序数据访问(如所述,每个表超过 40=80MB)并且不会在每次迭代中出现缓存未命中。

#define KSZ (10000000/64 + !!(10000000%63))
static uint64_t k[KSZ], k0[KSZ], k1[KSZ]; //use calloc instead?

void init_k(void){
  //I split these up to minimize cache misses
  for (size_t i;i<10000000;++i)
    k[i/64] |= (uint64_t)(!!T_RDY[i]) << (i&63);
  for (size_t i;i<10000000;++i)
    k0[i/64] |= (uint64_t)(!(~T_RWS[i] & M_RWS)) << (i&63);
  for (size_t i;i<10000000;++i)
    k1[i/64] |= (uint64_t)((T_NUM[i] + P_LVL) <= P_LEN) << (i&63);

  //now combine them 64 bits at a time
  for (size_t i;i<KSZ;++i)
    k[i] &= k0[i];
  for (size_t i;i<KSZ;++i)
    k[i] &= k1[i];
}

如果您像这样拆分它,您还可以在设置其他表时初始化(其中一些)它们。或者,如果表格更新了,您也可以更新 k 值。

【讨论】:

  • 你不需要 k,k0,k1。使用嵌套循环为 64x (~T_RWS[i] &amp; M_RWS) 值构建 uint64_t,然后将 &amp;= 构建为 k[i/64]。 (并且第一个循环可以累积一个标量 tmp 并使用 = 而不是 |= 以避免需要 calloc)。此外,如果您在存储之前累积了位图的 32 位甚至 16 位块,那么乱序 exec 可能会更轻松地使用 init 函数,以防串行依赖性成为问题。但是 C 语言并不容易访问具有不同块宽度的位图。我猜你需要 uint64_t[]uint32_t[] 的联合,并引入字节序。
  • 此外,init 上的较小块将使内部循环的完全展开更加实用,消除变量计数移位,更新位位置和循环开销。 (尽管如此大的输入元素在缓存中并不热,但它很可能仍然受内存限制。但这对 HT 更友好)。
  • 在现代 x86 硬件上读取 3 个单独的输入流并写入 1 个输出流可能是最好的。单独循环每个输入数组不会显着减少缓存未命中。例如,英特尔 CPU 上的硬件预取可以跟踪每 4k 页(L2 流媒体)的 1 个正向和 1 个反向流。并且 4 个流少于典型的 L1d 关联性(8 路),尽管 Skylake 客户端 CPU 具有 4 路关联 L2(低于 8 路)。因此,即使输入都别名为同一组 L1d 或 L2 缓存,它们也不会导致冲突未命中。每个数组都有不同的元素大小。
  • 顺便说一句,AVX512 非常适合将比较结果转换为位图数组。 AVX512 SIMD 比较将结果放入掩码寄存器中,您可以对另一个掩码进行零掩码比较,以免费比较结果。
  • @PeterCordes 感谢您提供额外的见解 - 我打算将线程作为将其拆分为 3 个块的原因,但后来我不想编写线程代码并省略了这一点。我还考虑过在存储之前积累一个完整的 uint64 - 也许在未来的编辑中。我没有现代 x86 硬件(甚至没有 avx2),所以我倾向于坚持 arm+neon 和 sse2 的共同点。