21. 存储设备原理

科普性质,简单记录一下 1-Bit 的存储:磁铁 要实现“持久化”存储,核心是要找到一个能反复改写的状态,很容易想到能够利用磁的特性,这就有了磁带的初步想法: 一个长条的带子上面均匀有磁性物质 定位到特定位置之后通过放大感应电流读取 用电磁铁改变磁化方向来写入数据 为了提高存储密度,可以把这样的带子给卷起来,于是就得到了磁带 这样的存储方式主要缺点是几乎不能随机读写(比如磁带收音机需要倒带),一般用于冷数据的存档和备份 为了解决这个缺点,可以想到用旋转的二维平面来替代卷起来的带子,这样读写延迟就不会超过旋转的周期,这就得到了磁鼓: 再在磁鼓的基础上进一步内卷,把用圆盘代替柱面,从而可以堆叠起来,进一步提高了存储密度,这就得到了磁盘: 磁盘作为存储设备的随机读写性能虽然相比磁带有了很大的改善,但是还是需要等待定位到正确的位置,性能仍然不够优秀,为了读写定位到一个扇区通常需要花费几个毫秒的时间,这一点可以通过缓存和调度算法来缓解,让数据尽可能连续存储 当我们在磁盘的基础上把读写头和盘片本体分开,我们就实现了数据的移动,这也就得到了软盘,这是上个数据数据发行的主要方式,虽然性能和可靠性都比较低,但是胜在了便捷、可移动 1-Bit 的存储:挖坑 古人实现持久化存储的方式是在石头上刻字,也就是通过挖坑来存储信息,这种方式可以跨越非常长的时间 而现代工业使我们可以挖出更加精细的坑,从而可以存储更高密度的信息 为了读取这样的信息,我们可以从光学的角度考虑:在反射平面上挖粗糙坑,激光扫过表面,在平面会反射回来,在坑里会发生漫反射,于是我们只要检测是否收到反射光就可以识别是坑还是表面,这也就是光盘 光盘最有趣的特性是容易复制,我们要制造光盘可以先仔细地制造一张反转的盘片,坑的位置对应其表面的突起,之后只需要直接用这个盘片压制加热的塑料再镀上反射膜就可以得到一张光盘,这种方式可以达到极高的写入速度 当然这种挖坑方式的一个重要特性就是不能修改已经写入的内容的,很难填上一个已经挖了的坑(当然通过特殊的制造材料和工艺也是可以做到的),这也就是说里面存储的数据是 append only 的,想要修改之前的内容可以采用可持久化二叉树的结构 光盘作为存储设备,价格低的同时容量和可靠性都比较高,同时顺序读性能一般,随机读性能低并且很难写入,一个重要的应用常见就是数字时代的内容分发 现代这种挖坑的存储方式还有一种应用方式是回归古人石碑的形式,把信息刻在很稳定的材料上来做到永久存储 1-Bit 的存储:电荷 前两种存储介质都存在比较大的缺陷: 磁:依赖机械部件,从而无法避免 ms 级别的延迟 坑(光):挖坑效率低,同时填坑很困难 而电荷则是一种非常理想的存储介质:电子的密度极高,并且电路的速度极快(还天然并行) 在电路中实现 1-bit 的持久存储,一个想法是我们可以挖一个坑,两种状态分别是: 在坑里填入电子 从坑里放跑电子 而这就得到了闪存 (Flash Memory) : 其作为存储设备,价格低,容量和可靠性高,而且读写性能极高(由于电路天然并行,所以容量越大,速度越快) 然而,闪存的物理原理也带来了其固有的缺陷,即会磨损 (wear out) 每次放电 (erase) 操作都无法 100% 将电子放干净,这会对存储单元造成微小的、不可逆的损伤 在经历数千或数万次擦写循环后,一些存储单元会因为累积的损伤而失效,无法再可靠地存储数据,这被称为 “死单元 (Dead Cell)” 为了解决闪存的磨损问题,并将其更好地呈现给操作系统,现代固态存储设备(如 SSD、U 盘、SD 卡)内部实际上都集成了一个微型计算机系统 这个系统运行着一层被称为 FTL (Flash Translation Layer) 的固件,它的核心功能之一是 磨损均衡 (Wear Leveling) ...

August 4, 2025 · 1 min · diefish

20. 设备和驱动程序

