如何为自定义类型解锁-ffast-math并提升代码代数运算效率?
我该如何提升自定义实现类型代码的代数运算效率?
是否存在可继承的虚拟空类型,能告知编译器该类型属于fast-math范畴?或是有现成的编译期类型可提供所需的全部表达式模板?
前置说明:任何关于编译期和运行期的假设均不成立,整个流程分为两个连续的编译与执行阶段。
- 第一阶段是编译并运行
initial_source.cpp,该程序运行后会生成generated_program.cpp文件,无论使用何种编译选项,生成的文件内容均不会改变。 - 接下来编译并运行
generated_program.cpp,我关注的是这最后一步的运行性能。实践表明,当initial_source.cpp的代数表达式更简洁时,生成的generated_program.cpp会更短、运行速度更快。 - 面向数学爱好者:任何在代数上等价但形式不同的
initial_source.cpp文件,会生成在代数上等价但形式不同的generated_program.cpp文件(忽略舍入误差)。
因此,我希望告知编译器的并非:
- 编译运行
initial_source.cpp时处理字符串和其他基础类型存在索引偏移,请对这部分进行优化。
而是:
- 你看到这些包含未知数字类型的代数表达式了吗?请将它们当作double类型处理,重新整理为最简洁的表达式,之后再以任意方式编译代码即可。
我有一个可被当作double或float等类型使用的自定义类型Number。
对于任意函数:
template<typename Tfloat> void f(Tfloat* y, Tfloat* x){ ... }
我可以传入自定义类型来调用它,并由此生成其他代码(比如伴随代码、切线代码、邻接矩阵、图等)。我希望生成的代码易于求值,但其长度和效率极大地取决于f内部的细节。
例如,若f中有如下代码:
auto z1 = (x-(-y)); auto z2 = -(x-(-y)); auto z3 = 1+x+2+3+4; auto z4 = cos(-x); auto z5 = -sin(-x);
显然,上述代码的效率远低于以下版本:
auto z1 = x+y; auto z2 = -z1; auto z3 = x+(1+2+3+4); auto z4 = cos(x); auto z5 = sin(x);
第一个版本生成的伴随代码长度可能翻倍,所需内存也翻倍,最终运行速度可能比第二个版本慢10倍。
然而,编译器自然不会修改涉及自定义Number类型的代数表达式和赋值操作,因为它无法确定该类型的加法是否满足交换律。
我知道如何实现表达式模板,但这需要大量工作。既然编译器在处理标准类型时擅长这类优化,我绝不愿花时间去实现一个性能更差的版本,更何况代数优化只是我项目任务中的一个小细节。
聊胜于无
我明白程序的最优代数形式取决于Number的实现细节,但我们完全可以假设其各操作的相对成本与double一致(即加法远快于乘法,除非使用FMA;无符号切换比有符号切换成本更低)。如果这涉及太多问题,核心需求是:我需要一个比当前方案更好的方法,且无需重复造轮子(即无需为符号切换、求和与乘积顺序实现表达式模板)。
编译器确实不会优化自定义类型的代数表达式(这正是问题的核心)
由于许多代数对象不满足交换律(比如矩阵乘法),即使x是像矩阵一样复杂的类型,我们只是想为每个元素添加常数,编译器也不会优化z3的表达式。同样,编译器不会将x-(-y)替换为x+y,因为开发者完全可以将一元负号运算符实现为非自反运算符。
理论上,全知的编译器可以将生成的代码(所有类型均为double)优化到与代数优化后的初始代码生成的代码相同的水平,毕竟这两种生成代码在代数上是等价的(忽略舍入误差)。但现实中,编译器从代数形式糟糕的初始代码生成的代码运行速度要慢得多。
澄清对话中的误解
部分读者认为编译器最终会将两种生成代码优化到同一水平。我们来拆解这个问题:假设Number类型每次执行操作时都会在控制台打印不同的字符串,那么无论使用何种优化选项,编译器都不会改变控制台输出。因此,使用该自定义类型运行初始代码生成的代码、文本文件或伴随代码,内容都不会因优化选项而改变,这凸显了z3存在的问题。
再来看伴随代码:每个操作都会在伴随记录(tape)中至少存储一个数值,后续会按记录的逆序进行导数计算。不难想象,这类代码可能长达数十万行。无论编译器多么优秀,都无法识别出在数十万行非线性自动生成代码中,两个相隔甚远的操作及其对应的记录元素可通过代数等价性移除。编译器仅会进行局部代码分析。理论上,所有生成的汇编代码都是等价的,在最优优化下会完全相同(若最优解唯一),但我需要的是现实可行的解决方案。
非等价性示例
假设存在如下代码:
void f(Tfloat& y, Tfloat& x){ y = 1+x+2+3+4; }
它会生成如下C++伴随代码文件:
void gf(Tfloat& y, Tfloat& x, Tfloat& gy, Tfloat& gx){ auto tmp1 = 1+x; auto tmp2 = tmp1+1; auto tmp3 = tmp2+2; y = tmp3+3; auto gtmp3+= gy; auto gtmp2+= gtmp3; auto gtmp1+= gtmp2; gx += gtmp1; }
相比之下,代数优化后的初始代码会生成如下截然不同的伴随记录:
void gf(Tfloat& y, Tfloat& x, Tfloat& gy, Tfloat& gx){ y = x+(1+2+3+4); gx += gy; }
诚然,理想的编译器可以从这两段生成代码中得到相同的优化结果,但在现实场景下,针对超大型文件时,这根本无法实现。
内容的提问来源于stack exchange,提问作者user23311233

