技术

数据湖 高性能计算与存储 Linux2.1.13网络源代码学习 《大数据经典论文解读》 三驾马车学习 Spark 内存管理及调优 Yarn学习 从Spark部署模式开始讲源码分析 容器狂占内存资源怎么办? 多角度理解一致性 golang io使用及优化模式 Flink学习 c++学习 学习ebpf go设计哲学 ceph学习 学习mesh kvm虚拟化 学习MQ go编译器以及defer实现 学习go 为什么要有堆栈 汇编语言 计算机组成原理 运行时和库 Prometheus client mysql 事务 mysql 事务的隔离级别 mysql 索引 坏味道 学习分布式 学习网络 学习Linux go堆内存分配 golang 系统调用与阻塞处理 Goroutine 调度过程 重新认识cpu mosn有的没的 负载均衡泛谈 单元测试的新解读 《Redis核心技术与实现》笔记 《Prometheus监控实战》笔记 Prometheus 告警学习 calico源码分析 对容器云平台的理解 Prometheus 源码分析 并发的成本 基础设施优化 hashicorp raft源码学习 docker 架构 mosn细节 与微服务框架整合 Java动态代理 编程范式 并发通信模型 《网络是怎样连接的》笔记 go channel codereview gc分析 jvm 线程实现 go打包机制 go interface及反射 如何学习Kubernetes 《编译原理之美》笔记——后端部分 《编译原理之美》笔记——前端部分 Pilot MCP协议分析 go gc 内存管理玩法汇总 软件机制 istio流量管理 Pilot源码分析 golang io 学习Spring mosn源码浅析 MOSN简介 《datacenter as a computer》笔记 学习JVM Tomcat源码分析 Linux可观测性 学习存储 学计算 Gotty源码分析 kubernetes operator kaggle泰坦尼克问题实践 kubernetes扩缩容 神经网络模型优化 直觉上理解深度学习 如何学习机器学习 TIDB源码分析 什么是云原生 Alibaba Java诊断工具Arthas TIDB存储——TIKV 《Apache Kafka源码分析》——简介 netty中的线程池 guava cache 源码分析 Springboot 启动过程分析 Spring 创建Bean的年代变迁 Linux内存管理 自定义CNI IPAM 共识算法 spring redis 源码分析 kafka实践 spring kafka 源码分析 Linux进程调度 让kafka支持优先级队列 Codis源码分析 Redis源码分析 C语言学习 《趣谈Linux操作系统》笔记 docker和k8s安全访问机制 jvm crash分析 Prometheus 学习 Kubernetes监控 容器日志采集 Kubernetes 控制器模型 容器狂占资源怎么办? Kubernetes资源调度——scheduler 时序性数据库介绍及对比 influxdb入门 maven的基本概念 《Apache Kafka源码分析》——server Kubernetes类型系统 源码分析体会 《数据结构与算法之美》——算法新解 Kubernetes源码分析——controller mananger Kubernetes源码分析——apiserver Kubernetes源码分析——kubelet Kubernetes介绍 ansible学习 Kubernetes源码分析——从kubectl开始 jib源码分析之Step实现 jib源码分析之细节 线程排队 跨主机容器通信 jib源码分析及应用 为容器选择一个合适的entrypoint kubernetes yaml配置 《持续交付36讲》笔记 mybatis学习 程序猿应该知道的 无锁数据结构和算法 CNI——容器网络是如何打通的 为什么很多业务程序猿觉得数据结构和算法没用? 串一串一致性协议 当我在说PaaS时,我在说什么 《数据结构与算法之美》——数据结构笔记 PouchContainer技术分享体会 harbor学习 用groovy 来动态化你的代码 精简代码的利器——lombok 学习 《深入剖析kubernetes》笔记 编程语言那些事儿 rxjava3——背压 rxjava2——线程切换 spring cloud 初识 《深入拆解java 虚拟机》笔记 《how tomcat works》笔记 hystrix 学习 rxjava1——概念 Redis 学习 TIDB 学习 如何分发计算 Storm 学习 AQS1——论文学习 Unsafe Spark Stream 学习 linux vfs轮廓 《自己动手写docker》笔记 java8 实践 中本聪比特币白皮书 细读 区块链泛谈 比特币 大杂烩 总纲——如何学习分布式系统 hbase 泛谈 forkjoin 泛谈 看不见摸不着的cdn是啥 《jdk8 in action》笔记 程序猿视角看网络 bgp初识 calico学习 AQS——粗略的代码分析 我们能用反射做什么 web 跨域问题 《clean code》笔记 《Elasticsearch权威指南》笔记 mockito简介及源码分析 2017软件开发小结—— 从做功能到做系统 《Apache Kafka源码分析》——clients dns隐藏的一个坑 《mysql技术内幕》笔记 log4j学习 为什么netty比较难懂? 递归、回溯、动态规划 apollo client源码分析及看待面向对象设计 学习并发 docker运行java项目的常见问题 OpenTSDB 入门 spring事务小结 分布式事务 javascript应用在哪里 《netty in action》读书笔记 netty对http2协议的解析 ssl证书是什么东西 http那些事 苹果APNs推送框架pushy apple 推送那些事儿 编写java框架的几大利器 java内存模型和jvm内存布局 java exception Linux IO学习 netty内存管理 测试环境docker化实践 netty在框架中的使用套路 Nginx简单使用 《Linux内核设计的艺术》小结 Go并发机制及语言层工具 Linux网络源代码学习——数据包的发送与接收 《docker源码分析》小结 docker namespace和cgroup zookeeper三重奏 数据库的一些知识 Spark 泛谈 链式处理的那些套路 netty回顾 Thrift基本原理与实践(二) Thrift基本原理与实践(一) 回调 异步执行抽象——Executor与Future Docker0.1.0源码分析 java gc Jedis源码分析 深度学习泛谈 Linux网络命令操作 JTA与TCC 换个角度看待设计模式 Scala初识 向Hadoop学习NIO的使用 以新的角度看数据结构 并发控制相关的硬件与内核支持 systemd 简介 quartz 源码分析 基于docker搭建测试环境(二) spring aop 实现原理简述 自己动手写spring(八) 支持AOP 自己动手写spring(七) 类结构设计调整 分析log日志 自己动手写spring(六) 支持FactoryBean 自己动手写spring(九) 总结 自己动手写spring(五) bean的生命周期管理 自己动手写spring(四) 整合xml与注解方式 自己动手写spring(三) 支持注解方式 自己动手写spring(二) 创建一个bean工厂 自己动手写spring(一) 使用digester varnish 简单使用 关于docker image的那点事儿 基于docker搭建测试环境 分布式配置系统 JVM执行 git maven/ant/gradle/make使用 再看tcp kv系统 java nio的多线程扩展 《Concurrency Models》笔记 回头看Spring IOC IntelliJ IDEA使用 Java泛型 vagrant 使用 Go常用的一些库 Python初学 Goroutine 调度模型 虚拟网络 《程序员的自我修养》小结 Kubernetes存储 访问Kubernetes上的Service Kubernetes副本管理 Kubernetes pod 组件 Go基础 JVM类加载 硬币和扑克牌问题 LRU实现 virtualbox 使用 ThreadLocal小结 docker快速入门

