不奢望岁月静好 只希望点滴积累

0%

GPU天生适合海量、并行的矩阵运算、于是大量用在深度学习的模型训练上
深度学习中计算量最大的是什么呢 ? 深度学习的推断部分

1
`推断部分`: 在完成深度学习训练之后、把训练完成的模型存储下来. 这个存储下来的模型、是许多个向量组成的参数、然后根据这些参数、计算输入的数据、得到结果. eg. 推测用户是否点击广告; 扫身份证进行人脸识别

思考: 模型的训练和推断有什么区别 ?

1
2
3
4
5
6
7
一、深度学习的推断、灵活性要求更低. 只需要计算一些矩阵的乘法、加法、调用一些sigmoid这样的激活函数、可能计算很多层、但也只是这些计算的简单组合

二、深度学习推断的性能、首先要保证响应时间的指标
模型训练的时候、只需要考虑吞吐率就可以、但推断不行. eg. 我们不希望人脸识别会超过几秒钟

三、深度学习的推断工作、希望功耗尽可能的小一些
因为深度学习的推断要7*24小时的跑在数据中心、且对应芯片要大规模的部署在数据中心、一块芯片减少5%的功耗、就可以节省大量的电力

于是: 第一代TPU的设计目标:
在保障响应时间的情况下、尽可能的提高能效比这个指标、也就是进行相同数量的推断工作、花费的整体能源要低于CPU和GPU

TPU的几点设计

  1. 向前兼容 2. TPU未设计成包含取指电路的GPU、而是通过CPU发送需要执行的指令
  2. 使用SRAM 作为统一缓冲区, SRAM一般用来作为CPU的寄存器或者高速缓存、SRAMDRAM快, 但因为电路密度小、占用空间大、价格也较贵、之所以选择SRAM是因为整个推断过程、它会高频反复地被矩阵乘法单元读写、来完成计算
  3. 细节优化, 使用8Bits数据

image.png

一、为什么需要程序栈
示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// function_Example.c
#include <stdio.h>
int static add(int a, int b)
{
return a+b;
}

int main()
{
int x = 5;
int y = 10;
int u = add(x, y);
}

编译代码、使用objdump打印出来

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
int static add(int a, int b)
{
0: 55 push rbp # bp是基址指针寄存器、处理函数调用、 push rbp 就是先将rbp的内存保存入栈
1: 48 89 e5 mov rbp,rsp #将rsp的值赋给rbp(rbp始终执行栈帧底部、rsp始终指向栈帧顶部)
4: 89 7d fc mov DWORD PTR [rbp-0x4],edi
7: 89 75 f8 mov DWORD PTR [rbp-0x8],esi
return a+b;
a: 8b 55 fc mov edx,DWORD PTR [rbp-0x4]
d: 8b 45 f8 mov eax,DWORD PTR [rbp-0x8]
10: 01 d0 add eax,edx
}
12: 5d pop rbp
13: c3 ret
0000000000000014 <main>:
int main()
{
14: 55 push rbp
15: 48 89 e5 mov rbp,rsp
18: 48 83 ec 10 sub rsp,0x10
int x = 5;
1c: c7 45 fc 05 00 00 00 mov DWORD PTR [rbp-0x4],0x5
int y = 10;
23: c7 45 f8 0a 00 00 00 mov DWORD PTR [rbp-0x8],0xa
int u = add(x, y);
2a: 8b 55 f8 mov edx,DWORD PTR [rbp-0x8]
2d: 8b 45 fc mov eax,DWORD PTR [rbp-0x4]
30: 89 d6 mov esi,edx
32: 89 c7 mov edi,eax
34: e8 c7 ff ff ff call 0 <add>
39: 89 45 f4 mov DWORD PTR [rbp-0xc],eax
3c: b8 00 00 00 00 mov eax,0x0
}
41: c9 leave
42: c3 ret

二、如何使用函数内联进行优化
-O 指令 或者 加上 inline关键字、来提示编译器进行函数内联

