llama2.c 源码阅读

1. 概述 前OpenAI著名工程师Andrej Kapathy开源了llama2.c项目,该项目是llama2模型推理代码的C语言实现,用大概970行C代码实现了LLama2模型的推理算法。整个项目代码简洁高效,值得深度阅读。对掌握大模型推理算法的细节有极大的帮助。 2. 源码阅读 2.1 基础算法 RMS归一化公式是: $$ o_i = w_i \times x_i \times \frac {1}{\sqrt{\frac{1}{n}\sum_{j=0}^{n-1} x_j^2 + \epsilon}} $$ 其中,\(\epsilon\) 为防止分母为0的数值。还有RMS因子是对x的归一化,w变量是gain变量,重新缩放标准化后的输入向量。 // ---------------------------------------------------------------------------- // neural net blocks; the dynamics of the Transformer void rmsnorm(float* o, float* x, float* weight, int size) { // calculate sum of squares float ss = 0.0f; for (int j = 0; j < size; j++) { ss += x[j] * x[j]; } ss /= size; ss += 1e-5f; ss = 1....

July 6, 2024 · Skyan

Google abseil开源项目介绍

Google abseil是Google开源的优秀C++基础库,持续维护并且持续迭代。该库的代码质量和工程化水平属于业界顶级,值得我们在实际生产中使用和学习。不仅要善于使用abseil库,还要多看abseil的文档和代码,从中学习Google业界领先的C++经验。 这里先介绍几个abseil库的经典组件: 容器 Recommendation Prefer absl::flat_hash_map or absl::flat_hash_set in most new code (see above). Use absl::node_hash_map or absl::node_hash_set when pointer stability of both keys and values is required (rare), or for code migrations from other containers with this property. Note: Do not use popularity as a guide. You will see the “node” containers used a lot, but only because it was safe to migrate code to them from other containers....

June 1, 2023 · Skyan

FlameGraph火焰图原理

