目录
- 1. 先看最明显的变化:`gprMax.py` 不再负责一切
- 2. 从“运行函数”到“运行上下文”
- 3. MPI 不再只有一种含义
- 4. 配置不再只是一个不断变化的 `args`
- 4.1 `SimulationConfig`:整次仿真的配置
- 4.2 `ModelConfig`:单个模型的配置
- 5. `Scene` 的出现改变了模型表达方式
- 6. 用户对象和内部对象被明确分成两个层次
- 7. `run_model()` 被拆成了模型生命周期
- 7.1 `Scene`:用户想模拟什么
- 7.2 `Model`:计算机真正需要计算什么
- 7.3 `Solver`:使用什么方式推进计算
- 8. 求解后端从条件逻辑变成策略对象
- 9. Python API 从包装器变成了建模接口
- 10. 日志系统不只是替换了 `print()`
- 11. 用一张图看懂旧版架构
- 12. 当前 `devel` 架构更像什么
- `SimulationConfig`
- `Context`
- `ModelConfig`
- `Scene`
- `UserObject`
- `Model`
- `Solver`
- 13. 这是否属于架构级重构
- 13.1 入口文件显著瘦身
- 13.2 过程函数被生命周期对象替代
- 13.3 模型表达和数值计算被分层
- 13.4 输入文件不再是唯一模型表示
- 13.5 并行机制被拆成不同层级
- 13.6 求解后端通过统一接口替换
- 13.7 配置按照生命周期拆分
- 14. 但它不是一次完全推倒重写
- 15. 这次重构解决了什么问题
- 15.1 新运行模式更容易加入
- 15.2 新后端更容易接入
- 15.3 Python API 更适合工作流集成
- 15.4 多模型状态更容易管理
- 15.5 并行语义更加清晰
- 15.6 用户建模和内部计算可以独立演进
- 15.7 测试边界更加明确
- 15.8 图形化和自动化工具更容易建立
- 16. 这次重构体现了哪些设计模式
- 16.1 模板方法
- 16.2 策略模式
- 16.3 工厂方法
- 16.4 分层架构
- 16.5 中间表示
- 16.6 前端与后端分离
- 17. 如何正确描述旧版与当前版本的关系
- 18. 总结:gprMax 正在从“程序”变成“框架”
gprMax 当前
devel分支真正发生变化的是:系统开始用一种不同的方式描述仿真任务。旧版 gprMax 更像一个以入口函数为中心的仿真驱动程序。命令行参数、GPU 检测、模型循环、MPI 调度、基准测试和单模型执行,都由少数几个过程函数串联起来。
当前devel分支则逐步形成了另一种结构:
SimulationConfig ↓ Context ↓ Scene ↓ Model ↓ Solver配置、运行环境、用户场景、离散模型和数值后端开始拥有各自独立的职责。
这意味着,gprMax 正在从一个“由主函数驱动的仿真程序”,迁移为一个“由多个具有明确边界的对象协作完成计算的科学计算框架”。
本文将沿着这条变化主线,拆解旧版和当前devel分支之间最重要的架构差异。
1. 先看最明显的变化:gprMax.py不再负责一切
理解一次架构重构,最直接的方法通常不是先看类图,而是先看程序入口。
因为入口文件反映了一个系统如何理解自己的职责。
在旧版master分支中,gprMax.py承担了大量工作,包括:
- 命令行参数解析
- Python API 封装
- 主机和 GPU 检测
- 运行模式判断
- 标准串行调度
- 基准测试
- MPI Spawn 任务农场
- MPI No Spawn 任务农场
- Taguchi 优化任务分发
- 多模型循环控制
其主要调用关系可以概括为:
main() / api() ↓ run_main() ↓ run_std_sim() run_benchmark_sim() run_mpi_sim() run_mpi_no_spawn_sim() ↓ run_model()这是一种很典型的过程式组织方式。
入口函数不仅负责接收用户参数,还负责回答大量执行层问题:
- 当前应该运行几个模型?
- 从哪个模型编号开始?
- 使用 CPU 还是 GPU?
- 哪个 GPU 分配给哪个任务?
- 是否启用 MPI?
- MPI 是动态派生进程还是使用已有进程?
- 模型应该以什么顺序执行?
- 每个模型何时开始,何时结束?
换句话说,旧版gprMax.py不只是入口。
它同时还是配置中心、调度中心和执行控制中心。
而在当前devel分支中,gprMax.py已经明显收缩。
它的核心职责基本可以归纳为三步:
接收 CLI 或 API 参数 ↓ 创建 SimulationConfig ↓ 选择 Context 并执行 context.run()核心逻辑接近:
config.sim_config=config.SimulationConfig(args)ifconfig.sim_config.args.taskfarm:context=TaskfarmContext()elifconfig.sim_config.args.mpiisnotNone:context=MPIContext()else:context=Context()results=context.run()入口文件不再亲自管理模型循环,也不再承担不同并行模式的完整执行逻辑。
标准运行、MPI 空间分解和任务农场被移动到contexts.py等专门模块中。
因此,最直观的变化可以写成:
旧版: gprMax.py = 入口 + 配置 + 调度 + MPI + 基准测试 + 模型循环 devel: gprMax.py = 入口 + Context 选择入口文件变短本身并不能证明发生了架构重构。
真正重要的是,原本集中在入口中的职责,被重新分配给了新的架构对象。
2. 从“运行函数”到“运行上下文”
旧版通过不同函数表示不同运行模式:
run_std_sim()run_mpi_sim()run_mpi_no_spawn_sim()run_benchmark_sim()run_main()根据命令行参数,通过if/elif分支选择其中一个函数。
这种模式的核心思想是:
每一种运行方式,对应一套独立执行过程。
标准仿真有自己的流程,MPI 有自己的流程,基准测试也有自己的流程。
问题在于,不同流程之间实际上存在大量共同生命周期:
- 开始仿真
- 确定模型范围
- 创建模型配置
- 获取模型场景
- 构建模型
- 选择求解器
- 执行计算
- 保存结果
- 结束仿真
如果这些步骤分别写在多个函数中,公共逻辑很容易重复,差异逻辑则散落在不同条件分支里。
当前版本使用显式的运行上下文来重新表达这些执行方式:
Context MPIContext TaskfarmContext标准上下文中的生命周期非常清晰:
defrun(self):self._start_simulation()foriinself.model_range:self._run_model(i)self._end_simulation()单模型执行则进一步拆解为:
model_config=self._create_model_config(model_num)scene=self._get_scene(model_num)model=self._create_model()scene.create_internal_objects(model)model.build()solver=create_solver(model)model.solve(solver)这里发生了一个重要变化。
旧版通过一组并列函数表示不同模式:
run_std_sim() run_mpi_sim() run_mpi_no_spawn_sim()当前版本则通过类体系表示不同模式:
Context ├── MPIContext └── TaskfarmContext这不再只是“函数移动到另一个文件”。
系统表达运行模式的方式已经发生改变。
标准生命周期由父类定义,不同上下文只需要覆盖特定步骤。
例如,MPIContext可以改变模型创建方式,将普通Model替换为MPIModel;TaskfarmContext则可以保留单模型内部生命周期,只改变模型任务如何分发。
这种结构带有明显的模板方法特征:
- 父类规定执行骨架
- 子类替换局部步骤
- 公共生命周期保持一致
- 运行模式通过多态表达
因此,旧版和新版之间的变化可以概括为:
旧版: 每种运行模式是一组独立过程函数 devel: 每种运行模式是一种 Context 类型这是一项真正的架构变化,因为系统从“条件分支驱动”转向了“对象协作与多态驱动”。
3. MPI 不再只有一种含义
并行机制的变化,是理解这次重构时最容易混淆的部分。
旧版中的:
-mpi主要表示 MPI 任务农场。
其基本模式是:
模型 1 → worker 1 模型 2 → worker 2 模型 3 → worker 3每个 worker 负责一个相对独立的模型,并分别调用run_model()。
这种方式非常适合:
- B 扫描
- 多位置 A 扫描
- 参数扫描
- 多模型批量仿真
- 优化问题中的多候选模型计算
它的并行单位是“模型”。
也就是说,模型之间互不依赖,可以被分配给不同工作进程。
旧代码中的注释也将其直接描述为:
MPI task farm for models当前devel分支则将两类 MPI 并行明确拆开。
第一类是:
--taskfarm它仍然表示模型级任务农场:
多个模型 ↓ 分配给多个 MPI worker第二类是:
--mpi x y z它表示模型内部的空间分解:
一个模型 ↓ 沿 x、y、z 方向划分空间 ↓ 多个 MPI rank 协同计算这两种并行方式解决的是完全不同的问题。
任务农场关注的是:
如何同时运行更多模型?
空间分解关注的是:
如何让多个进程共同运行一个更大的模型?
因此,旧版与新版之间不能简单写成:
run_mpi_sim() → MPIContext更准确的关系是:
旧版 run_mpi_sim() ↓ 新版 TaskfarmContext而新版MPIContext更接近新增或显著强化的模型内部空间分解能力。
可以把两种并行方式放在一起比较:
| 并行方式 | 并行单位 | 适用任务 |
|---|---|---|
| Taskfarm | 多个独立模型 | B 扫描、参数扫描、多模型批处理 |
| MPI 空间分解 | 单个模型中的子区域 | 超大网格、单模型分布式求解 |
这项拆分非常重要。
旧版把“MPI”近似等同于“多个模型分发”。
当前版本则开始明确区分:
- 模型级并行
- 模型内部并行
这种区分不仅体现在命令行参数上,也体现在上下文类型和模型类型上。
从架构角度看,这意味着并行策略不再是一个模糊的全局开关,而是被拆分为不同层次的执行概念。
4. 配置不再只是一个不断变化的args
旧版的运行状态主要保存在两个对象中:
args usernamespace其中,args通常来自argparse.Namespace,或者来自 API 临时构造的ImportArguments对象。
下游函数直接读取和修改这些字段:
args.gpu=gpus[0]args.gpu=gpus args.n args.restart args.task这类设计在脚本式程序中很常见。
它的优点是直接。
任何函数拿到args,都可以访问当前运行参数。
但随着运行模式增加,同一个字段可能逐渐承载不同含义。
例如,旧版中的args.gpu在不同阶段可能表示:
- 一个 GPU 设备编号
- 多个 GPU 设备编号
- 一个 GPU 对象
- 多个 GPU 对象
- 当前模型分配到的 GPU
- 整次仿真可用的 GPU 集合
字段名字没有变化,但它所代表的状态会随着执行过程变化。
这是一种典型的过程式共享状态。
当前版本引入了两个生命周期不同的配置对象:
SimulationConfig ModelConfig4.1SimulationConfig:整次仿真的配置
SimulationConfig管理的是仿真级状态,例如:
- 输入文件
- 输出路径
- 模型运行数量
- 起始模型编号
- 模型运行范围
- 求解器类型
- 可用计算设备
- MPI 分解维度
- 是否启用任务农场
- 日志级别
- 日志文件
- 进度条配置
- Scene 列表
- 全局输出设置
这些信息在整次仿真过程中通常保持稳定。
4.2ModelConfig:单个模型的配置
ModelConfig则服务于某一次具体模型运行。
它可能保存:
- 当前模型编号
- 当前输出文件
- 当前计算设备
- 当前材料状态
- 网格尺寸
- 时间步长
- 内存估算
- 数值色散信息
- 当前模型相关输出参数
代码中的说明非常直接:
classModelConfig:"""Configuration parameters for a model. N.B. Multiple models can exist within a simulation """这句话揭示了两个不同层次:
一次 Simulation ↓ 包含多个 Model因此配置也被拆成:
SimulationConfig ↓ 管理整次仿真 ModelConfig ↓ 管理一次模型运行这种拆分并不是为了“让类更多”。
它实际上是在回答一个重要的架构问题:
某个状态究竟属于整次仿真,还是只属于当前模型?
旧版的很多状态都集中在一个可变args中。
当前版本则开始按照生命周期管理状态。
这会直接改善:
- 状态含义
- 类型稳定性
- 多模型运行
- 并行执行
- 测试可控性
- API 可维护性
因此,这一变化可以概括为:
旧版: 一个 args 对象贯穿全局 devel: SimulationConfig 管理仿真级状态 ModelConfig 管理模型级状态5.Scene的出现改变了模型表达方式
如果只选择一个最能代表本次重构的对象,Scene很可能是最关键的候选。
旧版 gprMax 主要围绕输入文件运行。
run_model()接收:
inputfile usernamespace随后解析输入文件中的命令,建立网格、材料、几何体、源、接收器和输出对象。
在这种架构中,用户模型和.in文件语法高度绑定。
可以近似理解为:
输入文件 ≈ 用户模型当前版本引入了显式的:
SceneScene用来保存用户创建的高层建模对象,并按照职责分类:
self.single_use_objects self.grid_objects self.geometry_objects self.output_objects self.subgrid_objects这些分类不是简单的容器分组。
它们反映了不同用户对象在模型构建过程中的作用:
single_use_objects:只能出现一次的场景级命令grid_objects:控制网格、时间窗或计算域geometry_objects:描述材料分布和几何结构output_objects:描述接收器和输出要求subgrid_objects:描述子网格或多尺度结构
Scene还负责:
- 检查必要对象是否存在
- 验证对象之间的约束
- 组织对象构建顺序
- 建立网格相关对象
- 处理几何对象
- 处理子网格对象
- 将用户对象转换为内部模型对象
关键调用是:
scene.create_internal_objects(model)这个接口将两件过去高度耦合的事情拆开:
输入文件解析与:
模型场景表达当前流程更接近:
.in 输入文件 ↓ 解析为 UserObject ↓ 加入 Scene而 Python API 也可以绕过.in文件,直接创建:
scene=Scene()再调用:
run(scenes=[scene])这意味着,.in文件的地位发生了根本变化。
旧版中,它近似是模型本身。
当前版本中,它只是构造Scene的一种前端。
可以把这种变化表达为:
旧版: 输入文件是模型的主要表示 devel: Scene 是模型的高层表示 输入文件只是 Scene 的一种来源这是一种典型的输入层与领域模型解耦。
一旦Scene成为核心中间表示,系统就可以支持更多前端:
.in 文件 Python API 图形化建模界面 参数化建模脚本 外部数据转换器 自动场景生成工具这些前端最终都可以汇聚到同一种对象模型:
Scene这正是科学计算程序向可编程平台演化时非常关键的一步。
6. 用户对象和内部对象被明确分成两个层次
Scene的引入还带来了另一个重要变化:用户对象与计算对象之间的边界更加清晰。
当前架构可以大致分成两个对象层次。
第一层是面向用户的对象,例如:
Domain Material Cylinder Box Receiver Source GeometryView这些对象关注的是:
- 物理语义
- 参数命名
- 输入合法性
- 建模易用性
- API 可读性
用户可以按照电磁建模概念创建对象,而不必直接操作底层数组。
第二层是内部计算对象,例如:
网格材料数组 内部 Receiver 离散源对象 几何索引 更新系数 PML 数据结构 场分量数组 后端相关缓冲区这些对象关注的是:
- 数组索引
- 内存布局
- 数据类型
- 并行访问
- 计算效率
- 后端兼容性
两层之间通过类似下面的过程连接:
UserObject ↓ build() Internal Object或者从整体流程看:
Scene 中的用户对象 ↓ scene.create_internal_objects(model) ↓ Model 中的内部对象旧版当然也存在“输入命令转换为内部结构”的过程。
任何仿真程序都必须完成这一步。
区别在于,当前版本将这个层次显式表达出来:
- 用户 API 使用
UserObject Scene组织用户对象build()负责转换Model保存计算对象
这使系统可以同时优化两个目标。
用户层可以保持直观:
Cylinder(...)Receiver(...)Material(...)内部层则可以为计算效率服务:
连续数组 紧凑索引 预计算系数 后端专用数据结构这种分层在科学计算框架中非常常见。
因为对用户友好的对象结构,通常并不是对计算机最有效的对象结构。
一个良好的架构不会强迫二者使用同一种表示。
7.run_model()被拆成了模型生命周期
旧版的核心单模型执行边界是:
run_model(...)它承担了单个模型的输入处理、模型构建和数值执行。
从外部看,整个过程近似是一个黑盒:
run_model() ├── 解析 ├── 构建 └── 求解随着项目复杂度增加,这种统一函数会逐渐遇到问题。
因为“解析”“构建”和“求解”属于不同层次。
它们变化的原因也不同:
- 输入语法变化会影响解析
- 新建模对象会影响场景构建
- 网格结构变化会影响离散模型
- 新硬件后端会影响求解器
- MPI 空间分解会影响模型和通信
- 日志变化不应该影响数值算法
当前版本将单模型运行显式拆解为:
获取 Scene ↓ 创建 Model ↓ Scene 创建内部对象 ↓ Model.build() ↓ create_solver(Model) ↓ Model.solve(Solver)对应代码路径接近:
model_config=self._create_model_config(model_num)scene=self._get_scene(model_num)model=self._create_model()scene.create_internal_objects(model)model.build()solver=create_solver(model)model.solve(solver)这些步骤看起来只是多了几个对象,但它们代表不同的架构语义。
7.1Scene:用户想模拟什么
Scene描述的是高层物理场景:
- 计算域
- 网格设置
- 材料
- 几何体
- 激励源
- 接收器
- 时间窗
- 输出要求
它是用户意图的表达。
7.2Model:计算机真正需要计算什么
Model保存的是离散化后的计算结构:
- 网格尺寸
- 材料编号数组
- 电磁参数数组
- 电场和磁场数组
- 更新系数
- PML
- 内部源
- 内部接收器
- 时间步进参数
- 后端所需数据
它是数值模型的表达。
7.3Solver:使用什么方式推进计算
Solver负责执行时间推进和后端计算,例如:
- OpenMP
- CUDA
- OpenCL
- Metal
它回答的是:
这个离散模型应该由哪一个计算后端执行?
因此,当前架构的分层关系是:
Scene ↓ 描述物理问题 Model ↓ 描述离散计算问题 Solver ↓ 执行数值时间推进这比一个统一的run_model()更容易扩展。
因为未来新增功能时,可以更加准确地定位修改点:
- 新增几何对象:修改用户对象和 Scene 构建
- 新增离散策略:修改 Model 构建
- 新增计算后端:实现新的 Solver
- 新增运行方式:实现新的 Context
- 新增输入格式:增加新的 Scene 构造前端
这就是分层架构的实际价值。
8. 求解后端从条件逻辑变成策略对象
旧版 gprMax 的计算后端主要围绕:
OpenMP CPU CUDA GPUGPU 检测和设备选择在入口层完成,再通过args.gpu传递给下游。
随着硬件后端增多,这种方式会让入口层逐渐充满设备相关条件逻辑。
当前版本支持的后端更加广泛:
OpenMP CUDA OpenCL Metal命令行和 API 中分别提供了相应参数:
gpu opencl metal但更重要的变化不只是后端数量增加。
关键在于,求解器选择被收敛到统一接口:
solver=create_solver(model)model.solve(solver)这个结构表明,求解后端开始被当作可替换策略。
从调用者角度看,Context 并不需要了解每个后端的全部实现细节。
它只需要:
根据 Model 创建 Solver ↓ 让 Model 使用 Solver 计算这种设计可以近似理解为策略模式:
Model ↓ 使用统一 Solver 接口 Solver ├── OpenMPSolver ├── CUDASolver ├── OpenCLSolver └── MetalSolver不同后端可以在内部处理自己的:
- 内存分配
- 数据迁移
- 核函数
- 并行执行
- 设备同步
- 性能统计
而上层模型生命周期保持不变:
Model.build() ↓ create_solver() ↓ Model.solve()这比在入口文件中写大量:
ifgpu:...elifopencl:...elifmetal:...更容易维护。
它也说明 gprMax 的求解层正在从“内嵌条件分支”转向“可插拔后端”。
9. Python API 从包装器变成了建模接口
旧版 API 的典型形式是:
api(inputfile,n=1,...)它的主要工作,是将 Python 函数参数转换成一个类似命令行参数的临时对象,然后进入与 CLI 相同的run_main()流程。
因此,旧版 API 的本质是:
用 Python 调用一个以输入文件为中心的程序。
用户仍然需要先准备.in文件。
Python 只负责启动仿真、传递运行参数和控制模型数量。
当前版本中的 API 更接近:
run(scenes=None,inputfile=None,outputfile=None,n=1,mpi=None,taskfarm=False,gpu=None,opencl=None,metal=None,...)这里最重要的变化是:
scenes=NoneAPI 不再只能接收输入文件。
用户可以直接在 Python 中创建多个Scene,然后执行:
run(scenes=[scene])这使 Python API 不再只是 CLI 的包装器,而开始成为真正的建模接口。
两种 API 定位可以对比为:
旧版 API: Python → 参数包装 → 输入文件程序 devel API: Python → Scene 对象 → 仿真框架这项变化的影响远大于“函数签名变了”。
它意味着 gprMax 可以更自然地用于:
- 参数化模型生成
- 批量实验
- 优化算法
- 机器学习数据生成
- 不确定性分析
- 仿真工作流自动化
- 外部软件集成
- 交互式建模
- Python 数据处理流水线
例如,过去一个参数扫描任务可能需要:
- 生成大量
.in文件 - 修改文本模板
- 保存临时文件
- 调用 gprMax
- 再整理输出文件
当前架构则更容易形成:
scenes=[]forradiusinradii:scene=Scene()scene.add(Cylinder(radius=radius,...))scenes.append(scene)results=run(scenes=scenes)无论当前 API 的具体细节是否仍在演进,架构方向已经非常明确:
.in文件不再是唯一的一等公民,Scene对象正在成为核心建模接口。
10. 日志系统不只是替换了print()
旧版中,大量运行信息通过:
print(...)直接输出。
入口文件负责显示:
- 主机信息
- CPU 信息
- GPU 信息
- 开始时间
- 结束时间
- 模型进度
- 运行模式
- 错误提示
在单机脚本中,这种方式通常足够。
但在 MPI 和多后端环境中,直接print()会产生很多问题。
例如:
- 所有 rank 同时输出
- 日志顺序混乱
- 很难区分不同进程
- 无法统一控制日志级别
- 无法选择只记录 rank 0
- 无法为每个 rank 写独立日志
- 测试中难以捕获输出
- API 调用者难以控制输出行为
当前版本引入了独立日志配置,例如:
logging_config(...) logger.basic(...) logger.debug(...) logger.warning(...) logger.error(...)同时提供更细粒度的参数:
log_level log_file log_all_ranks show_progress_bars hide_progress_bars这些变化说明,日志已经被视为独立基础设施,而不是散落在业务代码中的输出语句。
特别是在 MPI 环境中,日志系统需要明确处理:
是否只显示 rank 0 是否记录所有 rank 不同 rank 是否写不同文件 进度条是否只由主进程显示 错误信息是否带进程上下文因此,日志重构不是单纯的代码风格升级。
它是 gprMax 从本地脚本式程序走向并行科学计算框架时必须补齐的工程基础设施。
11. 用一张图看懂旧版架构
旧版架构可以简化为:
CLI / Python API ↓ gprMax.py ├── 参数处理 ├── 主机检测 ├── GPU 检测 ├── 标准调度 ├── MPI 任务农场 ├── MPI No Spawn ├── benchmark ├── optimisation └── 多模型循环 ↓ run_model() ├── 输入解析 ├── 模型构建 └── 数值求解这个结构有一个非常明显的中心:
gprMax.py大量控制逻辑从入口向下分发。
而单模型执行又集中在:
run_model()所以旧版可以理解为两个大型控制边界:
gprMax.py ↓ 负责整次仿真 run_model() ↓ 负责单个模型这种结构并不意味着旧版设计错误。
对早期科学计算程序而言,它具有明显优势:
- 实现直接
- 调用链短
- 易于快速增加功能
- 便于研究代码迭代
- 对核心开发者容易理解
但随着功能增长,入口文件会逐渐承担越来越多职责。
最终,运行模式、硬件后端、模型状态和输入解析会相互影响。
此时继续增加条件分支,会让系统扩展成本迅速上升。
12. 当前devel架构更像什么
当前devel分支可以概括为:
CLI / Python API ↓ SimulationConfig ↓ Context ├── Context ├── MPIContext └── TaskfarmContext ↓ ModelConfig ↓ Scene ↓ UserObject.build() ↓ Model.build() ↓ create_solver() ↓ Model.solve()这条链路中的每个对象都回答一个不同问题。
SimulationConfig
回答:
整次仿真要怎样运行?
Context
回答:
多个模型和计算资源要怎样组织?
ModelConfig
回答:
当前这个模型使用什么局部配置?
Scene
回答:
用户想模拟什么物理场景?
UserObject
回答:
用户如何描述材料、几何体、源和接收器?
Model
回答:
这些物理对象如何转换成离散计算结构?
Solver
回答:
离散模型由哪个后端执行?
这种结构的最大变化,不在于类数量,而在于职责边界。
系统不再由一个入口函数“知道所有事情”。
不同对象只处理自己所属的层次。
13. 这是否属于架构级重构
答案是肯定的。
判断一次变化是否属于架构重构,不能只看:
- 文件是否重命名
- 函数是否移动
- 类是否增加
- 代码行数是否减少
更应该看:
系统的核心职责边界是否被重新定义。
在 gprMax 当前devel分支中,至少有以下边界发生了实质变化。
| 关注点 | 旧版 | 当前devel |
|---|---|---|
| 程序入口 | main()与api() | cli()与run() |
| 全局配置 | 松散的args | SimulationConfig |
| 单模型配置 | 函数参数和共享状态 | ModelConfig |
| 执行模式 | run_*_sim()函数 | Context类体系 |
| 用户模型表示 | 以输入文件为中心 | 显式Scene |
| 用户命令 | 解析后参与构建 | 分类的UserObject |
| 单模型执行 | 集中的run_model() | Model.build()与Model.solve() |
| 求解后端 | 条件逻辑和参数传递 | create_solver() |
| MPI | 主要表示任务农场 | 空间分解与任务农场分离 |
| Python API | 输入文件调用包装器 | 可直接传入Scene |
| 日志 | print()为主 | 独立日志系统 |
| 硬件后端 | OpenMP、CUDA | OpenMP、CUDA、OpenCL、Metal |
从架构特征看,这次变化同时具备以下典型信号。
13.1 入口文件显著瘦身
入口只负责参数接收、配置创建和运行上下文选择。
13.2 过程函数被生命周期对象替代
不同运行模式不再主要依赖平行函数和条件分支,而是由 Context 类体系表达。
13.3 模型表达和数值计算被分层
Scene、Model和Solver分别承担用户建模、离散结构和后端求解。
13.4 输入文件不再是唯一模型表示
.in文件成为构造Scene的一种方式,而不是唯一入口。
13.5 并行机制被拆成不同层级
任务农场负责模型级并行,MPIContext 负责单模型空间分解。
13.6 求解后端通过统一接口替换
后端选择被收敛到create_solver(),上层生命周期不再依赖大量设备分支。
13.7 配置按照生命周期拆分
仿真级状态和模型级状态分别由SimulationConfig和ModelConfig管理。
这些都不是局部代码整理能够解释的。
更准确的表述应当是:
gprMax 正在从以入口函数和
run_model()为中心的过程式仿真程序,迁移为以SimulationConfig → Context → Scene → Model → Solver为主线的分层科学计算框架。
14. 但它不是一次完全推倒重写
将这次变化称为架构重构,并不意味着 gprMax 被从零重写。
恰恰相反,它更像一次围绕既有数值内核展开的渐进式迁移。
底层大量能力仍然得到保留和延续,例如:
- FDTD 更新方程
- 材料处理
- 源
- 接收器
- PML
- 几何构建
- CUDA 加速
- 输出文件结构
- 既有数值工具
- 一部分历史模块
在迁移过程中,也仍然能够看到历史结构留下的痕迹。
例如:
#python输入块仍被保留,但已经被标记为 deprecated- 某些调用堆栈中仍可能出现
model_build_run.py - 新旧参数和兼容逻辑可能同时存在
- 部分对象已经重构,部分底层代码仍沿用原有组织
- 模块命名可能仍保留历史语义
这正是渐进式重构的典型状态。
大型科学计算项目通常很难通过一次性重写完成架构迁移。
原因很现实:
- 数值正确性必须保持
- 已有用户模型不能轻易失效
- 不同后端需要逐步迁移
- MPI 和 GPU 功能难以同时重写
- 科研用户依赖旧输入格式
- 底层优化代码具有较高验证成本
因此,更合理的方式是:
保留成熟数值内核 ↓ 逐步建立新对象边界 ↓ 迁移入口和调度逻辑 ↓ 迁移模型表达 ↓ 迁移求解后端 ↓ 逐渐废弃历史接口这也是为什么当前代码中会同时存在新架构和历史痕迹。
它不是架构判断的反例。
相反,它说明项目正处于真实的软件演化过程中。
15. 这次重构解决了什么问题
将架构拆成多个对象之后,收益并不只是代码看起来更整齐。
它解决的是科学计算项目发展到一定规模后必然出现的扩展问题。
15.1 新运行模式更容易加入
过去增加一种运行方式,可能需要修改:
- 入口分支
- 参数解析
- 模型循环
- GPU 分配
- 输出管理
- 错误处理
当前架构中,可以通过新增或扩展 Context 来表达新的执行模式。
15.2 新后端更容易接入
只要新后端能够满足 Solver 接口,上层 Scene 和 Model 生命周期可以保持稳定。
15.3 Python API 更适合工作流集成
用户不必始终生成输入文件,而可以直接构建 Scene。
15.4 多模型状态更容易管理
仿真级配置和模型级配置不再混在同一个可变对象中。
15.5 并行语义更加清晰
模型级任务农场和模型内部空间分解被明确区分。
15.6 用户建模和内部计算可以独立演进
用户对象可以追求可读性,内部对象可以追求性能。
15.7 测试边界更加明确
可以分别测试:
- 配置解析
- Scene 验证
- UserObject 构建
- Model 构建
- Solver 选择
- Context 生命周期
15.8 图形化和自动化工具更容易建立
一旦 Scene 成为统一中间表示,GUI、参数化脚本和外部转换工具就不必直接依赖.in文本语法。
16. 这次重构体现了哪些设计模式
不必强行给每一段代码贴设计模式标签,但当前架构确实呈现出几个明显的软件设计特征。
16.1 模板方法
Context.run()定义总体生命周期:
开始仿真 ↓ 遍历模型 ↓ 运行单模型 ↓ 结束仿真子类只覆盖特定步骤,例如模型创建或任务分发。
16.2 策略模式
不同 Solver 表示不同计算后端:
OpenMP CUDA OpenCL MetalModel 可以通过统一接口使用不同求解策略。
16.3 工厂方法
create_solver(model)根据模型配置和硬件参数创建适合的求解器。
16.4 分层架构
系统被拆成:
输入层 配置层 运行上下文层 场景层 离散模型层 求解器层16.5 中间表示
Scene开始承担统一高层模型表示的作用。
不同输入方式最终都转换为 Scene。
16.6 前端与后端分离
.in文件和 Python API 是建模前端。
OpenMP、CUDA、OpenCL 和 Metal 是计算后端。
Scene和Model位于二者之间。
17. 如何正确描述旧版与当前版本的关系
讨论 gprMax 的版本演化时,需要避免几个过度简化的说法。
第一种不准确说法是:
当前版本只是把
gprMax.py拆成了多个文件。
这忽略了 Context、Scene、Model 和 Solver 之间新的职责划分。
第二种不准确说法是:
旧版 MPI 直接变成了新版 MPIContext。
旧版run_mpi_sim()更接近当前TaskfarmContext,而当前MPIContext主要表示单模型空间分解。
第三种不准确说法是:
新版完全抛弃了旧版架构。
实际上,大量底层 FDTD 和物理建模能力仍然被继承。
第四种不准确说法是:
新版已经完成了彻底重写。
当前更像处于渐进迁移阶段,新旧结构可能同时存在。
一个更准确的总结是:
当前
devel分支正在围绕既有 FDTD 数值内核进行渐进式架构迁移。重构重点不在底层算法重写,而在上层模型表达、状态管理、运行调度、并行方式和多后端求解的重新组织。
18. 总结:gprMax 正在从“程序”变成“框架”
旧版 gprMax 的核心组织方式是:
入口函数 ↓ 运行模式函数 ↓ run_model()当前devel分支正在形成的新组织方式是:
SimulationConfig ↓ Context ↓ Scene ↓ Model ↓ Solver这条变化主线可以用一句话概括:
旧版更关注“如何把一次仿真运行起来”,当前架构则开始关注“如何用可扩展对象体系描述仿真、组织仿真并执行仿真”。
因此,这不是简单的文件拆分,也不是一次普通代码清理。
它是一次较明确的架构级重构。
更准确地说,gprMax 正在从:
以 gprMax.py 和 run_model() 为中心的过程式仿真驱动程序逐步转向:
以 SimulationConfig、Context、Scene、Model 和 Solver 为核心的分层科学计算框架底层 FDTD 数值能力仍然延续,但上层的系统边界已经发生实质变化。
对开发者而言,这意味着未来理解 gprMax 时,不应再只围绕“程序从哪个函数开始执行”来阅读代码。
更有效的方式是沿着五个问题展开:
SimulationConfig:整次仿真如何配置? Context:模型和计算资源如何组织? Scene:用户想模拟什么? Model:物理场景如何离散化? Solver:离散模型由什么后端执行?当这五个问题被分开之后,gprMax 的新架构也就变得清晰了。它不再只是一个能够运行.in文件的电磁仿真程序。它正在成为一个可以被脚本化、组合、扩展并嵌入其他科学工作流的电磁建模框架。