内联带来的优化是、CPU需要执行的指令数变少了、根据地址跳转的过程不需要了、压栈和出栈的过程也不需要了

但是内联意味着、把可复用的程序指令在调用它的地方完全展开了、若一个函数在很多地方都被调用了、就会被展开多次、整个程序占用的空间就会变大

1
2
readelf -s link_example.o //查看符号表
objdump -r link_example.o //查看重定位表

程序的CPU执行时间=指令数 * CPI * Clock Cycle Time(时钟周期)

CPI: 倒数是IPC,即: 一个时钟周期内能够执行的指令数、代表了CPU的吞吐率

那么: CPU的吞吐率能达到多少呢 ?

最佳情况下、也只能达到1, 即使达到了指令层面的乱序执行、CPU一个周期、仍然只能取一条指令、说明指令优化做的多好、一个时钟周期也只能完成一条指令、而现在的CPU一般能达到2、为什么呢 ?

多发射与超标量: 同一时间执行的两条命令

1
2
3
4
5
6
7
在指令乱序执行的过程中、取值IF 和 指令译码ID 部分并不是并行的、那么 可不可以呢 ?
1. 将取指令和指令译码、也通过增加硬件的方式、一次从内存里取出多条指令、然后并行的分发给多个并行的指令译码器、进行译码、然后交给不同的FU(功能单元)去处理、这样一个时钟周期可完成的指令就大于1 、即: IPC大于1

这种CPU设计叫 多发射(Mulitple issue) 和 超标量(Superscalar)

在超标量的CPU里边、有很多条并行的流水线、而不是只有一条流水线、
`超标量`这个词是说, 本来我们在一个时钟周期里、只能执行一个标量Scalar运算、在多发射的情况下、可以超过这个限制、同时进行多次运算

Intel的失败之作: 安腾的超长指令设计

1
2
3
4
5
6
7
8
9
10
无论是乱序执行、还是超标量技术、在实际的硬件层面、都需要解决依赖冲突(冒险)问题、所以、实施会比较复杂
CPU需要在指令执行之前、判断指令是否有依赖关系、若有, 则不能分发到不同的执行阶段、
所以: 超标量CPU发射、又被称为动态多发射处理器
对于 依赖关系的检测、会使得CPU电路变的更加复杂

于是: 科学家有一个大胆的想法: 将分析和解决依赖关系的事情、放到软件里
`超长指令设计`: VLIW(Very Long Insturction Word)、想通过编译器来优化CPI
编译器在汇编完成之后、也可以知道前后数据的依赖、可以让编译器把没有依赖关系的代码位置进行交换、然后把多条连续的指令打成一个指令包、安腾的CPU就是把3条指令打成一个指令包, 如下图所示:
CPU在运行的时候、就不再是取一条指令、而是取一个指令包、然后译码解析整个指令包、解析出3条指令并行运行
2.流水线停顿、也是编译器来做了、除了停下整个处理器流水线、CPU不能在某个周期停顿一下、等待前边依赖的操作完成、编译器需要在适当的位置插入NOP操作、直接在编译出来的机器码里、把流水线停顿设计完成

image.png

为什么失败呢
1
2
3
4
5
6
1. 最重要的原因是'向前兼容'
安腾处理器的指令集和X86不同、无法兼容、需要重新编译才可以
2. VLIW架构决定了、若安腾需要提升并行度、就需要增加一个指令包的指令数量、eg. 3个 -> 6个、
而一旦这么做了、同样是VLIW架构、同样指令集的安腾CPU也需要重新编译、甚至需要重写编译器、才能在原来的CPU上继续运行程序

所以、它既不向前兼容、又很难向后兼容、就比较容易失败

基本概念:
响应时间: 执行程序需要花费的时间
吞吐率: 一定时间内、可以执行的指令

性能一般定义为: 1/响应时间

计算机的计时单位: cpu时钟