架构

controller-runtime细节分析 finops学习 kubevela多集群 kubevela中cue的应用 基于k8s的工作流 容器和CPU那些事儿 kubevela源码分析 数据集管理fluid 应用管理平台kubevela karmada支持crd 多集群及clusternet学习 AutoML和AutoDL 特征平台 实时训练 分布式链路追踪 helm tensorflow原理——python层分析 如何学习tensorflow 数据并行——allreduce 数据并行——ps 机器学习中的python调用c 机器学习训练框架概述 embedding的原理及实践 tensornet源码分析 大模型训练 X的生成——特征工程 tvm tensorflow原理——core层分析 模型演变 《深度学习推荐系统实战》笔记 keras 和 Estimator tensorflow分布式训练 分布式训练的一些问题 基于Volcano的弹性训练 图神经网络 pytorch弹性分布式训练 从混部到统一调度 RNN pytorch分布式训练 CNN 《动手学深度学习》笔记 pytorch与线性回归 多活 volcano特性源码分析 推理服务 kubebuilder 学习 mpi 学习pytorch client-go学习 tensorflow学习 提高gpu 利用率 GPU与容器的结合 GPU入门 AI云平台梳理 tf-operator源码分析 k8s批处理调度 喜马拉雅容器化实践 Kubernetes 实践 学习rpc BFF openkruise学习 可观察性和监控系统 基于Kubernetes选主及应用 《许式伟的架构课》笔记 Admission Controller 与 Admission Webhook 发布平台系统设计 k8s水平扩缩容 Scheduler如何给Node打分 Scheduler扩展 controller 组件介绍 openkruise cloneset学习 controller-runtime源码分析 pv与pvc实现 csi学习 client-go informer源码分析 kubelet 组件分析 调度实践 Pod是如何被创建出来的? 《软件设计之美》笔记 mecha 架构学习 Kubernetes events学习及应用 CRI 资源调度泛谈 业务系统设计原则 grpc学习 元编程 以应用为中心 istio学习 下一代微服务Service Mesh 《实现领域驱动设计》笔记 概率论 serverless 泛谈 《架构整洁之道》笔记 处理复杂性 那些年追过的并发 服务器端编程 网络通信协议 架构大杂烩 如何学习架构 《反应式设计模式》笔记 项目的演化特点 反应式架构摸索 函数式编程的设计模式 服务化 ddd反模式——CRUD的败笔 研发效能平台 重新看面向对象设计 业务系统设计的一些体会 函数式编程 《左耳听风》笔记 业务程序猿眼中的微服务管理 DDD实践——CQRS 项目隔离——案例研究 《编程的本质》笔记 系统故障排查汇总及教训 平台支持类系统的几个点 代码腾挪的艺术 abtest 系统设计汇总 《从0开始学架构》笔记 初级权限系统设计 领域驱动理念入门 现有上传协议分析 移动网络下的文件上传要注意的几个问题 推送系统的几个基本问题 做配置中心要想好的几个基本问题 不同层面的异步 分层那些事儿 性能问题分析 当我在说模板引擎的时候,我在说什么 用户认证问题 资源的分配与回收——池 消息/任务队列

标签

