At a glance要点
Python against C++ on the same laptop: equality first, then speed, then the training result.同じノートPCでのPython対C++。一致、速度、学習結果の順に。
50M-step PPO run. Return 348.1 ± 8.1 of 500 over 3 seeds. Actions pickled out and observations back through 8 pipes every step.
50M stepのPPO学習。returnは500点中348.1 ± 8.1(3 seeds)。毎step、actionをpickleして8本のpipeで送り、observationを戻します。
Same 50M-step PPO run, ppo.py unchanged. Return 339.4 ± 2.3 over 3 seeds — no backend difference. Shared memory, one atomic counter; ASan · UBSan · TSan clean in CI.
同じ50M stepのPPO学習、ppo.pyは無修正。returnは339.4 ± 2.3(3 seeds)でbackend間の差なし。共有メモリとatomicカウンタ1つ。CIでASan · UBSan · TSanすべてクリア。
Numbers as a table数値を表で見る
| Measure項目 | Python | C++ |
|---|---|---|
| env-steps/s, 8 workers8 workerでのenv-steps/s | 6,385 | 12,327 (1.93×) |
| 50M-step PPO, wall time50M step PPOの所要時間 | 3.8–4.4 h | 3.1 h |
| return of 500, 3 seedsreturn(500点満点)、3 seeds | 348.1 ± 8.1 | 339.4 ± 2.3 |
| efficiency at 8 workers8 workerでの効率 | 42% | 45% |
| observations bit-identicalビット一致したobservation | 6,275 / 6,275 | |
- 01Bit-identical to the Python environment. 6,275 of 6,275 observations, 0 reward diffs over 25 episodes with no tolerance; CI reruns it on every push against a pinned commit of the Python repo.Python環境とビット単位で一致。25 episodesで6,275個中6,275個のobservationが一致、reward差0、許容誤差なし。CIがpushごとに、Pythonリポジトリの固定コミットに対して再実行。
- 021.9–2.0× the Python processes at 8 workers. 12,327 vs 6,385 env-steps/s under identical random actions, above the 1.3× the README set as the bar; the full environment alone runs 20,287 burst and 13,300–16,200 sustained over six minutes.8 workerでPythonプロセスの1.9〜2.0倍。同一のランダムactionで12,327対6,385 env-steps/s。READMEが定めた基準1.3×を上回る。環境単体では20,287 burst、6分間のsustainedで13,300〜16,200。
- 03Trains the same policy, faster. The 50M-step PPO run takes 3.1 h here vs 3.8–4.4 h; 339.4 ± 2.3 vs 348.1 ± 8.1 of 500 over 3 seeds, no backend difference in return or survival, and the same step-size failure and fix reproduce.同じpolicyをより速く学習。50M stepのPPO学習は3.8〜4.4時間に対して3.1時間。3 seedsで500点中339.4 ± 2.3対348.1 ± 8.1、returnにもsurvivalにもbackend間の差なし。同じstep sizeの失敗と対処も再現。
- 0434 C++ cases, 41 Python tests, three sanitizers. CI builds Release plus ASan/UBSan and TSan; four arithmetic details (numpy's pairwise sum,
pow, float32 widening, solver warm-start) decided the last bit.C++ 34ケース、Python 41テスト、3つのsanitizer。CIはReleaseに加えASan/UBSanとTSanでビルド。最終ビットを決めたのは4つの演算の細部(numpyのpairwise sum、pow、float32の拡張、ソルバーのwarm-start)。 - 05Scaling stalls at 45% — the chip, not the processes. Thread efficiency at 8 workers matches the Python processes' 42%, and the Python environment delivers about 6,400 env-steps/s, half the 13,300 its project had budgeted. Equality is proven on one machine, one compiler, one C library.スケーリングは効率45%で頭打ち — 原因はプロセスではなくチップ。8 workerでのスレッド効率はPythonプロセスの42%と同水準。Python環境は約6,400 env-steps/sで、同プロジェクトが見積もった13,300の半分。一致の証明は1台のマシン・1つのコンパイラ・1つのCライブラリでのもの。
In plain words概要
What a vector environment is, why this one is written in C++, and what "bit-identical" buys.
vector environmentとは何か、なぜC++で書いたのか、「ビット単位で一致」で何が得られるのか。
Reinforcement learning trains a policy by letting it act in a simulator millions of times. To do that quickly you run many copies of the simulated robot at once and feed the policy a batch of observations per step. The object that holds those copies and steps them together is called a vector environment.
強化学習では、シミュレータ内でpolicyに何百万回も行動させて学習します。これを速く回すには、シミュレーション上のロボットを多数同時に動かし、 1 stepごとにobservationをバッチでpolicyに渡します。この複数のコピーを保持してまとめてstepさせるものをvector environmentと呼びます。
The Microduck project has one written in Python. Python can only execute one thread of its own code at a time, so its vector environment starts eight separate processes and talks to them through pipes. That works, and every number in that project was measured on it. It also means a fixed cost per step that has nothing to do with physics: serialising actions, waking eight processes, waiting for the slowest one, copying observations back.
MicroduckプロジェクトにはPythonで書いたものがあります。Pythonは自身のコードを同時に1スレッドしか実行できないため、 そのvector environmentは8つの独立したプロセスを起動し、pipeで通信します。これは正しく動き、同プロジェクトの数値はすべてこの上で計測しました。 一方で、物理計算とは無関係な固定コストが毎stepかかります。actionのシリアライズ、8プロセスの起床、最も遅いプロセスの待ち、observationの書き戻しです。
This repository is the same environment in C++. The step loop has no interpreter in it, so eight environments run on eight threads of one process and share memory. A small binding lets Python call it, and the Microduck training script runs on it with no edits.
このリポジトリは同じ環境をC++で実装したものです。stepループにインタプリタが介在しないため、8つの環境が1プロセス内の8スレッドで動き、メモリを共有します。 小さなbindingでPythonから呼び出せるようにしてあり、Microduckの学習スクリプトは無修正で動きます。
A faster environment that behaves even slightly differently trains a different policy, and then nothing measured on the Python environment carries over. So the first thing tested, before any timing, is equality: given the same starting state and the same actions, the C++ environment returns the same 48 floating-point observations, the same reward and the same flags as the Python one, with no tolerance. Over 25 episodes and 6,275 observations there is not one differing bit. That test runs in CI against a pinned commit of the Python repository.
速くてもわずかに挙動が違う環境では別のpolicyが学習され、Python環境で計測したことが何も引き継げなくなります。 そこで時間計測より先に、まず一致をテストしました。同じ初期状態と同じactionを与えると、C++環境はPython環境と同じ48個の浮動小数点observation、 同じreward、同じフラグを、許容誤差なしで返します。25 episodes・6,275個のobservationで、異なるビットは1つもありません。 このテストは、Pythonリポジトリの固定コミットに対してCIで実行しています。
Getting there meant matching how numpy adds fourteen numbers, how Python squares a float, and how a float32 constant is promoted to float64. Those three details are the middle of this page. The throughput numbers are at the end, with their caveats, because they turned out to say something about the Python environment too.
そのためには、numpyが14個の数をどう足すか、Pythonがfloatをどう2乗するか、float32の定数がどうfloat64に昇格されるかを合わせる必要がありました。 この3点がページの中盤です。throughputの数値は注意点とあわせて最後に置いています。結果的にPython環境についても分かったことがあったためです。
Terms used on this pageこのページの用語
Env, VecEnv and the config structs, and passes numpy arrays without copying.C++のクラスをPythonに公開するheader-onlyライブラリ。ここではEnv、VecEnv、設定用の構造体をラップし、numpy配列をコピーなしで受け渡します。Why it exists作った理由
Two open questions from the Python project, and the rule that equality comes before speed.
Pythonプロジェクトに残った2つの疑問と、「速度より先に一致」というルール。
The Python project left two measurements it could not explain from inside Python. First, its environment wrapper cost 25% over bare physics on one core. Second, scaling across eight processes settled at 55% efficiency on one robot model and 42% on the one actually used for training, and the loss was attributed to a shared resource outside the cores, without proof. Whether process isolation itself was part of the loss was unknowable without an implementation that did not use processes.
Pythonプロジェクトには、Pythonの中からは説明できない計測結果が2つ残っていました。1つ目は、1コアで環境wrapperが物理計算単体より25%余計にかかっていたこと。 2つ目は、8プロセスへのスケーリング効率が、あるロボットモデルで55%、実際に学習に使ったモデルで42%にとどまったことです。 この損失はコア外の共有リソースによるものとしていましたが、証明はありませんでした。プロセス分離そのものが損失の一部かどうかは、プロセスを使わない実装がなければ分かりません。
A C++ version answers both, but only if it is the same environment. The order of work therefore was: port the environment, prove equality, and only then run the benchmarks. Timing code that has not been proven equal produces a number about a different environment.
C++版なら両方に答えられますが、それは同じ環境である場合に限ります。そのため作業順は、環境を移植し、一致を証明し、その後で初めてベンチマークを取る、としました。 一致が証明されていないコードの時間を測っても、それは別の環境についての数値にすぎません。
mj_step, one mj_forward, 48-dim observation, reward terms, three seeded pushes, fixed 250-step episodes, autoreset.10 × mj_step、mj_forward 1回、48次元observation、reward項、seed付きの3回のpush、250 step固定のepisode、autoreset。Everything measured in the Python project was measured inside a budget of eight of the machine's
fourteen cores. This repo inherits that rule in code: VecEnv refuses more than eight
threads unless the caller passes allow_overcommit.
Pythonプロジェクトの計測はすべて、マシンの14コアのうち8コアという枠内で行いました。このリポジトリはそのルールをコードとして引き継いでいます。
呼び出し側がallow_overcommitを渡さない限り、VecEnvは8を超えるスレッド数を拒否します。
Every file, and what it is for全ファイルとその役割
Every tracked file outside the vendored test framework, grouped by directory, with line counts.
同梱のテストフレームワークを除く全追跡ファイルを、ディレクトリごとに行数付きで示します。
About 4,100 tracked lines outside third_party/, including the README, CI and the
saved equality report. doctest is vendored under MIT and is the only third-party code.
third_party/以外の追跡行数は約4,100行で、README、CI、保存済みの一致レポートを含みます。
サードパーティのコードは、MITライセンスで同梱しているdoctestのみです。
| Fileファイル | Lines行数 | What it does役割 |
|---|---|---|
| include/vecenv/env.hpp | 139 | The environment interface. EnvConfig (variant, episode length, action scale, pushes,
initial noise, reward version, latency, sensor noise, DR), EpisodeParams (an episode's
drawn values supplied from outside) and StepInfo (reward terms, trunk height, upright
cosine, fallen, pushed, truncated, terminated, step index).環境のインターフェース。EnvConfig(variant、episode長、action scale、push、初期ノイズ、rewardのバージョン、latency、センサーノイズ、DR)、
EpisodeParams(外部から与えるepisodeの抽選値)、StepInfo(reward項、胴体高さ、upright cosine、fallen、pushed、truncated、terminated、step番号)。 |
| src/env.cpp | 281 | One Microduck. Keyframe reset, initial-noise clamp to the joint limits, push schedule, the
latency buffer, the 48-dim observation with float32 scaling, rewards v1 and v2, the
mj_forward that keeps every observation channel on one timestamp.Microduck 1体分。keyframeへのreset、初期ノイズの関節リミットへのクランプ、pushスケジュール、latencyバッファ、float32スケーリング付きの48次元observation、
reward v1とv2、全observationチャネルを同一時刻に揃えるmj_forward。 |
| include/vecenv/vecenv.hpp · src/vecenv.cpp | 88 · 129 | N environments on T threads. Generation counter, spin-then-block workers, env i on thread
i mod T, autoreset with terminal_obs, the eight-thread budget.N個の環境をTスレッドで実行。generationカウンタ、spinしてからblockするworker、env iをスレッドi mod Tに割り当て、
terminal_obs付きのautoreset、8スレッドの上限。 |
| include/vecenv/dr.hpp · src/dr.cpp | 55 · 113 | Domain randomisation: the five servo parameters between the four measured actuator fits, body mass and inertia, floor friction, action latency, initial noise, sensor noise. Apply and restore are exact; a test checks nothing compounds across episodes.Domain randomisation。計測した4つのアクチュエータfitの間で振る5つのサーボパラメータ、リンクの質量と慣性、床の摩擦、action latency、初期ノイズ、センサーノイズ。 適用と復元は厳密で、episodeをまたいで変化が累積しないことをテストで確認しています。 |
| include/vecenv/model.hpp · src/model.cpp | 71 · 139 | Loads a variant from the upstream assets, copies the prototype model per environment, resolves the strided joint indices the backlash variants need, and checks the header and library are the same MuJoCo version.upstreamのアセットからvariantを読み込み、プロトタイプのモデルを環境ごとにコピーし、backlash variantに必要な飛び飛びの関節インデックスを解決し、 ヘッダとライブラリのMuJoCoバージョンが同じであることを確認します。 |
| include/vecenv/numpy_compat.hpp | 66 | The three arithmetic details that decide bit equality: numpy's pairwise sum, Python's
pow, and clip. Section 05.ビット一致を左右する3つの演算の細部。numpyのpairwise sum、Pythonのpow、clip。05章参照。 |
| include/vecenv/rng.hpp · src/rng.cpp | 51 · 79 | PCG64 with numpy's exact core stream, random() and uniform(). Seeding,
normal() and integers() are documented as not numpy's.numpyと完全に同じコアストリームを持つPCG64と、random()・uniform()。seedの与え方、normal()、integers()はnumpyと異なることを明記しています。 |
| include/vecenv/mujoco.hpp | 19 | The one place MuJoCo's header is included, with a workaround so it compiles under AddressSanitizer. Section 07.MuJoCoのヘッダをincludeする唯一の場所。AddressSanitizer下でもコンパイルできるよう回避策を入れています。07章参照。 |
| include/vecenv/boxcheck.hpp · src/boxcheck.cpp | 21 · 94 | Reads the load average and the number of cores before a benchmark and records warnings into the output JSON, so a number taken on a busy machine cannot be read later as a clean one.ベンチマーク前にload averageとコア数を読み、警告を出力JSONに記録します。負荷のあるマシンで取った数値を、後からクリーンな数値と誤読しないためです。 |
| python/bindings.cpp | 336 | The pybind11 module _vecenv_cpp. Zero-copy numpy views over the observation, reward and
done buffers; the config structs; EpisodeParams construction from Python arrays.pybind11モジュール_vecenv_cpp。observation・reward・doneバッファに対するゼロコピーのnumpyビュー、設定用の構造体、Python配列からのEpisodeParams生成。 |
| python/vecenv_cpp/__init__.py | 161 | The drop-in. A VecEnv class with the Python project's constructor and step signature,
and converters that accept its DRConfig dataclass and DomainRandomizer
directly.差し替え用の部分。Pythonプロジェクトと同じコンストラクタ・stepシグネチャを持つVecEnvクラスと、
同プロジェクトのDRConfig dataclassやDomainRandomizerをそのまま受け取れる変換関数。 |
| python/vecenv_cpp/contract.py | 79 | Runs one episode on both environments from the same drawn values and compares everything that comes back. Used by the pytest suite and by the report script.同じ抽選値から両環境で1 episodeを実行し、返ってきたものをすべて比較します。pytestスイートとレポートスクリプトの両方で使います。 |
| scripts/contract_report.py | 63 | The 25-episode equality table, written to runs/contract.json. Counts exact matches
separately from within-tolerance matches so the two never blur.25 episodeの一致表をruns/contract.jsonに書き出します。完全一致と許容誤差内の一致を別々に数え、両者が混ざらないようにしています。 |
| scripts/ppo_cpp.py | 45 | Runs the Python project's ppo.py as __main__ with vecenv_cpp
installed under the module name vec_env. Nothing in the Python project changes.vecenv_cppをモジュール名vec_envとして差し込んだ状態で、Pythonプロジェクトのppo.pyを__main__として実行します。Pythonプロジェクト側は一切変更しません。 |
| bench/bench_vecenv.cpp | 358 | The benchmark, mirroring the Python project's protocol: bracketed sweep with reference windows,
drift and trend guards, a sustained mode, and a bare mode that runs the physics alone.Pythonプロジェクトと同じ手順のベンチマーク。reference windowで挟んだsweep、driftとtrendのガード、sustainedモード、物理計算だけを回すbareモード。 |
| bench/compare.py | 120 | Python processes against C++ threads, alternating backend every window so a machine that changes state biases both the same way.PythonプロセスとC++スレッドの比較。windowごとにbackendを交互に切り替え、マシンの状態変化が両者に同じように効くようにしています。 |
| tests/cpp/*.cpp | 665 | 34 doctest cases, 417,482 assertions: environment contract, DR apply/restore, RNG determinism and moments, the numpy summation order on an input where the orders provably differ, thread-count independence, N threaded envs equal N sequential ones.doctest 34ケース、417,482アサーション。環境のcontract、DRの適用と復元、乱数の決定性とモーメント、加算順序で確実に結果が変わる入力でのnumpy加算順序、 スレッド数への非依存性、N個のスレッド実行環境とN個の逐次実行環境の一致。 |
| tests/py/*.py | 249 | 41 pytest tests: the equality cases, the push-shift sensitivity check, PCG64 against numpy's internal state, version agreement, and the drop-in's shapes, dtypes and info keys.pytest 41テスト。一致ケース、pushをずらしたときの感度チェック、numpyの内部状態に対するPCG64の検証、バージョン一致、差し替えクラスのshape・dtype・infoキー。 |
| CMakeLists.txt · .github/workflows/ci.yml · verify.sh · Dockerfile | 91 · 68 · 84 · 28 | Build (asks Python where the MuJoCo wheel is, links its libmujoco.so), three CI jobs,
the one-command local check, and a container that runs it.ビルド(MuJoCo wheelの場所をPythonに問い合わせ、そのlibmujoco.soをリンク)、3つのCIジョブ、1コマンドで済むローカル検証、それを実行するコンテナ。 |
How the equality test works一致テストの仕組み
Python draws the randomness, C++ replays it, and everything that comes back is compared with no tolerance.
Pythonが乱数を引き、C++がそれを再生し、返ってきたものをすべて許容誤差なしで比較します。
The Python environment gets its randomness from numpy: initial joint noise, then which control steps
the pushes land on (a draw without replacement), their speeds and directions, and under domain
randomisation the servo parameters, mass, friction, latency and noise. Reproducing that draw in C++
would mean reimplementing numpy's seed-sequence expansion, its choice and its ziggurat
normal sampler, and getting all three right. It is possible. It is also beside the point, because the
draw is not what the environment does; it is what the environment is handed.
Python環境の乱数はnumpyから来ます。関節の初期ノイズ、pushが入る制御step(非復元抽出)、pushの速度と方向、
そしてdomain randomisation時のサーボパラメータ・質量・摩擦・latency・ノイズです。この抽選をC++で再現するには、numpyのseed-sequence展開、
choice、ziggurat法の正規分布サンプラーを再実装し、3つすべてを正確に合わせる必要があります。不可能ではありません。
しかし本題から外れます。抽選は環境が行う処理ではなく、環境に渡される入力だからです。
So the test runs the Python environment, reads what it drew, and passes those values to the C++
environment through EpisodeParams: the post-noise joint state, the push schedule, and the
DR factors if any. From that moment the two environments share every line of logic and receive the
same 250 actions.
そこでテストではPython環境を実行して抽選結果を読み取り、その値をEpisodeParams経由でC++環境に渡します。
渡すのはノイズ適用後の関節状態、pushスケジュール、そしてあればDRの係数です。この時点から2つの環境はすべてのロジックを共有し、同じ250個のactionを受け取ります。
pe = MicroduckEnv(seed=s, **kw); o_py = pe.reset(seed=s)
params = vecenv_cpp.episode_params(pe.data.qpos[qi], pe.data.qvel[vi],
pe._push_at, pe._push_vel, dr=pe.dr.current)
ce = vecenv_cpp.Env(make_config(**kw), s); o_cpp = ce.reset(params)
# 250 identical actions into both; compare obs, reward, terms, flags, info
Env::reset(const EpisodeParams&) is that entry point. Env::reset() with no
argument uses the environment's own PCG64 and is what training uses.
その入口がEnv::reset(const EpisodeParams&)です。引数なしのEnv::reset()は環境自身のPCG64を使い、学習ではこちらを使います。
Result結果
| episodes | observations compared比較したobservation数 | bit-identicalビット一致 | max abs diff (f32)最大絶対差(f32) | max rel reward diffreward最大相対差 | info mismatchesinfo不一致 |
|---|---|---|---|---|---|
| 25 | 6,275 | 6,275 (100.00%) | 0.0e+00 | 0.0e+00 | 0 |
The 25 episodes cover both reward versions, action latency 0 and 2, initial noise 0 and 0.02, an action
scale of 0.5 with five pushes, the two held-out physics variants (35 and 25 position coordinates, with
strided joint indices), and two DR cases with injected factors. Every observation is the same float32
array, every reward the same float64, and pushed, fallen, t,
truncated, terminated, trunk_height and upright_cos
agree on every step. The pytest suite asserts this at 1e-6 on observations and 1e-9 relative on rewards,
so a last-bit difference in a system library would pass while a real defect would not; the report script
counts exact matches separately so the distinction stays visible. One further test shifts a single push
by one step and checks that the comparison notices.
25 episodesは、rewardの両バージョン、action latency 0と2、初期ノイズ0と0.02、push 5回でaction scale 0.5の条件、
held-outの物理variant 2種(位置座標35個と25個、関節インデックスは飛び飛び)、係数を注入したDRケース2つをカバーしています。
すべてのobservationが同じfloat32配列、すべてのrewardが同じfloat64で、pushed、fallen、t、
truncated、terminated、trunk_height、upright_cosも毎stepで一致します。
pytestスイートではobservationを1e-6、rewardを相対1e-9で判定しています。システムライブラリによる最終ビットの差は通し、本物の不具合は通さないためです。
レポートスクリプトは完全一致を別に数えるので、この区別は見えたまま残ります。さらに、pushを1つだけ1 stepずらし、比較がそれを検出することを確かめるテストもあります。
Env(cfg, seed=7) and MicroduckEnv(seed=7) draw different pushes. The C++ seeds
PCG64 from a 64-bit integer through splitmix; numpy runs SeedSequence. The PCG64 core stream and its
uniform() are numpy's exactly (a test hands numpy's internal state to the C++ class and
checks 1,000 outputs of each), but normal() is Marsaglia polar, not the ziggurat, and
integers() uses a different rejection scheme. A Python run and a C++ run with the same seed
are two samples of the same distribution, not a replay. Compare them across seeds.
Env(cfg, seed=7)とMicroduckEnv(seed=7)は異なるpushを引きます。C++は64ビット整数からsplitmixでPCG64を初期化し、numpyはSeedSequenceを使うためです。
PCG64のコアストリームとuniform()はnumpyと完全に同じです(numpyの内部状態をC++クラスに渡し、それぞれ1,000個の出力を照合するテストがあります)。
ただしnormal()はziggurat法ではなくMarsagliaの極座標法で、integers()も棄却方式が異なります。
同じseedのPython実行とC++実行は、同じ分布からの2つのサンプルであって、再生ではありません。比較は複数のseedにわたって行ってください。
Three arithmetic details, and one about the solver3つの演算の細部と、ソルバーに関する1点
What was actually different the first time the two environments disagreed in the last bit, and how each was found.
2つの環境が最初に最終ビットで食い違ったとき、実際に何が違っていたのか。そしてそれぞれをどう見つけたか。
numpy does not add fourteen numbers left to rightnumpyは14個の数を左から順には足さない
The reward takes np.mean over the fourteen joints. For arrays of 8 to 128 elements numpy's
reduction keeps eight partial sums and combines them as a tree. Measured on 20,000 random 14-vectors: a
sequential loop matched np.mean on 15,831; a tree starting from the identity 0 matched on
20,000 of 20,000; a tree starting from the first element matched on 11,776. np::pairwise_sum
is the first of those, copied from numpy's own loop structure. A C++ test feeds it an input where the
two orders provably differ: {1e16, 1×7, -1e16, 0×5} sums to 0 sequentially and to 6 as a
tree.
rewardでは14関節についてnp.meanを取ります。要素数8〜128の配列では、numpyのreductionは8つの部分和を保持し、それをツリー状に合成します。
ランダムな14要素ベクトル20,000個で計測したところ、逐次ループがnp.meanと一致したのは15,831個、単位元0から始めるツリーは20,000個中20,000個、
先頭要素から始めるツリーは11,776個でした。np::pairwise_sumは2つのツリーのうち前者(単位元0から始める方式)で、numpy自身のループ構造を写しています。
C++のテストでは、2つの順序で確実に結果が変わる入力を与えます。{1e16, 1×7, -1e16, 0×5}は逐次加算では0、ツリーでは6になります。
Python's x ** 2 is pow(x, 2.0), not x * xPythonのx ** 2はx * xではなくpow(x, 2.0)
Python routes a float power through the C library's pow. Measured on 2,000,000 random
doubles, pow(x, 2.0) and x * x differ in 1,621 of them. The v1 height term uses
the power operator. GCC folds pow(x, 2.0) into a multiply unless stopped, so
np::py_pow passes the exponent through a volatile to force the library call.
Pythonはfloatのべき乗をCライブラリのpowで計算します。ランダムなdouble 2,000,000個で計測すると、pow(x, 2.0)とx * xは1,621個で異なりました。
v1の高さ項はこのべき乗演算子を使っています。GCCは止めない限りpow(x, 2.0)を乗算に畳み込むため、
np::py_powでは指数をvolatile経由で渡し、ライブラリ呼び出しを強制しています。
The observation scale is float32, the raw values are float64observationのスケールはfloat32、生の値はfloat64
The Python side multiplies a float64 array by a float32 constant table. numpy promotes the constant, so
the product is raw * double(float32(0.05)), and double(0.05f) is
0.05000000074505806, not 0.05. The C++ scale table is built from the float32 literals and widened the
same way.
Python側はfloat64配列にfloat32の定数テーブルを掛けています。numpyは定数のほうを昇格させるので、積はraw * double(float32(0.05))となり、
double(0.05f)は0.05ではなく0.05000000074505806です。C++のスケールテーブルもfloat32リテラルから作り、同じように拡張しています。
A second mj_forward is not a no-op2回目のmj_forwardは何もしない処理ではない
The Python reset calls mj_forward once on the keyframe and a second time only after
applying initial noise. Running the forward pass twice on an unchanged state lands a few units in the
last place elsewhere, because the constraint solver warm-starts from the previous acceleration. The C++
reset runs the second pass under exactly the same condition, init_noise > 0.
Pythonのresetはkeyframeに対してmj_forwardを1回呼び、初期ノイズを適用した場合にだけ2回目を呼びます。
状態が変わらなくてもforward passを2回実行すると、拘束ソルバーが前回の加速度からwarm-startするため、結果が最終桁で数単位ずれます。
C++のresetも、まったく同じ条件init_noise > 0のときだけ2回目を実行します。
The build also sets -ffp-contract=off and -fno-fast-math. A fused
multiply-add rounds once where numpy rounds twice, and one fused instruction anywhere in the reward would
have broken the table above.
ビルドでは-ffp-contract=offと-fno-fast-mathも指定しています。fused multiply-addは丸めが1回ですが、numpyは2回丸めます。
reward計算のどこかに融合命令が1つでも入れば、上の表は崩れていたはずです。
Threading designスレッド設計
One model per environment, a fixed thread assignment so results never depend on the thread count, and a wake-up scheme that costs nothing when idle.
環境ごとに1つのモデル、結果がスレッド数に依存しない固定のスレッド割り当て、そしてアイドル時にコストのかからない起床方式。
- one model per env環境ごとに1モデル
- Domain randomisation writes into
mjModel(damping, gains, masses, friction). A shared model would make DR a data race and every environment identical.Envtakes a prototype andmj_copyModels it. A test mutates one environment's masses and checks the prototype and its neighbours are untouched. Sixteen bodies and 82 geoms: the copies are kilobytes. - Domain randomisationは
mjModel(減衰、ゲイン、質量、摩擦)に書き込みます。モデルを共有すると、DRはデータ競合になり、全環境が同一になってしまいます。Envはプロトタイプを受け取り、mj_copyModelでコピーします。1つの環境の質量を書き換え、プロトタイプと他の環境が変わっていないことを確かめるテストがあります。 body 16個・geom 82個なので、コピーは数KBです。 - env i on thread i mod Tenv iをスレッドi mod Tに
- Always. So the assignment of environments to threads is fixed, and the result does not depend
on T. A test runs the same 300 steps on 1, 2 and 4 threads and compares observations, rewards,
dones and terminal observations with
==; another runs 260 steps as three sequentialEnvs, autoreset included, and gets the same bits. - 常にこの割り当てです。環境とスレッドの対応が固定されるため、結果はTに依存しません。
同じ300 stepを1・2・4スレッドで実行し、observation、reward、done、terminal observationを
==で比較するテストがあります。 別のテストでは260 stepを3つのEnvの逐次実行(autoreset含む)として回し、同じビットが得られることを確認しています。 - generation countergenerationカウンタ
- The caller writes the action batch, increments an atomic counter and works as thread 0. The other T−1 threads spin on the counter for tens of microseconds and then block on a condition variable. While the policy is computing its next action the pool burns nothing; while it is stepping, there is no futex call per step. Acquire and release ordering on the counter and on the completion count is what makes the action and observation buffers visible without a lock.
- 呼び出し側がactionのバッチを書き込み、atomicカウンタを1つ進め、自身もスレッド0として働きます。残りのT−1スレッドは数十マイクロ秒カウンタをspinで監視し、 その後condition variableでblockします。policyが次のactionを計算している間、スレッドプールはCPUを消費しません。stepを回している間は、stepごとのfutex呼び出しもありません。 カウンタと完了カウントに対するacquire/releaseのメモリ順序によって、ロックなしでactionとobservationのバッファが見えるようになっています。
- autoreset
- Matches the Python version: when an episode ends, the observation returned is the new episode's
first, and the ended episode's final observation is kept in
terminal_obs. Every episode here ends on the step limit, never on a terminal state, so the training loop bootstrapsV(terminal_obs); the flags are carried so a stock advantage estimator cannot get this wrong silently. - Python版と同じ挙動です。episodeが終わると、返されるobservationは新しいepisodeの最初のもので、終わったepisodeの最後のobservationは
terminal_obsに保持されます。 ここではすべてのepisodeが終端状態ではなくstep上限で終わるため、学習ループはV(terminal_obs)でbootstrapします。 フラグも一緒に渡しているので、標準的なadvantage推定器がここを黙って間違えることはありません。 - the budgetスレッド上限
- More than eight threads is refused unless the caller says
allow_overcommit. Every number in the Python project was taken inside eight of fourteen cores on a laptop with other work to do, and this repo inherits that as a constructor check rather than a comment. - 呼び出し側が
allow_overcommitを指定しない限り、8を超えるスレッド数は拒否されます。Pythonプロジェクトの数値はすべて、 他の作業もするノートPCの14コア中8コアの枠内で取ったものです。このリポジトリはそれをコメントではなくコンストラクタのチェックとして引き継いでいます。
Sanitizers and CISanitizerとCI
Three CI jobs, and two things about MuJoCo's prebuilt library that had to be worked around to run them.
3つのCIジョブと、それを動かすために回避が必要だったMuJoCoビルド済みライブラリの2つの問題。
CI runs three jobs on every push. The first builds Release, runs the 34 C++ cases, the 41 Python tests
and the equality report against the Python repository at a pinned commit, after fetching the robot
assets from their upstream (they are Creative Commons BY-SA-NC and are never committed). The second and
third rebuild the C++ suite under -fsanitize=address,undefined and under
-fsanitize=thread. The thread-count independence test is the one ThreadSanitizer exists
for: a race on the shared action or observation buffers would fail the build.
CIはpushごとに3つのジョブを実行します。1つ目はロボットのアセットをupstreamから取得したうえで(Creative Commons BY-SA-NCのためコミットしません)、
Releaseビルドを行い、C++ 34ケース、Python 41テスト、そして固定コミットのPythonリポジトリに対する一致レポートを実行します。
2つ目と3つ目は、C++スイートを-fsanitize=address,undefinedと-fsanitize=threadでそれぞれ再ビルドします。
ThreadSanitizerが本領を発揮するのはスレッド数非依存のテストで、共有されたactionやobservationのバッファで競合があればビルドが失敗します。
mujoco.h does not compile under AddressSanitizer in C++mujoco.hはC++ではAddressSanitizer下でコンパイルできない
MuJoCo's header includes mjsan.h, written for building MuJoCo itself under ASan. When the
compiler defines __SANITIZE_ADDRESS__ it declares inline helpers with an attribute syntax
g++ rejects in a C++ translation unit. This repo links the prebuilt, uninstrumented library and never
touches those helpers, so include/vecenv/mujoco.hpp predefines the header's include guard
and two stub macros, and every file includes MuJoCo through that one wrapper.
MuJoCoのヘッダは、MuJoCo自体をASanでビルドするためのmjsan.hをincludeしています。コンパイラが__SANITIZE_ADDRESS__を定義すると、
g++がC++の翻訳単位では受け付けない属性構文でinlineヘルパーが宣言されます。このリポジトリは計測コードの入っていないビルド済みライブラリをリンクし、
それらのヘルパーには一切触れません。そこでinclude/vecenv/mujoco.hppでヘッダのinclude guardと2つのスタブマクロを先に定義し、
すべてのファイルがこの1つのラッパー経由でMuJoCoをincludeするようにしました。
MuJoCo's XML compiler has its own thread poolMuJoCoのXMLコンパイラは独自のスレッドプールを持つ
mj_loadXML spawns a pool inside the prebuilt library. ThreadSanitizer cannot see the
synchronisation inside code it did not instrument, so it reports the pool's internal mutex as a race.
tests/cpp/tsan.supp contains one line, race:libmujoco, and nothing this repo
compiles is suppressed. On recent kernels TSan also aborts on an unexpected memory mapping;
setarch $(uname -m) -R disables address-space randomisation for that one process without
privileges, and CI lowers vm.mmap_rnd_bits on the runner instead.
mj_loadXMLはビルド済みライブラリの内部でスレッドプールを立ち上げます。ThreadSanitizerは自分が計測コードを入れていない部分の同期を見られないため、
プール内部のmutexを競合として報告します。tests/cpp/tsan.suppはrace:libmujocoの1行だけで、このリポジトリがコンパイルするコードは何も抑制していません。
また最近のカーネルでは、TSanが想定外のメモリマッピングでabortします。setarch $(uname -m) -Rで、そのプロセスだけアドレス空間のランダム化を権限なしで無効にでき、
CIでは代わりにrunner上でvm.mmap_rnd_bitsを下げています。
The C++ side links the same libmujoco.so that the Python side imports: CMake asks
the Python interpreter where the wheel lives and takes the headers and library from there, so nothing
is installed system-wide. check_library_version() refuses to run if the header and the
library disagree. The equality claim is only meaningful against the same physics binary.
C++側は、Python側がimportするのと同じlibmujoco.soをリンクします。CMakeがPythonインタプリタにwheelの場所を問い合わせ、
そこからヘッダとライブラリを取るので、システム全体には何もインストールしません。ヘッダとライブラリが食い違えばcheck_library_version()が実行を拒否します。
一致の主張は、同じ物理エンジンのバイナリに対してのみ意味を持つからです。
Measured throughput and training計測したthroughputと学習
Four measurements taken on 2026-09-22 on the same laptop, each with the guard verdict its own tool printed, and the full 50M-step PPO run on 2026-09-29/30.
2026-09-22に同じノートPCで取った4つの計測(それぞれツール自身が出したガード判定付き)と、2026-09-29/30に実施した50M stepのPPO本学習。
The protocol is the Python project's. A sweep runs 1, 2, 4 and 8 threads for 20 seconds each,
with a single-thread reference window before the sweep and after every row. The JSON is marked
unstable if a reference drifts more than 10% and trending if the reference moves in one
direction across three windows by more than 4%. A sustained run holds 8 threads for 18 windows of 20
seconds with no pause. All runs were under nice -n 10, eight of fourteen cores, no other work
on the machine.
手順はPythonプロジェクトと同じです。sweepでは1・2・4・8スレッドをそれぞれ20秒ずつ実行し、sweepの前と各行の後に1スレッドのreference windowを挟みます。
referenceが10%を超えてずれるとJSONにunstable、3 windowにわたって一方向に4%を超えて動くとtrendingの印が付きます。
sustained runでは8スレッドを20秒×18 window、休みなく回し続けます。すべてnice -n 10、14コア中8コア、マシン上で他の作業なしの条件で実行しました。
The full environment, sweep環境全体のsweep
| threadsスレッド数 | env-steps/s | per threadスレッドあたり | speedup高速化率 | efficiency効率 | ref drift |
|---|---|---|---|---|---|
| 1 | 5,639 | 5,639 | 1.00× | 100% | +1.3% |
| 2 | 10,471 | 5,236 | 1.86× | 93% | −0.1% |
| 4 | 16,601 | 4,150 | 2.94× | 74% | −0.2% |
| 8 | 20,287 | 2,536 | 3.60× | 45% | −0.8% |
Taken at 14:51 on a freshly rebooted machine, reference drift within 1.3%, marked stable by its own guard. The efficiency at 8 threads, 45%, is the same as the 42% the Python project measured with 8 processes on the same model. That settles the question section 02 asked: process isolation was not the loss. Whatever holds this chip back at eight workers holds threads in one address space back just as much, which points at the cores themselves (this CPU has three kinds) or at the package power limit, neither of which is readable from inside a WSL guest.
再起動直後のマシンで14:51に計測し、reference driftは1.3%以内、ツール自身のガードでstableと判定されました。8スレッドでの効率45%は、 Pythonプロジェクトが同じモデルを8プロセスで計測した42%と同水準です。これで02章の疑問に決着が付きます。損失の原因はプロセス分離ではありませんでした。 8 workerでこのチップの足を引っ張っている何かは、1つのアドレス空間内のスレッドにも同じだけ効いています。原因はコアそのもの(このCPUには3種類のコアがあります)か、 パッケージの電力制限を指しており、どちらもWSLゲストの中からは読めません。
Physics only, sweep物理計算のみのsweep
The same sweep in bare mode: 10 × mj_step per env step and nothing else. The
gap between this table and the one above is the cost of everything that is not physics.
同じsweepをbareモードで実行したものです。1 env stepあたり10 × mj_stepだけで、ほかには何もしません。
この表と上の表の差が、物理計算以外のすべてのコストです。
| threadsスレッド数 | env-steps/s | per threadスレッドあたり | speedup高速化率 | efficiency効率 | ref drift |
|---|---|---|---|---|---|
| 1 | 5,404 | 5,404 | 1.00× | 100% | −0.7% |
| 2 | 10,788 | 5,394 | 2.00× | 100% | +0.4% |
| 4 | 20,142 | 5,036 | 3.73× | 93% | +3.7% |
| 8 | 27,011 | 3,376 | 5.00× | 62% | +2.7% |
Taken at 21:03, machine rebooted minutes before. A first sweep just before it read 5,390 / 10,823 / 19,956 / 27,087, so every row reproduced within 1%. The guard nevertheless marked both sweeps trending: the single-thread reference rose 4.4% across three consecutive windows against a 4% bar. On this laptop the single-thread reference scatters about 5% from one window to the next, so the trend detector fires on scatter. A third sweep started immediately afterwards, with the package still warm, read 5,340 / 10,496 / 18,909 / 24,384: 10% lower at 8 threads. That is the chained-benchmark effect the Python project already documented, and it is why the second sweep, not the third, is the table.
21:03に、再起動の数分後に計測しました。直前に取った1回目のsweepは5,390 / 10,823 / 19,956 / 27,087で、全行が1%以内で再現しています。 それでもガードは両方のsweepをtrendingと判定しました。1スレッドのreferenceが連続3 windowで4.4%上昇し、閾値の4%を超えたためです。 このノートPCでは1スレッドのreferenceがwindowごとに約5%ばらつくので、trend検出がばらつきに反応してしまいます。 直後にパッケージが温まったまま始めた3回目のsweepは5,340 / 10,496 / 18,909 / 24,384で、8スレッドで10%低くなりました。 これはPythonプロジェクトですでに記録していたベンチマーク連続実行の影響で、表に3回目ではなく2回目のsweepを採用した理由です。
One thing the two sweeps cannot tell you: the bare 1-thread row, 5,404, is below the full-environment 1-thread row, 5,639, taken six hours earlier. Physics alone cannot be slower than physics plus observation, so the two sweeps were taken in different host power states. The host's power mode is not visible from inside the guest, and the env-to-bare gap cannot be read from these two tables to better than about 4%. Both tables are individually consistent; the comparison between them is not.
2つのsweepからは分からないこともあります。bareの1スレッド行5,404は、6時間前に取った環境全体の1スレッド行5,639より低くなっています。 物理計算単体が物理計算+observationより遅いことはあり得ないので、2つのsweepはホストの電力状態が異なる中で取ったことになります。 ホストの電源モードはゲストの中からは見えず、環境とbareの差はこの2つの表から約4%より細かくは読み取れません。各表はそれぞれ単独では整合していますが、表同士の比較は成り立ちません。
Sustained, 8 threads, six minutesSustained、8スレッド、6分間
| window | 0 | 1 | 2 | 4 | 6 | 8 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| env-steps/s | 18,724 | 20,829 | 19,440 | 18,548 | 16,724 | 16,001 | 13,782 | 13,327 | 13,329 | 15,590 | 16,238 | 14,393 | 15,074 | 14,908 |
Peak 20,829 in window 1, then a monotonic fall to 13,327 at window 11, a climb to 16,238 at window 14, and 14,908 in the last window. Over the last six windows the rate sits between 13,329 and 16,238, a 22% band; the last three average 14,792, 29% below peak. The shape is the same one the Python project saw with eight processes: a dip around window 11, then a partial recovery. Two implementations with nothing in common except the physics library and the machine producing the same curve is decent evidence that the curve belongs to the machine's power management, not to the code. Budgets should be taken from the low end of the band.
window 1で最大20,829、その後window 11の13,327まで単調に低下し、window 14で16,238まで上がり、最後のwindowは14,908でした。 最後の6 windowでは13,329〜16,238の22%の幅に収まり、最後の3 windowの平均は14,792で最大値より29%低くなっています。 この形はPythonプロジェクトが8プロセスで見たものと同じで、window 11付近で落ち込み、その後部分的に回復します。 物理ライブラリとマシン以外に共通点のない2つの実装が同じ曲線を描くことは、この曲線がコードではなくマシンの電力管理によるものだというそれなりの根拠になります。 見積もりはこの幅の下端から取るべきです。
Python processes against C++ threads, same hourPythonプロセスとC++スレッドの比較(同じ時間帯)
bench/compare.py drives both vector environments with uniform random actions in 20-second
windows and alternates backends every window, so a machine that changes state during the run biases both
the same way. Two rounds.
bench/compare.pyは両方のvector environmentを一様乱数のactionで20秒windowずつ動かし、windowごとにbackendを交互に切り替えます。
実行中にマシンの状態が変わっても、両者に同じように影響させるためです。2ラウンド実行しました。
| workers | Python processesPythonプロセス | C++ threadsC++スレッド | C++ / Python | C++ efficiencyC++の効率 |
|---|---|---|---|---|
| 1 | 2,336 | 4,867 | 2.08× | 100% |
| 2 | 3,463 | 7,429 | 2.14× | 76% |
| 4 | 4,270 | 10,370 | 2.43× | 53% |
| 8 | 6,385 | 12,327 | 1.93× | 32% |
An earlier run of the same script, started while the load average was still 3 from the sustained run, gave 6,668 against 13,612 at 8 workers, a ratio of 2.04×. The script's single-process Python reference was marked unstable in both runs, with scatter up to 39%: one forked worker behind a pipe has a jittery round trip, and the guard was designed around bare physics, which does not. The multi-worker rows agree within 10% across the two runs, and the ratio at 8 is between 1.9× and 2.0× either way.
同じスクリプトをその前に、sustained runの影響でload averageがまだ3ある状態で実行したときは、8 workerで6,668対13,612、比率2.04×でした。 スクリプトの1プロセスのPython referenceは両方の実行でunstableと判定され、ばらつきは最大39%でした。pipeの先にある1つのforkされたworkerは往復時間が揺らぎますが、 ガードはそうした揺らぎのないbareの物理計算を前提に設計されていたためです。複数workerの行は2回の実行で10%以内に一致し、8での比率はどちらでも1.9×〜2.0×です。
The README set a decision rule before the run: under 1.3× at 8 workers, the C++ environment is not worth its build step for training. At 1.9× to 2.0× it clears the rule.
READMEでは実行前に判断基準を決めていました。8 workerで1.3×未満なら、C++環境は学習用としてビルドの手間に見合わない、というものです。 結果は1.9×〜2.0×で、この基準をクリアしています。
The second conclusion is about the Python project. Its vector environment at 8 processes delivers about 6,400 env-steps/s in this driver, half of the 13,300 that project budgeted from bare physics times a measured wrapper factor. The inter-process round trip, pickling actions out and observations back through eight pipes each step, was never in that composition. The training script itself logs its rate every update, and that figure, not any composed one, is the number to budget from.
2つ目の結論はPythonプロジェクトについてです。そのvector environmentは8プロセスでこのドライバ上約6,400 env-steps/sで、 同プロジェクトがbareの物理計算に計測したwrapper係数を掛けて見積もった13,300の半分でした。毎stepで8本のpipeを通してactionを送り出しobservationを戻す プロセス間の往復は、その積算に含まれていなかったのです。学習スクリプト自体がupdateごとに速度をログに出しており、見積もりは積算値ではなくその数値から立てるべきです。
PPO on the C++ environment, 50M stepsC++環境でのPPO、50M steps
The Python project's step-3 run, repeated here with the same configuration and training seed, the
same schedule (25M steps without a KL stop, then 25M resumed with --target-kl 0.02), and its
ppo.py unchanged. Both final policies were then scored on the Python environment,
100 episodes on each of the same 5 evaluation seeds, ± over the seed means.
Pythonプロジェクトのstep 3の学習を、同じ設定・同じ学習seed・同じスケジュール(KL stopなしで25M steps、その後--target-kl 0.02付きで25M再開)、
そして無修正のppo.pyでここで再実行しました。最終的な2つのpolicyはどちらもPython環境で評価し、同じ5つの評価seedそれぞれで100 episodes、±はseed平均間のばらつきです。
| policy trained onpolicyの学習環境 | return of 500return(500点満点) | survival | upright | median trunk height胴体高さの中央値 |
|---|---|---|---|---|
| C++ environmentC++環境 | 341.9 ± 4.1 | 0.95 | 0.50 | 5.9 cm |
| Python environmentPython環境 | 344.8 ± 2.9 | 0.73 | 0.50 | 5.9 cm |
| PD hold-pose | 175.8 ± 0.2 | 0.47 | — | — |
Paired by evaluation seed, the C++-trained policy scores −2.8 lower on average (sd 3.9 over five seeds, about 1.6 standard errors). That is inside the evaluation noise. The evaluation noise is not the large term, though; the run-to-run spread is, so each backend was trained on two more seeds with the same protocol (four 12.5M-step invocations, the last two with the KL stop, the same evaluation).
評価seedごとに対にして比べると、C++で学習したpolicyは平均で−2.8低いスコアでした(5 seedsでsd 3.9、標準誤差の約1.6倍)。これは評価ノイズの範囲内です。 ただし大きいのは評価ノイズではなく、学習の実行ごとのばらつきです。そこで各backendについて、同じ手順(12.5M stepの実行を4回、うち後半2回はKL stop付き、評価も同じ)で さらに2 seedsずつ学習しました。
| training seed学習seed | C++ return | C++ survival | Python return | Python survival |
|---|---|---|---|---|
| 0 | 341.9 ± 4.1 | 0.948 | 344.8 ± 2.9 | 0.727 |
| 1 | 338.5 ± 8.1 | 0.998 | 357.3 ± 7.6 | 0.999 |
| 2 | 337.7 ± 7.6 | 0.873 | 342.2 ± 5.1 | 0.895 |
| mean ± sd over seedsseed間の平均 ± sd | 339.4 ± 2.3 | 0.94 ± 0.06 | 348.1 ± 8.1 | 0.87 ± 0.14 |
Across three seeds the backends do not differ: return −8.7 (Welch t −1.8), survival +0.07 (t 0.75). Upright is 0.49–0.50 for all six policies. A policy trained on this environment scores like one trained on the Python environment. That is what the equality test predicts, and it is what this run was for.
3 seedsで見ると、backend間に差はありません。returnは−8.7(Welch t −1.8)、survivalは+0.07(t 0.75)です。uprightは6つのpolicyすべてで0.49〜0.50でした。 この環境で学習したpolicyは、Python環境で学習したものと同等のスコアを出します。これは一致テストから予想されるとおりで、この学習の目的もそこにありました。
Survival is the column that looked different on seed 0, 0.95 against 0.73, and the extra seeds took the difference away. It counts steps with the trunk above 4 cm against a 12 cm standing height, so it says how often a low crouch dips under 4 cm. The seed-0 gap was real for those two policies, about 16 standard errors over 500 episodes each, but it was run-to-run spread, not the backend: Python's seeds span 0.73 to 1.00 and C++'s 0.87 to 1.00, and each run settles on its level within the first 12.5M steps. Python seed 1 also found the only taller posture of the six, a median trunk height of 8.8 cm instead of 5.9–6.0. The training curves below are seed 0.
seed 0で差があるように見えたのはsurvivalの列(0.95対0.73)で、追加のseedでこの差は消えました。survivalは立位高さ12 cmに対して胴体が4 cmより上にあるstepを数えるもので、 低くしゃがんだ姿勢がどれだけ頻繁に4 cmを下回るかを表します。seed 0の差はこの2つのpolicyについては本物で(各500 episodesで標準誤差の約16倍)、 ただしbackendではなく実行ごとのばらつきによるものでした。Pythonのseedは0.73〜1.00、C++は0.87〜1.00にわたり、各実行は最初の12.5M step以内に自分の水準に落ち着きます。 またPythonのseed 1は、6つの中で唯一高い姿勢を見つけました。胴体高さの中央値が5.9〜6.0ではなく8.8 cmです。下の学習曲線はseed 0のものです。
| block of 12.5M steps12.5M stepごとの区間 | return50 C++ / Python | clip fraction C++ / Python | KL max C++ / Python | env-steps/s C++ / Python |
|---|---|---|---|---|
| 0–12.5M | 345.1 / 337.9 | 0.56 / 0.58 | 11.0 / 25.1 | 4,725 / 3,396 |
| 12.5–25M | 331.5 / 330.4 | 0.69 / 0.73 | 65.7 / 173.1 | 4,755 / 3,123 |
| 25–37.5M, KL stop | 342.3 / 332.2 | 0.30 / 0.34 | 0.53 / 0.57 | 7,103 / 4,342 |
| 37.5–50M, KL stop | 349.5 / 344.3 | 0.21 / 0.23 | 0.08 / 0.08 | 5,998 / 3,735 |
The Python project's step-size failure reproduced on this backend: without a KL stop the clip fraction climbed to 0.69, the KL spiked to 66 and return50 fell from 345 to 332. The KL stop brought both runs back to the same clip fraction and KL within a few hundredths. A failure that shows on two environments with different push draws belongs to the configuration, not to one unlucky run.
Pythonプロジェクトで起きたstep sizeの失敗は、このbackendでも再現しました。KL stopなしではclip fractionが0.69まで上がり、KLは66に跳ね、return50は345から332に下がりました。 KL stopにより、両方の実行が数百分の一以内の同じclip fractionとKLに戻りました。pushの抽選が異なる2つの環境で同じ失敗が出るなら、それは1回の不運な実行ではなく設定に起因するものです。
50M steps took 3.1 hours here and about 4.4 on the Python environment: 1.41× faster over the first 25M and 1.45× over the second. That is less than the 1.9× to 2.0× under random actions above, because PPO puts the policy's forward pass and its gradient update, both single-threaded, on the same critical path. During the last chunk the one training process sat at 470% CPU with eight environment threads. The gain grows when the update shrinks: 1.39× to 1.52× per update while every update runs all its epochs, and 1.61× to 1.64× once the KL stop ends most of them early. Both backends' rates also moved 14–16% between two invocations of the same stage, which is why the ratio is quoted per stage. Seeds 1 and 2 took 3.2 and 3.1 hours here against 4.0 and 3.8 on the Python environment, 1.25× for both, so the end-to-end speed-up is 1.25× to 1.43× depending on the run; why the Python seed-0 run was the slow one is not measured.
50M stepsにかかった時間はここで3.1時間、Python環境では約4.4時間で、前半25Mで1.41×、後半で1.45×速くなりました。 上のランダムactionでの1.9×〜2.0×より小さいのは、PPOではpolicyのforward passとgradient更新(どちらもシングルスレッド)が同じクリティカルパス上に乗るためです。 最後のchunkでは、1つの学習プロセスが8つの環境スレッドとともにCPU 470%で推移していました。更新処理が小さくなるほど効果は大きくなります。 毎回のupdateが全epochを回す間はupdateあたり1.39×〜1.52×、KL stopでほとんどのupdateが早期終了するようになると1.61×〜1.64×です。 また両backendとも、同じ段階を2回実行した間で速度が14〜16%変動したため、比率は段階ごとに示しています。seed 1と2はここで3.2時間と3.1時間、Python環境では4.0時間と3.8時間で、 どちらも1.25×でした。したがってend-to-endの高速化は実行によって1.25×〜1.43×です。Pythonのseed 0の実行だけが遅かった理由は計測していません。
What it does not proveこの結果から言えないこと
The boundaries of the equality claim and of the speed numbers, stated so they are not read past.
一致の主張と速度の数値の適用範囲。拡大解釈されないよう明記します。
- Bit equality holds on one machine, one compiler, one C library. Python's
powandexpand the C++ ones are the same library calls on the same host, which is why they agree. A runner with a different glibc, or a numpy built with a different vectorisedexp, could differ in the last bit on the height term. The test tolerances are set so that would pass and a real defect would not. - The draw is not replicated. Same seed, different pushes. A step-by-step replay of a Python training run on the C++ backend is not available; comparisons between the two are statistical, across seeds.
- Sensor noise is not contract-tested, for the same reason: it is a Gaussian per step and the C++ draws its own. The contract cases run with it off.
- Three training seeds per backend. Enough to show the seed-0 survival gap was run-to-run spread; not enough to resolve a backend difference smaller than that spread (survival sd 0.06 to 0.14, return sd 2 to 8 over seeds).
- One robot.
Envis Microduck-shaped: 14 actuators, a standing keyframe, a named trunk body, named gyro and orientation sensors. The threading layer is not, and a second model behind the same interface is the natural next step. - No rendering. Image observations through MuJoCo's C rendering API are deferred until a machine with a working EGL context exists; the laptop this was written on has GLFW through WSLg and nothing else.
- The speed numbers are for this laptop. Fourteen cores of three kinds under a hypervisor, with a power state the guest cannot read. The ratios are more portable than the absolute rates, and the sustained band is more portable than any 20-second row.
- ビット一致は1台のマシン・1つのコンパイラ・1つのCライブラリでの話です。Pythonの
powやexpとC++のそれは、 同じホスト上の同じライブラリ呼び出しなので一致します。glibcが異なるrunnerや、ベクトル化されたexpの実装が異なるnumpyでは、 高さ項の最終ビットが変わる可能性があります。テストの許容誤差は、それなら通し、本物の不具合なら通さないように設定しています。 - 乱数の抽選は再現していません。同じseedでもpushは異なります。Pythonの学習実行をC++ backend上でstepごとに再生することはできず、 両者の比較は複数seedにわたる統計的なものです。
- センサーノイズはcontractテストの対象外です。理由は同じで、stepごとのガウスノイズをC++側が独自に引くためです。contractケースではノイズをオフにしています。
- 学習seedはbackendごとに3つです。seed 0のsurvivalの差が実行ごとのばらつきだと示すには十分ですが、 そのばらつき(seed間でsurvivalのsd 0.06〜0.14、returnのsd 2〜8)より小さいbackend間の差を見分けるには足りません。
- ロボットは1種類です。
EnvはMicroduck専用の形をしています。アクチュエータ14個、立位のkeyframe、名前付きの胴体body、 名前付きのジャイロと姿勢センサーです。スレッド層はそうではなく、同じインターフェースの裏に2つ目のモデルを載せるのが自然な次のステップです。 - レンダリングはありません。MuJoCoのCレンダリングAPIによる画像observationは、EGLコンテキストが動くマシンが用意できるまで保留しています。 これを書いたノートPCで使えるのはWSLg経由のGLFWだけです。
- 速度の数値はこのノートPCでのものです。ハイパーバイザ配下の3種類・14コアで、電力状態はゲストから読めません。 絶対値より比率のほうが他環境に持ち出しやすく、20秒の各行よりsustainedの幅のほうが持ち出しやすい数値です。
Running it実行方法
A C++17 compiler, CMake 3.18 or later, Python 3.8 or later, and the Python project next door.
C++17コンパイラ、CMake 3.18以降、Python 3.8以降、そして隣のディレクトリにPythonプロジェクトが必要です。
git clone https://github.com/AungKaung1928/microduck-rl.git ../microduck-rl
(cd ../microduck-rl && python3 -m venv .venv && . .venv/bin/activate \
&& pip install -r requirements.txt && ./fetch_assets.sh)
pip install -r requirements.txt # mujoco (headers + library inside), numpy, pybind11, pytest
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j4
./build/vecenv_tests
python -m pytest tests/py -q
python scripts/contract_report.py # the equality table, one core, about a minute
./verify.sh does all of that in order and prints the benchmark commands at the end.
MICRODUCK_RL or MICRODUCK_ASSETS point it at the Python project if it lives
somewhere else.
./verify.shが上記をすべて順に実行し、最後にベンチマーク用のコマンドを表示します。
Pythonプロジェクトが別の場所にある場合は、MICRODUCK_RLかMICRODUCK_ASSETSでその場所を指定します。
Benchmarks, on an idle machineベンチマーク(アイドル状態のマシンで)
nice -n 10 ./build/bench_vecenv --threads 1 2 4 8 --seconds 20 --ref-seconds 10 --tag main
nice -n 10 ./build/bench_vecenv --mode bare --threads 1 2 4 8 --seconds 20 --ref-seconds 10 --tag main
nice -n 10 ./build/bench_vecenv --sustained 8 --windows 18 --seconds 20 --tag sustained
nice -n 10 python bench/compare.py --workers 1 2 4 8 --seconds 20 --rounds 2 --tag main
Sanitizer buildsSanitizerビルド
cmake -S . -B build-san -DVECENV_SANITIZE=address,undefined -DVECENV_BUILD_PYTHON=OFF -DVECENV_BUILD_BENCH=OFF
cmake --build build-san -j4 && ./build-san/vecenv_tests
cmake -S . -B build-tsan -DVECENV_SANITIZE=thread -DVECENV_BUILD_PYTHON=OFF -DVECENV_BUILD_BENCH=OFF
cmake --build build-tsan -j4
TSAN_OPTIONS=suppressions=$PWD/tests/cpp/tsan.supp setarch $(uname -m) -R ./build-tsan/vecenv_tests
Training on itこの環境での学習
OMP_NUM_THREADS=1 nice -n 10 python scripts/ppo_cpp.py --total-steps 50000000 \
--chunk-steps 12500000 --tag v2cpp --reward v2 # VECENV_THREADS=8 is the default
OMP_NUM_THREADS=1 nice -n 10 python scripts/ppo_cpp.py --resume runs/ppo_v2cpp.ckpt.pt --tag v2cpp # to 25M
OMP_NUM_THREADS=1 nice -n 10 python scripts/ppo_cpp.py --resume runs/ppo_v2cpp.ckpt.pt --tag v2cpp \
--target-kl 0.02 # twice, to 50M
python scripts/compare_ppo_backends.py --py ../microduck-rl/runs/ppo_v2.json --cpp runs/ppo_v2cpp.json \
--out runs/ppo_backend_compare.json
import vecenv_cpp
envs = vecenv_cpp.VecEnv(n=8, seed=0, reward="v2", dr=True) # same kwargs as the Python env
obs = envs.reset()
obs, rew, done, info = envs.step(actions) # actions: (8, 14) float64
Licenceライセンス
MIT. doctest is vendored under MIT. MuJoCo comes from its pip wheel. The robot's meshes are Creative Commons BY-SA-NC and are fetched from their upstream by the Python project's script, never committed.
MITです。doctestはMITライセンスで同梱しています。MuJoCoはpipのwheelから取得します。ロボットのメッシュはCreative Commons BY-SA-NCで、 Pythonプロジェクトのスクリプトがupstreamから取得し、コミットはしません。
Measured environment計測環境
Intel Core Ultra 5 225H, 14 cores (4 performance, 8 efficiency, 2 low-power efficiency), no hyperthreading, 21 GB available to WSL2. Windows 11 + WSL2, Ubuntu 22.04, g++ 11.4, CMake 3.22, Python 3.10, MuJoCo 3.12.0, numpy 2.2.6. No CUDA anywhere.
Intel Core Ultra 5 225H、14コア(Pコア4、Eコア8、低消費電力Eコア2)、ハイパースレッディングなし、WSL2に割り当て可能なメモリ21 GB。 Windows 11 + WSL2、Ubuntu 22.04、g++ 11.4、CMake 3.22、Python 3.10、MuJoCo 3.12.0、numpy 2.2.6。CUDAは一切使っていません。
The short versionまとめ
The whole project in one paragraph.
プロジェクト全体を1段落で。
The Microduck balance task has a Python environment that runs eight forked processes. This is the same
environment in C++17 on eight threads, with a pybind11 binding and a drop-in class so the Python
training script runs on it unchanged. Before any timing, the two were made to agree to the bit: the
test lets Python draw the episode's randomness and hands the drawn values to the C++ side, then feeds
both 250 identical actions and compares everything that comes back with no tolerance. Reaching 6,275 of
6,275 identical observations took matching numpy's eight-accumulator summation tree, routing Python's
power operator through the C library's pow behind a volatile, widening a float32
scale table the way numpy does, and running MuJoCo's forward pass a second time only under the same
condition the Python code does, because the solver warm-starts. CI runs that comparison against a
pinned commit of the Python repository, and rebuilds the C++ suite under AddressSanitizer and
ThreadSanitizer, each of which needed a workaround for MuJoCo's prebuilt library. Measured afterwards
on the same laptop: the full environment runs 20,287 env-steps/s in a 20-second burst at eight threads
and settles between 13,300 and 16,200 over six minutes, with the same power-management curve the Python
version shows; head to head under identical random actions the C++ threads deliver 1.9 to 2.0 times the
Python processes at eight workers, above the 1.3× the README had set as the threshold for being worth
the build. The same comparison showed the Python vector environment delivering about half the rate its
project had budgeted from physics alone, because the inter-process round trip had never been in that
composition. Thread efficiency at eight workers, 45%, matched the Python processes' 42%, so process
isolation was not the scaling loss; the chip is. Then the proof that matters: the Python project's full
50M-step PPO run, repeated on this backend on three training seeds in about 3.1 hours each instead of
3.8 to 4.4, trains policies that score 339.4 ± 2.3 of 500 on the Python environment against 348.1 ± 8.1
for the ones trained there, with no backend difference in return or survival at three seeds,
and it reproduces the same step-size failure and the same fix along the way.
Microduckのバランスタスクには、8つのforkプロセスで動くPython環境があります。本プロジェクトは同じ環境をC++17で8スレッド上に実装し、
pybind11 bindingと差し替え用クラスによって、Pythonの学習スクリプトを無修正で動かせるようにしたものです。時間計測より先に、両者をビット単位で一致させました。
テストではPythonにepisodeの乱数を引かせ、その値をC++側に渡したうえで、両方に同じ250個のactionを与え、返ってきたものをすべて許容誤差なしで比較します。
6,275個中6,275個のobservationを一致させるには、numpyの8アキュムレータによる加算ツリーを合わせ、Pythonのべき乗演算子をvolatile経由でCライブラリのpowに通し、
float32のスケールテーブルをnumpyと同じように拡張し、さらにソルバーがwarm-startするため、MuJoCoのforward passの2回目をPythonコードと同じ条件のときだけ実行する必要がありました。
CIはこの比較をPythonリポジトリの固定コミットに対して実行し、C++スイートをAddressSanitizerとThreadSanitizerで再ビルドします。どちらもMuJoCoのビルド済みライブラリに対する回避策が必要でした。
その後同じノートPCで計測したところ、環境全体は8スレッドの20秒burstで20,287 env-steps/s、6分間では13,300〜16,200に落ち着き、Python版と同じ電力管理の曲線を示しました。
同一のランダムactionでの直接比較では、8 workerでC++スレッドがPythonプロセスの1.9〜2.0倍で、READMEがビルドに見合う閾値として定めた1.3×を上回りました。
同じ比較から、Pythonのvector environmentの速度は、同プロジェクトが物理計算だけから見積もった値の約半分だと分かりました。プロセス間の往復がその積算に含まれていなかったためです。
8 workerでのスレッド効率45%はPythonプロセスの42%と同水準で、スケーリングの損失はプロセス分離ではなくチップによるものでした。
そして本当に重要な証明として、Pythonプロジェクトの50M stepのPPO学習をこのbackendで3つの学習seedについて再実行しました。所要時間は3.8〜4.4時間ではなく各約3.1時間で、
得られたpolicyはPython環境で500点満点中339.4 ± 2.3、Python環境で学習したものは348.1 ± 8.1でした。3 seedsではreturnにもsurvivalにもbackend間の差はなく、
途中で同じstep sizeの失敗と同じ対処も再現しています。