vector初始化与否导致的巨大性能差异

C语言与CPP编程 2022-05-02 10:16

大家好!

最近在优化引擎代码,在优化的过程中发现一个很奇怪的问题,一个简单的对象,存放在std::vector<> v中,如果v定义的时候为每个元素指定初值,那么后面对v中每个元素的写就飞快;相反的,如果v定义的时候,不指定初始值,那么后面对v中元素写操作的时候,就花费大约前一种2-3倍的时间。

今天,借助此文,分享下原因的排查过程。

问题

为了能够快速说明问题,直接上代码吧,如下:

#include 
#include 
#include 
#include 

int const num = 1000000000;
struct AdItem
{

     AdItem() {}

     AdItem(int x, int y, int z)
     {
         x_ = x; y_ = y; z_ = z;
     }

     int x_;
     int y_;
     int z_;
};


void time_report(const std::function<void()> &f1, const std::function<void()> &f2) {
   auto start = std::chrono::high_resolution_clock::now();
   f1();
   auto end = std::chrono::high_resolution_clock::now();
   std::cout << "allocation done in " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << std::endl;

   start = std::chrono::high_resolution_clock::now();
   f2();
   end = std::chrono::high_resolution_clock::now();
   std::cout << "assignment done in " << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count() << "ms" << std::endl;
}

void uninitialized_performance() {
  std::vector v;
  time_report([&v]() { v.reserve(num); },
          [&v]() {
            for (int i = 0; i < num; ++i) {
              v[i] = {i + 2, i + 4, i + 6};
            } });
}

void initialized_performance() {
  std::vector v;
  time_report([&v]() { v.assign(num, AdItem{000}); },
          [&v]() {
            for (int i = 0; i < num; ++i) {
              v[i] = {i + 2, i + 4, i + 6};
            } });

}

int main()
{
     uninitialized_performance();
     initialized_performance();
     return 0;
}

程序输出如下:

allocation done in 0ms
assignment done in 59ms
allocation done in 46ms
assignment done in 27ms

无论使用-g -O2 -O3都是一样的效果,执行多次结果仍然一样。

CPU Cache

CPU缓存(CPU Cache)位于CPU与内存之间的临时存储器,它的容量比内存小但交换速度快。在缓存中的数据是内存中的一小部分,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可避开内存直接从缓存中调用,从而加快读取速度。

在CPU中加入缓存是一种高效的解决方案,这样整个内存储器(缓存+内存)就变成了既有缓存的高速度,又有内存的大容量的存储系统了。缓存对CPU的性能影响很大,主要是因为CPU的数据交换顺序和CPU与缓存间的带宽引起的。

看到结果以后,第一反应是CPU Cache导致,因为占用的内存大小一样,所以怀疑在第二次访问的时候,因为CPU Cache中已经有缓存,所以直接使用。

为了确认是否是因为CPU Cache而导致的性能差异,所以将测试分成两个独立的程序进行,即分别执行uninitialized_performance()initialized_performance()函数,结果与之前的一致,所以排除CPU Cache的原因

perf工具

perf是用来进行软件性能分析的工具,可以利用PMU、tracepoint和内核中的特殊计数器来进行性能统计,用来分析内核和应用程序的性能。

使用perf stat uninitialized_performance结果如下:

Performance counter stats for 'uninitialized_performance':

         47.923956      task-clock (msec)         #    0.985 CPUs utilized
                 6      context-switches          #    0.125 K/sec
                 0      cpu-migrations            #    0.000 K/sec
             1,007      page-faults               #    0.021 M/sec
        99,354,240      cycles                    #    2.073 GHz                      (83.33%)
        72,689,011      stalled-cycles-frontend   #   73.16% frontend cycles idle     (83.46%)
        48,738,086      stalled-cycles-backend    #   49.05% backend cycles idle      (66.66%)
        98,377,529      instructions              #    0.99  insn per cycle
                                                  #    0.74  stalled cycles per insn  (83.32%)
        11,399,060      branches                  #  237.857 M/sec                    (83.39%)
            19,617      branch-misses             #    0.17% of all branches          (83.16%)

