多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Geant4多线程加速实战:从单线程迁移到MT的完整指南

Geant4多线程加速实战:从单线程迁移到MT的完整指南 我头一回认真跑Geant4的模拟是在一个需要统计能量沉积的简化量热器模型上。10万个入射粒子单线程跑了一整夜第二天早上起来发现还没结束当时整个人都麻了。后来我才意识到问题不是模型写错了也不是物理列表选得不对而是我根本没用上Geant4的multi-thread多线程能力。把线程开起来之后同样的10万事件从几个小时压缩到了几十分钟速度和效率完全是两码事。这篇笔记就是我自己从单线程迁移到多线程的完整记录包括线程模型的理解、main函数的改造、线程安全注意点、随机种子管理以及我在迁移过程中踩进去又爬出来的几个坑。如果你已经有一个能跑通的单线程Geant4程序正想让它跑得更快这篇应该能帮你少走不少弯路。1. 为什么先做multi-thread单线程Geant4让我等了一整夜1.1 事件独立性是并行加速的前提Geant4模拟的本质是把一个又一个primary event送入探测器几何让粒子在材料中发生输运、产生次级粒子最终记录下你关心的物理量。这些event之间是什么关系在绝大多数应用场景下互不影响、彼此独立。第1000个事件里发生了什么第1001个事件完全不知道也不关心。这就给并行计算提供了天然的切分粒度既然事件本身就相互独立那让多个CPU核同时处理不同的事件理论上就是安全的不需要像流体力学那样纠结于网格点之间的耦合。单线程程序的问题在于不管你的机器是4核还是16核事件都是一个一个排队处理的。操作系统确实会调度多个进程一起跑但如果你的主程序里只有一个RunManager那实际干活的计算资源就只有单核。我之前就是这种状态CPU其余15个核心闲着我的模拟在那一个核上吭哧吭哧跑一整夜。多线程要解决的核心问题简而言之就是一句话让每个CPU核都有活干同时保证每个事件还是独立地被处理。1.2 为什么官方推荐线程并行而不是纯粹的多进程在考虑加速的时候很多人第一反应是那我把程序起多个实例每个实例跑不同的事件段不就行了这个想法本身没问题Geant4也确实支持类似的思想比如MPI并行。但官方从10.0版本开始力推的是multi-threadMT也就是线程级并行理由很实际线程之间共享同一份内存地址空间。几何结构、材料定义、物理过程这些重资产在单线程和MT模式下都可以只在进程内保存一份所有线程共享读取。如果改用多进程每个进程都要维护自己完整的一份几何和物理列表内存开销直接翻倍甚至更多。线程的创建和切换开销比进程小通信也更轻量。粒子输运这种访存密集型任务用线程天然更有优势。Geant4在MT模式下并不是简单地把事件丢给多个线程随便跑而是有一套完整的初始化、任务分配、结果合并机制。这套机制封装在G4MTRunManager里使用者大部分情况下不需要自己管理线程同步。当然MT也不是银弹。如果你跑的是超大几何、超多物理过程的模拟单台机器的核数总有用尽的一天那种规模通常要考虑MT和MPI混合的方案。不过那是进阶话题对绝大多数实验室工作站、个人电脑来说先把手头的单线程程序改成MT成本最低、收益最立竿见影。2. 线程模型拆解master负责初始化worker负责跑事件2.1 一个主线程加多个工作线程的架构Geant4多线程的核心架构是这样的启动时先有一个master线程主线程它的职责是完成整个模拟的准备动作包括构建探测器几何、初始化物理列表、加载粒子表等等。等这些只读资源准备完毕master线程会创建若干个worker线程工作线程每个worker线程各自持有事件循环需要的运行状态然后开始并行处理事件。有一个需要特别指出的细节master线程本身不参与事件输运。它更像是一个包工头负责把任务分给工人最后再把工人交上来的结果汇总起来。你可以理解为master管的是初始化阶段和收尾阶段worker管的是真正出数值的事件模拟阶段。明白了这一点你就知道为什么在MT模式下某些代码你不能想当然地写在run开始之后了。2.2 Run、Event、Track在线程间的分布逻辑Geant4有非常清晰的层级概念Run包含若干Event每个Event包含若干Track和Step。在MT模式下这个层级被分配给了不同的线程每个worker线程各自拥有一个属于自己的G4Run对象记录这个线程处理的所有事件统计。事件被拆成一个个小批次分配给worker线程。每个worker在处理完自己的批次后继续领取下一批。当所有事件都处理完master线程会收集每个worker的G4Run通过调用G4Run::Merge方法把各个线程的结果合并成一个完整的总run。这个每线程一个Run、最后合并的设计非常关键它解决了多线程下统计量汇总的问题。如果你自己定义了G4Run的子类并且想在run结束时输出总统计量就必须理解merge的流程你拿到的最终统计结果不是某个worker独自算出来的而是所有worker的结果叠加得到的。2.3 哪些内存是共享的哪些是每个线程一份的MT模式下的内存模型可以这样概括只读的共享可写的私有。几何结构G4VPhysicalVolume构成的层次树、材料表、物理列表这些在初始化完成后就不会变化的对象是进程内共享的所有worker线程共同读取这也是MT内存效率高的根本原因。而每个worker线程会独立持有的是粒子导航器G4Navigator用于计算粒子在几何中的位置和移动当前正在处理的Track和Step堆栈随机数引擎实例用户动作类的实例敏感探测器和Hit集合。所以如果你的程序里有一个全局静态变量某个Step在更新它另一个Step也在更新它——在MT模式下这个静态变量会被多个线程同时读写轻则统计错误重则直接崩溃。这个我在后面的踩坑部分会详细说。3. 最小改造只换RunManager也能跑通多线程3.1 main函数里的最小改动如果你已经有一个完整可运行的单线程Geant4代码那么实现多线程的第一步最小改动其实就是把main函数里的G4RunManager换成G4MTRunManager。下面是改造前后的对比// 单线程版本 #include G4RunManager.hh int main(int argc, char** argv) { G4RunManager* runManager new G4RunManager(); // 设置几何、物理列表、用户动作... runManager-Initialize(); runManager-BeamOn(100000); delete runManager; return 0; }// 多线程版本 #include G4MTRunManager.hh int main(int argc, char** argv) { G4MTRunManager* runManager new G4MTRunManager(); runManager-SetNumberOfThreads(8); // 设置几何、物理列表、用户动作... runManager-Initialize(); runManager-BeamOn(100000); delete runManager; return 0; }核心变化只有两处引入G4MTRunManager头文件把RunManager对象换成G4MTRunManager再调用SetNumberOfThreads设置线程数。其他代码包括SetUserInitialization、SetUserAction的调用方式不需要改。第一次跑通的时候我还有点不敢相信居然就这么简单后来我理解到这正是Geant4官方设计上的用心之处它把多线程的复杂度隐藏在G4MTRunManager内部让使用者在绝大部分情况下只需要替换一个类名。当然跑通只是第一步你的用户动作类、输出代码、随机数管理如果处理不当后面会有各种坑等你去踩。3.2 用户动作类会被自动复制到每个线程为什么main代码几乎不用改因为G4MTRunManager在初始化过程中会检测到你在master线程里设置了G4UserRunAction、G4UserEventAction、G4UserTrackingAction、G4UserSteppingAction等用户动作类然后自动为每个worker线程创建一个克隆实例。换句话说你的RunAction不需要你自己去为每个线程new一份框架帮你做了。但这同时带来一个硬性要求用户动作类必须是可拷贝构造的。如果类里持有文件指针、TFile指针、互斥锁这样不能随意复制的资源就可能出问题。正确做法是把这类资源放在按需创建的管理器里或者用Geant4提供的线程本地存储工具来管理。3.3 线程数怎么设代码、环境变量、命令行设置线程数有三种方式我平时的使用习惯是这样的方式写法优先级代码内设置runManager-SetNumberOfThreads(8);正常生效环境变量设置环境变量G4FORCENUMBEROFTHREADS8会覆盖代码内的设置命令行/宏在mac脚本或交互模式里使用/run/numberOfThreads 8适合动态调整需要注意一个容易误判的点如果环境变量G4FORCENUMBEROFTHREADS已经存在那么代码里SetNumberOfThreads的设置会被覆盖。所以当你调试时明明在代码里写了32跑起来却发现线程数不对先看一眼环境变量是不是还残留着旧值。这是我调过的一个很隐蔽的问题。还有一个经验如果你不在代码里显式设置线程数G4MTRunManager会调用硬件并发数作为默认值。这在某些机器上可能不等于物理核数比如开了超线程的机器硬件并发数往往是物理核数的两倍。直接用默认值不一定是最优的后面第6节我会给出一组实测数据来说明线程数怎么选更合理。4. 线程安全改造清单哪些对象能共享哪些必须线程私有4.1 几何、材料、物理列表共享但要保持只读在MT模式下探测器几何、材料定义、物理过程这些对象是所有worker线程共享的。这个共享隐含了一个严格约束模拟运行期间这些对象不能被修改。这条约束在单线程下其实也同样成立只不过单线程时你不一定感觉得到。在多线程下如果一个线程正在某个体积内进行粒子导航另一个线程突然修改了这个体积的尺寸或材料属性结果就是未定义行为可能是段错误可能是奇奇怪怪的物理结果。所以我在早期写代码时就培养一个习惯把初始化阶段要用到的几何、物理列表相关对象都加上const限定或者至少在逻辑上当作只读。这个习惯能帮你提前发现很多线程安全隐患。4.2 用户动作类每个线程各有一份用户动作类在MT下的行为模式是每个线程一份实例。也就是说你的G4UserRunAction在8线程下会有8份拷贝每个worker线程处理事件时使用的是属于它自己的那份。这个特性既有好处也有陷阱需要分两面看好的一面是如果你的RunAction里只是统计一些与具体事件无关的信息比如累加总沉积能量那么因为每个线程各有一份独立的累加变量天然就不会发生数据竞争。每个线程算自己的最后G4Run::Merge汇总时再整合。陷阱在于如果你没有实现Merge而只是在某个worker线程的EndOfRunAction里直接打印统计量那你看到的只是这个线程处理的那部分事件的结果而不是全部事件的结果。正确做法是在RunAction的EndOfRunAction里判断当前是否为主线程只有主线程那一次打印才是合并后的完整统计void MyRunAction::EndOfRunAction(const G4Run* aRun) { if (G4Threading::IsMasterThread()) { // 这里拿到的是所有worker线程合并后的run const G4Run* totalRun G4RunManager::GetRunManager()-GetCurrentRun(); // 输出完整统计量 } }4.3 G4Cache 与G4ThreadLocal线程本地存储的正确姿势有些时候你的用户动作类需要跨回调函数共享一些临时数据而这些数据又不属于最终要合并的物理统计量只是中间计算状态。比如你在SteppingAction里临时缓存上一次step的位置信息用来计算两次step之间的夹角。这种缓存数据如果作为类成员变量在MT模式下会因为每个线程各有一份实例而天然隔离这其实没问题。真正危险的是把这种临时数据放在静态成员或者全局变量里因为静态成员对整整个进程只有一份所有线程都会去读写它。如果确实需要一个进程内共享、但每个线程各有一份的存储Geant4提供了两个工具G4CacheT模板类内部按线程索引分配存储底层实现像是一个线程索引到值的映射。适合存放那些希望与当前线程绑定的对象或数值。G4ThreadLocalGeant4封装的thread_local关键字声明形如G4ThreadLocal int myCounter;的变量每个线程都会有自己的副本。我自己遇到的实际场景是写一个全局状态下发号施令的计数器用来给每个事件生成独立的编号。单线程下直接一个static int就完事了MT下必须改成G4ThreadLocal或者放到EventAction里作为成员否则多线程同时递增同一个变量轻则编号混乱重则数值错误。4.4 敏感探测器与Hit收集每线程独立但需要Merge如果你用Geant4做探测器响应模拟一定会用到敏感探测器Sensitive Detector和Hit类。MT模式下敏感探测器对象在每个worker线程各有一份HitCollection也是各线程独立的。这意味着每个线程只收集自己处理的那部分事件产生的Hit。等到run结束你想得到整个探测器上的Hit分布就必须把各线程的Hit集合合并起来。合并动作发生在G4Run::Merge里面。如果你自定义了Run类并且在其中保存了Hit数据记得重写Merge方法把另一个run里对应HitCollection的内容累加到自己身上。举个例子如果你统计每个像素的计数Merge里做的就是遍历另一个线程的像素计数数组逐个累加到当前数组上。这一步漏掉最后只能拿到一个线程的数据结果当然不对。5. 随机种子怎么分配多线程下的可复现性问题5.1 每个线程拥有独立的随机数引擎Geant4的随机数系统基于CLHEP库默认使用HepJamesRandom引擎。在单线程下整个模拟过程共享一个随机数引擎均匀地按顺序产生随机数。到了MT模式下情况变了如果所有worker线程共用一个随机引擎那必然会发生随机数竞争结果完全不可预测。所以Geant4的设计是每个worker线程拥有自己独立的随机数引擎实例并且它们的随机数序列在统计上互不重叠。master线程在初始化时通过某种种子分配算法给每个worker线程生成独立的种子称为split机制。你可以把这种机制理解成一个大河流分出多条支流每一条支流有自己独立的水量互不干扰但它们的水质统计分布特性是一致的。5.2 线程数改变后逐事件数值还复现吗这里有一个很多初学者会问的问题既然每个线程有了独立种子那我固定种子跑两次得到的结果应该完全一样吗答案是如果你连线程数、运行环境都不变是的两次结果应完全一致但如果你把线程数从4改成8即使种子相同各个线程处理的随机数序列分配方式也变了对应的具体事件结果就不会严格一致。再往深处说Geant4的多线程运行模式下事件到线程的映射是在运行过程中动态分配的小批次。不同线程数下一个特定的事件会落到哪个线程、使用哪条随机数子流是没有保证的。因此如果你希望在论文或者报告里说明结果可复现最稳妥的说法是固定Geant4版本、固定随机种子、固定线程数重新运行可以得到一致的最终统计结果。5.3 我平时固定种子的做法在Geant4里控制随机数的方式有几种比较常用在主程序初始化后通过G4Random类设置G4long seed 12345; G4Random::setTheSeed(seed);在宏文件里使用命令/random/setSeeds 12345 67890其中第一个数字是主种子第二个是辅助种子在MT模式下会被用于派生子种子的基础。我自己的习惯是每次正式模拟前在宏文件里固定一组种子并且把种子、线程数、Geant4版本号、编译器信息都记录到输出数据文件的metadata里。这样万一后来发现结果的某个数值可疑还能回到完全一样的条件下复现。这里建议所有做模拟的人都养成这个习惯不要小看这一步很多线上争论到最后比拼的就是你那个数到底能不能复现。6. 实测加速比与线程数选择我的性能和内存记录6.1 测试环境与模型说明为了给大家一个直观的参考我在自己的一台12核24线程工作台上做了一组测试。说明一下这是基于常见实践的补充数据不代表所有场景都长这样但趋势应该是有代表性的。测试环境CPU为12核24线程主频3.5GHz内存64GB。测试模型是一个简化的电磁量热器入射粒子为10万个质子物理列表选了FTFP_BERT统计量是探测器中的总能量沉积。几何规模属于中等不算特别大。6.2 从1线程到24线程的耗时变化跑完的实际记录如下线程数墙钟时间秒加速比备注131201.00单线程基线48503.67接近线性84606.78仍然不错123409.18基本用满物理核163309.45开始进入收益递减244207.43超线程反而拖慢了这个结果很有代表性。从1线程到12线程加速比基本在物理核数范围内保持了不错的扩展性但一旦用到超线程超过物理核数12加速比不再上升甚至在24线程时反而下降。6.3 为什么超线程会拖慢速度很多人不理解为什么线程越多反而越慢。道理其实不复杂粒子输运本质上是高度访存密集型的计算每一步都要访问几何结构、材料数据、物理过程表内存带宽很快就成为瓶颈。超线程的核心思想是让一个物理核在等待内存时切换到另一个线程的指令流但如果两个逻辑线程都拼命访问内存物理核的算术逻辑单元和内存控制器反而被争抢性能不升反降。还有一个因素线程数过多会增加线程调度、缓存争用和伪共享的开销。Geant4每个线程都有独立的导航器和一些运行时的临时对象它们分布在不同核心的缓存里当线程调度把某个线程从一个核搬到另一个核时缓存失效带来的损失也要算进去。6.4 怎么选线程数几条经验法则根据我自己的测试和一些实际使用经验线程数的选择可以这样参考首选物理核心数而不是逻辑线程数。我的测试机器是12核24线程最优区间基本就在12附近。如果你的机器还要跑其他任务预留1到2个核给操作系统和其他应用不要让Geant4把所有核都占满否则整机响应会卡顿总吞吐也不一定最大化。如果模型几何非常大、内存占用高线程数还要考虑内存容量每个线程额外占用的内存从几十MB到数百MB不等设得太高可能导致内存不足。如果是短时间调试比如只跑几百个事件验证逻辑可以降低线程数因为线程初始化本身的成本相对固定事件太少时分摊到每个事件上的初始化开销会变大。7. 多线程迁移踩坑记录四个典型问题与排查链路7.1 坑一EndOfRunAction里只打印出了最后一个线程的结果现象我在RunAction的EndOfRunAction里写了一行打印总沉积能量的代码跑完8线程后输出却只显示了一部分事件的统计值再仔细一看每次打印出来的数值都不一样看起来像某个线程单独的结果。排查过程先切回单线程打印正常。然后我意识到EndOfRunAction在MT模式下每个worker线程结束时会各调用一次再在master线程结束时会再调用一次。我在master那一次调用里没有去取合并后的Run打印的只是最后一次返回的那个Run对象而它实际上只包含部分数据。解决在EndOfRunAction里先判断G4Threading::IsMasterThread()再通过G4RunManager::GetRunManager()-GetCurrentRun()获取合并后的run。这个判断习惯建议大家写进自己的通用模板里后面写任何RunAction都用得上。7.2 坑二多个线程同时写同一个ROOT文件文件直接损坏现象我把原来的输出逻辑从单线程带到了MT下直接在UserRunAction里创建了一个TFile和TTree每个事件往里面填充分支。结果打开输出文件时提示文件损坏或者程序在运行过程中直接段错误。原因分析多个worker线程同时写入同一个TFileROOT内部的文件指针、缓冲区和压缩状态根本不做线程安全保护多个线程同时写同一个文件必然导致缓冲区互相覆盖文件自然就坏了。解决我后来放弃了手动写TFile改用Geant4内置的G4AnalysisManager。它在MT模式下会自动为每个线程创建独立的输出分支其实是线程本地文件run结束时再统一合并成一个文件整个过程对用户来说是透明的。如果你确实需要自己控制输出格式至少要做到每个线程写自己单独的文件最后再在外面合并绝不能让多个线程同时写同一个文件。7.3 坑三全局静态缓存变量导致随机性崩溃现象程序大多数时候能跑但偶尔会在某个step里崩溃报错位置在导航相关的代码里而且每次崩溃的位置不一样。单线程下同样的模型跑一整天也没事。排查我先把所有用户动作类里的成员变量过了一遍因为每线程各一份实例成员变量一般安全。最后发现问题出在一个static变量上——我在SteppingAction里为了提高效率缓存了上一次step所在体积的指针把它放在了一个static局部变量里。MT模式下多个线程同时读写这个static缓存一个线程刚写入的指针很快被另一个线程覆盖再被一个线程读出来用指向的地址已经不安全了。解决把static缓存改成G4ThreadLocal修饰的变量或者在类的成员变量里保存这个缓存这样每个线程实例各自持有。这是我踩过的最隐藏的一类坑也提醒我在用MT时凡是全进程共享的变量都要再三考虑。7.4 坑四开着可视化窗口跑多线程界面卡死现象我想在Qt交互界面里开着OpenGL可视化同时又想用多线程跑大量事件结果界面卡得几乎无法操作事件也跑得慢吞吞。原因可视化相关的绝大多数操作发生在master线程。在高事件量、高线程数下master线程既要负责给worker分配任务又要处理OpenGL渲染和界面响应负载一高界面自然就卡住了。解决我在实际调试时分开做两件事需要可视化预览几何和物理过程时用单线程、少量事件跑正式批量模拟时关掉可视化用纯批处理模式跑高线程数。如果一定要在交互式界面下用MT尽量降低线程数并避免在事件循环中频繁刷新视图。7.5 关于UI命令与多线程的一个提醒如果你习惯用宏文件驱动运行需要注意一点发给master线程的UI命令在执行时并不是直接操作worker线程内部的临时对象。大多数情况下Geant4会在master线程里统一处理命令再把结果广播给各worker。真正需要警惕的是那些直接修改几何、材料或物理过程的命令比如某些偷懒式的运行时几何修改命令在MT下是不允许的。如果宏文件在单线程下能用、切到MT就报错或者行为异常优先检查是不是踩到了运行期修改共享资源这条线。文末再分享一个实际心得如果你是从零开始写一个新的Geant4模拟项目我的建议是直接就上G4MTRunManager别把单线程当默认配置。虽然刚接触时多线程概念确实多一点但早期就把线程安全的习惯建立起来后面省掉的排查时间远比你一开始多花的那点学习时间值。如果你已经有现成的单线程代码也不要一上来就大改用户动作类先只替换RunManager跑通再用小事件量验证结果最后逐步处理输出、随机种子、用户自定义Run的Merge。一步一步来多线程其实没那么可怕。下一篇学习笔记我打算整理一下G4AnalysisManager在MT模式下的文件合并细节那也是我折腾了挺久的一块内容。
返回列表