虽然时间是衡量性能的标准、但是也有很大的差异

  1. 时间不准 应该参与比较的是刨除了io和cpu切换之外的实际CPU时间(user + sys)
  2. 就算拿到cpu时间也不一定可以比较 CPU满载运行时、可能会降频
    此外、还会受到主板、内存等的硬件影响

程序的cpu执行时间 = cpu时钟周期数 x 时钟周期时间
cpu 时钟周期时间越小、散热的压力也就越大
cpu时钟周期数 = 指令数 x cpi x Clock Circle Time
cpi : 每条指令的平均时钟周期数 Cycles Per Instruction

时钟周期时间: 取决于硬件
CPI: 取决于一条指令需要多少CPU周期、
指令数: 代表程序执行需要多少指令、用哪些指令

功耗 ~= 1/2 x 负载电容 x 电压的平方 x 开关频率 x 晶体管数量

1
2
3
4
所以: 
1.为了提升性能、需要增加晶体管数量, 同样面积、就要把体积造的小、 就是提升`制程`
2.但是 功耗增加太多、会导致CPU散热跟不上、就需要降低电压、
而功耗是和电压的平方成正比的、意味着电压下降到原来的 1/5 、功耗就会变成原来的 1/25.

提升计算机性能的方式

1
2
3
4
5
6
7
8
9
10
1. 摩尔定律: 增加晶体管数量(主频)
2. 并行原理: 多CPU
3. 加速大概率事件: gpu替代cpu
4. 通过流水线提升性能: 把cpu执行指令的过程细分
5. 通过预测提高性能: 分支和冒险, 局部性原理

应用:
加速大概率事件: 缓存(内存、CDN缓存)
流水线: 并发编程、异步编程、音频播放器边放边缓冲
预测: 下一页预加载、cdn预热、指令预加载

存储器系统是一个通过各种不同的方法和设备、一层层组合起来的系统.

SRAM

1
2
静态存储器, 只要通电、保存的数据就一直存在、断电则丢失, 1bit 数据、大概需要6~8个晶体管, 
密度不高、同样物理空间下、存储的数据有限、不过, 由于电路简单、访问速度特别快

6个晶体管组成SRAM的一个bit.png

1
2
3
4
CPU中、通常有L1、L2、L3三层高速缓存
每个CPU有一块属于自己的L1缓存(`指令缓存``数据缓存` )
L2缓存也是每个CPU有一块、但不在CPU内部、访问速度比L1稍慢
L3 Cache通常是多个CPU核心共用的、尺寸更大、访问更慢一些

DRAM

1
2
3
4
5
6
SRAM来说、DRAM的密度更高、容量更大、价格也便宜、CPU一般使用的是SRAM、内存一般使用的是DRAM

DRAM被称为动态存储器、是因为DRAM需要不断刷新、才能保持数据被存储起来
DRAM的一个bit、只需要一个晶体管和一个电容就可以、同样物理空间、存储数据更多、即: 存储密度更大
但: 数据是存储在电容里的、电容会不断漏电、需要定时刷新充电、才能保持数据不丢失
DRAM的数据访问电路和刷新电路都比SRAM更复杂、so. 访问延时更长

存储器的层次结构

1
整个存储器的层次结构、都类似于SRAM和DRAM在性能和价格上的差异、SRAM更贵、速度更快

存储器的层次关系图.png

1
2
Cache、内存、到SSD和HDD硬盘、计算机用到了所有存储设备、其中, 容量越小的设备速度越快、且: CPU不是直接合每一种存储设备打交道、而是只与相邻设备打交道.
eg. CPU Cache是从内存加载而来、数据需要写回内存、并不会直接写回数据到硬盘、也不会直接从硬盘加载数据到CPU Cache、而是先加载到内存、再从内存加载到Cache

这样、每个存储器只喝相邻的一层存储器打交道、且随着一层层向下、存储器的容量逐渐增大、访问速度逐层变慢、且单位存储成本也在逐层下降、就构成了存储器的层次结构