controller-runtime细节分析 finops学习 kubevela多集群 kubevela中cue的应用 基于k8s的工作流 容器和CPU那些事儿 kubevela源码分析 数据集管理fluid 应用管理平台kubevela karmada支持crd 多集群及clusternet学习 helm 从混部到统一调度 volcano特性源码分析 kubebuilder 学习 client-go学习 tf-operator源码分析 k8s批处理调度 喜马拉雅容器化实践 Kubernetes 实践 openkruise学习 基于Kubernetes选主及应用 Admission Controller 与 Admission Webhook k8s水平扩缩容 Scheduler如何给Node打分 Scheduler扩展 controller 组件介绍 openkruise cloneset学习 controller-runtime源码分析 pv与pvc实现 csi学习 client-go informer源码分析 kubelet 组件分析 调度实践 Pod是如何被创建出来的? Kubernetes events学习及应用 CRI 资源调度泛谈 如何学习Kubernetes 以应用为中心 kubernetes operator kubernetes扩缩容 serverless 泛谈 自定义CNI IPAM docker和k8s安全访问机制 Kubernetes监控 Kubernetes 控制器模型 Kubernetes资源调度——scheduler Kubernetes类型系统 Kubernetes源码分析——controller mananger Kubernetes源码分析——apiserver Kubernetes源码分析——kubelet Kubernetes介绍 Kubernetes源码分析——从kubectl开始 kubernetes yaml配置 CNI——容器网络是如何打通的 当我在说PaaS时,我在说什么 《深入剖析kubernetes》笔记 Kubernetes存储 访问Kubernetes上的Service Kubernetes副本管理 Kubernetes pod 组件

重新认识cpu

2020年10月26日

简介

  1. CPU 可以直接访问的存储资源非常少,只有:寄存器、内存(RAM)、主板上的 ROM。
  2. 寄存器的访问速度非常非常快,但是数量很少,大部分程序员不直接打交道,而是由编程语言的编译器根据需要自动选择寄存器来优化程序的运行性能。
  3. 内存的地位非常特殊,它是唯一的 CPU 内置支持,且和程序员直接会打交道的基础资源。

cpu 指令执行

计算机是如何读懂0和1的?- CPU (上)计算机是如何读懂0和1的?- CPU (下) 可以学到几个问题

  1. alu 是如何构成的
  2. 时钟如何驱动寄存器、控制单元、计算单元alu

ALU 包含加法器、移位器(左)、移位器(右)、比较器、与门、非门、或门 等逻辑电路,所有的单元都有输入a,对于需要两个输入的单元,b也被连接。通过op操作码,可以选择不同的逻辑或算数单元。ALU计算会输出四个标志位,分别为:carry out(进位)、a larger、equal和zero。

ALU op操作码所对应的选择为:

op 逻辑/算数单元 含义
000 ADD 加法
001 SHR 右移
010 SHL 左移
011 NOT 取反
100 AND
101 OR
110 XOR 异或
111 CMP 比较

对于一个8位CPU来说,指令可以分为两大类,一类为ALU相关指令,主要包括需要ALU参与完成的相关指令,比如加法、移位、比较等;另一类是非ALU相关指令,比如加载数据,存储数据,跳转等。无论哪种指令,都可以分为前4位和后4位,前四位和动作有关,后四位和数据有关。

五级流水线

一行代码能够执行,必须要有可以执行的上下文环境,包括:指令寄存器、数据寄存器、栈空间等内存资源。

许式伟:中央处理器支持的指令大体如下:

  1. 计算类,也就是支持我们大家都熟知的各类数学运算,如加减乘除、sin/cos 等等;
  2. I/O 类,从存储读写数据,从输入输出设备读数据、写数据;
  3. 指令跳转类,在满足特定条件下跳转到新的当前程序执行位置、调用自定义的函数。

为什么可以流水线/乱序执行?我们通常会把 CPU 看做一个整体,把 CPU 执行指令的过程想象成,依此检票进站的过程,改变不同乘客的次序,并不会加快检票的速度。所以,我们会自然而然地认为改变顺序并不会改变总时间。但当我们进入 CPU 内部,会看到 CPU 是由多个功能部件构成的。一条指令执行时要依次用到多个功能部件,分成多个阶段,虽然每条指令是顺序执行的,但每个部件的工作完成以后,就可以服务于下一条指令,从而达到并行执行的效果。比如典型的 RISC 指令在执行过程会分成前后共 5 个阶段。

  1. IF:获取指令;读取一条指令后,程序指针寄存器会根据指令的长度自动递增,或者改写成指定的地址。
  2. ID(或 RF):指令译码器对指令进行拆分和解释,识别出指令类别以及所需的各种操作数。
    1. 不过指令格式不同,指令译码模块翻译指令的工作机制却是统一的。首先译码电路会翻译出指令中携带的寄存器索引、立即数大小等执行信息。接着,在解决数据可能存在的数据冒险之后,由译码数据通路负责把译码后的指令信息,发送给对应的执行单元去执行
    2. 译码模块得到的指令信号,可以分为两大类。一类是由指令的操作码经过译码后产生的指令执行控制信号,如跳转操作 jump 信号、存储器读取 mem_read 信号等;另一类是从指令源码中提取出来的数据信息,如立即数、寄存器索引、功能码等。
    3. 根据译码之后的指令信息,我们可以把指令分为三类(由 2 位执行类型字段 aluCrtlOp标记),分别是算术逻辑指令、分支跳转指令、存储器访问指令。因为RISC-V格式上比较简单而且规整,所以不同类别的指令执行过程也是类似的。这样,RISC 执行单元的电路结构相比 CISC 就得到了简化。在指令执行阶段,上述的这三类指令都能通过 ALU 进行相关操作。比如,存储访问指令用 ALU 进行地址计算,条件分支跳转指令用 ALU 进行条件比较,算术逻辑指令用 ALU 进行逻辑运算。根据译码模块里产生的指令控制字段 aluCrtlOp,执行控制模块可以根据上述的三类指令,相应产生一个 4 位的 ALU 操作信号 aluOp,为后面的 ALU 模块提供运算执行码。这三类指令经过 ALU 执行相关操作之后,统一由数据通路来输出结果。
  3. EX:执行指令;在“执行”阶段最关键的模块为算术逻辑单元(Arithmetic Logical Unit,ALU),它是实施具体运算的硬件功能单元。
  4. ME(或 MEM):内存访问(如果指令不涉及内存访问,这个阶段可以省略);
  5. WB:写回寄存器。如果是普通运算指令,该结果值来自于“执行”阶段计算的结果;如果是存储器读指令,该结果来自于“访存”阶段从存储器中读取出来的数据。