使用perf stat initialized_performance结果如下:

76.092443      task-clock (msec)         #    0.991 CPUs utilized
                 6      context-switches          #    0.079 K/sec
                 0      cpu-migrations            #    0.000 K/sec
             1,007      page-faults               #    0.013 M/sec
       157,742,162      cycles                    #    2.073 GHz                      (82.94%)
       110,940,710      stalled-cycles-frontend   #   70.33% frontend cycles idle     (82.96%)
        72,531,622      stalled-cycles-backend    #   45.98% backend cycles idle      (65.92%)
       178,142,743      instructions              #    1.13  insn per cycle
                                                  #    0.62  stalled cycles per insn  (82.99%)
        31,488,875      branches                  #  413.824 M/sec                    (84.01%)
            24,309      branch-misses             #    0.08% of all branches          (84.17%)

因为perf只能得到整个程序执行期间的变化,而我们实际上需要的是核心部分,看来通过perf得到有用的信息,只能另寻他法。

源码跟踪

截止到现在,上述方案都没能解答本次问题的疑问,只能祭出屠龙刀,看源码了。

未初始化版调用信息如下:

-> std::vectorstd::allocator >::vector
--> std::vectorstd::allocator >::_M_default_initialize
---> std::__uninitialized_default_n_aunsigned long, AdItem>
----> std::__uninitialized_default_nunsigned long>
-----> std::__uninitialized_default_n_1<false>::__uninit_default_nunsigned long>
------> std::_Construct(std::__addressof(*__cur))

初始化版调用信息如下:

-> std::vector >::vector
--> std::vector >::_M_fill_initialize
---> std::__uninitialized_fill_n_a
----> std::uninitialized_fill_n
-----> std::__uninitialized_fill_n<false>::__uninit_fill_n
------> std::_Construct(std::__addressof(*__cur), __x)

从上述函数调用信息可以看出,初始化版本和都调用了std::_Construct函数,而该函数通过其名称就能看到,是调用了构造函数对内存块进行构造(底层是用placement new实现),得不到我们有用的信息。

汇编

下面我们从汇编角度来看下(为了篇幅,去掉了无关紧要的部分以及相同的部分)。

未初始化版本:

uninitialized_performance():
mov QWORD PTR [rsp+104], OFFSET FLAT:std::_Function_handler::_M_invoke(std::_Any_data const&)
mov QWORD PTR [rsp+80], rax
mov QWORD PTR [rsp+96], OFFSET FLAT:std::_Function_base::_Base_manager::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation)
mov QWORD PTR [rsp+48], rax
mov QWORD PTR [rsp+72], OFFSET FLAT:std::_Function_handler::_M_invoke(std::_Any_data const&)
mov QWORD PTR [rsp+64], OFFSET FLAT:std::_Function_base::_Base_manager::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation)

初始化版本:

initialized_performance():
mov QWORD PTR [rsp+64], rsp
mov QWORD PTR [rsp+88], OFFSET FLAT:std::_Function_handler::_M_invoke(std::_Any_data const&)
mov QWORD PTR [rsp+80], OFFSET FLAT:std::_Function_base::_Base_manager::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation)
mov QWORD PTR [rsp+32], rsp
mov QWORD PTR [rsp+56], OFFSET FLAT:std::_Function_handler::_M_invoke(std::_Any_data const&)
mov QWORD PTR [rsp+48], OFFSET FLAT:std::_Function_base::_Base_manager::_M_manager(std::_Any_data&, std::_Any_data const&, std::_Manager_operation)

从上述汇编结果没看到有什么区别,所以排除了语言层面的区别了,尝试从其它方面进行分析。

lazy allocation

