Performance¶
JoltGym is designed for high-throughput reinforcement learning training. This page documents its performance characteristics and scaling behavior.
Benchmark: HalfCheetah on Apple Silicon¶
All benchmarks run on the same machine with HalfCheetah-v0 (frame_skip=5, dt=0.01s).
JoltGym vs MuJoCo¶
| Environments | JoltGym (C++ WorldPool) | MuJoCo (AsyncVectorEnv) | Speedup |
|---|---|---|---|
| 1 | 11,935 sps | 17,477 sps | 0.68x |
| 8 | 33,292 sps | 11,024 sps | 3.0x |
| 64 | 64,503 sps | 18,608 sps | 3.5x |
| 256 | 73,606 sps | 18,287 sps | 4.0x |
sps = env-steps per second
Key Observations¶
- MuJoCo is faster single-threaded -- its hand-optimized C engine wins for a single environment
- JoltGym scales linearly -- C++ WorldPool distributes work across OS threads with near-zero overhead
- 3.8x faster at 64+ envs -- the crossover point is around 4-8 environments
- MuJoCo plateaus -- Python subprocess overhead (AsyncVectorEnv) limits MuJoCo's scaling
Why JoltGym Scales Better¶
The Python Subprocess Problem¶
MuJoCo's vectorized environments (SubprocVecEnv, AsyncVectorEnv) create one Python subprocess per environment. Each step requires:
- Serialize actions via IPC (pickle)
- Deserialize in the subprocess
- Call MuJoCo's C step function
- Serialize observations via IPC
- Deserialize in the main process
This IPC overhead dominates at high environment counts, capping throughput around ~18K sps regardless of CPU cores available.
JoltGym's WorldPool Approach¶
JoltGym's WorldPool eliminates Python entirely from the hot loop:
- GIL release -- Python's GIL is released before entering C++
- C++ ParallelFor -- Actions are distributed across
min(hardware_concurrency, 16)native OS threads - No serialization -- Actions and observations are raw float arrays in shared memory
- Single-threaded Jolt per world -- Each
PhysicsSystemusesJobSystemSingleThreaded, avoiding thread pool contention - Auto-reset in C++ -- Terminal environments are reset without returning to Python
Python ──release GIL──> C++ ParallelFor ──> [thread 0] step envs 0..15
──> [thread 1] step envs 16..31
──> ...
──> [thread N] step envs (N-1)*16..255
<──join──────────
<──acquire GIL── return batched arrays
Scaling Characteristics¶
Thread Scaling¶
Throughput scales approximately linearly up to hardware_concurrency threads:
Beyond the number of physical cores, returns diminish due to hyperthreading contention.
Environment Count Scaling¶
For a fixed number of threads, increasing environments beyond threads has diminishing returns as each thread handles more work sequentially:
| Environments | Threads Used | SPS |
|---|---|---|
| 1 | 1 | 11,935 |
| 8 | 8 | 33,292 |
| 16 | 16 | 48,000 |
| 64 | 16 | 64,503 |
| 256 | 16 | 73,606 |
Reproducing Benchmarks¶
WorldPool Benchmark¶
import time
import numpy as np
from joltgym.vector import JoltVectorEnv
for num_envs in [1, 8, 64, 256]:
envs = JoltVectorEnv(num_envs=num_envs,
model_path="python/joltgym/assets/half_cheetah.xml")
envs.reset(seed=42)
num_steps = 10000
start = time.time()
for _ in range(num_steps):
actions = np.random.uniform(-1, 1,
(num_envs, 6)).astype(np.float32)
envs.step(actions)
elapsed = time.time() - start
sps = num_envs * num_steps / elapsed
print(f" {num_envs:4d} envs: {sps:,.0f} sps")
MuJoCo Comparison¶
import gymnasium as gym
from gymnasium.vector import AsyncVectorEnv
import time
import numpy as np
for num_envs in [1, 8, 64, 256]:
envs = AsyncVectorEnv([
lambda: gym.make("HalfCheetah-v4")
for _ in range(num_envs)
])
envs.reset(seed=42)
num_steps = 10000
start = time.time()
for _ in range(num_steps):
actions = np.random.uniform(-1, 1,
(num_envs, 6)).astype(np.float32)
envs.step(actions)
elapsed = time.time() - start
sps = num_envs * num_steps / elapsed
print(f" {num_envs:4d} envs: {sps:,.0f} sps")
envs.close()
Optimization Tips¶
Choose the right vectorization
- 1-4 environments: use
joltgym.make()directly orSubprocVecEnv - 8+ environments: use
JoltVectorEnvfor the C++ WorldPool - 64-256 environments: maximum throughput zone for WorldPool
Frame skip
Higher frame_skip reduces the number of physics steps per env.step() call, proportionally increasing throughput. The default of 5 is a good balance between speed and simulation fidelity.
Batch size alignment
When using WorldPool with PPO, align n_steps * num_envs with your batch size for efficient GPU utilization.