【计算机组成原理】CPU存储器、缓存

403 阅读7分钟

计算机是怎么运行的?

图灵机

图灵机的组成

  • 纸带:由一个个连续的格子组成,每个格子可以写入字符

  • 读写头:可以读取、写入纸带。还有下面这些部件:

    • 存储单元:存放数据;
    • 控制单元:识别字符是数据还是指令,以及控制程序的流程等;
    • 运算单元:执行运算指令;
  • 执行过程:读取纸带,执行操作,写入纸带

冯诺依曼模型

image.png

组成:

  • 中央处理器(CPU)

    • 32 位 CPU 一次可以计算 4 个字节;

    • 64 位 CPU 一次可以计算 8 个字节;

    • 寄存器:存储计算时的数据

      • 通用寄存器:运算的数据,比如需要进行加和运算的两个数据。
      • 程序计数器:存储 CPU 要执行下一条指令「所在的内存地址」。
      • 指令寄存器:存放程序计数器指向的指令,指令被执行完成之前都存储在这里。
  • 内存:最小的存储单位是字节(byte)

  • 输入设备

  • 输出设备

  • 总线

    • 地址总线:用于指定 CPU 将要操作的内存地址;
    • 数据总线:用于读写内存的数据;
    • 控制总线:用于发送和接收信号,比如中断、设备复位等信号,CPU 收到信号后自然进行响应;
  • 线路位宽:线路的比特位个数

  • CPU位宽:最好不要小于线路位宽

程序执行的基本过程

  1. CPU 读取「程序计数器」得到指令的内存地址,「控制单元」操作「地址总线」指定需要访问的内存地址,通知内存设备准备数据,通过「数据总线」将指令数据传给 CPU,存入到「指令寄存器」。
  2. CPU 分析「指令寄存器」中的指令,确定指令的类型和参数,如果是计算类型的指令,就把指令交给「逻辑运算单元」运算;如果是存储类型的指令,则交由「控制单元」执行;
  3. CPU 执行完指令后,「程序计数器」的值自增,表示指向下一条指令。这个自增的大小,由 CPU 的位宽决定,比如 32 位的 CPU,指令是 4 个字节,需要 4 个内存地址存放,因此「程序计数器」的值会自增 4;

缓存

Why?

CPU与内存速度差别太大,引入缓存进行速度平衡

How?

  • 寄存器:几十到几百个,64 位 CPU 中大多数寄存器可以存储 8 个字节。时延0.5时钟周期0.1ns。

  • CPU Cache:SRAM(Static Random-Access Memory,静态随机存储器),分为三级

    • 一个 bit 的数据,通常需要 6 个晶体管
    • L1 高速缓存,0.5MB,2~4时钟周期1ns,分成指令缓存和数据缓存
    • L2 高速缓存,几MB,10~20时钟周期5ns
    • L3 高速缓存,几十MB,20~60时钟周期10ns,多个 CPU 核心共用
    • # L1 Cache 「数据」缓存的容量大小
      cat /sys/devices/system/cpu/cpu0/cache/index0/size
      # L1 Cache 「指令」缓存的容量大小
      cat /sys/devices/system/cpu/cpu0/cache/index1/size
      # L2 Cache 的容量大小
      cat /sys/devices/system/cpu/cpu0/cache/index2/size
      # L3 Cache 的容量大小
      cat /sys/devices/system/cpu/cpu0/cache/index3/size
      

image.png image.png

  • 内存:DRAM (Dynamic Random Access Memory,动态随机存取存储器)

    • 存储一个 bit 数据,只需要一个晶体管和一个电容,需要「定时刷新」电容
    • 时延200~300时钟周期,100ns
  • SSD(Solid-state disk),时延10~100缪秒

  • 机械硬盘(Hard Disk Drive, HDD),时延10ms

内存与Cache的映射

CPU 访问内存数据时,是一小块一小块数据读取(coherency_line_size )64KB

# 查看L1 Cache数据缓存一次载入数据的大小
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
  • 直接映射 Cache(Direct Mapped Cache):内存块的地址「映射」在一个 CPU Line(缓存块) 的地址

    • 取模映射

    • CPU Line的组成:

      • 索引
      • 组标记(Tag):当前 CPU Line 中存储的数据对应的内存块
      • 数据(Data)
      • 有效位(Valid bit):0无效
  • 一个内存的访问地址,包括组标记、CPU Line 索引、偏移量

CPU 访问内存地址的步骤:

  1. 根据内存地址中索引信息,计算在 CPU Cahe 中的索引(CPU Line 的地址)
  2. CPU Line有效直接读取,无效重新加载
  3. 对比内存地址中组标记和 CPU Line 中的组标记,确认 CPU Line 中的数据是我们要访问的内存数据
  4. 根据内存地址中偏移量信息,从 CPU Line 的数据块中,读取对应的字。

其他方法:

  • 全相连 Cache (Fully Associative Cache)
  • 组相连 Cache (Set Associative Cache)

如何写出让 CPU 跑得更快的代码?

  • 提高缓存命中率:

    • 数据缓存:顺序遍历数组,例如二维数组不要纵向遍历

    • 指令缓存:分支预测器 if likely 和 unlikely

      • 分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中,这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快。
  • 将线程绑定在某一个 CPU 核心上,提高 L1 L2 cache的更新

CPU 缓存一致性

Why?

CPU cache数据修改后与内存不一致,什么时候写回内存?

How?

  • 写直达(Write Through):把数据同时写入内存和 Cache 中

    • 每次写操作都会写回到内存,这样写操作将会花费大量的时
  • 写回(Write Back):新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中

缓存一致性(Cache Coherence)

Why?

多核心L1 L2 cache数据不一致导致的问题,例如一个核修改了数据,其他核缓存了这个数据,不知道已修改就会产生错误。

How?

  • 写传播(Wreite Propagation):某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache

    • 多核同时修改核传播,其他核收到消息的顺序不一样也会产生问题
  • 事务的串形化(Transaction Serialization):某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,消除了写传播顺序的问题。实现条件:

    • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
    • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新
  • MESI 协议:基于总线嗅探,用状态机机制降低了总线带宽压力,实现事务串行化。四个状态:

    • Modified,已修改
    • Invalidated,已失效
    • Exclusive,独占
    • Shared,共享
    • Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。

image.png

Cache 伪共享

Why?

伪共享(False Sharing):多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象。

How?

经常会并发修改的数据,避免在同一个 Cache Line 中。

  • Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。

    • struct test {
          int a;
          int b __cacheline_aligned_in_smp;
      }
      
    • 缺点:会浪费一定的空间
  • go语言伪共享速度对比:10倍效率提升

       // 伪共享速度对比
       type t1 struct {
          a int64
          b int64
          c [48]byte
          d int64
          e int64
          f [48]byte
       }

       func main() {

          t := t1{}

          wg := sync.WaitGroup{}
          wg.Add(2)

          t0 := time.Now()

          go func() {
             for i := 0; i < 1000000; i++ {
                t.a++
             }
             wg.Done()
          }()
          go func() {
             for i := 0; i < 1000000; i++ {
                t.d++
                //t.b++
             }
             wg.Done()
          }()
          wg.Wait()
          fmt.Println(time.Since(t0).String())
       }
       // 并发修改ab:5~10ms
       // 并发修改ad:0.5ms