FlameGraph是世界知名计算机性能优化专家Brendan Gregg发明的一种性能数据可视化方法。通过不同色块可交互的展示,可动态展示系统运行时的性能热点。FlameGraph如下图所示: 按照作者介绍,FlameGraph可以用于分析CPU耗时,内存分配,非CPU耗时(线程等待,调度性能),混合运行时(CPU和非CPU运行混合),以及性能对比这5种性能分析的可视化。我们常用FlameGraph生成CPU耗时分析图,用来找到服务运行性能的热点,并专项优化以节省资源成本。其实,FlameGraph也有一套标准数据格式,根据这个格式,可以在任何适合的场景中生成所需要的FlameGraph,并可视化可交互地展示和分析。 FlameGraph适合用于有调用栈的数据可视化,通过一种类似柱子的布局,加上暖色调的颜色,很像火焰一样的图案,所以被称为火焰图。以大家常用的CPU耗时火焰图为例,它的组成元素如下: 一个函数调用栈用一列方框组成,每个方框代表一个函数 y轴代表函数调用的深度,从底向上逐层递进调用,最顶端的函数代表性能采集时刻正在运行的函数,下面是它的父函数,依次类推 x轴代表不同采集栈的集合。需要注意的是x轴并不代表时间顺序,同一层的函数按照字母序从左往右排列。这样如果两个同名函数在同一层,将会被合并成一个区块。 方框的宽度代表该函数在调用栈抽样中出现的次数,如果宽度越宽,代表这个函数在栈抽样中出现的越频繁,从CPU耗时的角度来说,也表示这个函数更耗时。 如果方框宽度足够则展示函数的全名,如果不够则展示部分或者不展示,鼠标放上去可以展示全名 每个方框的背景色其实并没有什么特别,其实就是选取了一组随机的暖色调。主要是为了方便眼睛能区分不同层的方框。 一个火焰图可以用于可视化单线程,多线程,多进程甚至多主机的性能数据。也可以为每个线程生成一份独立的火焰图用于更详细的分析。 方框的宽度不仅限于表示抽样次数,也可以表示其他指标。例如宽度可以表示线程阻塞的时长,这样的一个火焰图可以很清晰的看出哪些函数在阻塞线程,以及整个线程阻塞时函数的调用栈情况 以上即是一个火焰图静态的组成部分,更为有趣的是,Gregg也为火焰图增加了互动功能,使得用户体验更佳。 火焰图本身是一个SVG文件,配合Javascript,支持鼠标悬浮,点击放大,以及搜索功能: 鼠标悬浮:当鼠标光标放置到一个方框上方时,可以展示该方框的详细数据(函数名,采样次数,以及占比) 点击放大:当鼠标点击一个方框时,火焰图按照垂直方向放大,该方框以上的函数将被放大展示,其他部分浅色处理,方便聚焦一个函数的分析 搜索:可以用Ctrl/Command+F快捷键或者右上角搜索功能,按照函数名搜索。搜索到的函数会高亮显示,还是在右下角展示所有搜索到的函数出现次数占比 火焰图生成流程是怎样的呢?按照Gregg开源的flamegraph.pl程序,生成一个火焰图只需要3步: 从perf,dtrace,gperftools等程序中获取运行时的调用栈数据 转换为折叠栈格式(Fold stacks) 调用flamegraph.pl生成火焰图 原理非常简单。所以搞懂火焰图只需要明白折叠栈是怎么回事即可。首先一个函数的调用栈可能长这样: func_c func_b func_a start_thread clone func_d func_a start_thread clone func_d func_a start_thread clone 上面展示了三次性能抽样,每次抽样从底向上显示了一个调用栈,折叠栈格式如下: clone;start_thread;func_a;func_b;func_c 1 clone;start_thread;func_a;func_d 2 其实就是将三个栈做了一个汇总,将同样的栈汇总到一起,每个函数用’;‘分隔,最后加空格和打印出现次数。 前面还可以加上程序的名称,例如cpp,如下也是一个合法的folded stacks: cpp;clone;start_thread;func_a;func_b;func_c 1 cpp;clone;start_thread;func_a;func_d 2 有了折叠栈数据,即可利用flamegraph.pl生成对应的火焰图svg文件。该文件可以嵌入到任何网页中展示,简单方便。 进一步,我们可以利用火焰图原理,生成各种场景所需要的“火焰图”,用于性能分析,数据可视化展示等功能。我们以应用层算子调度框架的性能分析为例,展示如何利用火焰图生成算子耗时可视化图。 应用层算子调度框架常见于RPC服务端的应用层,主要思想是将策略逻辑封装成若干个函数式的算子,再通过调度框架,串行或者并行调度这些算子运行,最终产出RPC的返回结果。算子调度的拓扑图有叫Tree,也有叫DAG的,也有叫图的。下图所示就是一个典型的算子调度框架的运行时拓扑图。 针对这样的算子调度,经常需要分析的问题有:哪个算子耗时最高,最长路径在哪里,哪个算子可以优化调度路径等,我们通常通过打印日志和统计最长路径等方法来分析运行时性能情况。但同样,通过火焰图也可以将运行拓扑的耗时可视化起来。 生成火焰图的关键即为生成栈的汇总数据,假设每个算子的耗时情况如下: 运行阶段 解析请求 访问A服务 访问B服务 汇总结果 产生返回数据 开始运行时间 0 10 10 24 32 结束运行时间 10 20 24 32 34 我们想象有一个抽样程序,可以按照上面的运行时图,从左往右分别垂直抽样,可以看出在不同时间段有这样几种“栈”:...

December 5, 2022 · Skyan

程序框架的最佳实践【翻译】