在执行指令的阶段,不同的指令也会由不同的单元负责。所以在同一时刻,不同的功能单元其实可以服务于不同的指令。五级流水线的 CPU 内就可以同时进行 5 个操作。这样平均下来,就相当于每条指令只需要五分之一的时钟周期时间来完成。处理器核参照 CPU 流水线的五个步骤 对功能模块进行划分,主要模块包括指令提取单元、指令译码单元、执行单元(比如运算器)、访问存储器和写回结果等单元模块。PS:从设计上说,cpu结构首先是由五级流水线决定的,其次才是传统课本上说的cpu=运算+控制单元。

多层次内存结构

CPU是如何与内存交互的?

RAM 分为动态和静态两种,静态 RAM 由于集成度较低,一般容量小,速度快,而动态 RAM 集成度较高,主要通过给电容充电和放电实现,速度没有静态 RAM 快,所以一般将动态 RAM 做为主存,而静态 RAM 作为 CPU 和主存之间的高速缓存 (cache),用来屏蔽 CPU 和主存速度上的差异,也就是我们经常看到的 L1 , L2 缓存。

一个 CPU 处理器中一般有多个运行核心,我们把一个运行核心称为一个物理核,每个物理核都可以运行应用程序。每个物理核都拥有私有的一级缓存(Level 1 cache,简称 L1 cache),包括一级指令缓存和一级数据缓存,以及私有的二级缓存(Level 2 cache,简称 L2 cache)。L1 和 L2 缓存是每个物理核私有的,不同的物理核还会共享一个共同的三级缓存。另外,现在主流的 CPU 处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。同一个物理核的逻辑核会共享使用 L1、L2 缓存。PS:笔者经历过cpu密集型服务(nlp 相关的机器训练,占用一个逻辑核)与其它业务(占用一个逻辑核)在一个 物理核上 导致性能下降明显的事情,因为k8s 也支持 分配完整物理核的策略(否则就不调度)

缓存速度的差异

从CPU到 大约需要的 CPU 周期 大约需要的时间
主存   约60-80纳秒
QPI 总线传输(between sockets, not drawn)   约20ns
L3 cache 约40-45 cycles, 约15ns
L2 cache 约10 cycles, 约3ns
L1 cache 约3-4 cycles, 约1ns
寄存器 1 cycle  

当CPU执行运算的时候,它先去L1查找所需的数据,再去L2,然后是L3,最后如果这些缓存中都没有,所需的数据就要去主内存拿。走得越远,运算耗费的时间就越长。如果你的目标是让端到端的延迟只有 10毫秒,而其中花80纳秒去主存拿一些未命中数据的过程将占很重的一块。如果你在做一些很频繁的事,你要确保数据在L1缓存中

当然,缓存命中率是很笼统的,具体优化时还得一分为二。比如,你在查看 CPU 缓存时会发现有 2 个一级缓存,这是因为,CPU 会区别对待指令与数据。虽然在冯诺依曼计算机体系结构中,代码指令与数据是放在一起的,但执行时却是分开进入指令缓存与数据缓存的,因此我们要分开来看二者的缓存命中率。

  1. 提高数据缓存命中率,考虑cache line size
  2. 提高指令缓存命中率,CPU含有分支预测器,如果分支预测器可以预测接下来要在哪段代码执行(比如 if 还是 else 中的指令),就可以提前把这些指令放在缓存中,CPU 执行时就会很快。例如,如果代码中包含if else,不要让每次执行if else 太过于随机。

在一个 CPU 核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU 核的寄存器值等),我们把这些信息称为运行时信息。同时,应用程序访问最频繁的指令和数据还会被缓存到 L1、L2 缓存上,以便提升执行速度。但是,在多核 CPU 的场景下,一旦应用程序需要在一个新的 CPU 核上运行,那么,运行时信息就需要重新加载到新的 CPU 核上。而且,新的 CPU 核的 L1、L2 缓存也需要重新加载数据和指令,这会导致程序的运行时间增加。因此,操作系统(调度器)提供了将进程或者线程绑定到某一颗 CPU 上运行的能力(PS:就好像将pod 调度到上次运行它的node)。建议绑定物理核,以防止绑到一个逻辑核时,因为任务较多导致目标线程迟迟无法被调度的情况。