输入输出设备 Everything is a File 在 Unix-like 系统中,与外部设备交互的核心思想是 Everything is a File 文件描述符 (File Descriptor):操作系统为上层软件提供了一个统一的抽象,即文件描述符,它是一个指向内核中任何 I/O 对象的“指针”或句柄 统一接口:无论是普通文件、硬件设备(如终端、磁盘)、还是网络连接,都可以通过 open 获得一个文件描述符,然后使用相同的 read/write 等系统调用来进行操作,这极大地简化了应用程序的编写 设备控制器与 MMIO “文件”这个美好的抽象背后,是具体的硬件工作原理 设备控制器 (Device Controller):每个 I/O 设备都有一个控制器,它是一个包含 CPU、内存和寄存器的微型计算机,作为 CPU 和物理设备之间的桥梁 设备寄存器:控制器通过一组寄存器与 CPU 通信,通常包括: 状态寄存器:用于表示设备当前是否繁忙、是否准备好等 指令寄存器:CPU 写入指令,告诉设备要做什么 数据寄存器:用于在 CPU 和设备之间传输数据 内存映射 I/O (MMIO):为了让 CPU 能访问这些寄存器,现代系统普遍采用 MMIO (Memory-Mapped I/O),操作系统会将设备的寄存器映射到物理内存地址空间中的特定区域,这样一来,CPU 就可以像访问普通内存一样,使用标准的 load/store 指令来读写设备寄存器,从而实现对设备的控制 GPIO GPIO (General-Purpose Input/Output) 是理解 I/O 设备原理最直观的例子,GPIO 就是一个物理引脚,可以通过编程设置为输入或输出模式 通过 MMIO,一个 GPIO 引脚的电平状态被映射到一个特定的内存地址,当 CPU 向这个地址写入 1 时,引脚就变为高电平;写入 0 时,则变为低电平,这个过程将一条内存写指令直接转化为了一个物理世界的动作(比如点亮一个 LED) ...

August 3, 2025 · 2 min · diefish

19. 真实世界的并发编程 (2)

CPU 内的并行编程 CPU 的功耗 $ P=C\cdot V^{2}\cdot f $ 导致纵使有更大的电路,热功耗限制了性能上限,从而有一堵“功耗墙”限制了 CPU 的性能,为此需要考虑如何在降低 $ V $ 和 $ f $ 的同时用面积换性能 有两个思路: 让一条指令能处理更多的数据:SIMD (Single Instruction, Multiple Data) “一条指令” 浪费的能量大致是定数 处理的数据越多,浪费越少 用更多更简单的处理器:多处理器系统、异构多处理器 同等面积,处理器越简单,数量越多 异构计算:最经典的例子是大小核架构(如 Apple M1) SIMD SIMD 的核心思想是在硬件层面实现数据级并行,它通过引入专门的硬件单元来达成这个目标: 宽位寄存器 (Wide Registers):CPU 内部增加了比通用寄存器宽很多的专用寄存器 Intel 的 SSE 指令集引入了 128 位的 XMM 寄存器,而最新的 AVX-512 拥有 512 位的 ZMM 寄存器 这些宽位寄存器可以一次性装入多个数据元素,比如一个 128 位的寄存器可以同时容纳 4 个 32 位的浮点数,或者 16 个 8 位的整数 这些被打包在一起的数据被称为 Vector 向量处理单元 (Vector ALU):CPU 内部也配备了能够对整个向量进行并行计算的 ALU ...

August 2, 2025 · 2 min · diefish

18. 真实世界的并发编程 (1)

并发编程的核心抽象是实现一个计算图,计算发生在节点上,边表示节点之间的依赖关系,同时计算图在运行时可能是动态变化的 使用条件变量、锁、信号量等 api 去实现计算图并不是一个优雅的实现方式,因为这样会在代码中引入众多干扰代码,也可能导致一些问题 为此可以增加一些功能受限的语法,可以在同样描述计算图的功能下减少了许多潜在的问题 高性能计算中的并行编程 在高性能计算中,计算图通常易于静态切分,尤其适用于物理模拟的网格划分,为此 HPC 发展出多种高效的并行编程模型,具体学习可以参考 SJTU HPC 学习手册 MPI: 分布式内存并行 每个 MPI 进程有独立的内存空间,进程间通过显式消息传递(发送/接收)交换数据 OpenMP: 共享内存并行 多个线程在同一地址空间中并行执行,所有线程可以直接访问相同的数据,使用 #pragma omp 指令实现并行化 对非计算机专业来说非常友好,只需要在正常的代码上面加上编译指令即可,能轻松实现高效的并行优化 CUDA: GPU 异构并行 CPU 调度,GPU 执行大规模并行计算 概念: 核函数 (Kernel) :在 GPU 上并行执行的函数 线程层次:线程 (threadIdx) 组成线程块 (blockIdx),线程块组成网格 (gridDim) 内存层次:寄存器、共享内存(块内高速)、全局内存(所有线程可访问) 我们身边的并发编程 从 Web 1.0 到 Web 2.0 在 Web 时代用的最广泛的是 Javascript Asynchronous JavaScript and XML (Ajax; ~1999) 允许网页实现“后台刷新” jQuery $ (2006): A DOM Query Language (编程抽象) Web 2.0 时代的并发编程 线程开销大,并且大多数 Web 开发者难以进行并发编程 ...

