【问题标题】:How to set up repetitive data so that most can be optimized away?如何设置重复数据,以便大部分可以优化掉?
【发布时间】:2019-02-17 16:18:19
【问题描述】:

我需要对 32 kbit 宽的数据执行按位与。这些值之一是固定位掩码。

我一次执行这个 AND 32 位。简化后,我的算法将如下所示:

(我将从此示例中删除内存管理、变量范围问题等)

#include <stdint.h>

const uint32_t mask[1024] = {
            0b00110110100101100111001011000111,
            0b10001110100101111010010100100100,
            0b11101010010000110001101010010101,
            0b10001110100101111010010100100100,
            (...) // 1019 more lines!
            0b00110110100101100111001011000111};

uint32_t answer[1024] = {0};
uint32_t workingdata = 0;
uint16_t i = 0;

int main(void)
{
    for (i=0; i<1024; i++)
    {
        workingdata = getnextdatachunk();
        answer[i] = workingdata & mask[i];
    }

    do_something_with_answer();

    return 0;
}

事情是这样的:如果您查看示例位掩码,掩码 [1] == 掩码 [3] 和掩码 [0] == 掩码 [1023]。

在我的实际位掩码中,大多数值都是重复的;整个 1024 值数组中只有 20 个不同的值。此外,在我的最终应用程序中我有 16 个不同的位掩码,每个位掩码都有相似的内部重复。

我正在寻找一种避免存储和遍历大量不必要数据的好方法。

我考虑过的一种方法类似于查找表,其中我的数组仅包含每个所需位掩码块的单个实例:

const uint32_t mask[20] = {
            0b00110110100101100111001011000111,
            0b10001110100101111010010100100100,
            (...) // only 17 more lines!
            0b11101010010000110001101010010101};

uint32_t answer[1024] = {0};
uint32_t workingdata = 0;
uint16_t i = 0;

int main(void)
{
    for (i=0; i<1024; i++)
    {
        workingdata = getnextdata();

        switch(i)
        {
            // the mask indexes are precalculated:

            case 0:
                answer[i] = workingdata & mask[5];
                break;
            case 1:
                answer[i] = workingdata & mask[2];
                break;
            case 2:
                answer[i] = workingdata & mask[2];
                break;
            case 3:
                answer[i] = workingdata & mask[0];
                break;
            case (...): // 1020 more cases!
                (...);
                break;
            default:
        }
    }

    do_something_with_answer();

    return 0;
}

或者,使用更紧凑的 switch 语句:

switch(i)
{
    // the mask indexes are precalculated:

    case 0,3,4,5,18,35,67,(...),1019:
        answer[i] = workingdata & mask[0];
        break;
    case 1,15,16,55,89,91,(...),1004:
        answer[i] = workingdata & mask[1];
        break;
    case (...): // Only 18 more cases!
        (...);
        break;
    default:
}

这两种解决方案确实让人不清楚发生了什么,我真的很想避免这种情况。

理想情况下,我想保留原始结构并让 gcc 的优化器删除所有不必要的数据。 如何让我的代码保持良好的编写并仍然高效?

【问题讨论】:

  • 我认为您的任何解决方案都不清楚。如果你用 1-2 行注释来限定它,我会很清楚。如果您要执行 switch 语句路由,可能会预先计算您的值并将它们保存为标题中的#define(s),然后包含它。
  • 原来的解决方案有那么糟糕吗? 32kbits 是 4 KB,适合 L1 缓存。可能有一些方法可以压缩它(例如两层查找表),但这些方法可能比用愚蠢、简单的方法来做要慢。
  • 所有优化都会导致权衡取舍,最常见的是更快但使用更多内存,或者更慢但使用更少内存。而所有优化将导致代码难以阅读、遵循、理解和维护。在你的情况下,我建议你保留你的大表,否则代码将难以理解,甚至可能占用更多空间,甚至可能更慢。
  • 最重要的是:在您测量代码是您程序中的前三大瓶颈之前,不要进行优化。并且始终在启用编译器优化的情况下进行测量。
  • 谢谢@NickODell!在原始问题中并不明显(我只是进行了编辑以使其更明显),但实际上我有 16 个这样的位掩码。但也许它仍然不值得调整:)