缓存的存取——cache line

高性能队列——Disruptor

Cache 的存取与替换都是以缓存行(Cacheline)为单位。。cpu和内存的速度差异 ==> 缓存 ==> 多级缓存 ==> Cache是由很多个cache line组成的。每个cache line通常是64字节,并且它有效地引用主内存中的一块儿地址。CPU每次从主存中拉取数据时,会把相邻的数据也存入同一个cache line。每次改写 Cache 中的数据都会将整个 Cache Line 置为无效。

  1. 下次访问第二个变量时,便需要从内存中加载到缓存,再加载到cpu。
  2. 伪共享问题。当两个线程同时各自修改两个相邻的变量,由于缓存是按缓存块来组织的,当一个线程对一个缓存块执行写操作时,必须使其他线程含有对应数据的缓存块无效。这样两个线程都会同时使对方的缓存块无效,导致性能下降。

java 和c 都有相关机制来解决伪共享问题。

struct foo {
    int a;
    int b ____cacheline_aligned;
};

通过内存对齐可以避免一个字段同时存在两个缓存行里的情况,但还是无法完全规避缓存伪共享的问题,也就是一个缓存行中存了多个变量,而这几个变量在多核 CPU 并行的时候,会导致竞争缓存行的写权限,当其中一个 CPU 写入数据后,这个字段对应的缓存行将失效,导致这个缓存行的其他字段也失效。

在 Disruptor 中,通过填充几个无意义的字段,让对象的大小刚好在 64 字节,一个缓存行的大小为64字节,这样这个缓存行就只会给这一个变量使用,从而避免缓存行伪共享,但是在 jdk7 中,由于无效字段被清除导致该方法失效,只能通过继承父类字段来避免填充字段被优化,而 jdk8 提供了注解@Contended 来标示这个变量或对象将独享一个缓存行,使用这个注解必须在 JVM 启动的时候加上 -XX:-RestrictContended 参数,其实也是用空间换取时间

缓存一致性

总的思路:CPU 从单核发展为多核,增加缓存,导致出现了多个核间的缓存一致性问题 –> 为了解决缓存一致性问题,提出了 MESI 协议 –> 完全遵守 MESI 又会给 CPU 带来性能问题 –> CPU 设计者又增加 store buffer 和 invalid queue –> 又导致了缓存的顺序一致性变为了弱缓存一致性 –> 需要缓存的顺序一致性的,就需要软件工程师自己在合适的地方添加内存屏障。PS:本质就是cpu 默认会进行性能优化,但如果多核共享缓存了(也就是多线程竞争变量了),cpu 也提供指令 放弃优化,由使用方在合适的位置 插入这些指令

MESI协议:多核CPU是如何同步高速缓存的?缓存一致性问题的产生主要是因为在多核体系结构中,如果有一个 CPU 修改了内存中的某个值,那么必须有一种机制保证其他 CPU 能够观察到这个修改。于是,人们设计了协议来规定一个 CPU 对缓存数据的修改,如何同步到另一个 CPU。

  1. 当 CPU 修改了缓存中的数据后,这些修改什么时候能传播到主存?
    1. 写回(Write Back),当 CPU 采取写回策略时,对缓存的修改不会立刻传播到主存,只有当缓存块被替换时,这些被修改的缓存块,才会写回并覆盖内存中过时的数据;
    2. 写直达(Write Through)。缓存中任何一个字节的修改,都会立刻传播到内存,这种做法就像穿透了缓存一样,所以用英文单词“Through”来命名。
  2. 当某个 CPU 的缓存中执行写操作,修改其中的某个值时,其他 CPU 的缓存所保有该数据副本的更新策略也有两种
    1. 写更新(Write Update)。每次它的缓存写入新的值,该 CPU 都必须发起一次总线请求,通知其他 CPU 将它们的缓存值更新为刚写入的值,所以写更新会很占用总线带宽。
    2. 写无效(Write Invalidate)。如果在一个 CPU 修改缓存时,将其他 CPU 中的缓存全部设置为无效。在具体的实现中,绝大多数 CPU 都会采用写无效策略。这是因为多次写操作只需要发起一次总线事件即可,第一次写已经将其他缓存的值置为无效,之后的写不必再更新状态,这样可以有效地节省 CPU 核间总线带宽。
  3. 当前要写入的数据不在缓存中时,根据是否要先将数据加载到缓存中
    1. 写分配(Write Allocate)。在写入数据前将数据读入缓存。
    2. 写不分配(Not Write Allocate)。在写入数据时,直接将要写入的数据传播内存,而并不将数据块读入缓存。

组合排列一下,因为写直达会导致更新直接穿透缓存,所以这种情况下只能采用写不分配策略。写更新和写不分配这两种策略在现实中比较少出现。

缓存和内存的更新关系 写缓存时 CPU 之间的更新策略 写缓存时数据是否被加载
写回 写更新 写分配
写回 写更新 写分配
写回 写无效 写不分配
写回 写无效 写不分配
写直达 写无效 写不分配

