代码优化指南(三)高级语言的欺骗

高级语言的欺骗

1.内存

        a.内存不是无限的

        所有的高级语言,从C/C++到JAVA到go,到rust,Ruby等等都和操作系统一起编织了一个美好的谎言,任意一台计算机的内存大小都是无限的,只要程序申请就能使用。

        但是实际的世界是残酷的,内存条大小是16G就不会变成32G,硬盘大小是1T就不会变成2T,操作系统用了虚拟技术和请求调页技术对内存大小进行扩充,而上层的高级语言也选择相信这一美好的谎言。

        b.对内存各部分的读取速度是不一致的

        高级语言同样隐藏了另外一个事实,从不同的位置读取数据的花销是不一致的

如图,一个代码可能从整个存储体系的任意位置读取指令和操作数,但是在存储结构中,越往下的数据存取速度越慢。很多地方会有这样一句话,叫只能从内存中获取数据用于程序运行,这句话对也不对,现代计算机操作系统会将部分数据向上移动到速度更快的cache中,也会在内存不足时将部分页面调入外存中,当需要时再将页面调入内存(即从整体数据流转上看其实是从外存中读取指令和操作数),甚至有些语言为了操作速度块,部分数据直接保留在寄存器中,一直到运算完毕再写回内存,因此实际上每条指令或者数据的获取速度是完全不一致的。

        c.内存导致的性能瓶颈

        高并发服务器是一个很好的例子,每一个TCP的socket都需要在内存中划定一片缓冲区,但是由于内存空间是有限的,所以当有超量的TCP连接被创建的时候,内存空间被严重占用,大量的内存页面被调入外存中,当其他程序需要运行时需要做大量的页面置换,导致一条指令的取指令时间严重延长,导致内存变为程序运行的性能瓶颈。

        冯诺依曼瓶颈      

        通往主内存的接口是限制执行速度的瓶颈。这个瓶颈甚至有一个名字,叫冯 • 诺伊曼
瓶颈。它是以著名的计算机体系结构先锋和数学家约翰 诺伊曼( 1903 1957 )的
名字命名的。
例如,一台使用主频为 1000MHz DDR2 内存设备的个人计算机(几年前典型的计
算机,容易计算其性能),其理论带宽是每秒 20 亿字,也就是每字 500 皮秒( ps )。但
这并不意味着这台计算机每 500 皮秒就可以读或写一个随机的数据字。
首先,只有顺序访问才能在一个周期内完成(相当于频率为 1000MHz 的时钟的半个时
标)。而访问一个非连续的位置则会花费 6 10 个周期。
多个活动会争夺对内存总线的访问。处理器会不断地读取包含下一条需要执行的指
令的内存。高速缓存控制器会将数据内存块保存至高速缓存中,刷新已写的缓存行。
DRAM 控制器还会“偷用”周期刷新内存中的动态 RAM 基本存储单元的电荷。多核
处理器的核心数量足以确保内存总线的通信数据量是饱和的。数据从主内存读取至某
个核心的实际速率大概是每字 20 80 纳秒( ns )。
根据摩尔定律,每年处理器核心的数量都会增加。但是这也无法让连接主内存的接口
变快。因此,未来核心数量成倍地增加,对性能的改善效果却是递减的。这些核心只
能等待访问内存的机会。上述对性能的隐式限制被称为 内存墙 memory wall )。
——《C++性能优化指南》

2.CPU与编译器

        a.CPU的核心是有限的

        cpu的核心是有限数,如Inter core I9 13900K内核数 24 Performance-core(性能核)数 8 Efficient-core(能效核)数 16,即在多线程的时候哪怕是开无限个线程的性能永远也高不过24线程(纯计算,无IO),而且还会导致运算的性能下降。

        b.CPU的指令执行是重叠的和乱序的

        现代的CPU普遍使用指令流水,即多条指令同时在运行,每条指令执行不同的阶段,即一条指令的平均执行时间会远小于完整执行完一条指令的时间,同时从高级语言的视角来看,每一条指令是顺序执行的,但是为了维持指令流水的运行,有的时候编译器和CPU会做出一些很抽象的优化,目的就是为了能够维持指令流水的运转。

        同时循环控制指令会打断整个指令流水

看下面一个例子

int j=1;
while(int i=i;i<10000000;i++)
{
    j++;
}
在C++的gcc -O3优化下整个语句会被优化为
int j=100000001;

即直接做编译期计算,将复杂度全部留在编译期

当然在没那么激进的优化的情况下会被展开

这是java的JIT的常见处理方式,减少指令跳转数量,减少总的数据指令数。

public class MIAN
{
    public static test()
    {
        int j=1;
        while(int i=1;i<1000000;i+=8)
        {
            j+=1;
            j+=1;
            j+=1;
            j+=1;
            j+=1;
            j+=1;
            j+=1;
            j+=1;
        }
     }
}

当然我们用手写可以写出更符合指令流水的代码(用C++实现一下)

int j1=1;
int j2=j1+1;
int j3=j1+2;
int j4=j1+3;
int j5=j1+4;
int j6=j1+5;
int j7=j1+6;
int j8=j1+7;
for(int i=1;i<100000000;i+=8)
{
    j1+=8;
    j2+=8;
    j3+=8;
    j4+=8;
    j5+=8;
    j6+=8;
    j7+=8;
    j8+=8;
}

int j=j1+j2+j3+j4+j5+j6+j7;

这个代码很好的利用了指令流水线,由于之前的代码每一个语句都在等待上一个语句的结果,因此指令流水实际上是受阻的,这个代码将数据展开,互相之间不干扰,可以的运转指令流水(当然这个代码只是做一个示例,在实际工程中一般不会这么写,也不应当在非极端情况下吹毛求疵)

3.语句,编译器和操作系统

        a.每条指令的开销实际上是不同的

        1.语言的封装性

        在越高级的语言中这个趋势越明显,我们看下面几个语句,从C到C++到Java

C语言

struct Int
{
    int i;
};


int main()
{
    Int data{1};
}

C++ 

struct Int
{
    int i;
    Int(int _i):i(_i){};
};

int main()
{
  Int data{1};
}

由于Java没有结构体,只能用类

public class Int
{
    int i;
    Int(int _i)
    {
        i=_1;
    }
}



public class MAIN
{
     static void main(String arge[])
    {
        Int data=new Int(1);
    }   
}

很明显三者的花销是不一致的,C语言直接在栈上构建数据,直接赋值,C++在栈上构造且要掉用构造函数,Java不仅要调用构造函数还需要内存分派函数。

        2.操作系统调用需要付出的代价远高于一般函数

        对操作系统调用的代价远高于一般函数,首先需要将整个核心切入内核态,所有的寄存器全部替换,在内核态中执行后再切回用户态,复杂度相当于做一次完整的内核级线程切换。

        这也就是为什么在性能优化的时候都建议使用内存池和线程池,即将所有的复杂度留在启动时,用启动时的复杂度与内存空间来换取运行时的高性能。

  • 23
    点赞
  • 24
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值