Skip to content

Commit 1b3bc9f

Browse files
ZhangYi1999claude
andcommitted
fix(inference): use spawn context to avoid CUDA fork error in inference worker
Replace fork-based multiprocessing with spawn context in LerobotPolicy to prevent RuntimeError when CUDA is already initialized in the parent process. Extract observation_space and env_metadata from env before spawning to avoid pickling unpicklable ROS2 handles. Add spawn verification test to test script. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent aeea9b9 commit 1b3bc9f

2 files changed

Lines changed: 71 additions & 11 deletions

File tree

crisp_gym/policy/lerobot_policy.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import json
44
import logging
5-
from multiprocessing import Pipe, Process
5+
import multiprocessing
66
from multiprocessing.connection import Connection
77
from pathlib import Path
88
from typing import Any, Callable, Tuple
@@ -52,16 +52,23 @@ def __init__(
5252
env (ManipulatorBaseEnv): The environment in which the policy will be applied.
5353
overrides (dict | None): Optional overrides for the policy configuration.
5454
"""
55-
self.parent_conn, self.child_conn = Pipe()
5655
self.env = env
5756
self.overrides = overrides if overrides is not None else {}
5857

59-
self.inf_proc = Process(
58+
ctx = multiprocessing.get_context("spawn")
59+
self.parent_conn, self.child_conn = ctx.Pipe()
60+
61+
# Extract env data before spawning (env may not be picklable — ROS2 handles)
62+
observation_space = env.observation_space
63+
env_metadata = env.get_metadata()
64+
65+
self.inf_proc = ctx.Process(
6066
target=inference_worker,
6167
kwargs={
6268
"conn": self.child_conn,
6369
"pretrained_path": pretrained_path,
64-
"env": env,
70+
"observation_space": observation_space,
71+
"env_metadata": env_metadata,
6572
"overrides": self.overrides,
6673
},
6774
daemon=True,
@@ -114,15 +121,17 @@ def shutdown(self):
114121
def inference_worker(
115122
conn: Connection,
116123
pretrained_path: str,
117-
env: ManipulatorBaseEnv,
124+
observation_space,
125+
env_metadata: dict,
118126
overrides: dict | None = None,
119127
): # noqa: ANN001
120128
"""Policy inference process: loads policy on GPU, receives observations via conn, returns actions, and exits on None.
121129
122130
Args:
123131
conn (Connection): The connection to the parent process for sending and receiving data.
124132
pretrained_path (str): Path to the pretrained policy model.
125-
env (ManipulatorBaseEnv): The environment in which the policy will be applied.
133+
observation_space: The environment's observation space (pre-extracted for spawn compatibility).
134+
env_metadata (dict): The environment metadata (pre-extracted for spawn compatibility).
126135
overrides (dict | None): Optional overrides for the policy configuration.
127136
"""
128137
setup_logging()
@@ -145,7 +154,7 @@ def inference_worker(
145154

146155
train_config = TrainPipelineConfig.from_pretrained(pretrained_path)
147156

148-
_check_dataset_metadata(train_config, env, logger)
157+
_check_dataset_metadata(train_config, env_metadata, logger)
149158

150159
logger.info("[Inference] Loaded training config.")
151160

@@ -176,7 +185,7 @@ def inference_worker(
176185
if USE_LEROBOT_PROCESSORS:
177186
preprocessor, postprocessor = make_pre_post_processors(policy_cfg=policy.config, pretrained_path=pretrained_path)
178187

179-
warmup_obs_raw = env.observation_space.sample()
188+
warmup_obs_raw = observation_space.sample()
180189
warmup_obs_raw["observation.state"] = concatenate_state_features(warmup_obs_raw)
181190
warmup_obs = numpy_obs_to_torch(warmup_obs_raw)
182191
if USE_LEROBOT_PROCESSORS:
@@ -238,15 +247,15 @@ def inference_worker(
238247

239248
def _check_dataset_metadata(
240249
train_config: TrainPipelineConfig,
241-
env: ManipulatorBaseEnv,
250+
env_metadata: dict,
242251
logger: logging.Logger,
243252
keys_to_skip: list[str] | None = None,
244253
):
245254
"""Check if the dataset metadata matches the environment configuration.
246255
247256
Args:
248257
train_config (TrainPipelineConfig): The training pipeline configuration.
249-
env (ManipulatorBaseEnv): The environment to compare against.
258+
env_metadata (dict): The environment metadata dict to compare against.
250259
logger (logging.Logger): Logger for logging information.
251260
keys_to_skip (list[str] | None): List of metadata keys to skip during comparison.
252261
"""
@@ -272,7 +281,6 @@ def _warn_if_missing(key: str):
272281
logger.info(
273282
"[Inference] Found crisp_meta.json in dataset, comparing environment and policy configs..."
274283
)
275-
env_metadata = env.get_metadata()
276284
with open(path_to_metadata, "r") as f:
277285
dataset_metadata = json.load(f)
278286
for key, value in dataset_metadata.items():

crisp_gym/scripts/test_lerobot_policy.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import argparse
99
import json
1010
import logging
11+
import multiprocessing
1112
import os
1213
import time
1314

@@ -19,6 +20,35 @@
1920
logger = logging.getLogger(__name__)
2021

2122

23+
def _spawn_worker(conn, policy_path: str, device: str):
24+
"""Top-level function for spawn test: loads policy on the given device and reports success/failure.
25+
26+
Must be a top-level function (not a lambda/closure) to be picklable for spawn.
27+
"""
28+
try:
29+
import json as _json
30+
from lerobot.policies.factory import get_policy_class
31+
32+
if os.path.isdir(policy_path):
33+
config_path = os.path.join(policy_path, "config.json")
34+
with open(config_path) as f:
35+
cfg = _json.load(f)
36+
else:
37+
from huggingface_hub import hf_hub_download
38+
with open(hf_hub_download(policy_path, "config.json")) as f:
39+
cfg = _json.load(f)
40+
41+
policy_type = cfg.get("type") or cfg.get("policy_type")
42+
policy_cls = get_policy_class(policy_type)
43+
policy = policy_cls.from_pretrained(policy_path)
44+
policy.to(device).eval()
45+
conn.send({"ok": True, "device": device})
46+
except Exception as e:
47+
conn.send({"ok": False, "error": str(e)})
48+
finally:
49+
conn.close()
50+
51+
2252
def make_dummy_obs(policy, device: str) -> dict[str, torch.Tensor]:
2353
"""Build a random observation batch (size 1) matching policy.config.input_features.
2454
@@ -73,6 +103,8 @@ def main():
73103
parser.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu")
74104
parser.add_argument("--n-warmup", type=int, default=5, help="Warmup iterations.")
75105
parser.add_argument("--n-bench", type=int, default=10, help="Benchmark iterations.")
106+
parser.add_argument("--test-spawn", action=argparse.BooleanOptionalAction, default=True,
107+
help="After inference test, spawn a subprocess to verify no CUDA fork error.")
76108
args = parser.parse_args()
77109

78110
print(f"\n{'='*60}")
@@ -155,6 +187,26 @@ def main():
155187
print(f" min / max : {min(times)*1000:.2f} / {max(times)*1000:.2f} ms")
156188
print("\nPASS: policy loaded and inference completed successfully.")
157189

190+
# ── Step 5: Spawn subprocess test (CUDA fork safety) ──────────
191+
if args.test_spawn:
192+
print(f"\n[5/5] Spawn subprocess test (CUDA already initialized in main process)...")
193+
ctx = multiprocessing.get_context("spawn")
194+
parent_conn, child_conn = ctx.Pipe(duplex=False)
195+
proc = ctx.Process(
196+
target=_spawn_worker,
197+
args=(child_conn, args.policy_path, args.device),
198+
daemon=True,
199+
)
200+
proc.start()
201+
child_conn.close() # close child end in parent
202+
result = parent_conn.recv()
203+
proc.join(timeout=300)
204+
if result.get("ok"):
205+
print(f"PASS: spawn worker loaded policy on {result['device']} without fork error")
206+
else:
207+
print(f"FAIL: spawn worker reported error: {result.get('error')}")
208+
raise SystemExit(1)
209+
158210

159211
if __name__ == "__main__":
160212
logging.basicConfig(level=logging.INFO)

0 commit comments

Comments
 (0)