ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++表达式模板:从临时对象开销到编译期优化

C++表达式模板:从临时对象开销到编译期优化 1. 一个“慢”的向量运算表达式模板要解决的问题1.1 临时变量堆出来的性能账单先从一个非常常见的场景说起。假设你在写一个数值计算程序里面有三维向量类型代码大概是这个样子的class Vec3 { public: double x, y, z; // 构造函数、运算符重载等 }; Vec3 operator(const Vec3 a, const Vec3 b) { return Vec3(a.x b.x, a.y b.y, a.z b.z); } Vec3 operator*(double s, const Vec3 v) { return Vec3(s * v.x, s * v.y, s * v.z); }看起来很有道理对吧运算符重载让代码读起来跟数学公式一样清爽。但问题在于当你写出下面这行代码时Vec3 result a b c * 2.0 d;你以为编译器会聪明地把这四次运算合成一次循环但实际情况是a b先产生一个临时Vec3c * 2.0再产生一个临时Vec3然后这两个临时对象加在一起又产生一个新的临时Vec3最后再加上d。这是三次内存分配、三次写入、三次读取。如果只是三维向量这个开销根本无所谓。但如果你在做一个粒子模拟每个时间步要对几十万个粒子做这样的运算呢如果向量维度变成256维甚至更高呢如果运算是发生在多层嵌套的循环里呢我印象最深的一次是在一个流体模拟项目里。最初版本用朴素的重载写法整个模拟跑一步需要几秒钟。后来分析热点发现花费时间的大头根本不是物理计算本身而是表达式求值过程中临时对象的反复创建和销毁。我们把核心运算改成表达式模板之后同一套物理逻辑跑的步数直接从每秒几帧提升到实时代码结构还几乎没改。1.2 内存带宽才是真正的瓶颈很多人以为临时对象开销只是构造函数和析构函数的调用次数变多了。其实真正致命的是内存带宽。现代CPU的算力非常恐怖一次SIMD向量指令就可以同时处理4个甚至8个浮点数。但内存访问的速度远远跟不上CPU的计算速度。在数值计算里每产生一个临时数组就意味着一次完整的“写入内存再读出来”的过程。表达式越长中间结果被写出读进的次数就越多。还是拿a b c * 2.0 d来说朴素重载版本的求值过程大概是计算a b把结果写入临时数组t1。计算c * 2.0把结果写入临时数组t2。读t1和t2相加把结果写入临时数组t3。读t3和d相加写入result。整个过程对内存的读写次数是手写循环版本的好几倍。当数据规模足够大、内存带宽成为瓶颈时这部分开销就会直接变成肉眼可见的运行时间差距。有一个很反直觉的点很多时候你觉得已经用-O3 -marchnative优化了编译器应该能帮你搞定这一切。但问题是operator的语义在标准里就是“返回一个新对象”编译器很难跨过这个语义去消解中间步骤尤其是当表达式被拆成嵌套函数调用、临时对象的地址还被传递引用时优化器能做的非常有限。1.3 编译器优化解决不了的问题这里得说清楚编译器其实已经在努力了。现代编译器对局部小对象可以做返回值优化RVO对某些情况下栈上临时对象也能进行标量替换Scalar Replacement也就是把对象拆成字段分别优化。但对于大数组、大向量类型情况完全不同。数组或向量的对象体内部是一个堆上的缓冲区operator里要做内存分配要对缓冲区整体赋值。这一步的语义本身就在强制一次完整的数据搬运。编译器没法在不改变程序行为的前提下把这个搬运凭空消失。所以如果想让表达式求值达到手写循环的性能唯一可靠的办法是让代码在编译期就变成“一次循环解决所有运算”而不是在运行期去创建和销毁中间对象。这就是表达式模板Expression Templates要干的事情。2. 从“值”转向“类型”表达式模板的核心思路2.1 让运算符不再返回计算结果而是返回“表达式本身”表达式模板最核心的思想说一说其实非常简单让运算符重载不要返回计算结果而是返回一个描述“我要做什么运算”的对象。这个对象本身不包含任何运算结果它只保存参与运算的操作数引用以及运算的类型信息。真正的计算全部推迟到你最终赋值给目标容器的那一刻才发生。我经常用一个类比来解释这个机制普通的运算符重载相当于你打电话告诉别人“把三号仓库的货和四号仓库的货合并再把五号仓库的货乘以2然后三批货再合到一起”对方听完立刻照着做每做一步都给你送一个箱子装中间结果。而表达式模板是给了一张提货单——“凭此单到三号仓、四号仓、五号仓各自取货再按单子上的规则合并”直到你需要货的时候拿单子一次性取完。这张提货单在C里的表现形式就是“类型”。不同类型的运算对应不同模板实例化出的类型类型本身携带着表达式的全部结构和信息。2.2 惰性求值把计算推迟到赋值那一刻惰性求值Lazy Evaluation是理解表达式模板的第二把钥匙。在表达式模板里operator和operator*只是构建表达式树的节点没有实际运算。真正的for循环是在operator或者转换函数里触发的。这个设计带来的直接收益就是任意复杂的表达式都只需要一次遍历。因为最终赋值的时候整个表达式是作为一个整体被求值的每个操作数只被读取一次结果只写一次中间不落任何临时存储。对比前面说的朴素重载版本内存访问次数从“表达式中每个二元运算各落一次临时数组”降为“只读写一次目标数组”。数据规模越大这个差距越明显。2.3 为什么函数对象和虚函数做不到这件事很多人在看过表达式模板之后会问如果用std::function或者虚函数把运算封装起来然后在循环里逐个调用不是也能避免临时对象吗答案是能避免一部分临时对象但代价往往是更大的性能损失。std::function的调用通常是间接调用编译器很难内联。虚函数同样如此。在数值计算这种场景单次运算本身只有几次浮点操作调用开销和分支预测失败的开销很可能超过运算本身。也就是说你把内存带宽省下来了却又把流水线停顿和函数调用开销引入进来了。表达式模板则完全不同。它把表达式结构编码在模板参数里整个表达式树在编译期就是确定的。编译器能看到完整的嵌套调用结构配合内联和常量传播最终生成的机器码和手写的循环几乎没有差别。这也是为什么不推荐用运行期多态来解决这个问题——这个问题的本质不是“如何组织代码”而是“如何把运算结构暴露给编译器”。3. 手写一个最小可用的表达式模板完整实现与逐步拆解3.1 第一步基础Vector类设计空谈原理没有意义。下面我们来手写一个最小可用的表达式模板。为了控制篇幅我做一个支持、*标量乘法和下标访问的Vector类足够说明全部关键机制。首先是最基础的Vector类#include cstddef #include vector #include iostream template typename T class Vector { public: explicit Vector(std::size_t n) : data_(n) {} // 这个是表达式模板的入口接受一个“表达式对象”来构造 template typename Expr Vector(const Expr expr) : data_(expr.size()) { for (std::size_t i 0; i data_.size(); i) { data_[i] expr[i]; } } std::size_t size() const { return data_.size(); } T operator[](std::size_t i) { return data_[i]; } const T operator[](std::size_t i) const { return data_[i]; } private: std::vectorT data_; };这个Vector本身不重载operator重载的是针对表达式对象的全局操作符。它的构造函数接受任意满足“有size()、有operator[]”的表达式类型然后在构造时一次性求值。3.2 第二步表达式节点与包装器接下来要定义表达式的“节点”。一个二元加法表达式需要保存两个操作数的引用一个标量乘法表达式需要保存一个向量引用和一个标量值。// 二元运算表达式节点保存左操作数和右操作数的引用 template typename LHS, typename RHS, typename Op class BinaryExpr { public: using value_type typename LHS::value_type; BinaryExpr(const LHS lhs, const RHS rhs, Op op) : lhs_(lhs), rhs_(rhs), op_(op) {} std::size_t size() const { return lhs_.size(); } value_type operator[](std::size_t i) const { return op_(lhs_[i], rhs_[i]); } private: const LHS lhs_; const RHS rhs_; Op op_; };这里有个细节BinaryExpr保存的是引用不是值。这意味着表达式对象的生命周期必须覆盖到最终求值完成之前。你通常不会去保存一个表达式对象而是直接把它传给构造函数或赋值操作符所以引用悬垂的问题在实际使用中很少出现。乘法表达式节点也一样template typename LHS, typename RHS class MultExpr { public: using value_type typename LHS::value_type; MultExpr(const LHS lhs, const RHS rhs) : lhs_(lhs), rhs_(rhs) {} std::size_t size() const { return lhs_.size(); } value_type operator[](std::size_t i) const { return lhs_[i] * rhs_[i]; } private: const LHS lhs_; const RHS rhs_; };为了统一让每个表达式对象都有value_typeVector内部要加一个类型别名template typename T class Vector { public: using value_type T; // 其余部分同上 };当然实际工程里可以把这两种节点合并成一个模板类用Op参数区分操作。这里分开写是为了阅读时更直观。3.3 第三步运算符重载与表达式组合现在来定义全局的operator和operator*。注意这里的返回值是一个表达式对象不是一个计算结果template typename T class Scalar { public: explicit Scalar(T value) : value_(value) {} T value() const { return value_; } private: T value_; }; // 向量 向量 template typename T, typename U auto operator(const VectorT lhs, const VectorU rhs) { return BinaryExprVectorT, VectorU, std::plusT(lhs, rhs, std::plusT()); } // 表达式 表达式处理 a b c 这种连续运算 template typename L1, typename R1, typename Op, typename U auto operator(const BinaryExprL1, R1, Op lhs, const VectorU rhs) { return BinaryExprBinaryExprL1, R1, Op, VectorU, typename BinaryExprL1, R1, Op::value_type( lhs, rhs, std::plustypename BinaryExprL1, R1, Op::value_type()); }写到这你会发现一个很麻烦的事情表达式节点一旦嵌套操作符重载就需要为“左操作数是表达式右操作数是Vector”、“左操作数是Vector右操作数是表达式”、“两边都是表达式”分别写重载。这是最早期表达式模板实现的繁琐之处。现代C里可以用C14的返回类型推导加一些模板技巧把重载数量大幅度削减。比如可以用一个统一的ExprRef包装层或者直接用auto配合模板模板参数。但对于本篇而言先理解“节点会嵌套嵌套需要对应的重载”这个机制是最关键的。标量乘法类似但要注意我们支持的是“标量 * 向量”还是“向量 * 标量”还是“表达式 * 标量”// 标量 * 向量 template typename T auto operator*(T scalar, const VectorT v) { return BinaryExprScalarT, VectorT, std::multipliesT(ScalarT(scalar), v, std::multipliesT()); } // 向量 * 标量 template typename T auto operator*(const VectorT v, T scalar) { return scalar * v; }这时一个像2.0 * a b的表达式类型就是BinaryExprBinaryExprScalardouble, Vectordouble, Vectordouble。每一层嵌套都对应一次运算。3.4 第四步赋值触发的真正求值现在Vector的构造函数里data_[i] expr[i]这句就会触发完整的求值链路。以Vectordouble result(a b c)为例先构造最内层的BinaryExprVectordouble, Vectordouble它保存了a和b的引用。再构造外层的BinaryExprBinaryExpr..., Vectordouble它保存内层表达式对象的引用和c的引用。result[i]的求值过程是(a[i] b[i]) c[i]。由于所有操作都是内联的编译器会把整个调用链拍平成一个表达式最终生成一个循环。这就实现了前面说的“一次循环完成所有运算无临时对象”。4. 编译期是怎么把表达式“编”成一遍循环的4.1 以 v a b c 为例追踪模板推导为了真正理解表达式模板光看代码还不够还得理解编译器看到了什么。我们用Vectordouble v a b c;来推演一遍。为了方便假设BinaryExpr只保存引用。那么a b的类型是BinaryExprVectordouble, Vectordouble, std::plusdouble。这个结果再和c相加除非我们有对应的重载否则会编译报错。所以需要再提供一个BinaryExpr和BinaryExpr相加的重载。实际工程中更常见的做法是把“任何支持operator[]和size()的对象”统一抽象成一个概念concept然后只写一个重载。在C17里用auto模板参数配合SFINAE到了C20直接用requires约束template typename L, typename R requires requires(L l, R r, std::size_t i) { { l[i] } - std::convertible_todouble; { r[i] } - std::convertible_todouble; { l.size() } - std::convertible_tostd::size_t; { r.size() } - std::convertible_tostd::size_t; } auto operator(const L lhs, const R rhs) { return BinaryExprL, R, std::plusdouble(lhs, rhs); }这样无论嵌套多深所有组合都匹配同一个模板。类型本身依然会嵌套但不再需要为每个组合写重载。4.2 内联与优化编译器如何把嵌套调用拍平成一次循环编译器在优化上面这段代码时关键一步是内联。BinaryExpr::operator[]是个非常简单的函数必然被内联。内联之后v[i] expr[i]展开成v[i] std::plusdouble()(std::plusdouble()(a[i], b[i]), c[i]);再展开就是普通的算术运算v[i] a[i] b[i] c[i];接着编译器会发现这个表达式只依赖a[i]、b[i]、c[i]不依赖其他迭代变量或循环内部状态于是可以放心地做循环展开、向量化。在现代编译器的-O3 -marchnative下最终生成的代码和你亲手写的for (i...) v[i] a[i] b[i] c[i];几乎没有差别。这里要说一个容易误解的地方有人觉得表达式模板就是靠“把表达式变成类型”来运行的这其实不是重点。重点是类型携带信息这件事让编译器能看到完整的运算结构从而放心大胆地优化。如果你用std::function来存lambda编译器看到的只是一个不可内联的函数指针或虚表它没法优化不是因为“类型不够多”而是因为它看不到运算细节。4.3 表达式的“形状”信息如何随模板参数传递表达式模板里的每个模板参数可以看作表达式的“形状”。所谓形状我指的是节点类型、操作数类型、运算类型。比如BinaryExprVector, Vector, plus表示两个向量的逐元素加法。BinaryExprScalar, Vector, multiplies表示标量乘以向量。BinaryExprBinaryExprVector, Vector, plus, Vector, plus表示相加后的结果再和另一个向量相加。编译期会把所有这些信息展开成符号层面的“树”。如果你在一个表达式中混入转置操作、切片操作、矩阵乘法等只要每一层的类型都正确最终求值循环就能自动符合对应的数学规则。这其实就是Eigen、Blaze这些C线性代数库的核心机制。这里顺带说一下表达式的形状信息也能用来做编译期优化。比如某个表达式在数学上等价于零矩阵乘以任何矩阵都为零编译器理论上可以“看到”这种结构并把它优化掉。不过这种优化需要库作者专门实现规则不是编译器自动做的。普通的逐元素运算表达式中这种优化不太需要真正能体现威力的是矩阵乘法、卷积之类的重型运算。5. 从玩具到生产性能实测、改进空间与适用范围5.1 实测对比手写循环 vs 朴素重载 vs 表达式模板下面这个表格是我在一个双精度浮点数组长度100万上对a b c * 2.0 d这种表达式做的粗略实测结果只有3组对照目的是看量级差异不同机器、不同编译器会有浮动实现方式相对耗时手写for循环1.0朴素重载每次返回临时Vector3.8表达式模板1.1从数据可以看出来表达式模板的性能已经非常接近手写循环。那10%左右的差距通常来自模板代码生成的指令排列不如手工精心写的循环整齐以及某些边界检查未完全消除。在更高优化等级下差距往往还会缩小。朴素重载的3.8倍耗时本质就是前面说的临时对象读写内存造成的。表达式越长差距越明显。如果表达式的长度是10个操作数朴素重载可能会落到10倍以上而表达式模板几乎保持不变。5.2 表达式模板的经典局限表达式模板虽然好在性能它有几个老生常谈的局限必须提前说明。第一表达式保存的是引用或者值拷贝。如果保存的是引用使用者的表达式对象生命周期必须覆盖到求值完成。不当使用会出现悬垂引用而且这类问题非常难调试。如果保存的是值拷贝那么对向量本身来说是一次不必要的深拷贝性能直接崩盘。如何平衡这两者是不同库设计哲学的分叉点。第二模板实例化的代码膨胀。每写一个新的表达式组合编译器就实例化一个新的类型。一个复杂表达式会变成一长串嵌套模板编译时间会明显增加。对于比较大的项目日志里“模板实例化最大深度”之类的报错也会多起来。这也就是为什么很多库的报错信息既长又晦涩。第三很多运算符在表达式模板里难以语义正确。比如逻辑运算符、比较运算符、短路语义等。if (a b)到底是想逐元素比较并返回一个布尔数组还是想判断整个向量是否小于另一个向量这个问题在表达式模板里很容易变成一次性返回一个表达式对象然后在bool语境下编译报错。Eigen为此专门提供了all()、any()等辅助函数。自己实现的时候要特别小心不要让类型隐式转为bool否则会写出极其隐蔽的bug。第四调试体验极差。你在GDB里看到一个变量的类型是BinaryExprBinaryExprBinaryExprScalardouble, Vectordouble, multipliesdouble, Vectordouble, plusdouble, Vectordouble, plusdouble头都快炸了。后面我会单独讲一些应对办法。5.3 现代C对表达式模板的增强C11/14/17特性如果你是在2011年之前学表达式模板那确实是“硬核老哥”的玩法。现代C让这套东西的实现和可用性都有了很大提升。C11带来的auto和decltype让运算符重载的返回值不必手写一大串类型。C14的返回类型推导更是直接让auto operator变成了可能写起来舒服多了。C17以后就更是如此。可变参数模板和折叠表达式让“任意长度的表达式组合”变得容易处理。C20的requires则让操作符重载的约束检查变得可读。还有两个容易被忽略的好用特性一是constexpr如果你想让某些极小规模表达式在编译期就完成计算constexpr配合表达式模板是可以做到的二是结构化绑定让表达式节点内部的调试访问更直观。不过说实话在实际生产中这些现代特性最大的价值还是体现在库作者体验上使用者的体验主要是靠库本身的质量决定的。6. 我踩过的坑调试、模板爆炸与可读性之痛6.1 报错信息看不懂怎么办表达式模板的编译报错是我见过最不友好的一类报错。当你写错一个运算符组合模板实例化的错误信息会像瀑布一样涌出来而且指向的往往是标准库内部。我见过最长的单条报错信息在终端里滚了上千行。解决这个问题我的经验是分两步。第一步在日常开发阶段把报错信息重定向到文件里然后用一些工具压缩。比较简单的办法就是g -stdc20 -c test.cpp 2 err.log wc -l err.log然后看文件头尾报错真正的根源通常在开头或者结尾。中间那一大段“required from here”是实例化链十有八九不是问题根源。第二步主动用static_assert和约束来拦截错误。C20的requires是最好的工具。比如template typename T concept ExprLike requires(T t, std::size_t i) { { t[i] } - std::convertible_todouble; { t.size() } - std::convertible_tostd::size_t; };有了这个约束你写的operator模板就只能匹配正确类型的操作数。一旦写错编译器会直接告诉你“约束不满足”而不是给你看几千行模板实例化链条。这能省下大量排查时间。6.2 模板代码膨胀的控制表达式模板还有一个工程上的坑就是调试版性能。在-O0调试版本下表达式模板会慢得离谱因为每一层operator[]没有被内联每次访问都是真实的函数调用一个简单的a b c可能被展开成几十次函数调用来完成一次元素访问。我的做法是在调试配置里关闭表达式模板的惰性求值或者直接用一个DEBUG开关切换成朴素重载。虽然行为会略有不同但调试阶段本来就更关注逻辑正确性而不是性能数字。到了发布版本再用NDEBUG切换回表达式模板。另一种控制代码膨胀的思路是限制模板递归深度。比如你可以把嵌套表达式包装成一个统一的DenseExpr层在构造函数里“拍平”多层运算这样模板类型不会无限嵌套下去。代价是拍平过程中会产生一些中间结果性能有所回落但换来的是代码体积和编译时间的显著下降。很多商业线性代数库会在这个权衡上做文章不是一味地追求零临时对象。6.3 调试技巧让编译器帮你打印类型在调试表达式模板代码的时候我最常用的一个技巧是用一个“故意让它出错”的static_assert来打印类型的完整名字。比如我写一个模板类template typename T struct TypePrinter; // 故意不定义 auto expr a b * 2.0; TypePrinterdecltype(expr) tp; // 这里就会编译报错报错信息里就是完整的类型名这么一搞编译器就能把expr的完整类型打印出来。这个方法在我做表达式模板开发时帮了大忙。比起一行行读GDB里边上显示的超长类型名这个方式更稳定、更直观。还有一个技巧是给表达式模板加一个name()静态方法返回一个描述该表达式结构的字符串。这样在调试时可以直接打印表达式树的结构template typename L, typename R, typename Op std::string BinaryExprL, R, Op::name() const { return ( lhs_.name() op_name rhs_.name() ); }配合日志系统你可以看到类似((a b) * 2.0 c)的输出确认表达式有没有按预期组合。这个信息在排查“表达式组合顺序错了”的问题时特别管用。最后再分享一个我在实际项目中反复用到的思路表达式模板最适合的是那种“运算逻辑稳定、表达式组合非常多、性能敏感”的场景。如果你的运算模式很简单或者性能要求不高直接用朴素重载完全没问题。但一旦你确定要上表达式模板就值得把调试工具链一并配齐否则后面维护代码的时候会非常痛苦。我的经验是花半小时设计好类型打印和约束检查比之后花一整天在某条几千行的报错信息里挣扎要划算得多。
返回列表