Context
Follow-up to #663。跑了一组独立于 UniLab 的 standalone probe(benchmark/benchmark_env_overhead_parallel.py, 812 行,无 UniLab 依赖)来回答:把 NpEnv.step 里 obs/reward/termination 那段 update_state 并行化,实测天花板在哪。
Probe 设置对齐真实训练:num_envs=8192/32768, act_dim=29, obs_dim=98/101, dtype=float32,15 个 active reward terms(对齐 motion-tracking scale),4 个 noise buffer,per-reward log mean,2% _reset_done_envs 拷贝。
三条路径对比:numpy 基线 / ThreadPoolExecutor + numpy shards / Numba prange 融合 kernel。
结论
1. Numba prange 融合 kernel 在三台机器上都拿到显著加速
Xeon 8568Y+ (160T, 2-socket)
| 配置 |
numpy 基线 |
Numba 最佳 |
Speedup |
| 8k envs, 双 socket (tbb) |
4.55 ms |
0.312 ms @ 32t |
14.6x |
| 8k envs, `numactl -N 0` |
4.55 ms |
0.260 ms @ 24t |
18.0x |
| 32k envs, 双 socket |
19.4 ms |
0.671 ms @ 48t |
28.8x |
| 32k envs, `numactl -N 0` |
19.4 ms |
0.664 ms @ 32t |
31.4x |
AMD Ryzen 9950X3D (16C/32T)
| 配置 |
numpy 基线 |
Numba 最佳 |
Speedup |
| 8k envs (omp) |
2.24 ms |
0.139 ms @ 32t |
16.14x |
| 32k envs (omp) |
8.84 ms |
0.334 ms @ 16t |
26.45x |
Apple M-series (18C, workqueue)
| 配置 |
numpy 基线 |
Numba 最佳 |
Speedup |
| 8k envs |
1.78 ms |
0.255 ms @ 16t |
6.96x |
| 32k envs |
9.40 ms |
0.567 ms @ 16t |
16.56x |
单核 fusion 贡献 2.3-3.8x(消掉每个 reward term 的中间 numpy array),并行再拿 ~5-10x。大 batch 一致地给出更高 speedup——每个 parallel section 摊到更多 env,barrier / launch cost 摊平。
2. False sharing 是并行的真正天花板
kernel 里 15 个 reward log 累加不能写成 shared scalar 的 `log_partial_sum[k] += ...`——所有 thread `+=` 同一 cache line 会造成持续 ping-pong,Xeon 上 `nthreads=32` 只到 5.5x。必须用 per-thread scratch `log_thread_sum[tid, k]`,kernel 结束后主线程 `axis=0` sum。同一份代码这个改动之后到 14.6x(8k)/ 31.4x(32k)。
3. update_state 只是 Env overhead 的一半,reset_done 是并列瓶颈
来自 #663 中 b44ae656 之后的完整细分(Xeon 8568Y+ / 8192 envs):
| Task |
Backend |
update_state |
reset_done |
主瓶颈 |
| g1_motion_tracking |
mujoco |
35.1 ms |
12.6 ms |
update_state 主导 |
| g1_motion_tracking |
motrix |
40.7 ms |
16.4 ms |
update_state 主导 |
| g1_walk_flat |
mujoco |
8.4 ms |
8.6 ms |
两者相当 |
| g1_walk_flat |
motrix |
10.3 ms |
12.4 ms |
reset_done 略大 |
`reset_done` 的开销分布:
- Terminal-observation copy / scatter:sub-ms,可忽略
- `reset call` 8-15.8 ms,主要在:
- `g1_walk_flat`:几乎全在 backend `set_state`(7.2-11.7 ms)
- `g1_motion_tracking`:reset plan + backend `set_state` + reset observation(其中 full-batch body-pose getter ~3.2-3.7 ms)
只上 Numba 优化 update_state 时:`g1_motion_tracking` 的 overhead 削减 71%(40.7→1.3ms + 16.4→16.4ms),`g1_walk_flat` 只削减 54%(10.3→0.3 + 12.4→12.4ms)。walk_flat 的 reset_done 会立即成为新瓶颈。
4. ThreadPool + numpy shards 是死路
Xeon 上 K=2 只 1.7x,K=4 已经打平,K≥8 全崩(GIL 争用 + Python log dict + 双路 NUMA)。Ryzen 稍好(K=4 @ 32k 到 2.95x),但仍远不及 Numba,也无法组合使用。
5. Distribute / aggregate 可忽略
`pool.submit` ~10 μs,aggregate(log 归并 + 2% final_observation 拷贝)~30-50 μs,总占比 <2%。不用为分片调度做优化。
6. RNG 是独立的天花板
Noise 生成(`rng.standard_normal` × 4)单线程占 5-6 ms,在 update_state 里占 55-80%(Xeon 上 80%)。Numba prange 无法帮它并行,需要独立方案(in-kernel numba RNG / 多 BitGenerator / 复用 buffer)。
推荐路径
主线 A(P0):Numba prange 融合 kernel — `update_state` 层,跨 task 通用
对 `g1_motion_tracking` 直接把 overhead 打掉 71%,对 `g1_walk_flat` 也能砍掉 update_state 全部时间。关键实现要点:
- 融合 reward dispatch + obs concat + termination 到一个 `@njit(parallel=True)` prange loop
- reward log 累加必须 per-thread scratch,`(nthreads, num_terms)` shape,最后 `axis=0` sum
- noise buffer 从 kernel 外传入(RNG 独立处理)
- kernel 内部通过 `numba.set_num_threads(24-32)` 控制并发,不要全进程 `numactl -N 0`(那会伤 Physics,Xeon 8568Y+ Physics 已经吃到 22-66ms/步)
主线 B(P1):reset_done 拆解优化 — 按 task 分头做,与 A 并行推进
- `g1_motion_tracking`:row-wise body-pose getter,只对 done 的 env 拉数据(3.2-3.7 ms → sub-ms)
- `g1_walk_flat`:sparse `backend.set_state` — 需要 backend 层增加 index-based 写接口(7-12 ms → 期望 sub-ms)
P2(可分阶段叠加)
- RNG 并行化(独立 ~5ms 收益,对所有 task 有效)
- Async pipeline(`step t+1 physics ‖ step t update_state`,乘性 ~2x)
不做
- `ThreadPoolExecutor + numpy shards`(Xeon 无效,Mac/Ryzen 上限低,无法组合 Numba)
- 对 distribute/aggregate 做优化(<2% 占比)
- 全进程 `numactl -N 0` pin(伤 Physics)
预期收益(Xeon 8568Y+ / 8192 envs / motrix)
| 阶段 |
动作 |
motion_tracking overhead |
walk_flat overhead |
motion_tracking throughput |
| 现状 |
— |
60.3 ms |
27.9 ms |
84k env/s |
| P0 |
Numba prange `update_state` |
17.7 ms (-71%) |
12.7 ms (-54%) |
~145k env/s (+72%) |
| P0+P1a |
motion_tracking row-wise body-pose |
~15 ms |
— |
~155k env/s |
| P0+P1b |
walk_flat sparse `set_state` |
— |
~5-7 ms |
walk_flat +20-30% |
CPU% 预期从当前 13-77% 提到 60-80% 区间(依 task 和 backend 不同)。P0 完成后 Physics 可能成为新瓶颈,届时再评估。
探针复现
- 代码:
benchmark/benchmark_env_overhead_parallel.py(branch: `bench/env-overhead-parallel-probe`,812 行,无 UniLab 依赖)
- 运行:`uv run --with numpy --with numba python benchmark/benchmark_env_overhead_parallel.py`
- 大 batch:`PROBE_NUM_ENVS=32768 uv run --with numpy --with numba python benchmark/benchmark_env_overhead_parallel.py`
- Xeon 单 socket 测试:`PROBE_NUM_ENVS=32768 numactl -N 0 --membind 0 uv run --with numpy --with numba python benchmark/benchmark_env_overhead_parallel.py`
Refs: #663
Context
Follow-up to #663。跑了一组独立于 UniLab 的 standalone probe(
benchmark/benchmark_env_overhead_parallel.py, 812 行,无 UniLab 依赖)来回答:把NpEnv.step里 obs/reward/termination 那段 update_state 并行化,实测天花板在哪。Probe 设置对齐真实训练:
num_envs=8192/32768,act_dim=29,obs_dim=98/101,dtype=float32,15 个 active reward terms(对齐 motion-tracking scale),4 个 noise buffer,per-reward log mean,2%_reset_done_envs拷贝。三条路径对比:numpy 基线 /
ThreadPoolExecutor + numpy shards/Numba prange 融合 kernel。结论
1. Numba prange 融合 kernel 在三台机器上都拿到显著加速
Xeon 8568Y+ (160T, 2-socket)
AMD Ryzen 9950X3D (16C/32T)
Apple M-series (18C, workqueue)
单核 fusion 贡献 2.3-3.8x(消掉每个 reward term 的中间 numpy array),并行再拿 ~5-10x。大 batch 一致地给出更高 speedup——每个 parallel section 摊到更多 env,barrier / launch cost 摊平。
2. False sharing 是并行的真正天花板
kernel 里 15 个 reward log 累加不能写成 shared scalar 的 `log_partial_sum[k] += ...`——所有 thread `+=` 同一 cache line 会造成持续 ping-pong,Xeon 上 `nthreads=32` 只到 5.5x。必须用 per-thread scratch `log_thread_sum[tid, k]`,kernel 结束后主线程 `axis=0` sum。同一份代码这个改动之后到 14.6x(8k)/ 31.4x(32k)。
3.
update_state只是 Env overhead 的一半,reset_done是并列瓶颈来自 #663 中
b44ae656之后的完整细分(Xeon 8568Y+ / 8192 envs):`reset_done` 的开销分布:
只上 Numba 优化 update_state 时:`g1_motion_tracking` 的 overhead 削减 71%(40.7→1.3ms + 16.4→16.4ms),`g1_walk_flat` 只削减 54%(10.3→0.3 + 12.4→12.4ms)。walk_flat 的 reset_done 会立即成为新瓶颈。
4. ThreadPool + numpy shards 是死路
Xeon 上 K=2 只 1.7x,K=4 已经打平,K≥8 全崩(GIL 争用 + Python log dict + 双路 NUMA)。Ryzen 稍好(K=4 @ 32k 到 2.95x),但仍远不及 Numba,也无法组合使用。
5. Distribute / aggregate 可忽略
`pool.submit` ~10 μs,aggregate(log 归并 + 2% final_observation 拷贝)~30-50 μs,总占比 <2%。不用为分片调度做优化。
6. RNG 是独立的天花板
Noise 生成(`rng.standard_normal` × 4)单线程占 5-6 ms,在 update_state 里占 55-80%(Xeon 上 80%)。Numba prange 无法帮它并行,需要独立方案(in-kernel numba RNG / 多 BitGenerator / 复用 buffer)。
推荐路径
主线 A(P0):Numba prange 融合 kernel — `update_state` 层,跨 task 通用
对 `g1_motion_tracking` 直接把 overhead 打掉 71%,对 `g1_walk_flat` 也能砍掉 update_state 全部时间。关键实现要点:
主线 B(P1):reset_done 拆解优化 — 按 task 分头做,与 A 并行推进
P2(可分阶段叠加)
不做
预期收益(Xeon 8568Y+ / 8192 envs / motrix)
CPU% 预期从当前 13-77% 提到 60-80% 区间(依 task 和 backend 不同)。P0 完成后 Physics 可能成为新瓶颈,届时再评估。
探针复现
benchmark/benchmark_env_overhead_parallel.py(branch: `bench/env-overhead-parallel-probe`,812 行,无 UniLab 依赖)Refs: #663