如何权衡价格和性能

1
2
存储器在不同层级之间的性能和价格差异、都至少在一个数量级以上、
eg. L1 Cache的访问延时是1ns、内存是100ns、在价格上也差出了400

存储器性能和价格对比图.png

一台惠普战66的笔记本配置如下:

  1. Intel i5-8265U 的CPU (4核)

    • 每个核有有32K、共128KB的L1指令Cache 和 128KB 的数据Cache、采用8路组相连的放置策略
    • 每个核有256KB、共1M的L2 Cache、采用的是4路组相连的放置策略
    • 多个核心共用的12MB 的L3 Cache、采用的是12路组相连的放置策略
  2. 8G的内存

  3. 128GB 的SSD硬盘

  4. 1T的HDD硬盘

    可以看到,在一台实际的计算机里面,越是速度快的设备,容量就越小。这里一共十多兆的 Cache,成本只是几十美元。而 8GB 的内存、128G 的 SSD 以及 1T 的 HDD,大概零售价格加在一起,也就和我们的高速缓存的价格差不多

平时服务端访问db遇到瓶颈的时候、大部分工程师会选择添加缓存、来缓解DB压力、提升程序性能、但一定是有效的吗 ?

添加缓存层.png

理解局部性原理

1
2
3
4
5
6
7
时间局部性: 若一个数据被访问了、短时间内再次被访问的概率就增加. 
eg. 今天读一本小说、还未读完、明天接着读的概率就很大
电商系统中、用户打开一个APP、看到首屏、推断会访问其它页面、将用户的个人信息、从存储在硬盘的数据库读取到内存的缓存中来
利用的就是时间局部性

空间局部性: eg. 存储数据时、数组内的多项数据会存储在相邻位置、
好比图书馆会将同一系列的书放到一个书架上、摆在一起、加载的时候一并加载、

同一数据在短时间内会反复访问.png

相邻的数据被连续访问.png

有了时间和空间局部性、可以不用将数据都放在内场车、也不用都放在HDD上、而是将访问次数多的数据、放在贵但是快一些的存储器、访问次数少、价格便宜的放在慢但容量大的容器里、组合使用各种存储设备

如何花最少的钱满足存储需求?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
假设: 需要提供一个亚马逊这样的电商网站、有6亿件商品、每件商品需要4MB 存储空间(共6亿 * 4MB = 2400TB)

1.数据都放在内存、需要3600w美元(2400TB/1MB * 0.015美元)
2.假设内存只存放1%的热门商品600w件、剩下存储到HDD硬盘上、存储成本就下降到 45.6w美元、原来的1.3%
3600w美元*1% + 2400T/1M * 0.00004美元 = 45.6w美元

利用的就是时间局部性、将用户访问过的数据、加载到内存中、一旦内存放不下、就把长时间未访问的数据从内存移走、
其实就是LRU缓存算法、商品访问越频繁、越容易在内存找到、很好的利用了内存的随机访问性

另外还要关注一个指标: 缓存命中率 (Hit Rate / Hit Ratio)

内存的随机访问需要100ns、即: 极限情况下、可以支持1000w次随机访问
假设: 用了24T(8G一条)内存、意味着3000条内存、可以支持每秒300亿次访问
24TB / 8GB * 1s/100ns = 300亿
20173亿的用户来看、每天的活跃用户1亿、平均访问100个商品、平均每秒访问的商品数量12w次
假设数据没有命中缓存、对应的数据请求访问HDD磁盘、一块硬盘每秒只能支撑100次随机访问、
2400TB的数据、4TB一块磁盘的话、有600块、每秒支撑 6w次请求
2400TB/4TB * 1s/10ms
这意味着: 所有的商品都直接到了HDD硬盘、HDD支撑不了这样的压力
至少需要50%的缓存命中率、HDD磁盘才可以支撑对应的访问次数

