最初,当我尝试时出现了问题优化算法根据 Profiler 的数据,Neon Arm 和其中的一小部分占据了 80%。我尝试测试看看可以采取哪些措施来改进它,为此我创建了指向优化函数的不同版本的函数指针数组,然后在循环中运行它们以在探查器中查看哪个性能更好:
typedef unsigned(*CalcMaxFunc)(const uint16_t a[8][4], const uint16_t b[4][4]);
CalcMaxFunc CalcMaxFuncs[] =
{
CalcMaxFunc_NEON_0,
CalcMaxFunc_NEON_1,
CalcMaxFunc_NEON_2,
CalcMaxFunc_NEON_3,
CalcMaxFunc_C_0
};
int N = sizeof(CalcMaxFunc) / sizeof(CalcMaxFunc[0]);
for (int i = 0; i < 10 * N; ++i)
{
auto f = CalcMaxFunc[i % N];
unsigned retI = f(a, b);
// just random code to ensure that cpu waits for the results
// and compiler doesn't optimize it away
if (retI > 1000000)
break;
ret |= retI;
}
我得到了令人惊讶的结果:函数的性能完全取决于它在 CalcMaxFuncs 数组中的位置。例如,当我将 CalcMaxFunc_NEON_3 交换为第一个时,它会慢 3-4 倍,并且根据分析器,它会在函数的最后一位尝试将数据从 neon 移动到 Arm 寄存器时停止。
那么,是什么导致它有时会造成停顿,而有时却不会呢?顺便说一句,如果重要的话,我会在 xcode 中对 iPhone6 进行配置。
当我通过在循环中调用这些函数之间混合一些浮点除法来故意引入 neon 管道停顿时,我消除了不可靠的行为,现在无论调用它们的顺序如何,它们都执行相同的操作。那么,为什么我首先会遇到这个问题,我该如何在实际代码中消除它呢?
Update:我尝试创建一个简单的测试函数,然后分阶段优化它,看看如何避免 neon->arm 失速。
这是测试运行器功能:
void NeonStallTest()
{
int findMinErr(uint8_t* var1, uint8_t* var2, int size);
srand(0);
uint8_t var1[1280];
uint8_t var2[1280];
for (int i = 0; i < sizeof(var1); ++i)
{
var1[i] = rand();
var2[i] = rand();
}
#if 0 // early exit?
for (int i = 0; i < 16; ++i)
var1[i] = var2[i];
#endif
int ret = 0;
for (int i=0; i<10000000; ++i)
ret += findMinErr(var1, var2, sizeof(var1));
exit(ret);
}
And findMinErr
这是:
int findMinErr(uint8_t* var1, uint8_t* var2, int size)
{
int ret = 0;
int ret_err = INT_MAX;
for (int i = 0; i < size / 16; ++i, var1 += 16, var2 += 16)
{
int err = 0;
for (int j = 0; j < 16; ++j)
{
int x = var1[j] - var2[j];
err += x * x;
}
if (ret_err > err)
{
ret_err = err;
ret = i;
}
}
return ret;
}
基本上,它计算每个 uint8_t[16] 块之间的平方差之和,并返回具有最小平方差的块对的索引。那么,那么我用霓虹灯内在函数重写了它(没有特别尝试使其更快,因为这不是重点):
int findMinErr_NEON(uint8_t* var1, uint8_t* var2, int size)
{
int ret = 0;
int ret_err = INT_MAX;
for (int i = 0; i < size / 16; ++i, var1 += 16, var2 += 16)
{
int err;
uint8x8_t var1_0 = vld1_u8(var1 + 0);
uint8x8_t var1_1 = vld1_u8(var1 + 8);
uint8x8_t var2_0 = vld1_u8(var2 + 0);
uint8x8_t var2_1 = vld1_u8(var2 + 8);
int16x8_t s0 = vreinterpretq_s16_u16(vsubl_u8(var1_0, var2_0));
int16x8_t s1 = vreinterpretq_s16_u16(vsubl_u8(var1_1, var2_1));
uint16x8_t u0 = vreinterpretq_u16_s16(vmulq_s16(s0, s0));
uint16x8_t u1 = vreinterpretq_u16_s16(vmulq_s16(s1, s1));
#ifdef __aarch64__1
err = vaddlvq_u16(u0) + vaddlvq_u16(u1);
#else
uint32x4_t err0 = vpaddlq_u16(u0);
uint32x4_t err1 = vpaddlq_u16(u1);
err0 = vaddq_u32(err0, err1);
uint32x2_t err00 = vpadd_u32(vget_low_u32(err0), vget_high_u32(err0));
err00 = vpadd_u32(err00, err00);
err = vget_lane_u32(err00, 0);
#endif
if (ret_err > err)
{
ret_err = err;
ret = i;
#if 0 // enable early exit?
if (ret_err == 0)
break;
#endif
}
}
return ret;
}
Now, if (ret_err > err)
显然是数据危险。然后我手动“展开”两个循环并修改代码以使用 err0 和 err1 并在执行下一轮计算后检查它们。根据探查器,我得到了一些改进。在简单的 neon 循环中,我将整个函数的大约 30% 花费在这两行中vget_lane_u32
其次是if (ret_err > err)
。在我展开两次之后,这些操作开始占用 25%(例如,我获得了大约 10% 的整体加速)。另外,检查armv7版本,设置err0时之间只有8条指令(vmov.32 r6, d16[0]
)以及当它被访问时(cmp r12, r6
). T
注意,在代码中提前退出是ifdefed out。启用它会使功能变得更慢。如果我把它展开四个并改为使用四个errN
变量并延迟检查两轮然后我仍然看到vget_lane_u32
在探查器中花费太多时间。当我检查生成的 asm 时,似乎编译器破坏了所有优化尝试,因为它重用了一些errN
有效地使CPU访问结果的寄存器vget_lane_u32
比我想要的早得多(我的目标是延迟 10-20 条指令的访问)。仅当我按 4 展开并将所有四个 errN 标记为易失性时vget_lane_u32
然而,在剖面仪中完全从雷达上消失了if (ret_err > errN)
检查显然变得非常慢,因为现在这些可能最终成为常规堆栈变量,总体而言,4x 手动循环展开中的这 4 次检查开始占用 40%。看起来使用适当的手动 asm 可以使其正常工作:提前退出循环,同时避免 neon->arm 停顿并在循环中包含一些arm逻辑,但是,处理arm asm所需的额外维护使其复杂了10倍在大型项目中维护这种代码(没有任何武器)。
Update:
这是将数据从 neon 移动到 Arm 寄存器时的停顿示例。为了实现早期存在,我需要每个循环从霓虹灯移动到手臂一次。根据 xcode 附带的采样分析器,仅此一步就占用了整个函数的 50% 以上。我尝试在 mov 之前和/或之后添加大量 noop,但似乎没有任何内容影响探查器中的结果。我尝试使用 vorr d0,d0,d0 进行 noops:没有区别。停顿的原因是什么,或者分析器只是显示错误的结果?