G1 motion-tracking 실험 M66은 4,096개 환경에서 PPO 100 update를 실행했습니다. 처리한 transition은 9,830,400개였고 wall-clock time은 5분 32초였습니다. 숫자만 보면 로봇 한 대가 980만 번 차례로 움직인 것처럼 보이지만, 실제 실행 구조는 정반대에 가깝습니다. 4,096대의 상태를 하나의 tensor로 묶어 같은 policy가 동시에 action을 만들고, 물리 시뮬레이터도 복제된 환경을 batch로 전진시킵니다. M66의 motion-tracking 관측 계약은 현재 73차원 flat walk와 다르지만, `num_envs × num_steps_per_env`로 rollout을 수집하는 기반 구조는 같습니다.
그렇다고 “환경 수를 늘리면 학습이 4,096배 빨라진다”라고 말할 수는 없습니다. 한 action 안에서도 PhysX가 네 번 움직이고, 24번의 policy step이 모여야 PPO update가 한 번 시작됩니다. GPU 처리량, 환경별 simulated time, 연구자가 기다리는 wall time은 서로 다른 시계입니다.
이 글은 PPO의 clipping과 advantage를 다시 설명하지 않습니다. 현재 G1 설정의 5ms physics step, decimation 4, 4,096개 vectorized environment, 24-step rollout이 어떤 순서로 연결되는지를 한 시간축으로 정리합니다. 그 시간축을 따라가다 발견한 현재 코드의 두 가지 단위 불일치도 함께 남깁니다.