IPL 指令级并行 Instruction-level parallelism
SMT 同时多线程 Simultaneous Multi-Threading
SIMD 单指令多数据流 Single Instruction Multiple Data
MIMD 多指令多数据流 Multiple Instruction Multiple Data
SIMT 单指令多线程 Single Instruction, Multiple Threads
ASIC 专用集成电路 Application-Specified Integrated Circuit
FPGA 现场可编程门阵列 Filed-Programmable Gate Array

之前都是程序正常运行的、不需要网络交互、无IO交互、执行过程无错误、

但: 实际的软件、程序不仅执行简单指令、还会和IO打交道、执行过程中也会遇到各种异常情况

此时计算机如何工作呢?

异常

关于异常、其实是一个硬件和软件结合到一起的处理过程、异常的发生和捕获、是在硬件层面完成的; 异常的处理、是软件完成的

1
2
3
4
5
6
7
8
9
10
11
计算机会为每一种可能发生的异常、分配异常代码(Exception Number), 或者叫中断向量(Interrupt Vector), 异常发生的时候、通常是CPU检测到了一个特殊的信号、eg. 按下键盘上个的按键、输入设备就会给CPU发送信号、或者 正在执行的指令发生了加法溢出、会有一个进位溢出的信号、
这些信号在组成原理里, 一般叫 发生了一个事件(event)
CPU在检测到事件的时候、就拿到了对应的异常代码

这些异常代码里、
1.IO发出的信号的异常代码是操作系统分配的、即: 软件来设定的、
2.像加法溢出这样的异常代码、则是CPU预先分配好的、也就是硬件来分配的

拿到异常代码之后、CPU会触发异常处理的流程, 在计算机的内存里、会保留一个异常表 (Exception Table), 又叫中断向量表、存放了不同异常代码对应的异常处理程序(Exception Handler)的地址

CPU拿到异常码 -> 保存当前程序执行现场(到程序桟) -> 根据异常码找到对应处理程序 -> 把后续指令执行的指挥权、交给异常处理程序来处理

异常的分类: 中断、陷阱、故障和终止

1
2
3
4
5
6
7
8
9
10
11
中断(Interrupt): 在程序执行的过程中被打断、这个打断执行的信号、来自于CPU外部的IO设备、eg. 按下键盘按键

陷阱(Trap): 主动触发的异常,
eg. 1. 程序调试时、设定断点
2.应用程序产生系统调用的时候、从用户态切换到内核态、
3. 应用程序通过系统调用去读取文件、创建进程、其实也是通过触发陷阱完成的

故障(Fault): 和陷阱的区别在于: 陷阱是开发者主动触发的、故障是意料之外的、会导致系统异常
和前两者的区别在于: 故障在异常处理完成后、仍然回来处理当前指令、而不是执行下一条指令、因为当前指令由于故障没有执行完成

中止(Abort): 与其说是一种异常、不如说是故障的特殊情况、当CPU遇到故障、但无法恢复时、就不得不终止

异常的处理: 上下文切换

1
2
3
4
5
6
7
8
在实际异常处理程序之前、CPU需要一次保存现场的操作、过程与函数调用类似
实际更复杂一些:

1. 异常情况往往发生在正常执行的预期之外、eg. 中断、故障发生时、除了本来程序压桟要做的事情、还要把CPU内当前运行程序用到的所有寄存器、放到桟中、最典型的是 条件码寄存器
2. 像陷阱这种异常、涉及程序指令在用户态和内核态之间的切换、对应压桟的时候、对应数据要压到内核栈、而不是程序桟
3. 像故障这样的异常、在异常处理程序执行完成之后、从桟里返回、继续执行的是引起故障的当前指令、而不是顺序的下一条指令

对于异常这样的处理流程、不像是顺序执行的指令间的函数调用关系、更像是两个不同的独立进程在CPU层面的切换、这个过程称为 上下文切换(Context Switch)

降低复杂性: 总线的设计思路来源