原文作者Chris Nokleberg and Brad Hawkes来自Google,Java框架开发者 原标题:程序框架最佳实践——虽然很强大,但并不适合所有人 共享代码库鼓励代码复用,实现不同团队技术的一致性并改进产品效率和质量。开发者需要选择合适的库,研究如何正确配置,最终把所有的库组装在一起。程序框架可以通过对库的默认安装和配置,来简化开发流程,提供更好的一致性,当然也损失了一些库的便利性。 框架不仅是一堆库的集合,还控制了整个程序的生命周期。确定的框架行为可以为开发开拓空间——例如,不用为每个程序应用都深入审查安全和隐私相关的代码。框架提供了跨团队和跨语言的功能一致性,同时也是更高级的自动化和智能系统的基础。 这篇文章先从框架的核心方面综述开始,然后深入到框架的优势、妥协以及我们推荐实现的最为重要的框架功能。最后,这篇文章展示了一个Google的实际框架应用案例:如何开发一个微服务平台,使得Google可以打破单一代码库的限制,以及框架如何使这一切成为可能。 什么是框架 框架和共享代码库在很多方面都类似。在Google,有两个技术原理用于区分框架和库:依赖反转和可扩展性。虽然看起来比较容易,但本文所讨论的框架很多优势,原则上都是从这两个原理衍生出来的。 IOC(依赖反转) 当从头开始开发一个程序的时候,工程师来决定程序的流程——这通常称为普通控制流。在一个基于框架的程序中,则是由框架来控制流程以及调用用户的代码——这叫做反转控制流。反转控制流有时也被引用为好莱坞原则:“别给我们打电话,我们会给你打电话”。框架控制流很好地定义了不同程序之间的标准。理想情况下,程序只需要实现它们特有的逻辑,而由框架来解决所有其他构建一个微服务所需要的各种细节。 可扩展性 扩展性是区分库和框架的第二个重要特点,它和依赖反转互相配合。因为框架的控制流是由框架负责的,唯一的改变框架运行方式的方法只能通过它暴露出来的扩展点。例如,一个服务端框架可能有一个扩展点是允许一个应用程序在每个请求来的时候运行一些代码。扩展点的模式也意味着框架的其他不可扩展性部分很固定,应用程序无法改变。 框架的好处 框架除了提供共享代码库所能提供的功能之外,还有很多好处,它们对不同的角色在不同方面都有益处。 对开发者 开发者这个角色最终决定是否使用一个可用的框架,也是最显然从框架中获益的角色。大部分的开发者的好处在于提升生产力,简便高效以及可应用最佳实践。开发者可以写很少的代码,利用内置的框架功能。由于框架处理了各种样板代码,因此他们所写的代码可以大幅简化。框架提供了合理的默认配置,消除了无意义而且浪费时间的决策判断,从而提供了充足的最佳实践。 对产品团队 除了为开发者提供生产力以外,框架也为产品团队解放了团队浪费在重复建设基础设施的资源。产品团队因此可以更关注于使他们产品与众不同的功能开发了。 框架可以让产品团队的开发与底层基础设施的变更隔离开,这也能让他们从中受益。虽然并不是普遍适用,但框架提供了额外的抽象,这意味着有些基础设施的迁移可以被当做实现细节,完全由框架维护者全权处理。 Google产品的发布需要很多团队的确认。例如,一个发布协调工程师负责审查产品安全性和有效性,同时有一个信息安全工程师检查程序应用的场景安全漏洞防范设计。当负责审查的团队熟悉框架并可以信任框架的功能保证时,框架就可以帮助简化审查流程。产品发布以后,标准化流程可以让系统更加便于管理。 对公司 从公司层面来说,常用的框架可以减少开发者上手启动一个新应用的时间,从而提升开发者的灵活性。如果一个公司有一个足够大的程序员社群,投入资源在高质量的文档和培训程序员将变得非常有价值。这也有助于吸引社群贡献文档和代码。一个被广泛应用的框架意味着,对框架很小的改进投入可以换来巨大的收益影响。 在过去一段时间,收敛框架架构可以使针对全局性的变更有广泛的应用范围。例如,如果你依赖一个统一的微服务/RPC框架,而且带宽比CPU更贵,那么框架可以基于成本权衡来优化默认的压缩参数。 框架的权衡 虽然框架有上述的多种好处,但也面临着各种权衡。 死板的框架能够掩盖创新 框架常常决定支持哪种技术方案。由于支持所有能想到的技术是不现实的,所以一个比较死板的框架也是有明显的好处的——那就是,它们更鼓励使用某种技术或者更偏好某种设计模式。 死板的框架可以极大简化一个开发者从无到有创建一个系统的工作量。当开发者有很多种方式来完成同一种任务时,他们很容易陷入是否会影响整个系统的细节决策中。对于这些开发者而言,接受一个框架所推荐的技术,可以让他们把重点放在构建他们系统的业务逻辑上。有一个普适并且一致的技术偏好对于整个公司而言更有利,虽然这个答案还不尽完美。 当然,你也必须处理程序和团队的长尾问题,有些产品需求或者团队喜好和现有的框架可能并不完全适配。框架的维护者被放在一个判断什么是最佳实践的位置上,也会需要确认一个不方便的使用案例是否“真实”,因为这个案例可能对每个用户来说都不方便。 另一个需要重点考虑的是,即使某些技术在今天看来的确是一个最佳实践,但技术也在不断快速地进化,对于框架而言也有一定的风险无法跟上技术创新的发展。采用不同的程序设计方案可能开发成本更高,因为开发者既需要学习框架实现细节,也需要框架维护者的协助。 普适性也会导致不必要的抽象 很多框架优势,例如通用的控制面板(后面解释),只有当大部分关键重要的应用都基于这个框架才有真正的意义。这样的一个框架必须足够普适来支持绝大多数用户的场景,这也意味着必须拥有丰富的请求生命周期,以及任何程序都需要的所有的扩展点。这些需求有必要在应用和底层库之间增加若干间接层,这些层也会增加学习成本以及CPU开销。对于应用开发者而言,软件栈中的更多层也会导致调试更复杂。 另一个框架潜在的缺陷是,它们需要工程师额外学习。当新来的Google员工学习如何让一个“hello world”样例运行的时候,他们经常会被他们所需要学习的技术的数量所抓狂。一个功能完备的框架也会让这个情况更糟而不会更好。 Google已经开始尝试让每个框架的核心尽量简单符合预期,让其他功能都成为可选模块,从而来解决这些问题。Google也会尝试提供配套框架的工具,可以帮助了解框架内部结构来简化调试。最终,虽然框架有一些你必须学习的成本,但你需要确保任何一个给定的框架提供了足够的好处来弥补这个成本。不同的编程语言的框架也许有不同的权衡组合,对于开发者而言这也是一个新的决策点和成本/收益权衡场景。 重要的框架特性 如上所述,反转控制和扩展性是框架最为基础的两个特点。除了这些基础能力以外,框架还需要负责若干其他功能。 标准化程序生命周期 再次重申,反转控制意味着框架拥有并且使一个应用程序的整个生命周期标准化,但这样的结构带来什么样的好处呢?我们以避免级联故障为例来说明这个问题。 级联故障是一个典型的引起系统超载的原因,Google内部也很有很多。它可能发生在分布式系统的部分服务失败,增加了其他部分失败的可能。关于级联故障更多的原因,以及如何避免他们,参考SRE(Site Reliability Engineering (O’Reilly Media, 2016))中的定位级联故障这一章。 Google的服务框架有很多内置的避免级联故障的保护功能,两个最重要的原则是: 持续运行。如果一个服务能成功响应请求,那么就应该保持。如果它能处理一部分请求但不能处理其他部分请求,那么就必须继续运行,而且响应它能服务的请求。 快速启动。一个服务必须尽量快地启动。更快地启动意味着可以快速从崩溃中恢复。服务必须避免类似启动时顺序等待RPC访问外部系统返回这样的操作。 Google生产环境给每个服务配置了一个成为健康状态(可以响应请求)的时长。如果超时了,系统则认为一个无法恢复的故障发生了,则终止这个服务进程。 有一个常见的反模式,常发生在缺乏框架的场景中:一个库创建他们管理的RPC连接,并等待连接可用。当服务端代码随着时间推移不断膨胀,你能获得一个实际有几十个这样的有序依赖的库。这样的结果是,处理初始化代码有效展开的话看起来如图1所示: 在通常的情况下,这样的代码工作很好。但因为没有任何潜在问题的提示,这样的代码会有个特殊问题。这个问题只有当一个相关的后端服务慢了或者同时挂了才会显现出来——这时关键服务启动已经延迟了。如果服务启动足够慢,那它将会在有机会处理请求之前被杀死,这会导致一场级联故障。 一种可能的改进是先创建RPC stubs,如图2所示,然后并行等待它们初始化完毕。在这种情况下,你只需等待stub初始化时间最长的那个而不是初始化时间之和。 虽然处理的并不十分完美,但这个有限的重构也说明了你需要某种在库所创建的RPC stubs之间的协调机制——它们必须可以有等待stub以及库以外的某些资源的能力。在Google的案例中,这样的事情是由服务端框架负责的,同时它也有如下功能: 通过定期拉取可用性来并行等待所有stubs可用(<1秒)。一旦配置的超时到了,即使并不是所有后端服务都准备好,服务依然可以继续初始化。 去掉用于人工或者机器可读的调试日志,而是用集成标准化监控和报警来代替。 通过一个通用的机制来支持插拔不同的资源,而不仅是RPC stubs。技术上来说,仅有返回布尔值的函数(为了“我是否准备好”)以及对应的名字,是有打印日志的必要。这些插拔点常被那些处理资源的通用库(例如:文件API)所使用;程序开发者通常只需要用这个库,就能自动地拥有这些能力 提供一个中心化的方式配置特定的关键后端服务,可以改变他们启动或者运行时的行为。 对任何一个单独的库来说,这些功能可能(也正确地)被认为是多了,但实现这些功能,能够让你在一个集中的地方实现,并在所有用这个库的后端服务都能生效。这也是有意义的一件事情。就像共享库是一种在不同应用程序中分享代码的方式,在这个定义下,框架也是一种在不同库之间共享功能的方式。 因为有类似这些的功能,SRE们更愿意支持基于框架的服务。他们也经过鼓励他们对口的程序员选择基于框架的开发方案。框架提供了一个生产规范的基础水平。这个生产规范将一堆没有关联的库关联到一起时是非常难以实现的——但也不是不可能。 标准化的请求生命周期 虽然细节取决于应用程序的类型,但很多框架支持在总的程序生命周期以外的生命周期管理。对于Google的服务端框架而言,最为重要的任务单位就是请求。除了遵循类似依赖反转的模型以外,请求生命周期管理的目标是将请求的不同方面的职责划分成独立的可扩展的代码片段。这允许程序开发者只关注于开发让他们的应用程序独一无二的实际业务逻辑。 这里有一个这样的实际使用的框架例子,它的组件片段见图3: Processors——拦截请求和返回的包。常用于打印日志,但也有一些短路请求的功能(例如,强制在整个程序中不强制执行不变量) Action——程序业务逻辑,接受请求,返回一个对象,可能有一些副作用。 Exception handler——将一个未捕捉的异常转换为一个响应对象。 Response handler——序列化一个响应对象给客户端。 虽然程序能从框架的扩展点中收益,程序代码的绝大部分都是以响应的形式执行,包括程序特有的业务逻辑。...

