第8章 性能与规模化:把仿真推进到“跑得快、跑得稳、跑得可扩展”前面第5章把“细胞 + 场”闭环打通,第7章把“后处理/可视化也纳入回归”。到这里,系统已经具备科研/工程两端都能用的骨架。第8章解决第三件事:性能——让同样的模型在更大规模、更长时间、更密集参数扫描时依然可控。本章会给一条很实用的路线:先用 profile 找瓶颈 → 再用数据结构/向量化解决 → 再做并行与批量运行 → 最后预留 GPU/PDE 加速接口。8.1 性能目标与度量:先定义“快”的含义不要一上来“优化”,先定指标。建议至少跟踪这三类 KPI:8.1.1 吞吐量steps/s:每秒多少步(最直观)cell-updates/s:每秒更新多少细胞(跨不同规模更公平)voxel-updates/s:场每秒更新多少体素(PDE 主导时关键)8.1.2 成本分摊(把时间拆账)每步 wall time 拆成模块占比:邻居/碰撞(contact / neighbor search)细胞状态更新(division/death/motion)scatter(细胞→场源项)PDE step(场扩散反应)gather(场→细胞采样/梯度)IO(CSV/NPZ/PNG 写盘)后处理(如果在线做)8.1.3 规模曲线(复杂度)固定模型逻辑,跑不同 N:细胞模块应尽量接近O(N)(或 O(N log N))邻居模块理想是O(N)(通过网格/哈希)PDE 是O(n_vox)(每步常数要小)最终想要的是一张表:N=1e4/5e4/1e5时每步耗时多少、瓶颈在哪。8.2 Profiling:用证据而不是直觉优化8.2.1 三种 profiler,各司其职cProfile(宏观):函数级耗时,找“大头”line_profiler(微观):定位到行,抓住循环热点py-spy(无侵入):线上/CI 也能采样,不改代码建议最小闭环流程:用一个固定scenario_small_perf.yaml(固定 seed)跑 2000 steps(足够暴露稳定瓶颈)输出 profile 报告(文本 + flamegraph)8.2.2 先把 IO 关掉再测性能评估一定要提供两套:compute-only:IO 全关(或大幅稀疏,比如每 200 步写一次)full pipeline:包含真实写盘频率(代表真实用户体验)否则很容易“优化了计算,但其实 60% 时间在写 CSV”。8.3 细胞-细胞相互作用:邻居搜索是第一大坑只要有碰撞、黏附、局部力场、接触抑制,邻居搜索几乎必然成为瓶颈。8.3.1 空间哈希 / 均匀网格(Uniform Grid)核心思想:把空间切成 cell_size ≈ interaction_radius 的立方体格子,细胞只与同格及 26 邻格交互。建表:O(N)查询:每个细胞查常数个桶 → 近似O(N)关键是:桶里列表要紧凑、减少 Python 对象开销实现要点:坐标→格子索引用整型计算(向量化最好)桶用list[int]或array('I'),避免存对象计算 pair 时尽量用 numpy 批处理(或 numba)8.3.2 Verlet List(如果运动小、步长小)如果每步位移很小,邻居列表不用每步重建:每隔 K 步重建一次中间只做“有效性检查”(是否超过 skin distance)这对长仿真会非常省。8.3.3 经验规则:先把 pair 数压下来,再谈向量化很多系统慢不是“算一次距离慢”,而是“算了太多不该算的距离”。先看两个数字:每步候选 pair 数每个细胞平均候选邻居数理想:平均邻居数稳定在一个小常数(几十以内)。8.4 场(PDE)是第二大坑:先优化内存与 stencil现在的 Field 是规则栅格,性能主要由:内存带宽(读写 3D 数组)stencil 算法常数子步次数(dt_max 限制)8.4.1 先做三件“免费加速”单精度 float32(场数组、源项都用 float32)避免临时大数组:stencil 计算时尽量复用 buffer连续内存 顺序遍历:确保c是 C-contiguous8.4.2 子步自适应:把 dt_max 与 accuracy 分离如果把 dt 压得很小,PDE 每个“细胞步”要子步几十次会爆炸。建议:细胞用较大 dt(例如 0.01)PDE 内部自动 substep,但设一个上限(例如最多 10 次)超过上限:降低细胞 dt 或输出警告(而不是默默变慢)8.4.3 PDE 与细胞耦合的降采样策略(非常实用)很多生物场变化比细胞慢:细胞每步都动场可以每 M 步更新一次(如 M=2~5)scatter 可以每步累积到 source buffer,再统一推进这通常是“工程上最划算”的提速。8.5 scatter/gather:别在 Python 循环里做 10^5 次采样8.5.1 gather 采样向量化如果在每个细胞上做三线性插值并且写成 Python for-loop,N 上去就慢。更优路径:一次性把所有细胞坐标转换到 grid index(numpy)批量取 8 个角点值(高级索引)批量算权重并汇总8.5.2 scatter 源项写入:用 bincount / add.at把细胞源项加到 voxel 上,本质是“很多点加到很多格子”:先算每个细胞对应 voxel id(扁平索引)用np.bincount(voxel_id, weights=...)生成 source或用np.add.at(source, voxel_id, w)(更通用但可能慢点)这能把 O(N) 的 Python 循环变成 numpy 内核操作。8.6 IO 与数据产品:写盘是隐形杀手8.6.1 三条铁律不要每步写 CSV:改成每 K 步写一次(K=10/50/100 视需求)二进制优先:npz/parquet(CSV 仅用于人读或轻量日志)异步/缓冲(如果后面允许):单独线程/进程写盘8.6.2 指标层分级metrics.csv:少量列、每步或每 5 步都行cells_*.csv:全体细胞大表,必须稀疏(每 50/100 步)field_*.npz:更大,通常每 100/200 步并明确给用户:“默认输出频率是为性能设置的;如需更密集输出,请自行增大磁盘和时间预算。”8.7 并行化路线:从“参数扫描并行”开始,而不是硬拆一场仿真对这类 agent-based + PDE 仿真,最稳妥的并行通常是:8.7.1 外层并行:多 run 参数扫描(推荐优先做)不用改核心算法可线性扩展到多核/多机适合敏感性分析、Bayes/网格搜索实现方式:一个run_one(config, seed, out_dir)一个sweep.py用 multiprocessing/joblib 提交多任务每个任务独立目录写输出(避免锁)8.7.2 内层并行:单 run 多线程(谨慎)Python GIL、内存带宽、锁竞争会让收益不稳定。如果要做,优先把热点挪进:numpy(本身可能多线程)numba(可并行)torch(GPU/并行)8.8 GPU 预留接口(与第5章的“backend 钩子”呼应)前面提到 B 路线 CPU 主跑 + GPU 预留,这里给一个“不会返工”的接口设计:8.8.1 Field3D 统一后端 APIstep(dt):内部决定 numpy/torchscatter_cells(...):输入都是 numpy arrays(torch 后端内部转换/缓存)sample_value(pos)/sample_gradient(pos):可批量输入8.8.2 GPU 上最值得搬的是 PDE stencil原因:stencil 是规则内存访问 + 大吞吐适合 GPU 的带宽优势而细胞-细胞邻居搜索在 GPU 上实现成本更高(除非彻底重写数据结构)路线建议:先把 PDE 换成 torch tensor(CPU 上先跑通,保证数值一致)再切到 CUDA(仅改 device)回归测试用第7章的曲线/末态阈值保证“加速不改结论”8.9 性能回归测试:把“跑得快”也纳入质量第7章做了“科学输出回归”,第8章补上“性能回归”(同样重要):8.9.1 基本策略选一个固定 perf 场景(小规模但足够代表)固定环境变量(单线程)记录step_time_ms、cells_update_ms、pde_ms等分解指标设置宽松阈值(例如不允许退化超过 15%)8.9.2 注意:性能回归不要在异构 CI 上做强约束不同 runner 波动很大。实操方案:PR:只输出 profile artifact + 提示nightly:在固定机器跑并做阈值 gating(更靠谱)8.10 本章落地清单(可以按这个顺序做)加一个--profile开关:输出模块分解耗时(每 100 步汇总一次)关 IO 跑 compute-only profile:确认瓶颈排序邻居搜索改均匀网格(把 pair 数压下来)gather/scatter 向量化(去掉 Python 细胞循环)PDE 内存优化 + 降采样(每 M 步更新场)输出频率分级(metrics 常写、cells/field 稀疏写)外层参数扫描并行(multiprocessing)Field backend 钩子 + torch CPU 版对齐(为 GPU 做准备)nightly 性能回归(宽阈值)下面给一套可直接落到仓库里的第8章脚手架代码(按前面章节的命名习惯:cell3d/做库、scripts/放可复现命令、tests/放 pytest)。我会尽量做到:不依赖额外三方包(除 numpy),同时给足够的扩展点(把第7章的回归输出也能顺手接上)。只需要把这些文件按路径保存,然后跑python -m scripts.profile_run ...或pytest -q -m perf即可。1) 目录结构(新增/修改)cell3d/ utils/ __init__.py timing.py # 新增:轻量计时器/统计 scripts/ profile_run.py # 新增:跑场景并输出性能分解 json/csv sweep.py # 新增:参数扫描并行(外层并行) tests/ conftest.py # 可选:加 marker 注册(若你还没做) test_perf_budget.py # 新增:性能预算(建议 nightly/本地固定机)2)cell3d/utils/timing.py一个“够用但不花哨”的计时/统计器:支持 context manager、支持分事件聚合、支持导出 json 友好结构。# cell3d/utils/timing.pyfrom__future__importannotationsimporttimefromdataclassesimportdataclass,asdictfromtypingimportDict,Optional,Any,List@dataclassclassTimingStat:"""Aggregate timing stats for one named section."""n:int=0total_s:float=0.0min_s:float=1e30max_s:float=0.0defadd(self,dt_s:float)-None:self.n+=1self.total_s+=dt_sifdt_sself.min_s:self.min_s=dt_sifdt_sself.max_s:self.max_s=dt_s@propertydefmean_s(self)-float:returnself.total_s/self.nifself.nelse0.0defto_dict(self)-Dict[str,Any]:d=asdict(self)d["mean_s"]=self.mean_s# 如果从未记录,min_s 保持一个极大值会难看ifself.n==0:d["min_s"]=0.0returndclass_SectionTimer:def__init__(self,timer:"Timer",name:str):self._timer=timer self._name=name self._t0:Optional[float]=Nonedef__enter__(self)-"_SectionTimer":self._t0=time.perf_counter()returnselfdef__exit__(self,exc_type,exc,tb)-None:assertself._t0isnotNonedt=time.perf_counter()-self._t0 self._timer.add(self._name,dt)classTimer:""" Lightweight event timer. Example: t = Timer() with t.section("neighbors"): ... with t.section("pde"): ... stats = t.summary() """def__init__(self)-None:self._stats:Dict[str,TimingStat]={}defsection(self,name:str)