1
2
3
4
5
6
7
8
9
10
计算机有很多不同的硬件设备、除了CPU和内存之外、还有大量的输入/出设备.
键盘、鼠标、显示器、硬盘或者通过USB接口连接的各种外部设备、都对应了一个设备
若各个设备间的通信、都是独立的. 假设有N个不同的设备、之间独立连接、复杂度为N²,
为了简化系统复杂度、引入总线、将N²的复杂度变成N的复杂度

如何降低呢 ?

设计一个公用的线路、CPU和设备的通信指令、对应数据或者设备和CPU的通信指令和数据都
发送到该线路上、设备间无需单独建立连接、只建立与总线的连接就好
这个设计思路就是`总线`

image.png

1
2
3
4
5
6
总线其实就是一组线路、各个设备通过这组线路相互通信.
对应的设计思路就是`事件总线`设计模式
在这个设计模式里、系统中的各个组件间也需要相互通信、各个模块触发对应的事件、并把事件对象发送到总线上.
即: 每个模块都是发布者Publisher, 同时把自己注册到总线上、监听总线上的事件、并根据事件的对象类型
或是对象内容来决定自己是否进行特定的处理或者响应
这样各个模块就是松耦合的、模块间无依赖关系、无论是代码的维护还是未来的扩展、都很方便

image.png

理解总线: 三种线路和多总线架构

1
2
3
4
现代Intel CPU的体系结构里、通常有好几条总线
首先, CPU和内存及高速缓存通信的总线、通常有两种, 称为`双独立总线`(DIB: Dual Independent Bus)
CPU里、有一个快速的`本地总线`(Local Bus)及一个相对较慢的`前端总线`(front-side Bus)
本地总线和高速缓存通信、前端总线用来和主内存及IO设备通信

image.png

1
2
3
4
5
6
7
在物理层面、可以将总线看做一组电线、不过这些电线之间也是有分工的、通常有3类:
1. 数据线(Data Bus): 用来传输实际的数据信息
2. 地址线(Address Bus): 用来确定将数据传输到哪里、是内存的额某个位置还是某个Io设备、
3. 控制线(Control Bus): 用来控制对总线的访问. 若将总线比喻为公交车、有人要坐车时需要通知司机、这个就是控制信号

总线减少了设备耦合、降低了系统设计的复杂度、但不能同时给多个设备提供通信功能
那多个设备都想使用总线、给谁用呢? 就需要一个机制来决定、这个机制叫`总线裁决`

异常的发生和捕获是在硬件层面完成的、异常的处理是软件完成的

异常分类

中断Interrupt 触发CPU内部开关值发生变化的信号
陷阱 Trap 主动触发的异常、比如调试断点
故障 Fault 非主动的错误、处理完异常之后回来处理当前指令、而不是去执行程序中的下一条指令
终止 Abort 故障的一直特殊情况、当CPU遇到故障、但无法恢复时、程序就必须终止了
异常.png

异常处理、上下文切换

1
2
3
4
5
6
7
8
类似异常处理函数调用、指令的控制权被切换到另外一个`函数`里、但比函数调用更复杂一些
1. 因为异常情况往往发生在正常执行的预期之外、eg. 中断、故障发生的时候、所以、除了本来程序压桟要做的事情之外、还需要把CPU内当前运行程序用到的所有寄存器都放在桟里边. eg. 条件码寄存器的内容

2. 类似陷阱这种异常、涉及程序指令在用户态和内核态之间的切换、对应压桟的时候、对应数据是压到内核栈、而不是程序桟

3. 类似故障这样的异常、在异常处理执行完成之后、从桟里返回出来、继续执行的不是顺序的下一条指令、而是故障发生的当前指令、因为当前指令因为故障没有正常执行成功、必须重新执行一次

对于异常这样的处理流程、不像是顺序执行的指令间的函数调用关系、更像是两个不同的独立进程之间在CPU层面的切换、称为 `上下文切换`