December 5, 2022 · Skyan

Memory Models读后感

读了Russ Cox关于Memory Models的三篇综述文章: Hardware Memory Models Programming Language Memory Models Updating the Go Memory Model 读完之后终于搞明白Memory Models的大体脉络。 首先在一个单核单线程的程序中,是没有内存模型问题的。随着多核多线程的引入,程序开始实现并行化提升性能,同时编译器、操作系统以及硬件针对并行化做了大量的优化,这就出现了很多新的并发数据访问问题,就需要设计规范的内存模型来解决。 内存模型分为硬件内存模型和编程语言内存模型两个层面。 其中硬件内存模型分为以Intel CPU为代表的x86-TSO模型和以ARM/POWER为代表的Relaxed Memory Model模型。前者模型中,多核对写入主存的数据提供全局序,后者模型中,多核之间不存在全局序的概念。为了协调不同硬件,又有DRF-SC(Data-Race-Free Sequential Consistency)模型,该模型是一种能让大部分硬件都能接受实现的顺序内存访问模型,这样DRF-SC可以作为硬件内存模型的统一标准。 编程语言内存模型的定义分为两个流派: 以Java,Javascript为代表的happen-before流派。特点是通过happen-before语义来定义语言的哪些方面如何实现数据的同步。该流派规范定义最早,也最为经典。Java9以后增加了弱同步,类似C++的弱同步(acquire/release)。 以C/C++,Rust,Swift等为代表的强-弱-无顺序流派。特点是分为三类同步语义,强代表前后顺序一致,和Java的happen before语义一致。弱代表在部分协调条件下的一致。无代表没有同步。 按照作者的介绍,这两个流派的内存模型都存在缺陷。Java的内存模型定义并不严格,还是有非因果、非一致的情况发生。C++的内存模型有很多未定义的情况,尤其是弱同步和无同步语义其实存在很多坑,甚至在不同硬件下运行结果可能还不一致。 Go语言采取中间流派,即只通过happen-before来定义同步语义,只支持顺序一致,不支持弱同步,不支持无同步,严格定义,同时约束编译器的优化,保证最终结果可预测,即使有未定义的情况发生,也能约束结果在有限的范围内。 最终结论是,截止目前2022年,内存模型还处于不断探索和研究的方向,Go的这个尝试也是建立在无数前人理论的基础之上,未来有待更加规范定义的模型出现。