July 31, 2025 · 1 min · diefish

17. 并发 Bugs

数据竞争 大多并发 bug 最后都会体现为数据竞争 (Data Race) 对于顺序程序而言,函数 f() 返回之后就已经完成了所有的状态修改,对于其他部分而言这个修改是立即生效的;如果对于并发程序而言模式的切换也在瞬间完成,那就不会导致并发的问题 然而实际上模式的切换需要时间,执行的操作在未来一段时间之后才会就绪,但是我们在实际编程时总是容易有“立即生效”的肌肉记忆,这就导致了并发问题的可能性 不过对于函数式编程而言,操作不存在对外状态的修改,没有副作用(只会操作局部变量),这就不会导致并发问题 Data Race 发生的实质是不同的线程同时访问同一内存,并且至少有一个是写,形象的理解就是不同的内存访问在“赛跑”,跑赢的操作先执行 Not that easy: 虽然我们将数据竞争形象地比喻为“赛跑”,但实际上,哪一个操作能“跑赢”并没有想象中那么简单和确定,其复杂性主要体现在以下几个方面 弱内存模型 (Weak memory model):在现代处理器架构中,为了提升性能,处理器可能会对内存操作进行重排序。这意味着,不同的线程或“观察者”在不同时间点看到共享内存的状态可能是不一致的。一个线程对内存的写入操作,可能不会立即对所有其他线程可见,导致不同线程观察到不同的结果。这种内存模型的一致性问题使得确定哪个操作“先发生”变得非常困难且不确定。 未定义行为 (Undefined Behavior):从 C++11 标准开始,数据竞争被明确规定为未定义行为。这意味着,如果你的程序发生了数据竞争,编译器可以自由地产生任何行为,无论是崩溃、产生错误结果,还是看似正常运行但结果不可预测。这使得数据竞争成为非常危险且难以调试的并发错误,因为它的表现可能是不确定、不稳定的。 多线程与多内存的复杂交互:在实际的并发程序中,通常会有多个线程同时访问多个共享内存位置。这些线程和内存之间存在复杂的读(R)写(W)交互。一个线程对一个内存位置的写入可能影响到其他多个线程对该位置的读取,同时,多个内存位置之间也可能存在复杂的依赖关系和缓存一致性问题。这种错综复杂的交互网络进一步加剧了数据竞争的不可预测性。 为了消灭数据竞争,我们需要保证程序的 serializability ,可能竞争的内存访问要么互斥,要么同步 实际编程中遇到的数据竞争 bug 大多属于上错了锁和忘记上锁两种情况的变种 Case 1: 上错了锁 1 2 void T_1() { spin_lock(&A); sum++; spin_unlock(&A); } void T_2() { spin_lock(&B); sum++; spin_unlock(&B); } Case 2: 忘记上锁 1 2 void T_1() { spin_lock(&A); sum++; spin_unlock(&A); } void T_2() { sum++; } 但是实际系统面临的情况比这复杂的多,因为 内存可以是地址空间的任何内存,比如全局变量、堆内存分配的变量、程序的栈…… 访问可以发生在任何代码,比如自己的代码、框架代码、一行没读到的汇编指令、某条 ret 指令 “一行没读到的汇编指令”造成的访问的情况有编译器优化造成的指令重排、硬件层面弱内存模型的内存访问重排、还有一些高层语言操作的隐式内存访问 实际系统中虽然难以避免,但是会尽可能保证底层的结构对上层尽可能封闭来防止这种错误 死锁 死锁 (Deadlock) 是指一个群体中的每个成员都在等待其他成员(包括自身)采取行动的状态 死锁有两种: AA-Deadlock: 自己等待自己 1 2 3 4 5 6 7 lock(&lk); // lk->locked == ✅; proceed ... // Possibly in interrupt handler lock(&lk); // while (lk->locked == ❌) ; 这样的错误虽然看起来很傻,但是在真实程序复杂的控制流中是可能出现的 ...

July 31, 2025 · 2 min · diefish

KV Cache 入门