먼저 네 개의 시계를 분리해야 합니다
학습 로그의 `step`이라는 단어는 문맥에 따라 다른 시간을 가리킵니다. 이 구분을 하지 않으면 200Hz physics와 50Hz policy를 같은 loop로 이해하거나, 9.83M transition을 9.83M초의 경험으로 잘못 읽게 됩니다.
| 시간축 | 현재 G1의 단위 | 답하는 질문 |
|---|---|---|
| physics time | 5ms, 200Hz | 관절·접촉·마찰을 얼마나 잘게 적분하는가 |
| policy / environment time | 20ms, 50Hz | 정책이 언제 새 action을 내고 reward·observation을 받는가 |
| rollout time | 환경당 24 step, 0.48초 | PPO update 전까지 각 환경에서 얼마를 수집하는가 |
| wall time | GPU와 scene에 따라 달라짐 | 사람이 실제로 얼마나 기다렸는가 |
physics time과 policy time은 시뮬레이션 안의 시간입니다. wall time은 Windows와 GPU가 계산을 끝내는 데 걸린 현실 시간입니다. headless 실행이 빨라져도 physics dt가 바뀐 것은 아니고, 환경 수를 늘려 초당 transition이 많아져도 로봇 한 대의 0.48초 rollout이 더 긴 경험으로 변하지는 않습니다.
G1의 물리 세계는 5ms마다 한 번씩 전진합니다
`G1WalkEnvCfg`의 `SimulationCfg`는 `dt=1/200`으로 설정돼 있습니다. 따라서 PhysX의 한 step은 0.005초, 주파수로는 200Hz입니다.
sim = SimulationCfg(
dt=1 / 200,
render_interval=4,
)
decimation = 4
physics dt는 policy network를 얼마나 자주 실행할지 정하는 값이 아닙니다. 관절 drive, 링크 관성, 발 충돌과 마찰을 다음 상태로 적분하는 간격입니다. 간격이 지나치게 크면 빠른 접촉 변화와 stiff actuator를 거칠게 계산할 수 있고, 작게 만들면 같은 simulated second를 얻기 위해 더 많은 물리 계산이 필요합니다.
현재 G1의 20초 episode를 물리 기준으로 보면 최대 4,000 physics step입니다. 그러나 PPO가 4,000개의 새 action을 내는 것은 아닙니다. 그 사이에 decimation이라는 두 번째 주기가 들어갑니다.
decimation 4는 하나의 action을 네 물리 step 동안 유지합니다
Isaac Lab의 `DirectRLEnv.step()`은 policy action을 받은 뒤 `_pre_physics_step()`을 한 번 호출합니다. 그 다음 `decimation`만큼 loop를 돌면서 `_apply_action()`, simulator step, scene buffer update를 반복합니다. loop가 끝나야 reward와 done을 계산하고 종료된 환경을 reset한 뒤 observation을 만듭니다. 이 순서는 DirectRLEnv 공식 소스와 direct workflow 튜토리얼에 명시돼 있습니다.
현재 G1에서는 다음처럼 진행됩니다.
t = 0 ms policy → 29D action, q_target 계산
t = 5 ms 같은 q_target으로 physics step 1
t = 10 ms 같은 q_target으로 physics step 2
t = 15 ms 같은 q_target으로 physics step 3
t = 20 ms 같은 q_target으로 physics step 4
done → reward → reset → 73D observation
정책 주기는 `physics_dt × decimation`, 즉 `0.005 × 4 = 0.020초`입니다. policy는 50Hz로 새 action을 내고, implicit actuator는 그 사이 네 번의 물리 적분에서 같은 position target을 따라갑니다. 이 action이 nominal joint pose와 어떻게 합쳐지고 torque로 이어지는지는 position target·PD actuator 글이 소유하는 범위입니다.
reward와 observation은 물리 step마다 새로 계산되지 않습니다
현재 `G1WalkEnv`의 `_apply_action()`은 robot data를 simulator에 쓰고, Isaac Lab base class가 이를 decimation loop 안에서 네 번 호출합니다. 반면 `_get_rewards()`, `_get_dones()`, `_get_observations()`는 네 번의 물리 step 뒤 environment step 경계에서 호출됩니다.
이 차이는 미분과 누적값에서 바로 드러납니다. 발 체공시간은 reward가 호출되는 주기에 맞춰 한 번에 0.02초씩 증가해야 합니다. 현재 코드는 이 부분을 다음처럼 계산합니다.
policy_dt = cfg.sim.dt * cfg.decimation
self._feet_air_time += policy_dt
0.005초만 더하면 실제 20ms가 지나갔는데 5ms만 기록하게 되고, 네 번 더하면 reward 함수가 한 policy step에 한 번만 실행되므로 구현 순서와 맞지 않습니다. 시간 기반 reward와 termination을 작성할 때 “이 함수가 어느 clock에서 호출되는가”를 먼저 확인해야 하는 이유입니다.
4,096개 환경은 4,096개의 Python process가 아닙니다
`InteractiveSceneCfg`는 `num_envs=4096`, `env_spacing=2.5`, `replicate_physics=True`로 설정돼 있습니다. `G1WalkEnv._setup_scene()`은 기준 환경을 만들고 `clone_environments()`로 복제합니다. 각 로봇은 scene 안에서 다른 origin을 갖지만 Python에서 환경 객체 4,096개를 순서대로 호출하는 구조는 아닙니다.
상태는 첫 번째 축이 환경인 tensor로 저장됩니다.
commands : [4096, 3]
observations : [4096, 73]
actions : [4096, 29]
rewards : [4096]
dones : [4096]
actor는 `[4096, 73]` 관측 batch를 받아 한 번의 batched forward에서 `[4096, 29]` action을 만듭니다. 환경 코드도 `torch.sum(…, dim=-1)`처럼 같은 연산을 환경 축 전체에 적용합니다. 환경마다 서로 다른 command, pose, contact와 reset 상태를 가지면서 계산 형태는 같은 이유입니다.
이 구조가 병렬 수집의 핵심입니다. 다만 모든 operation이 한 GPU kernel에 들어간다거나 4,096배의 선형 speedup을 보장한다는 뜻은 아닙니다. articulation과 contact가 복잡해질수록 물리 비용이 커지고, VRAM과 tensor 이동, PPO update 시간도 함께 늘어납니다.
한 번의 PPO update는 98,304개 transition을 받습니다
G1의 RSL-RL 설정은 `num_steps_per_env=24`입니다. RSL-RL `OnPolicyRunner`는 이 횟수만큼 `env.step(action)`을 반복해 rollout을 모은 뒤 return을 계산하고 PPO update를 수행합니다.
현재 기본값의 산술은 다음과 같습니다.
policy step = 0.005 s × 4 = 0.020 s
rollout per env = 24 × 0.020 s = 0.48 s
transitions/update = 4,096 × 24 = 98,304
physics substeps/env = 24 × 4 = 96
즉 update 한 번이 받는 경험은 환경별로는 0.48초짜리 조각이지만, 전체 batch에는 98,304개의 state-action-result 묶음이 있습니다. PPO 소개글에서 말한 advantage, value target, clipping은 바로 이 rollout이 만들어진 뒤의 일입니다.
0.48초 rollout이 20초 episode를 잘라 없애는 것은 아닙니다
`episode_length_s=20.0`이고 policy dt가 0.02초라면 한 episode의 최대 길이는 1,000 policy step입니다. 한 rollout의 24 step보다 훨씬 깁니다. 그렇다고 24 step이 끝날 때 모든 환경을 reset하지는 않습니다.
RSL-RL이 update를 수행한 뒤 다음 rollout은 각 환경의 이어지는 상태에서 시작합니다. 중간에 넘어진 환경만 done 신호에 따라 개별 reset됩니다. rollout 끝에서 critic의 value로 bootstrap하고 GAE를 계산하기 때문에, PPO update 경계와 episode 경계는 같은 선이 아닙니다.
이 차이는 낙상처럼 늦게 나타나는 결과를 해석할 때 필요합니다. 24-step rollout은 한 번의 update가 직접 담는 연속 구간이 0.48초라는 뜻이지, policy가 0.48초 이후의 결과를 전혀 배우지 못한다는 뜻은 아닙니다. 다만 value estimation과 여러 rollout에 걸친 credit assignment가 더 큰 역할을 하므로, 환경별 rollout을 지나치게 짧게 만들면 긴 시간의 원인과 결과를 연결하기 어려워질 수 있습니다.
M66의 9.83M transition은 처리량 증거이지 보행 합격표가 아닙니다
M66의 실제 실행값은 계산과 정확히 맞습니다.
4,096 env × 24 step × 100 update
= 9,830,400 transitions
해당 block은 5분 32초에 실행됐습니다. 이 기록은 vector environment와 PPO runner가 실제로 대규모 rollout을 처리했다는 증거입니다. 그러나 980만 transition이 서로 완전히 독립적이었다는 증거도, 그 policy가 자연스럽게 걸었다는 증거도 아닙니다. M66은 deterministic evaluation에서 별도의 구조·행동 gate를 통과해야 했고, 한 update canary는 wiring 검증으로만 분류됐습니다.
같은 이유로 `steps/s`가 높아졌다는 사실만 보고 checkpoint를 채택하지 않습니다. reset distribution이 좁거나 모든 환경이 같은 reward shortcut에 갇히면 큰 batch도 비슷한 실패를 반복할 수 있습니다. reward hacking 글에서 다룬 것처럼, transition 수는 환경이 어떤 경험을 만들었는지 대신 말해 주지 않습니다.
환경 수를 늘리면 바뀌는 것과 바뀌지 않는 것
4,096개 환경은 update 한 번의 batch를 크게 만들고 GPU의 병렬 계산을 활용합니다. 같은 wall time에 더 많은 command와 초기 상태를 볼 수 있다면 on-policy PPO가 새 policy로 다음 rollout을 얻는 속도도 빨라집니다. 그러나 다음 항목은 별도로 확인해야 합니다.
- 처리량: rollout 수집 시간과 PPO update 시간을 따로 기록해야 합니다.
- 메모리: observation, action, critic state, rollout storage와 physics buffer가 환경 수에 따라 커집니다.
- 경험 다양성: seed 하나와 같은 command 분포를 쓴다고 sample이 자동으로 독립적이지는 않습니다.
- episode 구성: 무작위 episode length 초기화와 asynchronous reset이 rollout의 phase 편향을 줄이는지 봅니다.
- 실패 비율: 환경 수가 커도 특정 termination이 batch 대부분을 차지하면 update가 그 실패에 지배됩니다.
`training/rl/train.py`는 기본 4,096개를 사용하지만 CLI의 `–num_envs`가 scene config를 덮어씁니다. 그래서 64-env deterministic evaluation과 4,096-env learning block을 같은 종류의 실행으로 취급하지 않습니다. 앞의 것은 반복 가능한 행동 평가에, 뒤의 것은 대규모 on-policy 수집에 맞춘 조건입니다.
render interval은 policy dt와 같은 개념이 아닙니다
현재 설정에서는 `render_interval=4`와 `decimation=4`가 같아서 화면 갱신 주기와 environment step이 모두 20ms입니다. 하지만 두 값의 책임은 다릅니다. decimation은 action과 environment step의 주기를 정하고, render interval은 GUI 또는 RTX sensor가 simulation frame을 언제 그릴지 정합니다.
현재 `g1_walk_env.py`의 command ramp, gait phase, 일부 time window와 timeout 계산은 다음 형태를 사용합니다.
policy_dt = cfg.sim.dt * cfg.sim.render_interval
지금은 `0.005 × 4`라서 결과가 0.02초로 맞습니다. 그러나 카메라 주기나 GUI 성능 때문에 render interval만 2 또는 8로 바꾸면 policy가 여전히 50Hz인데 gait phase와 termination 시간은 두 배 빠르거나 느리게 흐르게 됩니다. 이것은 현재 rollout에서 수치 오류가 관찰됐다는 주장이 아니라, 두 설정값이 우연히 같아 잠복한 결합입니다.
Isaac Lab은 이미 `physics_dt`와 `step_dt` property를 구분합니다. 시간 기반 task logic은 `self.step_dt`, episode 길이는 base class의 `max_episode_length`를 기준으로 삼는 편이 의미를 보존합니다.
관절 가속도 계산에는 실제로 네 배의 시간 불일치가 있습니다
더 직접적인 검토 지점은 joint acceleration입니다. `_prev_joint_vel`은 `_get_rewards()` 끝에서 저장되고, 다음 `_get_rewards()`에서 현재 joint velocity와 비교됩니다. 공식 step 순서에 따르면 두 sample 사이에는 physics step 하나가 아니라 policy step 하나, 즉 0.02초가 흐릅니다.
하지만 현재 계산은 다음과 같습니다.
joint_accel = (joint_vel - prev_joint_vel) / cfg.sim.dt
분모가 0.005초이므로 같은 속도 변화에 대해 `Δv / 0.02`보다 가속도가 네 배 크게 계산됩니다. cost가 acceleration 제곱합이면 clip 이전 값은 16배가 됩니다.
여기서 상태를 과장하면 안 됩니다. 현재 기본 reward profile인 `compact_v344_inertia_wbo_swing_lane`은 `rew_accel`을 최종 compact reward에 포함하지 않습니다. 따라서 이 불일치가 현재 기본 profile의 보행 실패 원인이라고 말할 증거는 없습니다. 다만 값은 매 reward call에 계산되고 일반 reward 경로에는 포함될 수 있어, profile을 바꾸거나 telemetry를 해석할 때 잘못된 단위가 다시 활성화될 수 있습니다.
시간축의 현재 상태를 구현·실행·채택으로 나누면
| 단계 | 현재 상태 | 근거 |
|---|---|---|
| 설계됨 | 200Hz physics, 50Hz policy, 4,096-env rollout | G1 environment·scene·agent config |
| 구현됨 | DirectRLEnv decimation과 RSL-RL vector wrapper 연결 | `g1_walk_env.py`, `train.py` |
| 실행됨 | M66 4,096-env, 100-update block | 9.83M transition, 5분 32초 기록 |
| 관측됨 | 대규모 rollout 처리와 checkpoint 생성 | runner log와 experiment report |
| 채택됨 | 시간 계약은 아직 보류 | render coupling과 acceleration 단위 불일치 |
| 배포됨 | 없음 | G1 target runtime에 승인된 동일 계약 없음 |
vectorized PPO 경로가 실행됐다는 사실과 시간 단위가 모든 reward·termination에서 일관되다는 결론은 다른 상태입니다. 이 글을 쓰면서 확인한 것은 전자이고, 후자는 추가 검사가 필요합니다.
다음 실험은 환경 수를 늘리는 일이 아니라 clock을 고정하는 일입니다
다음 수정에서는 먼저 수동으로 만든 `sim.dt × …` 표현을 의미 단위로 바꿀 계획입니다.
- physics 계산에는 `self.physics_dt`, policy·reward·phase에는 `self.step_dt`를 사용합니다.
- timeout은 수동 division 대신 `max_episode_length`와 같은 base contract에 맞춥니다.
- joint velocity sample 주기를 확인해 acceleration 분모를 `step_dt`로 고치거나, physics-step acceleration이 필요하면 physics loop에서 별도 측정합니다.
- decimation을 4에서 2로 바꾸고 render interval은 4로 유지하는 단위 검사를 만들어 gait phase·air time·termination의 실제 초가 보존되는지 확인합니다.
- 512, 1,024, 2,048, 4,096 환경에서 rollout collection time, PPO update time, peak VRAM, transition/s를 분리해 기록합니다.
- 같은 benchmark에서 command·reset·termination 분포까지 비교해 큰 batch가 같은 실패만 복제하지 않는지 검사합니다.
4,096개 환경의 의미는 로봇을 많이 띄웠다는 데 있지 않습니다. 20ms마다 정책이 만드는 4,096개의 서로 다른 결과를 같은 update에 모을 수 있다는 데 있습니다. 그 장점을 제대로 쓰려면 5ms physics clock, 20ms policy clock, 0.48초 rollout, 현실의 wall time을 끝까지 구분해야 합니다. 지금 남은 일은 더 큰 숫자를 만드는 것이 아니라, 코드 전체가 어느 시계를 읽는지 검증하는 것입니다.