October 9, 2022 · Skyan

并行编程之内存管理(总结)

这篇总结并行编程的三种常见的内存管理方法,三种方法如下: Reference Counting:并行编程之内存管理(一) Hazard Pointer:并行编程之内存管理(二) RCU:并行编程之内存管理(三) 三种方法各有利弊,优缺点对比如下: 引用计数(Reference Counting) Hazard Pointer RCU 读性能 低,不可扩展 高,可扩展 高,可扩展 可保护的对象数量 可扩展 不可扩展 可扩展 保护周期 支持长周期 支持长周期 用户必须限制保护周期长度 遍历是否需要重试 如果和删除冲突需要重试 如果和删除冲突需要重试 不需要 存在性保证 复杂 保证 保证 读竞争 高 无 无 对象遍历开销 CAS原子操作,内存屏障,cache missing 内存屏障 无 读遍历机制 无锁 无锁 有限制的无等待(wait free) 读引用获取 可能失败 可能失败 不会失败 内存开销 有限 有限 无限 内存回收机制 无锁 无锁 阻塞 自动回收 是 部分 部分 已经有很多C++项目,包括开源和大厂内部项目,都开始采用Hazard Pointer和RCU来实现并发数据结构,而且C++标准委员会也已经在讨论将这两个组件加入到C++26标准中。 时代在变,并行编程技术也在突飞猛进。多线程多核(multi-core)技术,甚至甚多核(many-core)技术都在飞速发展,加上各种并行编程范式的应用,可以预见到在一段时期内,并行编程将面临百花齐放,技术爆发的局面。

