仿真性能分析#
本页介绍如何度量为 Genesis World 仿真时间花在哪里,通过四个逐步深入的问题:
吞吐量: 整个场景每秒能跑多少步?这是最核心的数字,以 FPS 报告。
单步分解: 一步的时间中有多少给了物理,多少给了它周围的传感器、查看器和记录器?这个答案告诉你到底该不该优化物理。
启动延迟: GPU 是否真的在忙,还是 CPU 在内核启动之间停顿?这是限制那些尚未受 GPU 制约的大规模并行仿真的因素。
单内核耗时: 哪些求解器内核主导了一步,内核中又是哪些代码块?这告诉你该优化什么。
前两项是内置的、始终可用。后两项分别使用 PyTorch profiler(即使仿真完全不涉及 PyTorch 也能工作)和 Quadrants 的周期计数器,来为内核内部的代码计时。
用一次性缓存做基准测试#
在测量任何东西之前,先把编译缓存处理好。Genesis World 会即时编译 GPU 内核,并把结果缓存到本地持久文件夹,所以同一场景的重复运行启动很快。编译器 Quadrants 也以同样的方式维护自己的缓存。这正是日常开发想要的,但它会干扰性能分析和基准测试:第一次运行会编译内核,之后的运行则从热缓存中读取。
不要为了绕过这一点而清空持久缓存。它的影响会超出你的实验本身,在缓存重建之前,未来每一次仿真都会变慢。正确的做法是:在单次运行期间,通过设置几个环境变量,把两个缓存重定向到临时目录:
XDG_CACHE_HOME="$(mktemp -d)" \
GS_CACHE_FILE_PATH="$XDG_CACHE_HOME/genesis" \
QD_OFFLINE_CACHE_FILE_PATH="$XDG_CACHE_HOME/quadrants" \
python your_script.py
GS_CACHE_FILE_PATH:Genesis World 的缓存目录。QD_OFFLINE_CACHE_FILE_PATH:Quadrants 编译器的缓存目录。XDG_CACHE_HOME:基础缓存目录,仅在 Linux 上有效。
在 Linux 上,只用 XDG_CACHE_HOME 就足以重定位 Genesis World 缓存。在 Windows 和 macOS 上它会被忽略,因此需要像上面那样显式设置 GS_CACHE_FILE_PATH 和 QD_OFFLINE_CACHE_FILE_PATH。
解读 FPS 计数器#
默认情况下,Genesis World 会在仿真运行时把达到的步进速率打印到终端。输出大致如下:
Running at 43,000,000.00 FPS (1,433.33 FPS per env, 30000 envs).
三个数字,都是对最近若干步平滑后的结果:
总 FPS: 所有环境加总的每秒步数。这是与基准对比时使用的吞吐量指标。
单环境 FPS: 总和除以环境数量。在比较不同批大小的场景时很有用。
环境数: 传给
scene.build()的n_envs值。场景没有批维度时会省略。
Genesis World 度量的是从一步开始到下一步开始之间的周期,因此你在两次 scene.step() 调用之间的代码所花的时间也计算在内;它以每段 50 ms 的步进为一个样本做指数移动平均,所以无论步进速率如何,速率都会稳定在一个值上。每个样本打印一行。
配置计数器#
计数器由 gs.options.ProfilingOptions 控制,该对象传给场景:
scene = gs.Scene(
profiling_options=gs.options.ProfilingOptions(
show_FPS=True, # print the smoothed step rate; default True
FPS_tracker_alpha=0.95, # EMA momentum for the smoothed rate
),
)
show_FPS: 是否打印速率。需要安静的运行时,或日志行会干扰你自己的输出时,设为False。FPS_tracker_alpha: 平滑动量,取值在 0 到 1 之间。值越大反应越慢、读数越平稳;值越小越能紧跟速度的突变。
场景通过 scene.profiling_options 暴露这些实时选项,所以可以在构造之后切换计数器:
scene.profiling_options.show_FPS = False
ProfilingOptions 如何与其他选项对象配合,见 选项系统。
解读单步分解#
FPS 计数器报告的是整个循环的速率,其中包含场景围绕物理所做的一切,以及你在两步之间执行的代码。要看清这些时间花在哪里,可在步进之后读取 scene.timings。它把最新一步的每个阶段映射到以秒为单位的实际耗时:
阶段 |
涵盖内容 |
|---|---|
|
求解器在各子步中推进状态 |
|
传感器更新, |
|
查看器刷新 |
|
记录器读取状态 |
|
相机为录制渲染并编码一帧 |
|
对 |
|
从上一阶段开始到本步开始的墙钟时间,包含两步之间你自己的代码,因此其倒数就是循环实际达到的速率 |
一步跳过的阶段不会出现在映射中,所以没有传感器的场景永远不会报告 sensors 时间,第一步也没有 period。在第一次 scene.step() 返回之前,该映射一直为空;被步前回调否决的一步(例如查看器处于暂停状态)会把它清空,因为仿真没有推进,随后的一步也就没有 period。重置场景或恢复检查点不会改变这些计时。
每个读数只涵盖最新一步,所以会逐步抖动。对你每一步都要读取的量,可用 RunningMean 在若干步的窗口上或按指数移动平均做平滑。把某个阶段取倒数即可得到速率,例如不含周围传感器、渲染和记录的纯物理步进速率:
from genesis.utils.tools import RunningMean
physics_time = RunningMean(window=100) # mean over the latest 100 steps, NaN until 100 steps have run
for _ in range(1000):
scene.step()
physics_time.update(scene.timings["physics"])
physics_fps = 1.0 / physics_time.value # steps per second of the physics alone
examples/rigid/hibernation.py 会在物体堆逐渐稳定时,把这一速率与清醒刚体的数量一起实时绘制出来。
Warning
在 GPU 后端上,阶段度量的是主机端启动其内核所花的时间,而设备是异步执行这些内核的,因此只有在 CPU 后端上各阶段相加才等于真实的步进时间。只要一步中有代码从设备回读数据(这会让主机等待它),step 在每个后端上都是准确的。
测量吞吐量#
examples/speed_benchmark 中的脚本是在你自己的硬件上搭建干净基准测试的权威参考,所以下面的片段只突出关键选择。
examples/speed_benchmark/franka.py 在数万个环境中运行一台 Franka 机械臂:
gs.init(backend=gs.gpu, performance_mode=True)
scene = gs.Scene(
rigid_options=gs.options.RigidOptions(dt=0.01),
show_viewer=False,
)
# ... add plane and franka ...
scene.build(n_envs=30000, env_spacing=(1.0, 1.0))
三个选择使它成为吞吐量基准,而不是交互式会话:
performance_mode=True把静态张量形状固化进编译后的内核以加快步进,代价是场景变化时需要重新编译。用于固定的基准测试或训练运行;迭代开发时保持关闭。show_viewer=False以无头模式运行。渲染窗口会把吞吐量限制在显示刷新率,失去意义。较大的
n_envs让 GPU 保持饱和。吞吐量随批大小增长,直到显存耗尽。
examples/speed_benchmark/anymal_c.py 是足式机器人的对应版本。这些设置背后的理由以及其他提升吞吐量的方法,见 并行仿真 和 编写高效的 RL 环境。
用 PyTorch profiler 为 GPU 内核计时#
FPS 计数器告诉你有多快,而不是为什么。要查看单个 GPU 内核及其之间的空隙,在你关心的步进周围挂上 PyTorch profiler。无论你的仿真是否使用 PyTorch 张量,它都会记录 CPU 与 CUDA 活动。
profiler 接受一个调度表,用来挑选记录哪些步,从而把一次性初始化和最初那些需要编译的步排除在追踪之外。每仿真一步就用 profiler.step() 推进一次,并在上下文管理器关闭后导出追踪:
from torch.profiler import ProfilerActivity, profile, schedule
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=80, warmup=3, active=1, repeat=1), # tune to your own scene
record_shapes=False,
profile_memory=False,
with_stack=True,
with_flops=False,
) as profiler:
for _ in range(100):
scene.step()
profiler.step()
profiler.export_chrome_trace("trace.json")
调度表各阶段以 profiler.step() 的调用次数计数,所以至少要调用 wait + warmup + active 次:
wait: 开头跳过的步数,要足够长以越过所有你不想看的内容。warmup: profiler 预热自身时运行的步数,追踪结果会被丢弃。active: 实际记录的步数。通常一步就够,也能保持较低的内存占用。repeat: 循环运行多少次。保持为 1,即记录一次后停止。
上面的取值是起点而非推荐值,请按你的场景调整;其余细节见 PyTorch profiler 调度表文档。在 Perfetto 中打开 trace.json。examples/speed_benchmark/timers.py 展示了另一种常见输出:通过 on_trace_ready=torch.profiler.tensorboard_trace_handler("./benchmark") 为 TensorBoard 写出追踪。
只关注 CPU 侧时,像 py-spy 这样的采样分析器能给出类似的火焰图。PyTorch profiler 在这里有两个优势:调度表把初始化排除在追踪之外,并在每次运行时记录相同的步,因此不同运行的计时可以相互比较;它记录的是真实的调用序列,而不是对调用的统计采样。
解读追踪结果#
在 GPU 时间线上,你能得到每个内核启动的精确起始时间和持续时间。内核之间的白色空隙是启动开销:GPU 空转,等待 CPU 跟上。在大规模并行仿真中,这些空隙应当可以忽略;如果它们占了主导,说明仿真受 CPU 制约,在消除停顿之前,增加再多环境也无济于事。诊断并消除这些停顿是 编写高效的 RL 环境 的主题。
GPU 时间线本身没有调用层级,所以很难判断某个内核属于 Python 侧的哪个操作。在每一步之前插入一次同步,强制两者对齐:
qd.sync() # block until all Quadrants GPU work completes, so the CPU and GPU timelines line up
scene.step()
profiler.step()
Warning
每步都同步会使 CPU 和 GPU 串行化,吞吐量可能大约减半。用它来理解追踪结果,然后在测量速度之前去掉它。
为内核内部的代码计时#
PyTorch profiler 为整个 CUDA 内核计时,这已经比 CPU 分析器加一次同步深入一层。但它看不到内核内部,而单个 Quadrants 内核常常运行多个阶段,例如刚体求解器的线搜索、梯度更新和约束更新。要按环境分别给这些阶段计时,可在每个代码块前后读取硬件周期计数器 qd.clock_counter(),并把差值写入缓冲区。
刚体约束求解器为此保留了一个缓冲区:scene.rigid_solver.constraint_solver.constraint_state.timers,形状为 (10, n_envs):每个计时器一行,每个环境一列。求解器出厂时没有任何插桩,所以在你添加之前缓冲区一直是零。先定义一个为各行命名的枚举,让内核与读取结果的脚本对行号达成一致:
from enum import IntEnum
class Time(IntEnum):
Solve = 0
LineSearch = 1
UpdateGradient = 2
然后,在内核遍历环境的循环中,在每个要度量的代码块前后各取一次读数。例如,要给 _kernel_solve_monolith(genesis/engine/solvers/rigid/constraint/solver.py)中每个环境的整个约束求解计时:
for i_b in range(_B):
start = qd.clock_counter()
# ... the existing body of the loop ...
constraint_state.timers[Time.Solve, i_b] = qd.clock_counter() - start
要给连续的代码块计时,例如一次迭代中的 Time.LineSearch 和 Time.UpdateGradient,可把一个代码块的结束读数复用为下一个的起始读数。
计数器返回的是原始周期数而非秒数,所以应把读数相互比较,而不要当作时长来读。在 CUDA 上,它统计运行该线程的流式多处理器的周期,其频率跟随 GPU 时钟,而 GPU 时钟会加速和降频。qd.clock_freq_hz() 给出 GPU 的标称时钟频率,可据此推算近似时长。在 Metal 上,计数器始终读数为 0,且缓冲区存放的是 32 位整数而不是 64 位整数。
examples/speed_benchmark/timers.py 在每步之后读取该缓冲区,并在终端中按环境绘制第 0 行,这在没有显示器的远程服务器上也能用:
from genesis.utils.misc import qd_to_torch
timers = qd_to_torch(scene.rigid_solver.constraint_solver.constraint_state.timers)
不带参数运行它会收集并绘制计时;带 --profiling 运行则改为挂上 PyTorch profiler。每多填一行,就为其 TIMER_LABELS 添加一个标签。给求解器插桩意味着修改它的内核,所以这主要面向优化物理的贡献者,而不是典型的仿真代码。
另见#
并行仿真:扩展到大量环境以提升吞吐量。
编写高效的 RL 环境:在训练循环中消除 CPU–GPU 停顿。
选项系统:
ProfilingOptions如何与其他选项对象配合。