所谓缓存一致性,就是保证同一个数据在每个 CPU 的私有缓存(一般为 L1 Cache)中副本是相同的。为了保证缓存一致性,必须解决两个问题,分别是写传播和事务串行化。

  1. 写传播是指,一个处理器对缓存中的值进行了修改,需要通知其他处理器,也就是需要用到“写更新”或者“写无效”策略。
  2. 事务串行化是指,多个处理器对同一个值进行修改,在同一时刻只能有一个处理器写成功,必须保证写操作的原子性,多个写操作必须串行执行。PS:反面是乱序

写传播

怎样解决写传播所带来的缓存一致性问题呢?那就需要缓存一致性协议,前面提到缓存中的值同步给主存有两种策略(写回和写直达),而且,不同的写策略,对应不同的缓存一致性协议。

  1. 基于“写直达”的缓存一致性协议,VI协议,缺点是需要很高的带宽
  2. 基于“写回”策略的缓存一致性协议,MESI 协议,MESI 协议通过引入了 Modified 和 Exclusive 两种状态,并且引入了处理器缓存之间可以相互同步的机制,非常有效地降低了 CPU 核间带宽

有的CPU,比如x86,它的硬件提供了比较强的缓存一致性支持,但有的CPU,比如Arm,它指供的缓存一致性支持就很弱,这就需要软件工程师在正确的位置插入内存屏障来保证这种一致性。

有序执行内存操作——内存屏障

内存模型:有了MESI为什么还需要内存屏障?如果 CPU 严格按照 MESI 协议进行核间通讯和同步,核间同步就会给 CPU 带来性能问题。严格遵守 MESI 协议的 CPU 设计,在它的某一个核在写一块缓存时,它需要通知所有的同伴:我要写这块缓存了,如果你们谁有这块缓存的副本,请把它置成 Invalid 状态。Invalid 状态意味着该缓存失效,如果其他 CPU 再访问这一缓存区时,就会从主存中加载正确的值。发起写请求的 CPU 中的缓存状态可能是 Exclusive、Modified 和 Share,每个状态下的处理是不一样的。如果缓存状态是 Exclusive 和 Modified,那么 CPU 一个核修改缓存时不需要通知其他核,这是比较容易的。但是在 Share 状态下,如果一个核想独占缓存进行修改,就需要先给所有 Share 状态的同伴发出 Invalid 消息,等所有同伴确认并回复它“Invalid acknowledgement”以后,它才能把这块缓存的状态更改为 Modified,这是保持多核信息同步的必然要求。这个过程相对于直接在核内缓存里修改缓存内容,非常漫长。这也就会导致,某个核请求独占时间比较长。

那怎么来解决这个问题呢?CPU 的设计者为每个核都添加了一个名为 store buffer 的结构,store buffer 是硬件实现的缓冲区,它的读写速度比缓存的速度更快,所有面向缓存的写操作都会先经过 store buffer。它会收集多次写操作,然后在合适的时机进行提交。

在这样的结构里,如果 CPU 的某个核再要对一个变量进行赋值,它就不必等到所有的同伴都确认完,而是直接把新的值放入 store buffer,然后再由 store buffer 慢慢地去做核间同步,并且将新的值刷入到 cache 中去就好了。而且,每个核的 store buffer 都是私有的,其他核不可见。但用 store buffer 也会有一个问题,那就是它并不能保证变量写入缓存和主存的顺序。为了解决这个问题,CPU 设计者就引入了内存屏障,屏障的作用是前边的读写操作未完成的情况下,后面的读写操作不能发生。

store buffer 的存在是为提升写性能,放弃了缓存的顺序一致性,我们把这种现象称为弱缓存一致性。在正常的程序中,多个 CPU 一起操作同一个变量的情况是比较少的,所以 store buffer 可以大大提升程序的运行性能。但在需要核间同步的情况下,我们还是需要这种一致性的,这就需要软件工程师自己在合适的地方添加内存屏障了。

核间同步还有另外一个瓶颈,也就是“读”的问题。当一个 CPU 向同伴发出 Invalid 消息的时候,它的同伴要先把自己的缓存置为 Invalid,然后再发出 acknowledgement。这个从“把缓存置为 Invalid”到“发出 acknowledgement”的过程所需要的时间也是比较长的。而且,由于 store buffer 的存在提升了写入速度,那么 invalid 消息确认速度相比起来就慢了,这就带来了速度的不匹配,很容易导致 store buffer 的内容还没有及时更新到 cache 里,自己的容量就被撑爆了,从而失去了加速的作用。为了解决这个问题,CPU 设计者又引入了“invalid queue”,也就是失效队列这个结构。加入了这个结构后,收到 Invalid 消息的 CPU,比如我们称它为 CPU1,在收到 Invalid 消息时立即向 CPU0 发回确认消息,但这个时候 CPU1 并没有把自己的 cache 由 Share 置为 Invalid,而是把这个失效的消息放到一个队列中,等到空闲的时候再去处理失效消息,这个队列就是 invalid queue。经过这样的改进后,CPU1 响应失效消息的速度大大提升了。