June 14, 2022 · Skyan

并行编程之内存管理(三)

RCU(Read-Copy-Update) 前述的几种内存管理方式只能同时管理一个内存对象,而RCU是一种同时管理多个对象的并发读写的方法。它实际上是提供了一种受保护的内存区域,通过RCU方法,将对象关联到受保护内存区域后,可以保证读操作完成以后才会回收对象,而且读操作开销极其低(5ns左右),适合多个对象的批量保护,以及读多写少的场景。自从2002年RCU功能引入Linux内核以后,在内核中使用次数突飞猛进,如下图所示: 在内核中RCU API被广泛应用于网络,内存管理,文件系统等内核组件中。除了内核,RCU也有开源的user space RCU库,被广泛应用于QEMU模拟器,DPDK,Go语言的垃圾回收等项目中。 那么RCU到底是什么神秘的方法呢?其实原理正如其名:Read-Copy-Update。当需要修改对象的时候,先拷贝这个对象,修改产生新版本对下,然后变更新版本对象,等待老版本对象的reader完成读操作,再销毁老版本对象。可以明显看出,这个思想和Copy-On-Write思想一脉相承,和MVCC的思想更是极其类似。在生产环境中,按照类似原理实现的支持并行变更的数据结构也屡见不鲜,例如索引领域的hashmap,B+树等数据结构都可以按照类似思想实现并发读写。因此,思想并不新,但如果要正确实现,有三个原则需要保证,有很多细节需要处理,这也是RCU比较神秘之处。 RCU正确实现的三个原则是:宽限期保证(grace-period guarantee),发布-订阅保证(publish-subscribe)和内存顺序保证(memory-order guarantee)。解释如下: 宽限期保证 允许所有的更新操作等待之前存在的读操作完成。之前存在的读操作其实就是还在访问修改前的内存对象的reader,因此如果不等待这些reader就销毁老版本的对象,则会导致并发错误。在一个链表的场景中,先删除一个节点的链接,然后通过宽限期保证等待所有该节点的访问结束之后,就可以安全销毁这个节点。 相关的RCU接口有rcu_read_lock,rcu_read_unlock, synchronize_rcu,其中rcu_read_lock,rcu_read_unlock用于读操作,标记当前线程正在读该内存区域,rcu_read_lock,rcu_read_unlock之间访问的所有内存对象被称为读端临界区(read-side critical section)。synchronize_rcu接口用于执行宽限期保证,等待所有还在读老版本数据的线程完成。 发布-订阅保证 该保证是说所有reader读取一个新版本对象的时候,可以保证该对象已经初始化好了。这里reader就是所谓的订阅者,RCU通过rcu_assign_pointer接口实现发布操作,RCU的这个保证可以保证订阅者看到的一定是发布者发布时刻的对象,不存在乱序等因素的干扰。实现上,发布方可以通过std::memory_order_release操作来更新对象,而读方可以通过std::memory_order_consume来读取对象,这样就可以避免乱序读取的情况发生。 内存顺序保证 该保证是宽限期保证的自然延续。读端临界区之间保护的内存对象和synchronize_rcu调用之间,存在一个严格保证的内存顺序关系。 下面几个例子说明该顺序的含义,其中X和Y的初始值都为0: 类别 图示 描述 情况1 如果按照绿色箭头的顺序执行,RCU可以保证读端临界区之后,即rcu_read_unlock发生之后才发生synchronize_rcu,这样如果r2==1,那么可以保证r1==0 情况2 如果CPU 0在读端临界区间运行时发生synchronize_rcu调用,RCU可以保证synchronize_rcu之前发生的事件一定在读端临界区中生效。在这个例子中,如果r2==1,那RCU一定保证r1==1 情况3 如果两个读端临界区并行执行,RCU是无法保证两者之间的内存顺序的。在这个例子中,CPU0和CPU2的读端临界区中对象的读取顺序是无法保证的,因此如果按照绿色箭头执行,r1==0,r3==0,是无法保证X=1和r2=X之间不乱序的 情况4 如果RCU的读端临界区和宽限期保证synchronize_rcu调用重叠,如果按照绿色箭头执行顺序,RCU可以保证读端临界区先于synchronize_rcu执行之前,即r1==1,r2==0 那么,如何实现RCU的呢?实际上,一个最简单的实现方式只需要10行代码,如下所示: static void rcu_read_lock(void) { spin_lock(&rcu_gp_lock); } static void rcu_read_unlock(void) { spin_unlock(&rcu_gp_lock); } void synchronize_rcu(void) { spin_lock(&rcu_gp_lock); spin_unlock(&rcu_gp_lock); } 可以看出rcu_read_lock和rcu_read_unlock通过一把锁来保证和synchronize_rcu之间严格的顺序关系,同时synchronize_rcu必须等待rcu_read_unlock执行完成之后才能执行。当然,这种naive的实现性能最低,而且不支持并发读,在生产环境中完全不可用。 有几种可以考虑的改进思路: 通过引用计数的方式来实现并发读; 通过TLS的方式减少读之间的竞争; 通过多版本的方式来减少锁竞争。 这样一个优化的实现如下所示: DEFINE_SPINLOCK(rcu_gp_lock); // 全局spin lock,用于保证并发只有一个写线程 DEFINE_PER_THREAD(int [2], rcu_refcnt); // 每个线程缓存一个引用计数 long rcu_idx; // 全局多版本号 DEFINE_PER_THREAD(int, rcu_nesting); // 每个线程缓存一个内嵌计数,用于支持读端临界区的重复进入 DEFINE_PER_THREAD(int, rcu_read_idx); // 每个线程缓存一个版本号 // 进入读端临界区 static void rcu_read_lock(void) { int i; int n; n = __get_thread_var(rcu_nesting); if (n == 0) { // 第一次进入的时候更新引用计数 i = atomic_read(&rcu_idx); // 获取可用的全局版本号 __get_thread_var(rcu_read_idx) = i; // 在对应版本计数 __get_thread_var(rcu_refcnt)[i]++; } __get_thread_var(rcu_nesting) = n + 1; // TLS内嵌计数++ smp_mb(); } // 退出读端临界区 static void rcu_read_unlock(void) { int i; int n; smp_mb(); n = __get_thread_var(rcu_nesting); if (n == 1) {// 最后一次退出的时候更新引用计数 i = __get_thread_var(rcu_read_idx); // 获取可用的全局版本号 __get_thread_var(rcu_refcnt)[i]--; // 在对应版本更新计数 } __get_thread_var(rcu_nesting) = n - 1; // TLS内嵌计数-- } // 反转全局版本号,保证所有读线程完成 static void flip_counter_and_wait(int i) { int t; atomic_set(&rcu_idx, !...