既然源码和汇编都看不出来区别,那么只能从另外一个方向来进行考虑,那就是内存分配,有没有可能虽然是都调用了new进行内存分配,但是实际上并没有分配呢?

记得之前看过一些文章,说是操作系统有个lazy allocation(延迟分配)机制,即用户调用API进行内存分配的时候,操作系统并不会直接分配给用户这么多内存,而是直到用户真的访问了申请的page时产生一个page falut,然后将这个page真的分配给用户,并重新执行产生page fault的语句。

我们使用以下例子进行验证:

#include 

struct X {
  int x_[1 << 24];
};

int main() {
    auto x = new X;
    sleep(100);
    return 0;
}

然后通过htop -p xxx命令查看信息,如下:

然后,我们尝试将代码进行修改如下:

#include 
#include 

struct X {
  int x_[1 << 24];
};

int main() {
    auto x = new X;
    memset(x->x_, 0sizeof(x->x_)); // 增加了该句,仅仅为了访问
    sleep(100);
    return 0;
}

如下所示:

通过上述htop的输出信息的RES块可以看出,第二次调用分配了足够的虚拟内存,这就验证了我们这次的结论,即使使用了new,在真正使用之前是没有被真正的分配虚拟内存

Lazy allocation simply means not allocating a resource until it is actually needed. This is common with singleton objects, but strictly speaking, any time a resource is allocated as late as possible, you have an example of lazy allocation.

By delaying allocation of a resource until you actually need it, you can decrease startup time, and even eliminate the allocation entirely if you never actually use the object. In contrast, you could pre-allocate a resource you expect to need later, which can make later execution more efficient at the expense of startup time, and also avoids the possibility of the allocation failing later in program execution.

现在,我们从lazy allocation角度来分析该问题。对于未初始化的vector,该vector申请了一大块内存,operator new只是返回了虚拟地址(此时并未将物理地址与其关联),当具体访问数据(for循环)的时候,操作系统才会将虚拟地址与物理地址进行关联。而在构造时给初值,算是访问大块连续逻辑空间,操作系统会将虚拟地址与物理地址相关联,这样在后面访问的时候,因为已经访问了一次了,所以会很快。

在源码一节中,我们提到未初始化版本和初始化版本最终分别调用了std::_Construct(std::__addressof(*__cur))_Construct(std::__addressof(*__cur), __x),下面是其具体实现。

未初始化版本:

template<typename _T1, typename... _Args>
   inline void
   _Construct(_T1* __p, _Args&&... __args) { 
    ::new(static_cast<void*>(__p)) _T1(std::forward<_Args>(__args)...); 
  }

初始化版本如下:

  inline void
   _Construct(_T1* __p, const _T2& __value) {
     ::new(static_cast<void*>(__p)) _T1(__value);
   }

std::_Construct是在std::vector<>定义中进行调用的,也就是说都访问了对应的地址,但是为什么区别这么大呢?后面,对示例代码进行了修改:

1、在默认的构造函数中对变量进行了赋值初始化操作
  AdItem() {
         x_ = 0; y_ = 0; z_ = 0;
  }
2、对构造函数使用default关键字
 AdItem() = default;
修改之后,再次编译,运行,未初始化版本和初始化版本的性能结果基本一致,进一步验证了我们的猜想lazy allocation引起的性能差异。

结语

开发过程,就是一个不断采坑的过程,从一个坑到另一个坑,技术才能成长。

好了,今天的文章就到这,我们下期见!