在 CPU 的具体实现中,通过放宽 MESI 协议的限制来获得性能提升。具体来说,我们引入了 store buffer 和 invalid queue,它们采用放宽 MESI 协议要求的办法,提升了写缓存核间同步的速度,从而提升了程序整体的运行速度。但在这放宽的过程中,我们也看到会不断地出现新的问题,也就是说,一个 CPU 的读写操作在其他 CPU 看来出现了乱序。甚至,即使执行写操作的 CPU 并没有乱序执行,但是其他 CPU 观察到的变量更新顺序确实是乱序的。这个时候,我们就必须加入内存屏障来解决这个问题。在不同的情况下,我们需要的内存屏障是不同的。使用功能强大的内存屏障会给系统带来不必要的性能下降,为了更精细地区分不同类型的屏障,CPU 的设计者们提供了分离的读写屏障 (alpha),或者是单向屏障 (Arm)。PS:cpu 本身为了速度也会乱序执行。

示例:Arm 上 dmb((Data Memory Barrier)) 指令,cpu会保证当遇到dmb指令的时候,cpu会停下来,直到条件满足了才会继续执行。

cpu/服务器 三大体系numa smp mpp

Kubelet从入门到放弃:识透CPU管理

为什么 NUMA 会影响程序的延迟

  1. SMP(Symmetric Multi-Processor) 所谓对称多处理器结构,是指服务器中多个CPU对称工作,无主次或从属关系。各CPU共享相同的物理内存,每个 CPU访问内存中的任何地址所需时间是相同的,因此SMP也被称为一致存储器访问结构(UMA:Uniform Memory Access)。SMP服务器的主要特征是共享,系统中所有资源(CPU、内存、I/O等)都是共享的。也正是由于这种特征,导致了SMP服务器的主要问题,那就是它的扩展能力非常有限。对于SMP服务器而言,每一个共享的环节都可能造成SMP服务器扩展时的瓶颈,而最受限制的则是内存。由于每个CPU必须通过相同的内存总线访问相同的内存资源,因此随着CPU数量的增加,内存访问冲突将迅速增加,最终会造成CPU资源的浪费,使 CPU性能的有效性大大降低。实验证明,SMP服务器CPU利用率最好的情况是2至4个CPU。
  2. NUMA(Non-Uniform Memory Access)基本特征是具有多个CPU模块,每个CPU模块由多个CPU(如4个)组成,并且具有独立的本地内存、I/O槽口等。由于其节点之间可以通过互联模块(如称为Crossbar Switch)进行连接和信息交互,因此每个CPU可以访问整个系统的内存(这是NUMA系统与MPP系统的重要差别)。显然,访问本地内存的速度将远远高于访问远地内存(系统内其它节点的内存)的速度,这也是非一致存储访问NUMA的由来。由于这个特点,为了更好地发挥系统性能,开发应用程序时需要尽量减少不同CPU模块之间的信息交互。
  3. MPP(Massive Parallel Processing)其基本特征是由多个SMP服务器(每个SMP服务器称节点)通过节点互联网络连接而成,每个节点只访问自己的本地资源(内存、存储等),是一种完全无共享(Share Nothing)结构,因而扩展能力最好,理论上其扩展无限制。在MPP系统中,每个SMP节点也可以运行自己的操作系统、数据库等。但和NUMA不同的是,它不存在异地内存访问的问题。换言之,每个节点内的CPU不能访问另一个节点的内存。节点之间的信息交互是通过节点互联网络实现的,这个过程一般称为数据重分配(Data Redistribution)。但是MPP服务器需要一种复杂的机制来调度和平衡各个节点的负载和并行处理过程。

并行计算能力

Java和操作系统交互细节

SIMD(Single Instruction Multiple Data)

SIMD 表示 利用单条指令执行多条数据,是相对于传统的 SISD(Single Instruction Single Data)来说的。

图的左边,代表的是单条指令执行单条数据的实现方式,我们可以看到,针对两组包含 4 个元素的数据,在进行两两相加的操作时,最少需要 12 条指令(8 条 mov 指令,4 条 add 指令)才能完成业务。而图的右边,因为在 CPU 芯片中集成了比较大的寄存器,从而就实现了多条数据导入和多条数据相加操作都可以在一条指令周期内完成,减少了执行 CPU 的指令数,进一步也就提升了计算速度。对于 GPU 来说,也正是因为它可以实现通过单条指令来运行矩阵或向量计算,才可以在数据处理和人工智能领域有比较大的性能优势。

指令集

假如你有一条狗,经过一段时间的训练,它能“听懂”了你对它说一些话。当你对它说“坐下”,它就乖乖地坐在地上;当你对它说“汪汪叫”;它就汪汪汪地叫起来,当你对它说“躺下”,它马上就会躺下来……这里你说的“坐下”、“汪汪叫”、“躺下”这些命令,就相当于计算机世界里的指令。

