百科问答小站 logo
百科问答小站 font logo



为什么 C++ 没有 C 语言快? 第1页

  

user avatar   haozhi-yang-41 网友的相关建议: 
      

感谢资深的内联党 @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兼容性理论也是个很有趣的说法,因为在这个话题下:

  1. 意思就是第一版的stl作者是个sb咯?
  2. 不同版本的stl,尤其是gcc/vc/sgi,他们本来就没有任何的兼容性:要不要做个实验,在vc下编一个库,导出个map给gcc用,看看跑起来core不core?我都懒得去问实验结果了,我就问问会不会真的有谁大声的告诉大家:“我真的跑去做了这个实验”?
  3. 既然不同实现的stl没有abi兼容性,也不需要维护跨实现的abi兼容性,那第一条结论就要增强为:“所有stl实现第一个版本的作者,全都是sb”……哇……震惊啊!


总之,我无意在avl和rbt之间作出什么结论(实际上这两货吵了几十年也没吵出个结果来,我凑什么热闹啊?)

但不管怎么样,在“stl的map(rbt)全面落败于我的avl(胜率10%)”这个事实面前:

  1. stl的实现水平太差(作者水平太差)
  2. C++的模板展开机制无法保证最优性能
  3. stl的数据结构和算法选择出了问题,rbt明显比avl慢

上述三个结论,不可能同时被否定。内联党们,我知道你们非常不愿意肯定第二条,那剩下的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,这真的是哭笑不得了。


@IceBear

测试模型改成这样之后:

很不走运,insert模型,map还是输:

你要不要再写一篇新的文章来找个新借口?


user avatar   peter-43-43-80 网友的相关建议: 
      

我们就用 @alvmu 安利的 freebsd 在 C 语言里用宏实现的红黑树与 C++ 用模板实现的做比较,而且大家都用 malloc 分配节点,很公平

C 410ms, gcc-12 编译器,开 O2

C++ 386ms, g++-12 编译器,开 O2

而且 C++ 我还没关异常,关了异常更快


少听那些半吊子的学习资源胡扯


std::sortqsort 哪个快?

std::sort 利用模板的优势直接把比较操作内嵌;而 qsort 每比较一次都需要函数调用。这还没完,每次调比较函数传的都是数据的地址,比较函数里面还得间接取址两次。


vector 跟侵入式链表哪个遍历的快?哪个存储结构对缓存友好?哪个能利用 SIMD?哪个内存占用小?


还有链表实现的栈/队列,和 deque 封装的比,都慢到姥姥家了。


不是说 C 语言不能实现比较内联的 sort,也不是说它实现不了 vectordeque 一样的高级结构。

要么,你对每一种类型,每一种的比较方式都写一遍排序,对每一种类型都实现一遍 vectordeque —— 累死你,维护恶心死你。

要么,用万能的宏?那玩意搞复杂的东西比模板还复杂还黑魔法还难调试。


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 慢,那就换用快的设计。

连内存管理都是交给你允许你控制的。

所以,多自己了解了解。

那些鬼话十年前就有了,十年后还是一样的话术,用词都没改。


user avatar   my-zh 网友的相关建议: 
      

这是我看到的最准确的总结。

总的来说,就是中国的高考相对公平,所以性价比极高,所以其他活动都可以适当让步。




  

相关话题

  有哪些让你目瞪口呆的 Bug ? 
  为什么当年 Android 选择用 Java 作为开发语言? 
  Java为什么选择interface来作为"接口"这个功能的关键字? 
  C语言为何不改进数组? 
  for(int i = 0; ; i++); printf("i love you"); 请问这条代码是什么意思,可以运行出什么结果? 
  在公司里开发软件 和学校有什么不同? 
  c#中虽然异步和多线程是两码事,但是是否异步微软提供的async函数内部还是多线程去实现的? 
  程序员一不小心删除了自己的代码是怎么样一种体验? 
  如何正确理解java中的泛型类型推导? 
  在程序员的日常学习中你是如何抗遗忘的? 

前一个讨论
c++11如何实现单例模式?
下一个讨论
BOSS 直聘放假全员信曝光:全员留在工作地过年,这一规定合理合法吗?





© 2024-11-21 - tinynew.org. All Rights Reserved.
© 2024-11-21 - tinynew.org. 保留所有权利