C语言与CPP编程 C语言/C++开发,C语言/C++基础知识,C语言/C++学习路线,C语言/C++进阶,数据结构;算法;python;计算机基础等
评论 (0)
  • 你是不是也有在公共场合被偷看手机或笔电的经验呢?科技时代下,不少现代人的各式机密数据都在手机、平板或是笔电等可携式的3C产品上处理,若是经常性地需要在公共场合使用,不管是工作上的机密文件,或是重要的个人信息等,民众都有防窃防盗意识,为了避免他人窥探内容,都会选择使用「防窥保护贴片」,以防止数据外泄。现今市面上「防窥保护贴」、「防窥片」、「屏幕防窥膜」等产品就是这种目的下产物 (以下简称防窥片)!防窥片功能与常见问题解析首先,防窥片最主要的功能就是用来防止他人窥视屏幕上的隐私信息,它是利用百叶窗的
    百佳泰测试实验室 2025-04-30 13:28 613浏览
  • 5小时自学修好BIOS卡住问题  更换硬盘故障现象:f2、f12均失效,只有ESC和开关机键可用。错误页面:经过AI的故障截图询问,确定是机体内灰尘太多,和硬盘损坏造成,开机卡在BIOS。经过亲手拆螺丝和壳体、排线,跟换了新的2.5寸硬盘,故障排除。理论依据:以下是针对“5小时自学修好BIOS卡住问题+更换硬盘”的综合性解决方案,结合硬件操作和BIOS设置调整,分步骤说明:一、判断BIOS卡住的原因1. 初步排查     拔掉多余硬件:断开所有外接设备(如
    丙丁先生 2025-05-04 09:14 72浏览
  • 随着智能驾驶时代到来,汽车正转变为移动计算平台。车载AI技术对存储器提出新挑战:既要高性能,又需低功耗和车规级可靠性。贞光科技代理的紫光国芯车规级LPDDR4存储器,以其卓越性能成为国产芯片产业链中的关键一环,为智能汽车提供坚实的"记忆力"支持。作为官方授权代理商,贞光科技通过专业技术团队和完善供应链,让这款国产存储器更好地服务国内汽车厂商。本文将探讨车载AI算力需求现状及贞光科技如何通过紫光国芯LPDDR4产品满足市场需求。 车载AI算力需求激增的背景与挑战智能驾驶推动算力需求爆发式
    贞光科技 2025-05-07 16:54 44浏览
  • 浪潮之上:智能时代的觉醒    近日参加了一场课题的答辩,这是医疗人工智能揭榜挂帅的国家项目的地区考场,参与者众多,围绕着医疗健康的主题,八仙过海各显神通,百花齐放。   中国大地正在发生着激动人心的场景:深圳前海深港人工智能算力中心高速运转的液冷服务器,武汉马路上自动驾驶出租车穿行的智慧道路,机器人参与北京的马拉松竞赛。从中央到地方,人工智能相关政策和消息如雨后春笋般不断出台,数字中国的建设图景正在智能浪潮中徐徐展开,战略布局如同围棋
    广州铁金刚 2025-04-30 15:24 333浏览
  • 二位半 5线数码管的驱动方法这个2位半的7段数码管只用5个管脚驱动。如果用常规的7段+共阳/阴则需要用10个管脚。如果把每个段看成独立的灯。5个管脚来点亮,任选其中一个作为COM端时,另外4条线可以单独各控制一个灯。所以实际上最多能驱动5*4 = 20个段。但是这里会有一个小问题。如果想点亮B1,可以让第3条线(P3)置高,P4 置低,其它阳极连P3的灯对应阴极P2 P1都应置高,此时会发现C1也会点亮。实际操作时,可以把COM端线P3设置为PP输出,其它线为OD输出。就可以单独控制了。实际的驱
    southcreek 2025-05-07 15:06 39浏览
  • 这款无线入耳式蓝牙耳机是长这个样子的,如下图。侧面特写,如下图。充电接口来个特写,用的是卡座卡在PCB板子上的,上下夹紧PCB的正负极,如下图。撬开耳机喇叭盖子,如下图。精致的喇叭(HY),如下图。喇叭是由电学产生声学的,具体结构如下图。电池包(AFS 451012  21 12),用黄色耐高温胶带进行包裹(安规需求),加强隔离绝缘的,如下图。451012是电池包的型号,聚合物锂电池+3.7V 35mAh,详细如下图。电路板是怎么拿出来的呢,剪断喇叭和电池包的连接线,底部抽出PCB板子
    liweicheng 2025-05-06 22:58 167浏览
  • 某国产固态电解的2次和3次谐波失真相当好,值得一试。(仅供参考)现在国产固态电解的性能跟上来了,值得一试。当然不是随便搞低端的那种。电容器对音质的影响_电子基础-面包板社区  https://mbb.eet-china.com/forum/topic/150182_1_1.html (右键复制链接打开)电容器对音质的影响相当大。电容器在音频系统中的角色不可忽视,它们能够调整系统增益、提供合适的偏置、抑制电源噪声并隔离直流成分。然而,在便携式设备中,由于空间、成本的限
    bruce小肥羊 2025-05-04 18:14 130浏览
  • 多功能电锅长什么样子,主视图如下图所示。侧视图如下图所示。型号JZ-18A,额定功率600W,额定电压220V,产自潮州市潮安区彩塘镇精致电子配件厂,铭牌如下图所示。有两颗螺丝固定底盖,找到合适的工具,拆开底盖如下图所示。可见和大部分市场的加热锅一样的工作原理,手绘原理图,根据原理图进一步理解和分析。F1为保险,250V/10A,185℃,CPGXLD 250V10A TF185℃ RY 是一款温度保险丝,额定电压是250V,额定电流是10A,动作温度是185℃。CPGXLD是温度保险丝电器元件
    liweicheng 2025-05-05 18:36 187浏览
  • UNISOC Miracle Gaming奇迹手游引擎亮点:• 高帧稳帧:支持《王者荣耀》等主流手游90帧高画质模式,连续丢帧率最高降低85%;• 丝滑操控:游戏冷启动速度提升50%,《和平精英》开镜开枪操作延迟降低80%;• 极速网络:专属游戏网络引擎,使《王者荣耀》平均延迟降低80%;• 智感语音:与腾讯GVoice联合,弱网环境仍能保持清晰通话;• 超高画质:游戏画质增强、超级HDR画质、游戏超分技术,优化游戏视效。全球手游市场规模日益壮大,游戏玩家对极致体验的追求愈发苛刻。紫光展锐全新U
    紫光展锐 2025-05-07 17:07 40浏览
  • 想不到短短几年时间,华为就从“技术封锁”的持久战中突围,成功将“被卡脖子”困境扭转为科技主权的主动争夺战。众所周知,前几年技术霸权国家突然对华为发难,导致芯片供应链被强行掐断,海外市场阵地接连失守,恶意舆论如汹涌潮水,让其瞬间陷入了前所未有的困境。而最近财报显示,华为已经渡过危险期,甚至开始反击。2024年财报数据显示,华为实现全球销售收入8621亿元人民币,净利润626亿元人民币;经营活动现金流为884.17亿元,同比增长26.7%。对比来看,2024年营收同比增长22.42%,2023年为7
    用户1742991715177 2025-05-02 18:40 174浏览
  • ‌一、高斯计的正确选择‌1、‌明确测量需求‌‌磁场类型‌:区分直流或交流磁场,选择对应仪器(如交流高斯计需支持交变磁场测量)。‌量程范围‌:根据被测磁场强度选择覆盖范围,例如地球磁场(0.3–0.5 G)或工业磁体(数百至数千高斯)。‌精度与分辨率‌:高精度场景(如科研)需选择误差低于1%的仪器,分辨率需匹配微小磁场变化检测需求。2、‌仪器类型选择‌‌手持式‌:便携性强,适合现场快速检测;‌台式‌:精度更高,适用于实验室或工业环境。‌探头类型‌:‌横向/轴向探头‌:根据磁场方向选择,轴向探头适合
    锦正茂科技 2025-05-06 11:36 281浏览
我要评论
0
0
点击右上角,分享到朋友圈 我知道啦
请使用浏览器分享功能 我知道啦