June 8, 2022 · Skyan

并行编程之内存管理(二)

Hazard Pointer Hazard Pointer的思想和引用计数恰好相反。不在数据内部存储一份引用计数,而是从外部来管理对象。流程如下:首先为每个需要管理的对象标记一个指针,放到一个全局或者per-thread的list中。在这个list中的标记指针就叫做hazard pointer。需要销毁对象的时候将hazard pointer放到一个待回收list,根据一些策略来决定是否正式销毁对象。销毁待回收的对象的方式还有主动和异步的接口,主动的方式是直接调用接口回收销毁。异步是通过异步线程定期销毁。销毁回收对象的时候,首先查看下hazard pointer list中有没有指向这个对象指针,有就不回收,没有就可以安全回收。其实本质上通过统计指向这个对象的hazard pointer数量来作为“引用计数”。将复杂性放到回收阶段,这也是为什么hazard pointer适合读多写少的场景的原因。 实现Hazard Pointer的主要复杂度在于管理正确处理被保护的hazard pointer和待回收的hazard pointer之间的关系。对于C++来说,可以通过构造/析构函数(RAII)来自动实现加入/退出hazard pointer的保护。整个流程可以见如下示意图所示: 从上图可以看出,销毁hazard pointer所管理的对象是通过延迟销毁来解决。通过对比被销毁的hazard pointer地址和当前正在被保护的hazard pointer地址,从而避免销毁掉正在使用中的指针对象,从而实现保护的目的。 作为样例,我们首先实现一个全局的hazard pointer基类,所有需要拥有hazard pointer保护功能的类都需要继承这个基类。 // 该类实现基本的hazard pointer回收方法 class HazPtrObj { using ReclaimFnPtr = void (*)(HazPtrObj*); template <typename T> friend class HazPtrObjBase; friend class HazPtrDomain; // 对象回收函数 ReclaimFnPtr reclaim_; // 下一个待回收对象。本质上是通过这个指针,将所有待回收对象连成一个链表 HazPtrObj* next_; // 将自身加入到待回收对象list中,依赖全局对象domain,domain统一管理所有已保护和待回收的hazard pointer list void push_obj(HazPtrDomain& domain) { hazptr_domain_push_retired(this, this, domain); } // 对象回收函数 ReclaimFnPtr reclaim() noexcept { return reclaim_; } // 原始指针 const void* raw_ptr() const { return this; } public: HazPtrObj() noexcept : next_(this) {} HazPtrObj(const HazPtrObj& o) noexcept : next_(this){} HazPtrObj(HazPtrObj&& o) noexcept : next_(this) {} /** Copy operator */ HazPtrObj& operator=(const HazPtrObj&) noexcept { return *this; } /** Move operator */ HazPtrObj& operator=(HazPtrObj&&) noexcept { return *this; } HazPtrObj* next() const noexcept { return next_; } void set_next(HazPtrObj* obj) noexcept { next_ = obj; } }; // hazard pointer基类,所有需要保护的类都继承该类 template <typename T> class HazPtrObjBase: public HazPtrObj { // 默认的对象回收函数 void delete_obj(T* p) { delete p; } public: // 将this放到待回收list中,准备被回收 void retire(HazPtrDomain& domain = default_hazptr_domain()) { assert(next_ == this); this->reclaim_ = [](HazPtrObj* p) { auto hobp = static_cast<HazPtrObjBase*>(p); auto obj = static_cast<T*>(hobp); hobp->delete_obj(obj); }; this->push_obj(domain); } }; 实现代码也比较直观,通过继承HazPtrObjBase类,任意一个对象都可以拥有一个retire方法,这个方法可以认为和delete等同用于销毁对象,但并不是立即回收,而是通过hazard pointer的延迟回收。 接下来需要实现一个保护HazPtrObjBase的对象,如下样例代码所示:...

