CPU领域中最广为人知的一条定律——摩尔定律:预计18个月会将芯片的性能提高一倍。过去几十年,各大公司致力于提高CPU晶体管密度和提高CPU工作频率,使得CPU的性能提升基本符合摩尔定律。但随着工艺不断发展,晶体管密度提升已经接近物理极限,CPU工作频率也由于功耗和发热的制约而无法继续提升。在基础物理领域没有大的突破的前提下,单核CPU的性能提升日益困难,于是,各大公司将目光投向了通过增加CPU核心数提高性能领域,双核、4核、8核、16核等一系列多核CPU相继问世。
怎样合理调度多个CPU核心运行应用程序从而充分利用多核CPU的优势成为热门的研究问题,本文介绍的CPU亲和性便是解决该问题的方法之一。
引用一下维基百科的说法,CPU亲和性就是绑定某一进程(或线程)到特定的CPU(或CPU集合),从而使得该进程(或线程)只能运行在绑定的CPU(或CPU集合)上。CPU亲和性利用了这样一个事实:进程上一次运行后的残余信息会保留在CPU的状态中(也就是指CPU的缓存)。如果下一次仍然将该进程调度到同一个CPU上,就能避免缓存未命中等对CPU处理性能不利的情况,从而使得进程的运行更加高效。
CPU亲和性分为两种:软亲和性和硬亲和性。软亲和性主要由操作系统来实现,Linux操作系统的调度器会倾向于保持一个进程不会频繁的在多个CPU之间迁移,通常情况下调度器都会根据各个CPU的负载情况合理地调度运行中的进程,以减轻繁忙CPU的压力,提高所有进程的整体性能。除此以外,Linux系统还提供了硬亲和性功能,即用户可以通过调用系统API实现自定义进程运行在哪个CPU上,从而满足特定进程的特殊性能需求。
Linux系统中每个进程的task_struct
结构中有一个cpus_allowed
位掩码,该掩码的位数与系统CPU核数相同(若CPU启用了超线程则为核数乘以2),通过修改该位掩码可以控制进程可运行在哪些特定CPU上。Linux系统为我们提供了CPU亲和性相关的调用函数和一些操作的宏定义,函数主要是下面两个:
sched_set_affinity()
(修改位掩码)
sched_get_affinity()
(查看当前的位掩码)
除此之外还提供了一些宏定义来修改掩码,如CPU_ZERO()
(将位掩码全部设置为0)和CPU_SET()
(设置特定掩码位为1)。
下面采用一个以@Eli Dow提供的程序为基础修改的程序介绍CPU亲和性的使用方法,该程序使用CPU亲和性API将N(CPU数量)个进程分别绑定到N个CPU上,代码如下:
1 | #include <iostream> |
代码编译运行结果如下:
通过ps -eo pid,args,psr
命令查看CPU与进程是否绑定成功:
可以看出,进程号6644的进程为父进程,该进程运行在CPU3上,6645、6646、6647三个子进程分别运行在CPU1、CPU2、CPU3上,可知进程与CPU绑定成功,进程只会运行在绑定的CPU上而不会被操作系统调度到其他CPU上。
一般情况下,Linux系统的进程调度器已经做得足够好,不需要我们干预进程的调度,但是系统的进程调度是面向所有应用程序的,势必会为了通用性而牺牲掉一部分性能,但对于特定应用程序而言,我们可以通过CPU亲和性去优化程序的性能表现。
我们相对于计算机的优势就是我们知道我们的程序的功能、每个进程的重要程度,所以可以根据进程的重要程度更合理的分配计算机的CPU资源。
]]>CPU领域中最广为人知的一条定律——摩尔定律:预计18个月会将芯片的性能提高一倍。过去几十年,各大公司致力于提高CPU晶体管密度和提高CPU工作频率,使得CPU的性能提升基本符合摩尔定律。但随着工艺不断发展,晶体管密度提升已经接近物理极]]>
虽然C++标准并没有规定编译器实现虚函数的方式,但是大部分编译器均是采用了虚函数表来实现虚函数,即对于每一个包含虚成员函数的类生成一个虚函数表,一个指向虚函数表的指针被放在对象的首地址(不考虑多继承等复杂情况),虚函数表中存储该类所有的虚函数地址。当使用引用或者指针调用虚函数时,首先通过虚函数表指针找到虚函数表,然后通过偏移量找到虚函数地址并调用。关于虚函数表的更多细节,建议阅读《深度探索C++对象模型》这本书。
空间开销
首先,由于需要为每一个包含虚函数的类生成一个虚函数表,所以程序的二进制文件大小会相应的增大;其次,对于包含虚函数的类的实例来说,每个实例都包含一个虚函数表指针用于指向对应的虚函数表,所以每个实例的空间占用都增加一个指针大小(32位系统4字节,64位系统8字节)。这些空间开销可能会造成缓存的不友好,在一定程度上影响程序性能。
时间开销
虚函数的时间开销主要是增加了一次内存寻址,通过虚函数表指针找到虚函数表,虽对程序性能有一些影响,但是影响并不大。
上述虚函数表面上的开销其实是微不足道的,真正影响虚函数性能的是隐藏在背后的,不被人轻易察觉的,只有对计算机体系结构有一定理解才能探寻出藏在背后的“性能杀手”。
首先我们先看调用虚函数时,在汇编层生成了什么代码:
1 | ... movq (%rax), %rax movq (%rax), %rax movq -24(%rbp), %rdx movq %rdx, %rdi call *%rax ... |
上述汇编代码最重要的就是第6行,在AT&T格式汇编中,这是一个间接调用,意义是从%rax指明的地址处读取跳转的目标位置。这也是虚函数调用与普通成员函数的区别所在,普通函数调用是一个直接调用。直接调用与间接调用的区别就是跳转地址是否确定,直接调用的跳转地址是编译器确定的,而间接调用是运行到该指令时从寄存器中取出地址然后跳转。
有了上面的基本认识,我们就可以分析虚函数的性能开销所在了,其实说到底,这个隐藏在背后的关键点就是分支预测器,如果看过我之前的博客,相信对分支预测器已经很熟悉了,如果感觉分支预测器还是很陌生,推荐阅读我以前的分支预测器的四篇文章:
有了分支预测器和CPU指令流水线的基本知识,我们可以发现对于直接调用而言,是不存在分支跳转的,因为跳转地址是编译器确定的,CPU直接去跳转地址取后面的指令即可,不存在分支预测,这样可以保证CPU流水线不被打断。而对于间接寻址,由于跳转地址不确定,所以此处会有多个分支可能,这个时候需要分支预测器进行预测,如果分支预测失败,则会导致流水线冲刷,重新进行取指、译码等操作,对程序性能有很大的影响。
网上有部分文章中说对于虚函数这种间接跳转会直接导致流水线冲刷,这种说法明显是自相矛盾的,如果间接跳转必定会导致流水线冲刷,那把这些指令放进流水线的意义何在呢?其实查阅资料就可以知道,Intel和AMD的CPU中存在两级自适应预测器用于预测间接跳转,此预测器可以预测多分支跳转。
本文探究出影响到虚函数调用性能的背后原因是流水线和分支预测,由于虚函数调用需要间接跳转,所以会导致虚函数调用比普通函数调用多了分支预测的过程,产生性能差距的原因主要是分支预测失败导致的流水线冲刷性能开销。
本文的目的并不是为了说明虚函数调用有额外开销而让大家避免使用虚函数,使用不使用虚函数应该由自己程序的需要而定,如果程序逻辑需要使用动态绑定,如果不使用虚函数而是自己实现相应逻辑的话产生的性能损耗一般会比使用虚函数的性能损耗大得多。但对于一些性能敏感的程序,在虚函数可用可不用的时候,可以考虑不使用虚函数以提高性能。
]]>虽然C++标准并没有规定编译器实现虚函数的方式,但是大部分编译器均是采用了虚函数表来实现虚函数,即对于每一个包含虚成员函数的类生成一个虚函数表,一个指向虚函数表的指针被放在对象的首地址(]]>
C语言的指针让我们有了直接操控内存的强大能力,同时指针也是使用C语言时最容易出问题的地方。C++在继承了C语言的指针的同时又给我们提供了另外一个武器:引用。今天我们来探讨一下指针与引用的异同以及两者之间的性能差异。
引用与指针的区别也算是C++中老生常谈的话题了,无论是在期末考试的试卷上还是找工作时的笔试面试上,这个问题都是“常客”。对于这个问题更加详细的解答请参考《More Effective C++》的条款1,本文主要提一下以下三个区别:
引用必须初始化,而指针可以不初始化。
我们在定义一个引用的时候必须为其指定一个初始值,但是指针却不需要。
1 | int &r; //不合法,没有初始化引用 |
引用不能为空,而指针可以为空。
由于引用不能为空,所以我们在使用引用的时候不需要测试其合法性,而在使用指针的时候需要首先判断指针是否为空指针,否则可能会引起程序崩溃。
1 | void test_p(int* p) |
引用不能更换目标
指针可以随时改变指向,但是引用只能指向初始化时指向的对象,无法改变。
1 | int a = 1; |
只看两者区别的话,我们发现引用可以完成的任务都可以使用指针完成,并且在使用引用时限制条件更多,那么C++为什么要引入“引用”呢?
限制条件多不一定是缺点,C++的引用在减少了程序员自由度的同时提升了内存操作的安全性和语义的优美性。比如引用强制要求必须初始化,可以让我们在使用引用的时候不用再去判断引用是否为空,让代码更加简洁优美,避免了指针满天飞的情形。除了这种场景之外引用还用于如下两个场景:
引用型参数
一般我们使用const reference参数作为只读形参,这种情况下既可以避免参数拷贝还可以获得与传值参数一样的调用方式。
1 | void test(const vector<int> &data) |
引用型返回值
C++提供了重载运算符的功能,我们在重载某些操作符的时候,使用引用型返回值可以获得跟该操作符原来语法相同的调用方式,保持了操作符语义的一致性。一个例子就是operator []操作符,这个操作符一般需要返回一个引用对象,才能正确的被修改。
1 | vector<int> v(10); |
指针与引用之间有没有性能差距呢?这种问题就需要进入汇编层面去看一下。我们先写一个test1函数,参数传递使用指针:
1 | void test1(int* p) |
该代码段对应的汇编代码如下:
1 | pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movq -8(%rbp), %rax movl $3, (%rax) nop popq %rbp ret |
上述代码1、2行是参数调用保存现场操作;第3行是参数传递,函数调用第一个参数一般放在rdi寄存器,此行代码把rdi寄存器值(指针p的值)写入栈中;第4行是把栈中p的值写入rax寄存器;第5行是把立即数3写入到rax寄存器值所指向的内存中,此处要注意(%rax)两边的括号,这个括号并并不是可有可无的,(%rax)和%rax完全是两种意义,(%rax)代表rax寄存器中值所代表地址部分的内存,即相当于C++代码中的*p,而%rax代表rax寄存器,相当于C++代码中的p值,所以汇编这里使用了(%rax)而不是%rax。
我们再写出参数传递使用引用的C++代码段test2:
1 | void test2(int& r) |
这段代码对应的汇编代码如下:
1 | pushq %rbp movq %rsp, %rbp movq %rdi, -8(%rbp) movq -8(%rbp), %rax movl $3, (%rax) nop popq %rbp ret |
我们发现test2对应的汇编代码和test1对应的汇编代码完全相同,这说明C++编译器在编译程序的时候将指针和引用编译成了完全一样的机器码。所以C++中的引用只是C++对指针操作的一个“语法糖”,在底层实现时C++编译器实现这两种操作的方法完全相同。
C++中引入了引用操作,在对引用的使用加了更多限制条件的情况下,保证了引用使用的安全性和便捷性,还可以保持代码的优雅性。在适合的情况使用适合的操作,引用的使用可以一定程度避免“指针满天飞”的情况,对于提升程序鲁棒性也有一定的积极意义。最后,指针与引用底层实现都是一样的,不用担心两者的性能差距。
]]>C语言的指针让我们有了直接操控内存的强大能力,同时指针也是使用C语言时最容易出问题的地方。C++在继承了C语言的指针的同时又给我们提供了另外一个武器:引用。今天我们来探讨一下指针与引用的异同以及两者之间的性能差异。
几乎每本面向初学者的C语言或C++书籍在前面两章都会提到分支控制语句if……else和switch……case,在某些情况下这两种分支控制语句可以互相替换,但却很少有人去深究在if……else和switch……case语句的背后到底有什么异同?应该选择哪一个语句才能使得效率最高?要回答这些问题,只能走到switch语句的背后,看看这些语句到底是怎么实现的。
相信学过C/C++的同学对这两个语句的异同早就了如指掌,if语句作为条件判断,满足条件进入if语句块,不满足条件则进入else语句块,而且if和else语句块又可以继续嵌套if语句。switch则是通过判断一个整型表达式的值来决定进入到哪一个case语句中,如果所有case条件都不满足则进入到default语句块。
1 | //简单的if语句 |
1 | //简单的switch语句 |
现在编译器已经足够智能和强大,经过测试,g++实现switch语句的方式就至少有三种,编译器会根据代码的实际情况,权衡时间效率和空间效率,去选择一个对当前代码而言综合效率最高的一种。
编译器实现switch语句的三种方式:
后面我们将就这三种实现方法适用的代码场景进行测试和分析。
逐条件判断法其实就是和if……else语句的汇编实现相同,编译器把switch语句中各个case条件逐个进行判断,直到找到正确的case语句块。这种方法适用于switch语句中case条件很少的情况,即使逐个条件判断也不会导致大量时间和空间的浪费,比如下面这段代码:
1 | #include <algorithm> |
该代码对应的汇编代码如下:
1 | movl -4(%rbp), %eax cmpl $1, %eax je .L3 cmpl $2, %eax je .L4 testl %eax, %eax jne .L8 movl $0, -8(%rbp) jmp .L6 .L3: movl $1, -8(%rbp) jmp .L6 .L4: movl $2, -8(%rbp) jmp .L6 .L8: movl $3, -8(%rbp) nop |
eax寄存器存储的是判断条件值(对应于C++代码中的a值),首先判断a是否等于1,如果等于1则跳转到.L3执行a==1对应的代码段,然后判断a是否等于2,如果等于2则跳转到.L4执行a==2对应的代码段……可能难理解的是第6行代码testl %eax, %eax
,其实这只是编译器提高判断一个寄存器是否为0效率的一个小技巧,如果eax不等于0则跳转到.L8代码段,执行default代码段对应的代码,如果eax等于0则执行a==0对应的代码段。
由上面对编译器生成汇编代码的分析,我们可以发现:编译器在这种情况下使用逐个条件判断来实现switch语句。
在编译器采用这种switch语句实现方式的时候,会在程序中生成一个跳转表,跳转表存放各个case语句指令块的地址,程序运行时,首先判断switch条件的值,然后把该条件值作为跳转表的偏移量去找到对应case语句的指令地址,然后执行。这种方法适用于case条件较多,但是case的值比较连续的情况,使用这种方法可以提高时间效率且不会显著降低空间效率,比如下面这段代码编译器就会采用跳转表这种实现方式:
1 | #include <algorithm> |
该代码对应的汇编代码如下:
1 | movl -4(%rbp), %eax movq .L4(,%rax,8), %rax jmp *%rax .L4: .quad .L3 .quad .L5 .quad .L6 .quad .L7 .quad .L8 .quad .L9 .quad .L10 .quad .L11 .quad .L12 .quad .L13 .text .L3: movl $0, -8(%rbp) jmp .L14 .L5: movl $1, -8(%rbp) jmp .L14 #后面省略…… |
在x64架构中,eax寄存器是rax寄存器的低32位,此处我们可以认为两者值相等,代码第一行是把判断条件(对应于C++代码中的a值)复制到eax寄存器中,第二行代码是把.L4段偏移rax寄存器值大小的地址赋值给rax寄存器,第三行代码则是取出rax中存放的地址并且跳转到该地址处。我们可以清楚的看到.L4代码段就是编译器为switch语句生成的存放于.text段的跳转表,每种case均对应于跳转表中一个地址值,我们通过判断条件的值即可计算出来其对应代码段地址存放的地址相对于.L4的偏移,从而实现高效的跳转。
如果case值较多且分布极其离散的话,如果采用逐条件判断的话,时间效率会很低,如果采用跳转表方法的话,跳转表占用的空间就会很大,前两种方法均会导致程序效率低。在这种情况下,编译器就会采用二分查找法实现switch语句,程序编译时,编译器先将所有case值排序后按照二分查找顺序写入汇编代码,在程序执行时则采二分查找的方法在各个case值中查找条件值,如果查找到则执行对应的case语句,如果最终没有查找到则执行default语句。对于如下C++代码编译器就会采用这种二分查找法实现switch语句:
1 | #include <algorithm> |
改代码段对应的汇编代码为:
1 | movl -4(%rbp), %eax cmpl $50, %eax je .L3 cmpl $50, %eax jg .L4 cmpl $4, %eax je .L5 cmpl $10, %eax je .L6 jmp .L2 .L4: cmpl $200, %eax je .L7 cmpl $500, %eax je .L8 cmpl $100, %eax je .L9 jmp .L2 |
代码第二行条件值首先与50比较,为什么是50而不是放在最前面的4?这是因为二分查找首先查找的是处于中间的值,所以这里先与50进行比较,如果eax等于50,则执行case 50对应代码,如果eax值大于50则跳转到.L4代码段,如果eax小于50则继续跟4比较……直至找到条件值或者查找完毕条件值不存在。可以看出二分查找法在保持了较高的查询效率的同时又节省了空间占用。
何时应该使用if……else语句,何时应该使用switch……case语句?
通过上面的分析我们可以得出结论,在可能条件比较少的时候使用if……else和switch……case所对应的汇编代码是相同的,所以两者在性能上是没有区别的,使用哪一种取决于个人习惯。如果条件较多的话,显而易见switch……case的效率更高,无论是跳转表还是二分查找都比if……else的顺序查找效率更高,所以在这种情况下尽量选用switch语句来实现分支语句。当然如果我们知道哪种条件出现的概率最高,我们可以将这个条件放在if判断的第一个,使顺序查找提前结束,这时使用if……else语句也可以达到较高的运行效率。
switch语句也有他本身的局限性,即switch语句的值只能为整型,比如当我们需要对一个double型数据进行判断时,便无法使用switch语句,这时只能使用if……else语句来实现。
]]>几乎每本面向初学者的C语言或C++书籍在前面两章都会提到分支控制语句if……else和switch……case,在某些情况下这两种分支控制语句可以互相替换,但却很少有人去深究在if……else和switch……case语句的背后到底有什]]>
在C++多线程程序的开发过程中,资源的互斥访问是第一个要考虑的问题,常用的方法就是使用互斥锁对共享数据进行保护,而使用锁最大的风险就是有可能产生死锁,导致程序异常退出。因为这个风险的存在,所以我每次使用互斥锁的时候都小心翼翼,看互斥锁lock之后是否在合适的地方进行了unlock,在多个互斥锁同时使用的时候反复检查加锁的顺序是否会导致死锁……但是,百密一疏,自己还是亲手写出了一把死锁,导致付出了很多时间和精力去Debug。
在测试股票自动化交易软件的时候,软件运行一定时间之后会突然失去响应并且崩溃,而且问题无法复现,后来经过打印log去打印出每次运行后各个变量的值,期望还原“事故现场”,经过反复测试,发现当资金量较小时程序会崩溃,联想到最近修改的资金量相关代码,最终将问题锁定到策略线程中一段代码:
1 | //判断资金量是否足够完成本次交易 |
我们分析一下这段代码,由于全局变量g_iCash是所有线程共享的,所以需要使用互斥锁g_MutexCash进行保护,在我们读写g_iCash变量之前,先调用g_MutexCash.lock()加锁,然后对g_iCash读写完毕之后,调用g_MutexCash.unlock()进行解锁。看似没有问题,实际这段代码是会产生死锁的。代码的3,4两行我们判断当前资金是否大于本次交易耗费资金,如果当前资金不足,则直接放弃本次交易,调用return返回,当我们在这里return之后,return后面的语句都不会再继续执行,所以g_MutexCash.unlock()语句不会被调用,所以导致互斥锁永远不会被释放,最终导致死锁。
相信稍微有点多线程编程经验的同学都会很轻易的发现上述代码可能会产生死锁,而且在我们每次使用互斥锁都小心使用,这段代码为什么会犯这种低级错误?
其实上面代码初始版本是不包含第3、4两行的,当不存在3、4两行时,我们这样写是没有问题的,但是由于后面软件需求更改,我们需要加入第3、4行的判断资金量逻辑,所以在原来代码基础上直接添加上了第3、4行,添加的时候却忘记了这段代码处于临界区中,没有仔细考虑添加代码之后会不会产生死锁,进而导致了出现了上述代码中的低级错误。
通过这个问题我们可以总结出如下经验:
在C++多线程程序的开发过程中,资源的互斥访问是第一个要考虑的问题,常用的方法就是使用互斥锁对共享数据进行保护,而使用锁最大的风险就是有可能产生死锁,导致程序异常退出。因为这个风险的存在,所以我每次使用互斥锁的时候都小心翼翼,看互斥锁l]]>
私以为个人的技术水平应该是一个螺旋式上升的过程:先从书本去了解一个大概,然后在实践中加深对相关知识的理解,遇到问题后再次回到书本,然后继续实践……接触C++并发编程已经一年多,从慢慢啃《C++并发编程实战》这本书开始,不停在期货高频交易软件的开发实践中去理解、运用、优化多线程相关技术。多线程知识的学习也是先从最基本的线程建立、互斥锁、条件变量到更高级的线程安全数据结构、线程池等等技术,当然在项目中也用到了简单的无锁编程相关知识,今天把一些体会心得跟大家分享一下,如有错误,还望大家批评指正。
在编写多线程程序时,最重要的问题就是多线程间共享数据的保护。多个线程之间共享地址空间,所以多个线程共享进程中的全局变量和堆,都可以对全局变量和堆上的数据进行读写,但是如果两个线程同时修改同一个数据,可能造成某线程的修改丢失;如果一个线程写的同时,另一个线程去读该数据时可能会读到写了一半的数据。这些行为都是线程不安全的行为,会造成程序运行逻辑出现错误。举个最简单的例子:
1 | #include <iostream> |
上面代码main函数中建立了两个线程thread1和thread2,两个线程都是运行iplusplus函数,该函数功能就是运行i++语句10000000次,按照常识,两个线程各对i自增10000000次,最后i的结果应该是20000000,但是运行后结果却是如下:
i并不等于20000000,这是在多线程读写情况下没有对线程间共享的变量i进行保护所导致的问题。
对于保护多线程共享数据,最常用也是最基本的方法就是使用C++11线程标准库提供的互斥锁mutex保护临界区,保证同一时间只能有一个线程可以获取锁,持有锁的线程可以对共享变量进行修改,修改完毕后释放锁,而不持有锁的线程阻塞等待直到获取到锁,然后才能对共享变量进行修改,这种方法几乎是并发编程中的标准做法。大体流程如下:
1 | #include <iostream> |
代码14行和16行分别为互斥锁加锁和解锁代码,29行我们打印程序运行耗时,代码运行结果如下:
可以看到,通过加互斥锁,i的运行结果是正确的,由此解决了多线程同时写一个数据产生的线程安全问题,代码总耗时3.37328ms。
原子操作是无锁编程的基石,原子操作是不可分隔的操作,一般通过CAS(Compare and Swap)操作实现,CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较下旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不交换。C++11的线程库为我们提供了一系列原子类型,同时提供了相对应的原子操作,我们通过使用这些原子类型即可摆脱每次对共享变量进行操作都进行的加锁解锁动作,节省了系统开销,同时避免了线程因阻塞而频繁的切换。原子类型的基本使用方法如下:
1 | #include <iostream> |
代码的第8行定义了一个原子类型(int)变量i,在第13行多线程修改i的时候即可免去加锁和解锁的步骤,同时又能保证变量i的线程安全性。代码运行结果如下:
可以看到i的值是符合预期的,代码运行总耗时1.12731ms,仅为有锁编程的耗时3.37328ms的1/3,由此可以看出无锁编程由于避免了加锁而相对于有锁编程提高了一定的性能。
无锁编程最大的优势是什么?是性能提高吗?其实并不是,我们的测试代码中临界区非常短,只有一个语句,所以显得加锁解锁操作对程序性能影响很大,但在实际应用中,我们的临界区一般不会这么短,临界区越长,加锁和解锁操作的性能损耗越微小,无锁编程和有锁编程之间的性能差距也就越微小。
我认为无锁编程最大的优势在于两点:
如果是为了提高性能将程序大幅改写成无锁编程,一般来说结果可能会让我们失望,而且无锁编程里面需要注意的地方也非常多,比如ABA问题,内存顺序问题,正确实现无锁编程比实现有锁编程要困难很多,除非有必要(确定了性能瓶颈)才去考虑使用无锁编程,否则还是使用互斥锁更好,毕竟程序的高性能是建立在程序正确性的基础上,如果程序不正确,一切性能提升都是徒劳无功。
]]>私以为个人的技术水平应该是一个螺旋式上升的过程:先从书本去了解一个大概,然后在实践中加深对相关知识的理解,遇到问题后再次回到书本,然后继续实践……接触C++并发编程已经一年多,从慢慢啃《C++并发编程实战》这本书开始,不停在期货高频交易]]>
在开始正式讨论我们的问题之前,我们先想象这么一个小场景:
场景1:6只小鸟停在电线上休息,都在等待食物。
场景2:我们向鸟群投放一条小虫,作为它们的食物。
场景3:6只小鸟看到有食物到来,都停止休息,一起飞起来去抢夺食物。
场景4:最终只有一只小鸟(bird4)能够吃到食物,其他小鸟无奈而又伤心的回到电线上继续休息。
上面我们的小场景实际就是一个现实中的惊群问题,明明只有一条小虫子子到来,6只小鸟却都要停止休息去抢夺食物,除了抢到食物的小鸟,其他抢不到食物的小鸟又需要重新飞回去休息,对于这部分小鸟来说,无谓浪费了很多体力。
那么计算机中惊群又是什么样呢?其实与上述场景类似,多个线程(或者进程)同时等待一个事件的到来并准备处理事件,当事件到达时,把所有等待该事件的线程(或进程)均唤醒,但是只能有一个线程最终可以获得事件的处理权,其他所有线程又重新陷入睡眠等待下次事件到来。这种线程被频繁唤醒却又没有真正处理事件导致CPU无谓浪费称为计算机中的“惊群问题”。
Linux2.6内核版本之前系统API中的accept调用
在Linux2.6内核版本之前,当多个线程中的accept函数同时监听同一个listenfd的时候,如果此listenfd变成可读,则系统会唤醒所有使用accept函数等待listenfd的所有线程(或进程),但是最终只有一个线程可以accept调用返回成功,其他线程的accept函数调用返回EAGAIN错误,线程回到等待状态,这就是accept函数产生的惊群问题。但是在Linux2.6版本之后,内核解决了accept函数的惊群问题,当内核收到一个连接之后,只会唤醒等待队列上的第一个线程(或进程),从而避免了惊群问题。
epoll函数中的惊群问题
如果我们使用多线程epoll对同一个fd进行监控的时候,当fd事件到来时,内核会把所有epoll线程唤醒,因此产生惊群问题。为何内核不能像解决accept问题那样解决epoll的惊群问题呢?内核可以解决accept调用中的惊群问题,是因为内核清楚的知道accept调用只可能一个线程调用成功,其他线程必然失败。而对于epoll调用而言,内核不清楚到底有几个线程需要对该事件进行处理,所以只能将所有线程全部唤醒。
线程池中的惊群问题
在实际应用程序开发中,为了避免线程的频繁创建销毁,我们一般建立线程池去并发处理,而线程池最经典的模型就是生产者-消费者模型,包含一个任务队列,当队列不为空的时候,线程池中的线程从任务队列中取出任务进行处理。一般使用条件变量进行处理,当我们往任务队列中放入任务时,需要唤醒等待的线程来处理任务,如果我们使用C++标准库中的函数notify_all()来唤醒线程,则会将所有的线程都唤醒,然后最终只有一个线程可以获得任务的处理权,其他线程在此陷入睡眠,因此产生惊群问题。
在开始正式讨论我们的问题之前,我们先想象这么一个小场景:
场景1:6只小鸟停在电线上休息,都在等待食物。
场景2:]]>
《CSAPP》讲到了局部性原理:一个编写良好的计算机程序常常具有良好的局部性(loacality)。也就是说,它们倾向于引用邻近于其他最近引用过的数据项,或者最近引用过的数据项本身。这种倾向性,被称为局部性原理(principle of locality),是一个持久的概念,对硬件和软件系统的设计和性能都有着极大的影响。
为了让大家更加直观的感受到局部性原理对我们程序性能的影响,我们先看一段代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48#include <iostream>
#include <chrono>
#include <vector>
using namespace std;
//求和函数,行优先
int sum_row_first(const vector<vector<int>> &data){
int sum = 0;
for(int i = 0; i < 1024; i++){
for(int j = 0; j < 1024; j++){
sum += data[i][j];
}
}
return sum;
}
//求和函数,列优先
int sum_col_first(const vector<vector<int>> &data){
int sum = 0;
for(int j = 0; j < 1024; j++){
for(int i = 0; i < 1024; i++){
sum += data[i][j];
}
}
return sum;
}
int main(){
vector<vector<int>> data(1024,vector<int>(1024,0));
//初始化二维数组
for(int i=0;i < 1024; i++){
for(int j=0; j < 1024; j++){
data[i][j] = rand() % 10;
}
}
chrono::steady_clock::time_point start_time = chrono::steady_clock::now();
sum_row_first(data); // 计算行优先求和函数耗时
chrono::steady_clock::time_point stop_time = chrono::steady_clock::now();
chrono::duration<double> time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time);
std::cout << "行优先耗时:"<< time_span.count() << " ms" << endl;
start_time = chrono::steady_clock::now();
sum_col_first(data); //计算列优先求和函数耗时
stop_time = chrono::steady_clock::now();
time_span = chrono::duration_cast<chrono::microseconds>(stop_time - start_time);
std::cout << "列优先耗时:"<< time_span.count() << " ms" << endl;
}
上面这段代码我们主要定义了两个二维数组求和函数,其中sum_row_first函数按照二维数组的行求和(先访问第一行的所有元素,然后第二行……直到最后一行),而sum_col_first函数按照二维数组的列求和(先访问第一列的所有元素,然后第二列……直到最后一列)。逻辑上来讲,无论行优先访问还是列优先访问对函数运行的时间应该不会造成影响,毕竟,两个函数都是遍历了整个二维数组,但是实际运行结果如何呢?
从上图运行结果我们发现,行优先求和函数的总运行时间为0.004661ms,列优先求和函数的总运行时间为0.007287ms,列优先求和函数运行时间几乎是行优先求和函数运行时间的两倍,造成这两个函数运行速度相差巨大的原因就是局部性原理。
介绍局部性原理之前,我们需要先了解一下缓存的概念:在伪共享这篇博客中,我们提到了CPU中的缓存行的概念,CPU使用高速缓存以提高访问数据的速度,每次CPU取数据都首先检查高速缓存中是否已有所取数据,如果没有则从内存中取到数据(包括相邻的数据),缓存到CPU高速缓存中以供下次使用。缓存的概念充斥在现代操作系统的各个层次,从硬件到操作系统、再到用户软件,很多项目架构也使用到了缓存的思想,如使用访问速度快的内存数据库(如Redis)作为访问速度慢的关系型数据库(如Mysql)的缓存,以降低整个系统的访问时间。
CSAPP中定义了空间局部性:在一个良好空间局部性的程序中,如果一个存储器位置被引用了一次,那么程序很可能在不远的将来引用附近的一个存储器位置。
在C/C++编译系统中,二维数组的数据是按行存放的,所以每一行的数据在内存中是连续的,每一列的数据在内存中是相距较远的,上面代码中的行优先求和函数按行访问数据,当某个数据被缓存的时候,它附近跟它同一行的数据也被同时缓存进了高速缓存,所以当程序继续运行访问下一个数据时,由于该数据已经被缓存了,所以CPU可以很快的从高速缓存获取到该数据,而不用重新从内存中读取。但是列优先求和函数访问某一个数据时,把跟它同行的数据缓存进了高速缓存,但程序下一步却是访问下一行的对应列数据,所以导致缓存不命中,所以程序每一次访问数据都需要重新从内存中访问,所以速度相比于行优先程序有所降低。所以说行优先求和函数比列优先求和函数有更好的空间局部性。
我们已经明白了局部性原理对我们程序性能的影响,在以后的代码编写时应尽量保证程序有较好的局部性,局部性比较好的程序更容易有较高的缓存命中率,而缓存命中率高的程序往往比缓存命中率低的程序运行速度更快。
最后请大家思考一个问题:为何堆排序平均复杂度和最差复杂度都为O(NlogN),而快速排序平均复杂度O(NlogN),最差复杂度会到O(N*N),但为何快速排序应用反而远比堆排序应用广泛?
……
答案:快速排序的每一步访问的数据都是连续的,而堆排序中需要不停比较父子节点,如果父节点时N,则子节点是2N+1,两者在内存中相距较远。快速排序相比堆排序有更好的局部性,所以虽然两种排序方法的理论平均复杂度相同,但在计算机上运行时往往快速排序的表现大幅优于堆排序。
]]>《CSAPP》讲到了局部性原理:一个编写良好的计算机程序常常具有良好的局部性(loacality)。也就是说,它们倾向于引用邻近于其他最近引用过的数据项,或者最近引用过的数据项本身。这种倾向性,被称为<]]>
KMP算法是一种优化后的字符串匹配算法,可以将复杂度由暴力匹配的O(m*n)降低到O(m+n),具体原理就不再赘述,相信几乎任何一本算法书上面都会有KMP算法的详细介绍与实现。以前虽然学习过KMP算法,也清楚算法的原理,但是却从来没有完整实现过一次,闲来无事便打开Visual Studio准备使用C++独立实现一下。
代码如下:
1 | #include <iostream> |
我们使用如下代码对上述KMP算法正确性进行验证:
1 | int main() |
我们可以看到字符串”aac”是包含在”aabaacg”中的,KMP函数应该返回匹配点位置3,但是程序运行后却得到如下结果:
KMP函数返回值为-1,即没有在”aabaacg”中找到子串”aac”,显然这个结果并不正确。
为了找到问题所在,我们单步执行跟踪函数的运行,跟踪结果表明,KMP函数中的循环(代码第25行)在k==-1的情况下退出了,显然这并不符合逻辑。在这个地方仔细想一下就能发现问题出现的原因,我们判断退出条件时使用了vector的成员函数length()获得字符串长度,length()函数返回值为size_t,也就是一个unsigned int类型的值,k的类型却为int,当int与unsigned int比较时,编译器会将int类型转换为unsigned int类型,所以当k==-1时就被转换为了最大的无符号整数INT_MAX,所以k<p.length()条件就不再满足,导致循环提前退出,最终导致函数逻辑出现错误。
问题解决办法为在循环前面定义两个int变量len_str和len_p,并将str和p的大小赋值给这两个变量,在循环中使用len_str和len_p替换str.length()和p.length()即可。
这个错误完全是因为编码习惯不够严谨导致的,在C++中随手就会写出这种循环:
1 | for(int i = 0;i < p.length(); i++){ |
这种代码在 i >= 0 的时候可以运行良好,但是一旦出现i < 0的情况,就会导致意想不到的事情发生。
后面一定要改善自己的编码习惯,对于不同类型之间运算一定要仔细考虑默认类型转换,同时对编译器发出的”waring”引起足够的重视,虽然”waring”不会引起语法错误,但是可能会引起错误代价更大的逻辑错误。
]]>KMP算法是一种优化后的字符串匹配算法,可以将复杂度由暴力匹配的O(m*n)降低到O(m+n),具体原理就不再赘述,相信几乎任何一本算法书上面都会有KMP算法的详细介绍与实现。以前虽然学习过KMP算法,也清楚算法的原理,但是]]>
在多核并发编程中,如果将互斥锁的争用比作“性能杀手”的话,那么伪共享则相当于“性能刺客”。“杀手”与“刺客”的区别在于杀手是可见的,遇到杀手时我们可以选择战斗、逃跑、绕路、求饶等多种手段去应付,但“刺客”却不同,“刺客”永远隐藏在暗处,伺机给你致命一击,防不胜防。具体到我们的并发编程中,遇到锁争用影响并发性能情况时,我们可以采取多种措施(如缩短临界区,原子操作等等)去提高程序性能,但是伪共享却是我们从所写代码中看不出任何蛛丝马迹的,发现不了问题也就无法解决问题,从而导致伪共享在“暗处”严重拖累程序的并发性能,但我们却束手无策。
为了进行下面的讨论,我们需要首先熟悉缓存行的概念,学过操作系统课程存储结构这部分内容的同学应该对存储器层次结构的金字塔模型印象深刻,金字塔从上往下代表存储介质的成本降低、容量变大,从下往上则代表存取速度的提高。位于金字塔模型最上层的是CPU中的寄存器,其次是CPU缓存(L1,L2,L3),再往下是内存,最底层是磁盘,操作系统采用这种存储层次模型主要是为了解决CPU的高速与内存磁盘低速之间的矛盾,CPU将最近使用的数据预先读取到Cache中,下次再访问同样数据的时候,可以直接从速度比较快的CPU缓存中读取,避免从内存或磁盘读取拖慢整体速度。
CPU缓存的最小单位就是缓存行,缓存行大小依据架构不同有不同大小,最常见的有64Byte和32Byte,CPU缓存从内存取数据时以缓存行为单位进行,每一次都取需要读取数据所在的整个缓存行,即使相邻的数据没有被用到也会被缓存到CPU缓存中(这里又涉及到局部性原理,后面文章会进行介绍)。
在单核CPU情况下,上述方法可以正常工作,可以确保缓存到CPU缓存中的数据永远是“干净”的,因为不会有其他CPU去更改内存中的数据,但是在多核CPU下,情况就变得更加复杂一些。多CPU中,每个CPU都有自己的私有缓存(可能共享L3缓存),当一个CPU1对Cache中缓存数据进行操作时,如果CPU2在此之前更改了该数据,则CPU1中的数据就不再是“干净”的,即应该是失效数据,缓存一致性就是为了保证多CPU之间的缓存一致。
Linux系统中采用MESI协议处理缓存一致性,所谓MESI即是指CPU缓存的四种状态:
每个CPU缓存行都在四个状态之间互相转换,以此决定CPU缓存是否失效,比如CPU1对一个缓存行执行了写入操作,则此操作会导致其他CPU的该缓存行进入Invalid无效状态,CPU需要使用该缓存行的时候需要从内存中重新读取。由此就解决了多CPU之间的缓存一致性问题。
何谓伪共享?上面我们提过CPU的缓存是以缓存行为单位进行的,即除了本身所需读写的数据之外还会缓存与该数据在同一缓存行的数据,假设缓存行大小是32字节,内存中有“abcdefgh”八个int型数据,当CPU读取“d”这个数据时,CPU会将“abcdefgh”八个int数据组成一个缓存行加入到CPU缓存中。假设计算机有两个CPU:CPU1和CPU2,CPU1只对“a”这个数据进行频繁读写,CPU2只对“b”这个数据进行频繁读写,按理说这两个CPU读写数据没有任何关联,也就不会产生任何竞争,不会有性能问题,但是由于CPU缓存是以缓存行为单位进行存取的,也是以缓存行为单位失效的,即使CPU1只更改了缓存行中“a”数据,也会导致CPU2中该缓存行完全失效,同理,CPU2对“b”的改动也会导致CPU1中该缓存行失效,由此引发了该缓存行在两个CPU之间“乒乓”,缓存行频繁失效,最终导致程序性能下降,这就是伪共享。
避免伪共享主要有以下两种方式:
一般伪共享都很隐蔽,很难被发现,当伪共享真正构成性能瓶颈的时候,我们有必要去努力找到并解决它,但是在大部分对性能追求没有那么高的应用中,伪共享的存在对程序的危害很小,有时并不值得耗费精力和额外的内存空间(缓存行填充)去查找系统存在的伪共享。还是那句我一直以来遵循的话“不要过度优化,不要提前优化。”。
]]>在多核并发编程中,如果将互斥锁的争用比作“性能杀手”的话,那么伪共享则相当于“性能刺客”。“杀手”与“刺客”的区别在于杀手是可见的,遇到杀手时我们可以选择战斗、逃跑、绕路、求饶等多种手段去应付,但“刺客”却不同,“刺客”永远隐藏在暗]]>
循环展开,英文中称(Loop unwinding或loop unrolling),是一种牺牲程序的尺寸来加快程序的执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具有多个功能单元的处理器提供指令级并行。也有利于指令流水线的调度。
我们直接以实际代码向大家展示循环展开的作用,首先看未经过循环展开优化的代码:
1 | #include <iostream> |
类似于上面的这段代码是我们平常工作中经常见到的,函数目的就是求得1+2+……+9998+9999的累加和,每次循环把i累加到sum变量上,循环次数一共10000次。代码运行结果如下:
可以看出代码运行耗时0.0000279秒。
下面我们将循环展开一次,即把上述代码中的循环改为如下代码:
1 | for(int i = 0;i < count;i += 2){ |
即每次循环将i和i+1一起累加到sum变量上,这样可以把循环次数从10000次降低到5000次,由于CPU的高度流水线化,连续两个加法指令增加耗时很低,所以此版本代码可以一定程度上提高程序运行速度,运行结果如下:
代码运行耗时0.0000159秒,相较于未优化代码速度快了将近一倍。
当然,我们可以继续增加循环展开次数以进一步提高程序运行速度,但是这个增加循环展开次数也是有限度的,当达到了CPU的最高吞吐量之后,继续增加循环展开次数是没有意义的。
上述循环展开后的代码依然有进一步优化的空间,那就是消除连续指令的相关性,以达到指令级并行,我们可以看到循环展开后的代码,循环体中有两条语句:sum += i 和 sum += i+1,第二条语句sum += i+1依赖于第一条命来sum += i的执行结果,所以这两条语句只能依次执行,限制了CPU进一步提高性能的可能。如果我们将循环体改为如下代码:
1 | int sum1=0,sum2=0; |
我们新建了两个变量sum1和sum2用于存储循环展开时两个累加语句的累加结果,最后在循环体外将两部分结果相加得到最终结果。该代码中两个累加语句之间是互不相关的,所以CPU可以并行执行这两条指令,以达到性能的进一步提高。下面是运行结果:
代码运行耗时0.0000073秒,相较于只进行循环展开的代码速度又快了将近一倍。
由上面三段代码的运行速度对比可以看出,循环展开对程序性能有着很重要的影响,可以减少分支预测错误次数,增加取消数据相关进一步利用并行执行提高速度的机会。但是,并不建议大家进行手动的循环展开,在代码中进行循环展开会导致程序的可读性下降,代码膨胀。为了直观感受循环展开对性能的影响,上述代码运行结果均是在不开编译器优化的情况下进行的测试,其实在我们开启了编译器优化的时候,编译器会自动对我们的循环代码进行循环展开,让我们可以在保持了代码可读性的同时,又能享受到循环展开对我们程序性能的提高。
]]>循环展开,英文中称(Loop unwinding或loop unrolling),是一种牺牲程序的尺寸来加快程序的执行速度的优化方法。可以由程序员完成,也可由编译器自动优化完成。循环展开最常用来降低循环开销,为具]]>
在开发股票自动化交易软件过程中,我们需要将股票的tick数据(每3s一笔)存储下来,供我们的股票交易策略部分使用。这种情况下首先想到使用MySql这种关系型数据库进行存储,但是我们的股票自动化交易软件对速度的要求极高,可能相差几十毫秒就是赚钱与亏钱的区别,而MySql这种传统数据库的插入与查询是很慢的,无法满足我们的要求。当然我们也可以选择既满足存储数据的要求又能达到我们需要的速度要求的Redis这种内存数据库,但考虑到我们交易软件是日内交易,不需要股票数据持久化到硬盘上,而且经过大概估算,我们服务器的内存远大于股票每日的数据量,所以考虑将股票数据直接保存在内存中,软件运行时直接对内存中的股票数据进行使用即可。
我们使用map保存股票的数据,map的key是股票代码(string类型),value是该股票的行情数据(vector类型),然后建立行情类MarketData(单例模式)将map封装起来,并实现线程安全的插入数据和取数据的方法,这样就实现了一个极其简单的“内存数据库”。在MarketData建立后需要初始化股票数据,使用vector的reserve方法为每支股票预先分配足够存储一天数据的内存空间。代码如下:
1 | ///初始化mapStockData,初始化后该map节点位置不会再变化,避免多线程安全问题 |
上述代码在订阅股票数量较少(小于10支)的时候一直工作都很正常,昨天我们添加新的股票交易策略,新的策略需要订阅更多的股票(大概100支股票),这时候这段代码就出现了std::bad_alloc错误,即内存申请失败。
既然是内存分配错误,那我们就计算一下内存占用,经过计算,每支股票每天的数据量大约48M,一百支股票的占用就是4800M,总的数据量不到5个G,但我们服务器是64G内存,应该不存在内存不够用的情况。后来又推测是不是vector需要申请连续的内存,而恰好服务器内存不存在这么多连续的内存,细想一下这种可能性也不大。
没啥头绪就先把这个问题放下去吃饭了,在去食堂的路上,突然想到我们项目中使用的是x86模式,即我们的程序是32位的程序,虽然服务器是64位,但是32位的程序的寻址空间最大只有4G,所以程序是不会寻址大于4G的内存地址的,所以对我们程序而言,服务器64G的物理内存能用的只有4G,而且这4G也不是进程独占的,对于Linux系统而言,内核空间占用1G,32位程序最大的可利用内存只有3G,对于Windows系统而言,系统空间占用2G,32位程序最大可利用内存只有2G(堆内存1.6G左右)。2G内存只能存储大约40支股票,如果再考虑到内存碎片对vector请求连续内存的影响,可能只能存储20多支股票的数据,所以在我们订阅了100支股票的时候,就会出现内存分配失败的错误。
查阅资料得知:Windows操作系统有一个 boot.ini 开关,可以为应用程序提供访问3GB的进程地址空间的权限,从而将内核模式地址空间限定为 1 GB,从而用户模式地址空间可用3GB。这种方法虽然可以将可用内存增加1GB,但对我们上百支股票数据而言仍旧是杯水车薪。
最好的办法就是将应用程序按照x64模式重新编译,编译出来的程序便是64位程序,地址空间远远大于32位程序,此时,对程序分配内存的限制就只有物理内存大小,但我们的物理内存是完全足够的,更改了编译参数之后,上述内存申请失败的错误便不再出现。
当前,64位CPU和64位操作系统已经完全普及了,基于这些平台开发应用程序的时候,如果没有兼容32位系统的需求,应该优先考虑开发64位的应用程序,对于需要大量内存的程序尤为重要。
]]>在开发股票自动化交易软件过程中,我们需要将股票的tick数据(每3s一笔)存储下来,供我们的股票交易策略部分使用。这种情况下首先想到使用MySql这种关系型数据库进行存储,但是我们的股票自动化交易软件对速度的要求极高,可]]>
事情是这样的:在股票的自动化交易软件开发过程中,我们使用了万得的一套股票行情获取API,伴随着API还提供了一个示例程序,我们在开发过程中借用了部分示例程序中的代码,其中就包括一个配置读取类:ConfigSettings,这个类负责从配置文件中读入软件配置参数,并把参数的值保存在成员变量中,以供后面程序进行参数读取。
但是除了原来支持的参数之外,我们又在配置文件中增加了新的配置参数,是股票代码和其初始持仓,我们需要从配置文件中读取股票代码和其初始持仓,于是我们便想到了在原来的配置读取类(ConfigSettings)基础上进行拓展,拓展思路也很简单,在原来的类中增加一个成员变量用于保存股票代码和其对应持仓,我们采用了标准库里的unordered_map(hash table),键就是string类型的股票代码,值就是该股票代码对应的初始持仓,后续程序想要获取持仓信息只需要读取该map即可。
添加上读取股票代码和对应持仓部分的代码后,测试运行时程序却出现了崩溃现象(runtime error),使用断点调试,发现错误出现在向map中添加新的键值对的代码处:
1 | std::istringstream record(line); |
运行完第4行代码后,程序就会崩溃退出,经过反复检查,都没有发现代码有什么逻辑问题,一个很让人摸不着头脑的BUG。
为了确定问题出现在哪里,我们将类的成员mapStockInitialPosition变为static类型并在类外进行初始化,程序编译后运行正常。类的static成员变量与普通成员变量的区别就是存储区域不一样,static成员存储在程序的全局数据区,是类的所有对象共用的变量。既然将普通成员变量换成了static成员变量错误就消失了,说明map作为普通成员变量时遭到了破坏,所以导致了后面对map进行操作时出现了错误。我们检查ConfigSettings类的构造函数,发现了如下代码:
1 | //配置读取类的构造函数 |
从代码中我们看出,ConfigSettings的构造函数使用了C语言中常用的memset()函数进行类成员的初始化,memset()函数将对象的所有内存都初始化为0,这在ConfigSettings类是一个POD类型(Plain Old Data)时是没有问题的,因为POD类型的二进制内容可以随便复制、移动、重置而不会改变POD类型对象的值。ConfigSettings成员都是POD类型(int,char,float…),而且ConfigSettings类既不存在虚继承也不存在虚函数,所以可以对ConfigSettings使用memset()函数进行初始化。更多C++ POD类型的相关信息请参考这篇博客:C++ POD(Plain Old Data)类型
但是后面我们对ConfigSettings类进行拓展时加入了unordered_map类型的成员变量,但是unordered_map这种标准库容器不是POD类型,这就导致ConfigSettings也不是一个POD类型,所以当使用memset()函数进行初始化时会对对象造成破坏,unordered_map底层是哈希表,其中必然包括各种指针,当使用memeset将对象所有内存初始化为0的时候,unordered_map中的各种指针必然就变成了null_ptr,整个结构已经被完全破坏了,所以会后面对unordered_map进行操作的时候出现崩溃的现象也就不难理解了。
每次踩坑都要反思和总结,才能避免以后再次掉进同一个坑里,这次这个问题虽然看似不复杂,但是依然耗费了一两个小时去解决它。虽然花费了时间,但是收获也是有的,为了解决这个问题,我对POD类型的理解更深了,也对C语言与C++之间的区别更加了解。为了避免这种错误再次出现,在以后的编程中需要遵循下面的原则:
事情是这样的:在股票的自动化交易软件开发过程中,我们使用了万得的一套股票行情获取API,伴随着API还提供了一个示例程序,我们在开发过程中借用了部分示例程序中的代码,其中就包括一个配置读取类:ConfigSettings,这个类负责]]>
在上篇文章中,我们通过分析一段典型程序的汇编级代码更加清楚的看到了分支预测对程序性能的影响,当数据对分支预测器预测不友好的时候,我们的程序性能下降巨大。那么,怎么才能避免分支预测频繁出错对我们程序运行的不利影响呢?
分支预测对有规律的分支跳转可以实现非常高的预测正确率,比如在循环判断中,在循环终止之前,分支预测都可以基本保证完全的预测正确,预测错误只会出现在最后跳出循环的条件满足时。但是,对于每次跳转结果都不确定的分支判断,分支预测率的预测正确率就很低了,可能只有50%左右,基本相当于每次都随机猜测,这样的情况下,流水线会经常被打断,影响程序性能。对于这种严重依赖于数据的分支跳转命令,最好的替代方法就是条件传送指令。
在使用条件传送的条件下,命令中是没有跳转命令的,也就避免了使用分支预测器去预测一个很难预测的分支。
对于C和C++这样的高级语言是没有提供控制底层实现到底是使用条件控制转移指令还是条件传送指令的功能的,底层命令实现靠编译器实现。但是,我们可以通过我们写代码的具体方法去间接影响编译器生成的汇编代码,从而达到使用条件传送命令替代条件控制转移命令的目的。
下面的C++代码经过编译器编译后生成的汇编代码是通过条件控制转移命令实现分支跳转的(我们在上一篇文章中已经就此代码进行了分析):
1 | for(unsigned c = 0; c < arraySize; ++c){ |
上述代码的汇编代码:
1 | .L8: movl $0, -131104(%rbp) .L7: cmpl $32767, -131104(%rbp) ja .L5 movl -131104(%rbp), %eax movl -131088(%rbp,%rax,4), %eax cmpl $127, %eax jle .L6 movl -131104(%rbp), %eax movl -131088(%rbp,%rax,4), %eax cltq addq %rax, -131096(%rbp) .L6: addl $1, -131104(%rbp) jmp .L7 |
其中jle .L6
就是条件控制转移指令,当数据随机的时候,此命令的整体效率会极大降低。
但如果我们使用下面C++代码实现上面程序同样的功能的话,就可以避免产生条件控制转移指令:
1 | for(unsigned c = 0; c < arraySize; ++c){ |
上述C++代码首先使用一个减法和移位获取data[c]与128比较的结果,如果data[c] >= 128,则t的所有位均为0,否则t的所有位均为1,然后将t取非操作并于data[c]做与操作,以决定累加到sum上的数据是0还是data[c]。产生的汇编代码如下:
1 | .L6: cmpl $32767, -131108(%rbp) ja .L5 movl -131108(%rbp), %eax movl -131088(%rbp,%rax,4), %eax addl $-128, %eax sarl $31, %eax movl %eax, -131100(%rbp) movl -131100(%rbp), %eax notl %eax movl %eax, %edx movl -131108(%rbp), %eax movl -131088(%rbp,%rax,4), %eax andl %edx, %eax cltq addq %rax, -131096(%rbp) addl $1, -131108(%rbp) jmp .L6 |
可以看到,上述汇编代码完整实现了我们C++代码的思路,而且没有产生分支控制跳转命令,我们通过一些hack技巧实现了条件传送命令替代条件控制跳转命令,这样的代码对于任何数据表现都是一样的,即程序的性能不会因为输入数据的随机与否而变化。
经过测试,修改过后的程序比随机数据+条件控制跳转程序提高了3倍,而且性能表现对于可预测数据和随机数据均一致。
第一个花费时间7.42568秒是程序在随机数据上的表现结果,第二个花费时间7.4234秒是程序在排序后数据上的表现结果,可以看多两者表现相差可以忽略不计,可以说更改后的程序达到了性能与输入数据无关的目的。
上面代码将条件控制转移指令转换成了条件传送代码,但是,代码的可读性急剧下降,int t = (data[c] - 128) >> 31; sum += ~t & data[c];
这句代码很难让人一眼看出代码的目的,这种代码会对整个项目的维护带来巨大麻烦。
所以,在现代处理器和现代编译器的帮助下,千万不要过度关心分支预测带来的影响,对于程序中大部分分支命令,分支预测器都可以有很高的预测正确率,而对于那些分支预测器很难预测的分支,现代编译器可以对其进行自动优化,比如gcc中开启-O3优化的时候,编译器会自动把条件跳转转为条件传送以提高程序运行效率。
大部分情况下,请相信处理器、相信编译器!有在完全确定了程序的性能瓶颈所在的时候再去针对这部分代码做特殊优化,避免提前优化和过度优化。
]]>在上篇文章中,我们通过分析一段典型程序的汇编级代码更加清楚的看到了分支预测对程序性能的影响,当数据对分支预测器预测不友]]>
前面两篇文章,我们大体介绍了分支预测器的基本概念及经典实现方法:1. 分支预测器的概念与作用 2. 分支预测器的经典实现方法 ,我们又参与CPU架构相关的工作,那么我们了解到的分支预测器这些知识用处在哪里呢?或者换句话说,这些知识对我们写程序有什么指导意义吗?
条件判断与循环这些程序基本组成部分是我们写程序必须用到的结构,而条件判断与循环终止判断反映在机器指令上就是条件分支跳转与否,决定着CPU下次取指的地址,所以提高CPU分支判断准确率,降低流水线冲刷频率对我们提高程序性能有着极大的影响。
分支预测器是CPU硬件层面的东西,既看不见也摸不着,那我们只能从一段简单的包含循环和条件判断的程序开始,直观的去感受下分支预测器对我们程序的影响。以下这段代码来自Stack OverFlow的一个经典问题:Why is it faster to process a sorted array than an unsorted array? - Stack Overflow ,我做了一点稍微修改,代码如下:
1 | //branch_predictor.cpp |
上述程序逻辑很简单,就是使用0-255范围的随机数填充大小为32768的数组data,对data进行排序,循环100000次,每次循环都遍历data数组,并将数组中大于128的数累加到sum变量中。
该程序在我电脑上使用g++默认优化等级编译后,运行结果如下:
如果我们将上面代码中的排序注释掉,即我们不对数组中的数字进行排序,而是直接使用原始的乱序的数组进行100000次累加,得到的运行结果如下:
我们发现同样一段代码,却因为数组中数据是否有序而导致运行时间相差了大约3倍(6.95695 : 20.272),看我们这段代码,逻辑上与数组是否有序是没有任何关系的,无论数组是否有序,for循环里面的if语句进入的次数是一样的,那么到底什么原因导致了程序运行性能跟数据的有序与否有关呢?
没错,就是分支预测器的原因,在本例子中,有序的原始数据被称为对分支预测器友好的数据,而乱序的原始数据便是对分支预测器不友好的数据。对于有序的数据,前n次循环,分支都是不跳转,后m次循环,分支都是跳转,这对于分支预测器来说很好预测,只有在循环开始结束及跳转不跳转分界点容易产生预测错误,所以分支预测的准确度非常高,流水线效率也就更高;而对于无序的数据,每一次数据都有可能大于或者小于128,也就是每次跳转的概率是50%,而且跳转不跳转完全随机,所以分支预测器对于这样的分支跳转的预测准确率很低,无法做出有效的分支预测,导致流水线被频繁冲刷,严重影响了流水线的吞吐量。
为了更为充分的理解分支跳转命令在程序中的存在,我们通过g++的汇编指令,看branch_predictor.cpp的汇编级代码是什么样的,在shell中运行如下命令:
1 | g++ -S branch_predictor.cpp |
运行该命令后,g++会将生成的汇编代码branch_predictor.s放在当前文件夹,通过vim查看汇编代码(AT&T格式汇编,不是intel格式):
1 | .L8: movl $0, -131104(%rbp) .L7: cmpl $32767, -131104(%rbp) ja .L5 movl -131104(%rbp), %eax movl -131088(%rbp,%rax,4), %eax cmpl $127, %eax jle .L6 movl -131104(%rbp), %eax movl -131088(%rbp,%rax,4), %eax cltq addq %rax, -131096(%rbp) .L6: addl $1, -131104(%rbp) jmp .L7 .L5: |
为了便于阅读,上述汇编代码并不是完整版代码,只是对应于原始C++代码中关键的遍历数组的汇编代码,对应的C++部分代码是:
1 | for(unsigned c = 0; c < arraySize; ++c){ |
汇编代码分析:.L8是给循环变量c赋初始值0,.L7部分是循环主体,首先判断c是否大于32767,如果大于32767则跳转到.L5,代表循环结束,否则进入循环体执行判断与累加。这段汇编代码中最最关键的两句汇编命令就是:
1 | cmpl $127, %eax jle .L6 |
判断data[c]是否大于128,如果data[c]大于128,则跳转到.L6,否则继续顺序执行累加操作,分支预测器就是在这里起到了关键作用,如果分支预测器预测会发生跳转,则会从.L6代码段取指,如果预测不跳转,则顺序取指。假设遇到分支指令cmpl时分支预测器预测不会发生跳转,则顺序取指,累加部分指令进入流水线进行译码等操作,等cmpl执行完毕后,发现分支判断运行结果是跳转到.L6,即分支预测错误,则在流水线中的累加部分指令则必须被冲刷掉,然后重新从.L6代码段取指、译码、执行……,如果预测错误出现太频繁,势必会影响到流水线的吞吐量,导致性能下降。
我们上面分别从C++代码层面和汇编代码层面看到了分支预测器是如何影响到程序的运行性能的,也希望能在实际项目中正确的应用以提高程序执行效率,避免写出对分支预测器不友好的分支判断程序。下一篇文章,我将就如何避免分支预测影响程序执行效率讲一点点自己在项目中的心得。
]]>前面两篇文章,我们大体介绍了分支预测器的基本概念及经典实现方法:1. 分支预测器的概念与作用 2.
在上篇文章中,我们介绍了分支预测器在提高CPU流水线效率上的重要作用,上篇文章最后还提到,分支预测器的预测准确率对CPU执行命令效率有巨大影响,当预测错误时必须将流水线冲刷,然后重新从正确的地址取指,分支预测错误将会产生很大的代价,而且这种代价随着流水线的深度的增加而快速增长。
过去几十年,国内外科研界及工业界提出了多种分支预测模型以提高分支预测的准确率,本篇文章,我们主要挑选部分典型的分支预测模型,探究其分支预测算法。
根据分支预测器利用的信息可以将分支预测器分为静态分支预测器和动态分支预测器。静态分支预测器主要利用静态分支的信息或编译器提供的信息对分支方向进行预测,而动态分支预测器除了利用上述信息外,还会在程序执行过程中动态收集分支历史跳转等信息进行预测。
静态分支预测器一般实现比较简单,在类似于嵌入式处理器这种资源较少的处理器中应用比较多,但是因为静态预测器实现逻辑比较简单,所以预测准确率相应一半也不高。
静态分支预测器的实现算法主要有下面这几种:
所有的分支指令都不跳转,每次CPU都顺序取出下一条命令放入流水线。
所有的分支指令都跳转,每次CPU遇到分支判断指令都默认会发生跳转,并从跳转地址取指。
某些指令一律跳转,某些指令一律不跳转,此方法是方法1和方法2的结合。
与上次跳转结果保持一致,比如,上次该指令发生了跳转,那么本次依旧跳转,如果上次该指令没有跳转,则本次也不跳转。
向前的分支会跳转,向后的分支不跳转。该方法主要针对程序中的循环进行的优化。
虽然看似在概率上,这些静态方法的预测正确率只有50%,但是由于一般程序中都有大量循环存在,针对循环优化的静态方法一般都有高于70%的正确率。
对于哪些对于预测准确率要求不算太高,且本身资源有限制或者对功耗要求高的场景,静态分支预测器是一个很好的选择。
静态预测70%的准确率虽然看似不低,但是对于拥有非常深流水线的现代处理器,30%的错误率会导致流水线被频繁冲刷(flush),对CPU效率的影响是巨大的。因此,为了进一步提高分支预测器的预测准确度,人们开始研究更为复杂的动态分支预测器。
动态分支预测中最简单的方法就是双模态预测器(bimodal predictor),该预测器采用4个状态的状态机对分支跳转进行预测,分支指令执行完毕后根据实际跳转结果更新状态机,用于下次预测,状态机大体流程如下:
看状态机机的状态跳转可能不太好理解互相之间的关系,那我们用通俗一点的话来解释一下。想象这么一个场景:在遥远的地方有这么一个班级,为了区分学生优差,按照学习成绩将学生分为了4类:
学生的等级就靠每次考试成绩区分,考试成绩分为两类:考得好(跳转)、考得差(不跳转),等级按如下规则变化:
其中,我们优等生和中等偏上学生我们认为是好学生(预测他下次会考得好),差生和中等偏下学生我们认为是坏学生(预测他下次会考得差)。
这个班级学生状态变化如下图所示:
当然上面这个班级的例子只是想象出来解释双模态预测器的,我们反对这种将学生只按学习成绩划分三六九等的方法。但上述例子基本原理与双模态预测器的原理是相同的,不知道有没有让读者对双模态预测器了解更透彻一点。
上述双模态预测器的预测准确度相对于静态预测器已经有了极大的提高,预测准确度基本可以达到90%以上。但是人们追求分支预测器准确度的脚步是不会停的,后面发展出了两级自适应预测器、局部/全局分支预测器、融合分支预测器、神经分支预测器等等动态分支预测器,将预测准确度进一步提高,此处限于篇幅就不再详细解释了。
这两篇文章解释了分支预测器的意义及常见的简单实现方法,下一篇文章,我将会就分支预测器对我们写程序的指导意义做一些简单的测试与阐述。
]]>在上篇文章中,我们介绍了分支预测器在提高CPU流水线效率上的重要作用,上篇文章最后还提到,分支预测器的预测准确率对CPU执行命令效率有巨大影响,当预测错误时必须将流水线冲刷,然后重新从正确的地址取指,分支预测错误将会产生很大的代价,而且]]>
我们首先看一下维基百科中对分支预测器的定义:
在电脑架构中,分支预测器(英语:Branch predictor)是一种数字电路,在分支指令执行结束之前猜测哪一路分支将会被运行,以提高处理器的指令流水线的性能。使用分支预测器的目的,在于改善指令管线化的流程。现代使用指令管线化处理器的性能能够提高,分支预测器对于现今的指令流水线微处理器获得高性能是非常关键的技术。
由定义可以看出,分支预测器的主要作用就是预测分支指令的跳转与否,如果预测结果是跳转,预测器还负责预测跳转的地址。
<!-- excerpt -->
上面我们大体了解了分支预测器的定义及作用,那么为什么CPU需要分支预测器,或者说分支预测器在程序指令执行过程中扮演什么角色?
如果要解释这个问题,我们需要先了解现代处理器的工作原理,任何一条指令在CPU中的执行都必须经历如下这些步骤:
现代处理器使用流水线架构主要是为了提高程序执行效率,比如在第一条指令进入执行阶段时,第二条指令已经开始译码,第三条指令处于取指阶段……相对于第一条指令完全执行完并写回内存再开始第二条指令的取指,效率提高了很多倍。当然,现代处理器一般流水线深度高达10-31级,对程序执行速度有着显著提高。
上述流水线架构对于顺序执行的命令,效果提高显著,但是遇到跳转命令时效率便会急剧下降,对于分支跳转指令,我们在执行完该指令之前是不知道是否发生跳转的,也就是说,我们在分支指令执行完之前,我们无法确定分支指令的下一条指令的地址,所以也就没法把分支指令的下一条命令放入流水线中,只能等待分支指令执行完毕才能开始下一条命令的取指步骤,所以流水线中就会出现气泡(Bubble),这会大大降低流水线的吞吐能力。
为了解决上述问题,分支预测器应运而生。当指令执行到分支跳转指令时,CPU不再是空等待分支跳转指令执行完毕给出下一条命令的地址,而是根据模型预测分支是否发生跳转以及跳转到哪里,CPU将预测到的指令直接放入流水线,去执行指令的取指、译码等工作。
当分支跳转指令完成执行阶段后,给出是否跳转的结果,CPU即可判断分支跳转预测是否正确,如果指令执行后的跳转结果与分支预测器预测结果相一致,则流水线继续往下执行,如果发现分支预测结果出现错误,则需要清空流水线,将前面不该进入流水线的指令清空,然后将正确的指令放入流水线重新执行。
在分支预测器预测准确时可以提高CPU流水线的吞吐量,但是如果预测错误导致清空流水线指令,也会导致CPU效率降低。由上面的分析可以看出,分支预测的准确性对CPU执行效率影响很大,提高分支预测器的预测准确度是几十年来学术界及工业界研究的目标,后面的文章我会分析几个典型分支预测器模型的实现原理。
]]>我们首先看一下维基百科中对分支预测器的定义:
在电脑架构中,分支预测器(英语:Branch predictor)是一种数字电路,在分支指令执行结束之前猜测哪一路分支将会]]>
被pywin32和多线程之间的关系折磨了一上午,终于大体弄懂了,将一些经验记录一下,以备不时之需。
在成像声纳幅相校正程序( https://github.com/Root-lee/hex2bin )中,软件2.0版本,在原来版本的基础上,通过COM接口调用Matlab程序,将幅相校正的第一步(使用Matlab计算幅相校正系数)也包含在软件之中,为操作人员又省去一个操作步骤。当前版本软件只需选择声纳上位机软件采集到的AD数据之后,即可自动完成计算及转换。
实现软件功能时需要在程序中调用pywin32模块中的Dipatch,测试程序如下:1
2
3
4
5
6
7
8
9# -*- coding: utf-8 -*-
import os
from win32com.client import Dispatch
h = Dispatch("Matlab.application")
h.Visible = 0
h.Feval('cd',0,0,os.getcwd())
path = u'AD自动采集数据_调整noise.dat'
h.Feval('generate',0,0,path)
程序运行正常。
但是当把此程序移植到主程序中,这部分代码放在一个后台线程中,代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28# -*- coding: utf-8 -*-
from PyQt4 import QtCore, QtGui
from win32com.client import Dispatch
import time,os
#继承 QThread 类
class BigWorkThread(QtCore.QThread):
"""docstring for BigWorkThread"""
def __init__(self, file_path = []):
super(BigWorkThread, self).__init__(None)
self.path = file_path
def run(self):
self.gen_txt_file()
input = open(u'幅相系数记录.txt','r')
raw = input.read()
output = open(u'幅相系数记录.dat','wb+')
for i in range(len(raw)/2):
output.write(chr(int(raw[2*i:2*i+2],16)))
j = int(2*i/len(raw)*100)
self.emit(QtCore.SIGNAL("where"),j)
self.emit(QtCore.SIGNAL("finish_show"))
def gen_txt_file(self):
h = Dispatch("Matlab.application") #打开Matlab进程
h.Visible = 0 #隐藏Matlab界面
h.Feval('cd',0,0,os.getcwd()) #Matlab工作路径切换到当前软件目录
h.Feval('generate',0,0,self.path) #调用generate.m中的函数,产生txt格式文件
这时,当程序运行到gen_txt_file()函数中的h = Dispatch(“Matlab.application”) 这一句时便会出错:pywintypes.com_error:CoInitialize
为什么这段代码在主程中可以正常工作,放进多线程中就不能工作了呢?
因为COM对象属于一个线程,该线程与当前的线程无法正常通信,所以导致在多线程中调用Dispatch函数会报错。
我们需要Windows提供的函数Coinitialize来创建一个套间,使得他们可以正常关联和执行,具体方法就是在多线程中调用COM对象代码前面加上pythoncom.CoInitialize(),最后在COM对象调用结束后加上pythoncom.CoUninitialize()释放资源。
代码如下:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15# -*- coding: utf-8 -*-
from PyQt4 import QtCore, QtGui
from win32com.client import Dispatch
import time,os
import pythoncom
# 中间代码同上...
def gen_txt_file(self):
pythoncom.CoInitialize()
h = Dispatch("Matlab.application") #打开Matlab进程
h.Visible = 0 #隐藏Matlab界面
h.Feval('cd',0,0,os.getcwd()) #Matlab工作路径切换到当前软件目录
h.Feval('generate',0,0,self.path) #调用generate.m中的函数,产生txt格式文件
pythoncom.CoUninitialize()
代码经过这样改动之后即可以正常运行。
其实这个问题还有个更简单的方法,那就是在多线程中添加代码:1
from win32com.client import Dispatch
将文件开头的这句代码删除。
这样之后在多线程中调用pywin32模块就不会再出问题,即记住:什么时候调用COM对象,什么时候才import win32com。
代码可以这样改:1
2
3
4
5
6
7
8
9
10
11
12
13# -*- coding: utf-8 -*-
from PyQt4 import QtCore, QtGui
#from win32com.client import Dispatch
import time,os
# 中间代码同上...
def gen_txt_file(self):
from win32com.client import Dispatch
h = Dispatch("Matlab.application") #打开Matlab进程
h.Visible = 0 #隐藏Matlab界面
h.Feval('cd',0,0,os.getcwd()) #Matlab工作路径切换到当前软件目录
h.Feval('generate',0,0,self.path) #调用generate.m中的函数,产生txt格式文件
将最上面的from win32com.client import Dispatch移动到gen_txt_file函数中,这样可以保证代码修改量小,而且更容易理解。
]]>被pywin32和多线程之间的关系折磨了一上午,终于大体弄懂了,将一些经验记录一下,以备不时之需。
在成像声纳幅相校正程序(
在项目hex2bin( https://github.com/Root-lee/hex2bin ) 中,需要实现将一个txt文本中的十六进制码转换成相应的ascii码符号并写入一个.dat文件中,以用于声纳项目中的幅相校正操作。为了实现功能先在Linux虚拟机中写了一个小的python程序用来测试可行性,代码如下:1
2
3
4
5
6input = open('raw.txt','r')
raw = input.read()
output = open('bin.dat','w+')
for i in range(len(raw)/2):
print chr(int(raw[2*i:2*i+2],16))
output.write(chr(int(raw[2*i:2*i+2],16)))
该程序在Linux系统中运行可以得到预期结果,但是在Windows中加上相关GUI,程序经过简单修改之后,运行后得到的结果却有点偏差。
预期结果:
错误结果:
上面两图是用winhex软件打开本软件运行结果.dat文件的情形,第一张图是预期结果的文件结尾,第二张图是得到的错误结果的文件结尾,对比可以发现,Windows下运行最后生成的结果比预期结果多了几位。
为了找出问题所在,我对错误结果和预期结果进行一位一位仔细比对,发现错误结果比预期结果多出的位数都是”0D”,而且每个多出来的”0D”后面都紧跟着一个”0A”:
预期结果:
错误结果:
16进制“0D”对应ascii码是13,查询ascii码表得知13对应的是换行符CR,16进制“0A”对应ascii码是10,查询ascii码表得知13对应的是换行符LF,猜测可能与windows系统中的换行符有关:Window系统中的换行符使用\r\n表示,而Linux系统中使用\n表示,所以当写入换行符\n时,windows自动在\n的前面加上一个\r以符合windows系统的换行要求。所以结果中的所有”0A”都被自动换成了”0D0A”。
Windows 平台上 Python 区分 Binary 和 ASCII 模式。ASCII 模式下换行符会在读写时自动换为 \r\n ,但是Binary模式下却不会自动替换,所以我们只需将文件读写模式由ASCII模式改为Binary模式即可,具体到我们的代码中,我们只需将代码1
output = open('bin.dat','w+')
改为:1
output = open('bin.dat','wb+')
即可解决问题。
]]>在项目hex2bin( https://github.com/Root-l]]>
独热码,在英文文献中称做 one-hot code,直观来说就是有多少个状态就有多少比特,而且只有一个比特为1,其他全为0的一种码制。例如,有6个状态的独热码状态编码为:000001,000010,000100,001000,010000,100000。
最近在做一个根据一个人的信息预测此人人品的机器学习项目:
https://github.com/Root-lee/ML_micro_loan
在项目中,数据中有上千维的特征,大部分是数值型特征,但是也有93个分类特征,在训练模型时需要将这些分类特征用OneHot编码,使得模型能正确理解这些分类特征。
所幸,著名机器学习包已经提供了独热码的编码器:preprocessing.OneHotEncoder()
我们只需要直接调用就可以了,于是我写了如下代码编码分类特征:
1 | import pandas as pd |
但是运行程序时却出现了错误:
OneHot编码出现ValueError: X needs to contain only non-negative integers.
错误出现在 enc.fit(train) 这一句代码上,根据报错信息,原来scikit-learn库中的onehot编码器要求分类特征是非负整数,但是我们的数据train中的分类特征的值中含有负数,所以会出现错误。
在调用onehot编码器之前我们需要先对分类特征数据进行一点处理,将所有的值都变为非负数,可以使用如下代码实现目的:
1 | #OneHot编码要求特征是非负整数 |
把上面这段代码放在onehot编码之前,再次运行程序错误即可消失。
]]>独热码,在英文文献中称做 one-hot code,直观来说就是有多少个状态就有多少比特,而且只有一个比特为1,其他全为0的一种码制。例如,有6个状态的独热码状态编码为:000001,000010,000100,001000,010]]>