推理效率对于 llm 是一个至关重要的问题。当模型生成文本时,尤其是以自回归方式逐词生成时,效率瓶颈会变得非常明显。KV Cache 就是为了解决这一问题而诞生的技术。 1. What is KV Cache? KV Cache,全称 Key-Value Cache,是一种优化技术,用于加速 Transformer 架构在自回归生成过程中的推理速度。它的核心思想是缓存并重用在注意力机制中计算得到的 Key (K) 和 Value (V) 向量。 2. Transformer Attention Mechanism Review 要理解 KV Cache,首先需要对 Transformer 架构中的自注意力机制有一个基本认识。自注意力机制允许模型在处理序列中的某个词时,考虑序列中所有其他词的重要性。 每个输入 token(词或子词)在进入注意力层时,都会被转换成三个不同的向量: Q 向量:代表当前 token 的“查询”信息 K 向量:代表所有 token 的“键”信息,用于与 Query 进行匹配 V 向量:代表所有 token 的“值”信息,用于加权求和,得到最终的输出 自注意力机制的计算过程为以下步骤: 计算 Query 与所有 Key 的点积,得到注意力分数 将注意力分数进行缩放,除以 $ \sqrt{d_k} $($ d_k $ 是 Key 向量的维度) 对缩放后的分数进行 Softmax,将其转换为注意力权重,表示每个 token 对当前 token 的重要性 将注意力权重与 Value 向量进行加权求和,得到当前 token 的注意力输出 公式为: $$ \text{Attention}(Q, K, V) = \text{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V $$ 其中矩阵 $ Q,K,V \in \mathbb{R}^{L \times d} $ ,$ L $ 为当前上下文长度 ...

July 30, 2025 · 3 min · diefish

16. 并发控制:同步信号量

信号量 互斥锁在某种意义上也可以认为实现了 “happens-before” 的依赖关系—— release 必然发生在 acquire 之前。我们可以试着利用这种依赖关系来实现计算图的调度:为每条边分配一个互斥锁,代表数据或前置任务的完成;一个节点必须获得所有入边对应的互斥锁才能开始计算,计算完成后,就释放所有出边对应的互斥锁,通知下游节点输出就绪(但是这种直接使用互斥锁作为边状态信号的方式是 undefined behavior,因为互斥锁主要用于保护临界区,其释放通常要求由持有它的线程完成,若释放未曾获取的锁,则行为未定义) 我们可以从这种想法中抽象出其本质,也就是用一个“信号”去获取资源的许可,类似餐厅的取号吃饭 这种信号的思想很适合用来管理计数类型的同类资源,比如停车场的空位,为了实现这种 producer-customer 的问题,用 条件变量 可以轻易解决,进入的条件就是存在空位 count < capacity ,那我们从减少变量的角度出发,这实际上也就是剩余空位的数量大于零,我们停车相当于消耗了一个车位,离开相当于创造了一个车位,这也就得到了所谓“信号量”的机制 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void P(sem_t *sem) { // Prolaag - try + decrease/down/wait/acquire mutex_lock(&sem->lk); while (!(sem->count > 0)) { cond_wait(&sem->cv, &sem->lk); } sem->count--; // 消耗一个信号 (车位) mutex_unlock(&sem->lk); } void V(sem_t *sem) { // Verhoog - increase/up/post/signal/release mutex_lock(&sem->lk); sem->count++; // 创建一个信号 (车位) cond_broadcast(&sem->cv); mutex_unlock(&sem->lk); } 根据这个一路推出信号量的思路,或许可以认为这是互斥锁的扩展 ...

July 21, 2025 · 1 min · diefish

15. 并发控制:同步条件变量

同步和条件变量 互斥实现了原子性,但是无法实现确定性,也就是无法正确实现 “happens-before” 的关系 因此需要引入条件变量来实现线程的同步,形成受控制的并发事件的发生顺序(可以用乐团指挥来类比),把一系列不确定的状态在某一个时间点同步到了一个确定的状态,将发散的并发程序状态 “收束” 实现同步 实现 $ A\to B $: 1 2 3 4 5 6 7 A; can_proceed = true; (signal) while(!can_proceed); B // B: wait until the condition is satisfied 这样的思路大致正确,但是自选的循环有很大的性能问题,因此需要一个更加底层的机制来帮助实现这一点 最理想的 API 是 wait_until(cond) ,但是过去为了简化设计,变成了 条件不满足时等待:wait - 直接睡眠等待 条件满足时继续:signal/broadcast - 唤醒所有线程 (小时候的 scratch 编程其实已经有了这样的思想😂) 在 c++ 代码中我们可以把条件放到 $ \lambda $ 表达式中: 1 2 3 4 5 6 7 8 9 10 11 12 std::mutex mtx; std::condition_variable cv; void T_player() { std::unique_lock lk(mtx); cv.wait(lk, []{ return can_proceed; } ); cv.notify_all(); lk.unlock(); } 注意条件变量在等待时需要带着一把锁(需要确保检查和等待是原子操作) ...

July 21, 2025 · 2 min · diefish