感谢资深的内联党 @IceBear 同学的一夜辛苦劳动:
终于为我们揭开了本问题“为什么 C++ 没有 C 语言快”的终极答案。
来看论证过程(全都摘自上文及评论区的发言):
这就意味着,所有的stl版本,使用rbt都是在算法模型上的失败?毕竟C++标准里,并没有任何一个地方规定stl的set/map必须使用红黑树。
所以,题目里:“为什么 C++ 没有 C 语言快”?
回答就很显然了:@IceBear 同学严正指出:“因为你们无比信赖的stl库在一开始的数据结构和算法的选型上就错了啊——先天就有方向性错误,靠后天补回来?怎么补?”另外,这么无知的作者写出来的stl,又是怎么被广大的C++程序员这么信任的?靠层层封装、细节和实现隐藏到云里雾里(反正你们基本上没几个看得懂),所以张嘴吹就行了?
当我提出这个疑问之后,@IceBear 同学就开始找补了:
那我的回答也很简单:
如果所谓的“avl和rbt打得有来有回”理论是对的话,那按照他的意思修改后的测试结果依然一边倒(insert/find各五次,map/rbt只在100量级的insert上赢了一次——胜率10%),就只能理解为“stl实现挫了吧”?
反过来,如果非要说“stl实现很棒,已经是最高性能”,那还赢不了我的avl……是不是只能理解为“rbt在算法上就是不如avl”?于是“stl作者选型失误”这口锅又该谁背呢?
另外,C++(尤其是stl)的abi兼容性理论也是个很有趣的说法,因为在这个话题下:
总之,我无意在avl和rbt之间作出什么结论(实际上这两货吵了几十年也没吵出个结果来,我凑什么热闹啊?)
但不管怎么样,在“stl的map(rbt)全面落败于我的avl(胜率10%)”这个事实面前:
上述三个结论,不可能同时被否定。内联党们,我知道你们非常不愿意肯定第二条,那剩下的1/3条,你们看着挑一个顺眼一点的?
如果你们非要同时否定上述三个结论,也不是不行,那剩下唯一的结论就是:我的avl实现水平太高(毕竟是在一堆debuff面前还能拿到90%的胜率)。
当然,伸手不打笑脸人。你们真要是这么吹捧我了……上面的什么“C++还是C快”的话我就不较真了,爱咋咋的。
嗯。
大过年了,挺开心的。
这个话题本来是个挺好的话题,但简单看了一下,发现无论正方反方,居然都是觉得内联就无敌的内联党(包括template党/CRTP党/inline党/macro党)?然后所有的争论,都是在争C和C++谁的内联更好?!对了,其实我也不知道这“好”的定义是什么——展开更小?用起来更方便?封装性更好?还是可读性更好?
有意思。
我经常喜欢说点大家不喜欢听的话,这次自然也不例外。于是随手写了个小例子,给你们内联党们(不管正方还是反方)演示下超越内联的威力:
核心的测试模型在此,已经排除了内存分配和测试数据差异的影响:
map/hash(unordered_map)都是标准的stl,而avl和btree是很多年前写的,现在公司内多个核心项目中都在用。
怎么说呢?
纯C的数据结构要想超于stl实际上并不难,哪怕是一般意义上操作更复杂的avl/btree对比红黑树,依然能做到全程领跑。甚至在迷你小数据量(<100)时,还能达到甚至超越哈希表(特指:stl版本)的水平(实际项目中,真正存有海量数据的数据结构是极少的,往往散布这大量的这类迷你小结构)。
诀窍在哪?
虽然这两份代码是公司版权,不开源,但给个核心数据结构定义应该没问题,老手们应该都能看出门道:
typedef uint64_t avl_key_t; typedef struct avl_node { avl_key_t key; struct avl_node* left; struct avl_node* right; } avl_node_t; typedef struct info //随手写个avl的用例 { avl_node_t avl; uint32_t argc; const voidt* argv[0]; } info_t; typedef struct btree_page { uint64_t keys[PAGE_ITEMS]; union { struct btree_page* page; void* data; //for leaf } ptrs[PAGE_ITEMS]; struct { uint8_t count : 8; bool leaf : 1; }; } btree_page_t;
看出来区别了吗?
且不说模板能inline的东西,C一样能用,就说通过对数据和代码分布的精确调整和定义,一样能够极大的优化数据分布结构,极大的优化cpu的执行效率。我还可以很明确的说,我的这两份代码里,一条汇编优化都没用到,inline也极少用到,手动循环展开这事也没做,基本上都用的是传统的C函数的最标准写法(因为有用在IOT设备中,二进制大小有严格要求)。
而在这个case里,内联党们输就输在你们所自以为傲的内联上:所有的内联手段都会极大的导致代码膨胀。而在很多场景下,尤其是x64平台寄存器充足且非benchmark的复杂场景下,一个call所带来的消耗(而且call之后子函数往往能被预测和预加载),实际上并不如很多人想象的那么大。而滥用内联带来的负面影响,却会被很多人忽视。
说完了代码指令膨胀,我就来说一下数据分布问题:
在说这个问题之前,先说一下C风格和C++风格的区别。毕竟除非C的代码中用到了少数偏门的关键字(如_Generic),不然,C的代码可以几乎毫无阻碍的在C++编译器中编译通过。所以,“何为C++代码”,在这个问题里并不是一个纯搞笑问题。
在我看来,C++风格的代码就是以各种类为单元,各种层层叠叠的(继承/组合)而形成的对象体系。而C风格则是要么都以基本数据类型组合,要么以指针相互连接组合的体系。
回到这个问题:
在rbt/avl/btree的话题下,C++的风格是这样的:
template <class _Ty1, class _Ty2> class _Compressed_pair<_Ty1, _Ty2, false> final { // store a pair of values, not deriving from first public: _Ty1 _Myval1; _Ty2 _Myval2; }
然后用起来,哪怕做了最极限的优化排布,大概是这样的:
class btree_page { _Compressed_pair<T1, T2> data[size]; }; class avl_node { _Compressed_pair<T1, T2> data; avl_node* left; avl_node* right; }; //没仔细研究过stl的内存布局,说不定是这样的排布,可能会更好点(如果没有其他因素干扰的话) class avl_node { avl_node* left; avl_node* right; _Compressed_pair<T1, T2> data; };
对比一下我上面的btree_page,C++问题在于:在树中进行搜索的时候,我们都只需要T1(key),而不需要T2(value)。硬把T2塞在T1的后面,只会让T1支离破碎,降低cpu的数据cache的使用效率。二叉树场景也是类似的:在二叉树搜索中,key/children ptr才是最高频用到的,自然都应该集中到开头,一起被prefetch才是最优的。甚至如果T2足够大的话,按照C++的内存布局,cpu的prefetch会完全没意义甚至还可能有自作聪明般的反效果。而且在这种场景下,万能的编译优化都帮不上忙——它总不能帮你优化到把内存布局都给你改了吧?
所以,在指令cache和数据cache都不友好的情况下,C++风格的代码实际上会有一个戏剧性的效果:
在小规模的demo/benchmark代码中,表现异常优异——因为这个时候代码内联展开的场景很有限,于是展开的次数不多,而且数据一般是高度规整而且在一定程度上有过下意识的优化的,于是效果拔群。
但是在实际大型生产项目中,C++风格的代码的表现就不行了(特指性能方面,而且和C比)——我代码里写一个模板,在编译后能生成多少份不同的实例,谁数的清?各种类的层层叠叠,实际一个类的内存布局结构,不借助工具或文档,又有多少人能了如指掌?我就不说别的,你们各个号称精通C++ STL的人,不翻代码不看文章,我就挑个简单点的,有多少人能立刻就闭眼就说出std::string或者std::shared_ptr完全展开后的最终实际内存布局?稍微进阶点的问题就是:std::map<int,int>和std::multimap<int,int>的实际内存布局的差异有哪几处?
古语有之:知己知彼,百战不殆。在什么都两眼一抹黑的情况下,你告诉我一个C++程序员对大量使用stl的大型复杂代码有比C代码更高的优化能力?
最后
声讨完内联党,回到原始问题:C和C++都是会变成实际的cpu指令来运行的。因此,它们在性能相关的任意方面,在本质上是没有区别的。
但是——凡事都有个但是——在实际项目中,尤其是大型项目中,C的种种约束(换言之:不爽),会带来更强的思考性、更低的封装能力(换言之:麻烦),会带来更高的细节优化空间、更少的代码特性(换言之:古老),会带来更精确可控的代码。
C++则完全反之。
当然,作为C/C++双修党,我在写C时,总会无限怀念:自动推导(auto)、函数重载(吐槽下半残的_Generic)、默认参数、lambda……但两全其美的东西总是存在于幻想中。
总之,这个话题其实能展开很多方面深入下去。但是变成了内联党们一统天下的狂欢party,这真的是哭笑不得了。
测试模型改成这样之后:
很不走运,insert模型,map还是输:
你要不要再写一篇新的文章来找个新借口?
我们就用 @alvmu 安利的 freebsd 在 C 语言里用宏实现的红黑树与 C++ 用模板实现的做比较,而且大家都用 malloc 分配节点,很公平
C 410ms, gcc-12 编译器,开 O2
C++ 386ms, g++-12 编译器,开 O2
而且 C++ 我还没关异常,关了异常更快
std::sort
跟 qsort
哪个快?
std::sort
利用模板的优势直接把比较操作内嵌;而 qsort
每比较一次都需要函数调用。这还没完,每次调比较函数传的都是数据的地址,比较函数里面还得间接取址两次。
vector
跟侵入式链表哪个遍历的快?哪个存储结构对缓存友好?哪个能利用 SIMD?哪个内存占用小?
还有链表实现的栈/队列,和 deque
封装的比,都慢到姥姥家了。
不是说 C 语言不能实现比较内联的 sort,也不是说它实现不了 vector
、deque
一样的高级结构。
要么,你对每一种类型,每一种的比较方式都写一遍排序,对每一种类型都实现一遍 vector
、deque
—— 累死你,维护恶心死你。
要么,用万能的宏?那玩意搞复杂的东西比模板还复杂还黑魔法还难调试。
C 语言的语法限制就决定了在人类能接受的了的复杂度下,用 C 语言写代码只能使用函数指针作为通用的回调接口,只能将侵入式链表作为通用的数据结构。
复杂的东西不是 C 语言实现不了。C 语言是图灵完备的,冯·诺依曼计算机上能跑的软件理论上它都能实现出来。
而是以这个复杂程度,你实现不了,实现的了也维护不了。
最经常被拿出来说 C++ 比 C 慢的是哪个特性?
虚函数
你说慢那就改成 CRTP,改成 variant,还慢个锤子。
CRTP 完全静态,比 C 去 if else 比较转发还快。
发现这种逻辑的蛮不讲理了吗?
它对比的完全是不同的实现。
你要是用 C 语言去实现和虚表一样的结构,速度也是跟 C++ 在一个水平线上。甚至因为编译器不能做针对性的优化,速度还不如 C++。
你会说 C 语言模拟面向对象一般是把函数指针直接放在类内啊,不需要虚表这样二次寻址。
对啊,那 C++ 也可以这样搞。哪有 C 能写的设计模式,C++ 就不能写的?C++有高级语法支撑,实现起来、用起来还更简单。
不公平的比较就是放屁。
Python 等脚本语言比非脚本语言慢是慢在动态的检查
Java 等虚拟机语言比 native 语言慢是慢在解释器这个二道贩子(JIT 激活前),慢在 GC 的停顿
C,C++,Rust 之间的相对快慢是因于设计思想、设计理念。
但是 C/C++ 相比于其他语言的好处是,一切都是允许你控制的。
不像 GIL 去不掉,类型检查去不掉,不必要的 null 检查去不掉,GC 停顿不能控制。
如果一个设计慢,那就去掉,不会不允许你去换。
比如虚函数慢,比如 iostream 慢,那就换用快的设计。
连内存管理都是交给你允许你控制的。
所以,多自己了解了解。
那些鬼话十年前就有了,十年后还是一样的话术,用词都没改。
这是我看到的最准确的总结。
总的来说,就是中国的高考相对公平,所以性价比极高,所以其他活动都可以适当让步。