Skip to content

Commit 289d0cd

Browse files
authored
fix gr00t checkpoint portability (#1219)
## Summary This PR fixes two GR00T N1.5 checkpoint portability issues. ### 1. Processor device portability Previously, `policy_preprocessor.json` could save `device_processor.device` from the training environment. For example, a checkpoint trained on MUSA could contain `device: musa`, which would fail when serving on Ascend/NPU. This PR overrides `device_processor.device` at serve/inference load time using the runtime device from the current config. ### 2. Base model path portability GR00T N1.5 checkpoints require `base_model_path` to load the base model during serve/inference. If `base_model_path` is empty, serving can fail with a HuggingFace repo id validation error. This PR: - Saves local `checkpoint_dir` values as absolute `base_model_path` values. - Preserves HuggingFace repo ids as-is. - Adds a clear error when `base_model_path` is empty. ## Validation Validated on Ascend 910B: - Ran GR00T N1.5 5-step training and saved a checkpoint. - Verified `config.json` contains a non-empty absolute `base_model_path`. - Verified `base_model_path` exists in the runtime environment. - Manually changed `policy_preprocessor.json` device to `musa` to simulate a cross-device checkpoint. - Served the checkpoint with runtime device `npu`. - `/healthz` returned `OK`. - WebSocket inference returned `actions shape: (16, 7)`.
1 parent e9988a6 commit 289d0cd

6 files changed

Lines changed: 94 additions & 13 deletions

File tree

flagscale/inference/inference_pi.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from flagscale.models.utils.constants import ACTION, OBS_STATE
1515
from flagscale.platforms import get_platform # noqa: F401 must be before model imports
1616
from flagscale.runner.utils import logger
17+
from flagscale.train.processor.pipeline import get_device_override
1718
from flagscale.train.train_pi import make_pre_post_processors
1819

1920

@@ -126,7 +127,7 @@ def run_inference(config_path: str):
126127

127128
processor_kwargs = {}
128129
processor_kwargs["preprocessor_overrides"] = {
129-
"device_processor": {"device": engine_cfg.device},
130+
**get_device_override(getattr(engine_cfg, "device", None)),
130131
"normalizer_processor": {
131132
"stats": dataset_stats,
132133
"features": {**policy_config.input_features},

flagscale/inference/inference_qwen_gr00t.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from flagscale.models.vla import TrainablePolicy
1111
from flagscale.platforms import get_platform # noqa: F401 must be before model imports
1212
from flagscale.train.processor import PolicyProcessorPipeline
13+
from flagscale.train.processor.pipeline import get_device_override
1314

1415

1516
def load_image(image_path: str, size: tuple[int, int] | None = None) -> torch.Tensor:
@@ -35,11 +36,13 @@ def run_inference(config_path: str):
3536
generate_cfg = cfg.generate
3637

3738
pretrained_dir = engine_cfg.model
38-
model = TrainablePolicy.from_pretrained(pretrained_dir, device=engine_cfg.device)
39+
runtime_device = getattr(engine_cfg, "device", None) or "cpu"
40+
model = TrainablePolicy.from_pretrained(pretrained_dir, device=runtime_device)
3941

4042
preprocessor = PolicyProcessorPipeline.from_pretrained(
4143
pretrained_dir,
4244
config_filename="policy_preprocessor.json",
45+
overrides=get_device_override(runtime_device),
4346
)
4447
postprocessor = PolicyProcessorPipeline.from_pretrained(
4548
pretrained_dir,

flagscale/models/vla/gr00t_n1_5/configuration_gr00t_n1_5.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ class Gr00tN15Config(PreTrainedConfig):
1818
# Path to the base GR00T N1.5 pretrained model (local or HuggingFace hub ID)
1919
base_model_path: str = "nvidia/GR00T-N1.5-3B"
2020

21+
# Whether to initialize model weights from base_model_path.
22+
# Fine-tuned checkpoints set this to False and load weights from model.safetensors.
23+
load_pretrained: bool = True
24+
2125
# Fine-tuning control flags (passed to GR00TN15.from_pretrained)
2226
tune_llm: bool = False
2327
tune_visual: bool = False

flagscale/models/vla/gr00t_n1_5/modeling_gr00t_n1_5.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,14 +33,23 @@
3333
from LeRobot, see `GrootPolicy.finetune_with_groot_runner` below.
3434
"""
3535

36+
import dataclasses
3637
import os
38+
from pathlib import Path
3739

3840
import torch
41+
from safetensors.torch import save_file
3942
from torch import Tensor
4043

44+
from flagscale.models.utils.constants import (
45+
SAFETENSORS_FILE,
46+
resolve_pretrained_dir,
47+
)
4148
from flagscale.models.vla.base_policy import TrainablePolicy
4249
from flagscale.models.vla.gr00t_n1_5.configuration_gr00t_n1_5 import Gr00tN15Config
43-
from flagscale.models.vla.gr00t_n1_5.gr00t_n1 import GR00TN15
50+
from flagscale.models.vla.gr00t_n1_5.gr00t_n1 import GR00TN15, GR00TN15Config
51+
52+
GR00T_CONFIG_DIR = "gr00t_config"
4453

4554

4655
class Gr00tN15(TrainablePolicy):
@@ -52,13 +61,28 @@ def __init__(self, config: Gr00tN15Config):
5261

5362
self._handle_flash_attention_compatibility()
5463

55-
self._groot_model = GR00TN15.from_pretrained(
56-
pretrained_model_name_or_path=config.base_model_path,
57-
tune_llm=config.tune_llm,
58-
tune_visual=config.tune_visual,
59-
tune_projector=config.tune_projector,
60-
tune_diffusion_model=config.tune_diffusion_model,
61-
)
64+
if config.load_pretrained:
65+
self._groot_model = GR00TN15.from_pretrained(
66+
pretrained_model_name_or_path=config.base_model_path,
67+
tune_llm=config.tune_llm,
68+
tune_visual=config.tune_visual,
69+
tune_projector=config.tune_projector,
70+
tune_diffusion_model=config.tune_diffusion_model,
71+
)
72+
else:
73+
gr00t_config = GR00TN15Config.from_pretrained(config.base_model_path)
74+
self._groot_model = GR00TN15(
75+
gr00t_config,
76+
local_model_path=config.base_model_path,
77+
)
78+
self._groot_model.backbone.set_trainable_parameters(
79+
tune_visual=config.tune_visual,
80+
tune_llm=config.tune_llm,
81+
)
82+
self._groot_model.action_head.set_trainable_parameters(
83+
tune_projector=config.tune_projector,
84+
tune_diffusion_model=config.tune_diffusion_model,
85+
)
6286

6387
self._groot_model.compute_dtype = config.compute_dtype
6488
self._groot_model.config.compute_dtype = config.compute_dtype
@@ -68,6 +92,46 @@ def __init__(self, config: Gr00tN15Config):
6892
if config.output_features:
6993
self.output_features = config.output_features
7094

95+
def _save_pretrained(self, save_directory: Path, state_dict=None) -> None:
96+
"""Save a self-contained GR00T N1.5 checkpoint."""
97+
save_directory = Path(save_directory)
98+
99+
gr00t_config_dir = save_directory / GR00T_CONFIG_DIR
100+
gr00t_config_dir.mkdir(parents=True, exist_ok=True)
101+
self._groot_model.config.save_pretrained(gr00t_config_dir)
102+
103+
save_config = dataclasses.replace(
104+
self.config,
105+
base_model_path=GR00T_CONFIG_DIR,
106+
load_pretrained=False,
107+
)
108+
save_config._save_pretrained(save_directory)
109+
110+
if state_dict is not None:
111+
state_dict = {k: v.clone().contiguous() for k, v in state_dict.items()}
112+
else:
113+
state_dict = {k: v.clone().contiguous() for k, v in self.state_dict().items()}
114+
save_file(state_dict, str(save_directory / SAFETENSORS_FILE))
115+
116+
@classmethod
117+
def from_pretrained(cls, pretrained_path, device="cpu", *, config=None):
118+
"""Load a fine-tuned GR00T N1.5 checkpoint without reloading the base model."""
119+
path = resolve_pretrained_dir(Path(pretrained_path), SAFETENSORS_FILE)
120+
if config is None:
121+
config = Gr00tN15Config.from_pretrained(path)
122+
123+
base_model_path = Path(config.base_model_path)
124+
if not base_model_path.is_absolute():
125+
base_model_path = path / base_model_path
126+
127+
config = dataclasses.replace(
128+
config,
129+
base_model_path=str(base_model_path),
130+
load_pretrained=False,
131+
)
132+
133+
return super().from_pretrained(pretrained_path, device=device, config=config)
134+
71135
def forward(self, batch: dict[str, Tensor]) -> dict[str, Tensor]:
72136
"""Training forward pass.
73137

flagscale/serve/run_serve_vla.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
from flagscale.serve.processor.image_resize_processor import ImageResizeProcessorStep
1717
from flagscale.serve.websocket_policy_server import WebsocketPolicyServer
1818
from flagscale.train.processor import PolicyProcessorPipeline, ProcessorStepRegistry
19+
from flagscale.train.processor.pipeline import get_device_override
1920

2021
# TODO: (yupu) to constant.py?
2122
TASK_KEY = "task"
@@ -136,9 +137,8 @@ def load_policy(self) -> None:
136137
"""Load the policy model and all processor pipelines from the checkpoint."""
137138
t_s = time.perf_counter()
138139
pretrained_dir: str = self.config_engine.model
139-
self.model = TrainablePolicy.from_pretrained(
140-
pretrained_dir, device=self.config_engine.device
141-
)
140+
runtime_device = getattr(self.config_engine, "device", None) or "cpu"
141+
self.model = TrainablePolicy.from_pretrained(pretrained_dir, device=runtime_device)
142142
self._load_processors(pretrained_dir)
143143
# TODO: (yupu): model.to(dtype)?
144144
logger.info(f"Policy model loading latency: {time.perf_counter() - t_s:.2f}s")
@@ -147,9 +147,11 @@ def _load_processors(self, pretrained_dir: str) -> None:
147147
"""Load pre/post-processors from the checkpoint and build the serve preprocessor."""
148148
self.rename_map = self.config_engine.get("rename_map")
149149

150+
runtime_device = getattr(self.config_engine, "device", None) or "cpu"
150151
self.preprocessor = PolicyProcessorPipeline.from_pretrained(
151152
pretrained_dir,
152153
config_filename="policy_preprocessor.json",
154+
overrides=get_device_override(runtime_device),
153155
)
154156
self.postprocessor = PolicyProcessorPipeline.from_pretrained(
155157
pretrained_dir,

flagscale/train/processor/pipeline.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1455,6 +1455,13 @@ def process_complementary_data(self, complementary_data: dict[str, Any]) -> dict
14551455
PolicyProcessorPipeline: TypeAlias = DataProcessorPipeline[TInput, TOutput]
14561456

14571457

1458+
def get_device_override(runtime_device: str | None) -> dict[str, Any]:
1459+
"""Return processor overrides to align checkpoint device with runtime device."""
1460+
if not runtime_device:
1461+
return {}
1462+
return {"device_processor": {"device": runtime_device}}
1463+
1464+
14581465
class ObservationProcessorStep(ProcessorStep, ABC):
14591466
"""An abstract `ProcessorStep` that specifically targets the observation in a transition."""
14601467

0 commit comments

Comments
 (0)