June 8, 2022 · Skyan

并行编程之内存管理(一)

前言 随着现代处理器的发展和多核多CPU体系结构的大面积应用,C和C++编程面临着更加复杂和陡峭的学习曲线。特别是基于多线程带来的并行编程,带来了很多内存并行访问的问题。这需要非常专业的知识,深入了解CPU指令集,内存访问,CPU Cache等体系结构的底层知识,才能正确写好高性能和安全的并行程序。最近十多年,学术和工业界在并行编程方面进行了非常多创新的探索和研究,总结出一套优秀的编程实践和并行内存管理组件,并在Linux内核和大型开源软件中广泛应用。这里选取和内存对象管理有关的三个编程组件进行介绍,分别是引用计数Reference Counting,Hazard Pointer和RCU,都属于延迟处理类型的组件。本文目的一是为了个人学习的总结,另外也是给更多感兴趣的同学以启发。 引用计数 引用计数的思想很简单,通过原子变量来追踪对象的引用数量,来防止错误地销毁对象。这种思想最早可以追溯到20世纪40年代:当时工人们如果要修理危险的大型机械,他们会在进入机器之前,在机器开关上面挂一把锁,防止他在里面的时候被其他人误开机器。这也说明了引用计数的作用:通过计数来管理对象的生命周期。 以shared pointer为例,参考gcc shared_ptr实现,做了一些简化,样例代码如下: // 计数管理类 template <typename T> class RefCount { public: RefCount(T *p = nullptr) : ptr_(p), cnt_(1) {} ~RefCount() { // 如果计数为0,销毁自己 if (Release()) { delete this; } } RefCount(const RefCount<T> &rc) = delete; void operator=(const RefCount<T> &rc) = delete; // 原子++ void AddRef() { ++cnt_; } // 原子--,如果计数为0,销毁保存的对象 bool Release() { if (cnt_.fetch_sub(1, std::memory_order_acq_rel) == 1) { delete ptr_; return true; } return false; } // 返回管理的指针 T *Get() const noexcept { return ptr_; } private: T *ptr_; // 管理的对象指针 std::atomic_int32_t cnt_; // 原子计数 }; // 包装为智能指针 template <typename T> class SharedPtr { public: SharedPtr() noexcept : rc_(nullptr) {} SharedPtr(T *p) : rc_(nullptr) { rc_ = new RefCount(p); } // 创建引用计数对象 SharedPtr(const SharedPtr<T> &sp) : rc_(sp....

June 6, 2022 · Skyan