在 CPU 发展初期,CISC 体系设计是合理的,设计大量功能复杂的指令是为了降低程序员的开发难度。因为那个时代,开发软件只能用汇编或者机器语言,这等同于用硬件电路设计帮了软件工程师的忙。编译器技术的发展,导致各种高级编程语言盛行。这些高级语言编译器生成的低级代码,比程序员手写的低级代码高效得多,使用的也是常用的几十条指令。文明的发展离不开工具的种类与材料升级。指令集的发展,我们也可以照这个思路推演。芯片生产工艺升级之后,人们在 CPU 上可以实现高速缓存、指令预取、分支预测、指令流水线等部件。不过,这些部件的加入引发了新问题,那些一次完成多个功能的复杂指令,执行的时候就变得捉襟见肘,困难重重。比如,一些串操作指令同时依赖多个寄存器和内存寻址,这导致分支预测和指令流水线无法工作。另外,当时在 IBM 工作的 John Cocke 也发现,计算机 80% 的工作由大约 20% 的 CPU 指令来完成,这代表 CISC 里剩下的 80% 的指令都没有发挥应有的作用。这些最终导致人们开始向 CISC 的反方向思考,由此产生了 RISC——精简指令集计算机体系结构。RISC 设计方案非常简约,通常有 20 多条指令的简化指令集。每条指令长度固定,由专用的加载和储存指令用于访问内存,减少了内存寻址方式,大多数运算指令只能访问操作寄存器。而 CPU 中配有大量的寄存器,这些指令选取的都是工程中使用频率最高的指令。由于指令长度一致,功能单一,操作依赖于寄存器,这些特性使得 CPU 指令预取、分支预测、指令流水线等部件的效能大大发挥,几乎一个时钟周期能执行多条指令。

RISC-V 采用的是模块化的指令集,RISC-V 规范只定义了 CPU 需要包含基础整形操作指令,如整型的储存、加载、加减、逻辑、移位、分支等。其它的指令称为可选指令或者或者用户扩展指令,比如乘、除、取模、单精度浮点、双精度浮点、压缩、原子指令等,这些都是扩展指令。扩展指令需要芯片工程师结合功能需求自定义。

其它

许式伟:最早期的计算机毫无疑问是单任务的,计算的职能也多于存储的职能。每次做完任务,计算机的状态重新归零(回到初始状态)都没有关系。汇编语言的出现要早于操作系统。操作系统的核心目标是软件治理,只有在计算机需要管理很多的任务时,才需要有操作系统。

许式伟:引入了输入输出设备的电脑,不再只能做狭义上的计算(也就是数学意义上的计算),如果我们把交互能力也看做一种计算能力的话,电脑理论上能够解决的计算问题变得无所不包。

从下图的视角看,运算器、输入、输出、内存在控制器看来地位差不多。

通用寄存器数目的多少是由ISA决定的,指令编码时会单独给寄存器字段进行编码,比如5bits就可以索引2^5=32个通用寄存器,一条指令的长度是有限的,指令类型,源操作数字段、目的操作数字段,可能的状态位字段(比如谓词寄存器等)以及立即数字段都需要一些bits来编码,所以相互之间存在竞争关系,如果寄存器太多,一条指令上能携带的操作数种类和数量就会受限,这里需要一个精巧的权衡。x86一开始并没有使用太多的通用寄存器,原因之一(注意,只是之一)是当时的编译器无力进行寄存器分配,让编译器自动决定程序中众多变量哪些应该装入寄存器哪些应该换出、哪些变量应该映射到同一个寄存器上,并不是一件易事,JVM采用堆栈结构的原因之一就是不信任编译器的寄存器分配能力,转而使用堆栈结构,躲开寄存器分配的难题。到80年代早期,IBM的G. J. Chaitin公开了他们的图染色寄存器分配算法之后,编译器的分配能力获得长足进步,形成了现在这样的编译器主导的寄存器分配格局。

重排序的种类

  • 编译期重排。编译源代码时,编译器依据对上下文的分析,对指令进行重排序,使其更适合于CPU的并行执行。
  • 运行期重排,CPU在执行过程中,动态分析依赖部件的效能(CPU0检查 memory bank0 的可用性,发现 bank0 处于 busy 状态,那么本来写入cache bank0的数据操作会延后),对指令做重排序优化。

前者是编译器进行的,不同语言不同。后者是cpu 层面的,所有使用共享内存模型进行线程通信的语言都要面对的。

为什么会有人觉得优化没有必要,因为他们不理解有多耗时

Teach Yourself Programming in Ten Years

Remember that there is a “computer” in “computer science”. Know how long it takes your computer to execute an instruction, fetch a word from memory (with and without a cache miss), read consecutive words from disk, and seek to a new location on disk.

Approximate timing for various operations on a typical PC:

  耗时
execute typical instruction 1/1,000,000,000 sec = 1 nanosec
fetch from L1 cache memory 0.5 nanosec
branch misprediction 5 nanosec
fetch from L2 cache memory 7 nanosec
Mutex lock/unlock 25 nanosec
fetch from main memory 100 nanosec
send 2K bytes over 1Gbps network 20,000 nanosec
read 1MB sequentially from memory 250,000 nanosec
fetch from new disk location (seek) 8,000,000 nanosec
read 1MB sequentially from disk 20,000,000 nanosec
send packet US to Europe and back 150 milliseconds = 150,000,000 nanosec
上下文切换 数千个CPU时钟周期,1微秒
上下文切换 CPU上下文 线程相关结构:栈等 进程相关结构:虚拟内存TLB等
进程 切换 切换 切换
进程内线程 切换 切换 不切换
中断 切换 用户态不切换,内核态切换 用户态不切换,内核态切换
系统调用 切换 未切换 未切换