标签: c gcc optimization


【解决方案1】:

让我们发明一个积分系统,假设从 L1 缓存中提取数据需要 4 点,从 L2 缓存中提取需要 8 点,不可预测的分支需要 12 点。请注意,选择这些点是为了粗略地表示“平均但未知的 80x86 CPU 的周期”。

具有单个 1024 条目表的原始代码每次迭代的总成本为 4 点(假设它的执行频率足以使性能变得重要,因此假设数据的使用频率足够频繁以位于 L1 缓存中)。

使用 switch 语句,编译器将(希望 - 如果分支是性能噩梦的系列)将其转换为跳转表并执行goto table[i]; 之类的操作,因此它可能算作从表中获取(4 分) 后跟一个不可预知的分支(12 分);或每次迭代总共 16 个点。

请注意,对于 64 位代码,编译器生成的跳转表将是 1024 个条目,其中每个条目是 64 位;并且该表将是第一个选项表的两倍(即 1024 个条目,每个条目为 32 位)。但是,许多 CPU 中的 L1 数据缓存为 64 KiB,因此 64 KiB 跳转表意味着进入 L1 数据缓存的任何其他内容(源数据被 ANDed,生成的“答案”数据,CPU 堆栈上的任何内容)导致(64 字节或 8 个条目)跳转表的片段从缓存中被逐出以腾出空间。这意味着有时您会为“L1 未命中,L2 命中”付出代价。假设这种情况发生的概率为 5%,因此每次迭代的实际成本最终为“(95 * (4+12) + 5 * (8+12)) / 100 = 16.2” 点。

假设您希望第一个选项的性能更好(“每次迭代 16.2 点”明显大于“每次迭代 4 点”),并且您希望第一个选项的可执行文件大小更好选项(即使不考虑switch 中每个case 的任何代码,32 KiB 表的大小也是 64 KiB 表的一半),并且考虑到第一个选项具有更简单(更易于维护)的代码;我看不出你想要使用第二个选项的单一原因。

为了优化这段代码,我会尝试处理更大的部分。举个简单的例子,你能不能这样做:

    uint64_t mask[512] = { ....

    uint64_t workingdata;
    uint64_t temp;

    for (i=0; i<512; i++)
    {
        workingdata = getnextdatachunk() << 32 | getnextdatachunk();
        temp = workingdata & mask[i];
        answer[i*2] = temp;
        answer[i*2+1] = temp >> 32;
    }

如果你能做这样的事情,那么它可能(充其量)将性能提高一倍;但是,如果您可以执行“每次迭代 64 位,进行一半的迭代”,您也可以使用 SIMD 内在函数来执行“每次迭代 128 位,进行四分之一的迭代”或“每次迭代 256 位,进行八分之一的迭代”迭代”,并且可能能够使其快近 8 倍。

当然,除此之外的步骤是缓冲足够的源数据,以提高使用多个线程(多个 CPU)的效率(例如,这样可以有效地摊销同步成本)。 4 个 CPU 并行运行,每次迭代执行 256 位,您将获得“比使用单个 CPU 版本每次迭代 32 位时原始 1024 次迭代快 32 倍”的(理论上的最佳情况)加速。

【讨论】:

  • 谢谢。这种类型的分析很有帮助!
【解决方案2】:

我个人认为您的方法实际上取决于您的用例。您有 2 种不同的模式:

  • 如果运行速度很重要,请将数组作为一个整体保留在内存中(考虑到数组不会变得太大而无法与缓存混淆)。
  • 如果代码大小很重要,请使用您的想法或PSkocik 建议的方法。

要选择合适的代码设计,您需要考虑很多不同的因素。例如,如果您的代码将在嵌入式设备上运行,我可能会采用更小的代码大小的方法。但是,如果您是普通 PC 编码,我可能会选择第一个。

【讨论】:

  • 谢谢。这很有趣;我是一名嵌入式(裸机)设计师。这是我为实际计算机平台编写的第一个应用程序!有这么多可用资源似乎很奇怪:)
猜你喜欢
  • 1970-01-01
  • 2021-10-23
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2012-01-27
  • 2020-06-05
  • 1970-01-01
  • 2010-12-04
相关资源
最近更新 更多