Update Wall-X to 1.1.0 (#104)

This commit is contained in:
Starrick Liu
2026-06-15 11:40:00 +08:00
committed by GitHub
parent e23a586846
commit 72834e7de5
200 changed files with 33916 additions and 16771 deletions
+171
View File
@@ -0,0 +1,171 @@
"""Load checkpoint weights and apply fused-format conversion when needed.
Model-instance operations such as ``load_state_dict`` and ``set_normalizer``
are intentionally left to the adapter.
"""
from __future__ import annotations
import os
from typing import Callable, Optional
import torch
from safetensors.torch import load_file
def _noop_log(_msg: str, **_kw) -> None:
pass
def _align_checkpoint_tensor(
param: torch.Tensor,
target: torch.Tensor,
name: str,
log_fn: Callable,
) -> torch.Tensor | None:
"""Crop or pad a checkpoint tensor to match the current model parameter."""
if param.shape == target.shape:
return param
if param.ndim != target.ndim:
log_fn(
f"Skipping '{name}': ndim mismatch "
f"checkpoint={param.ndim} model={target.ndim}"
)
return None
overlap = tuple(
slice(0, min(src, dst)) for src, dst in zip(param.shape, target.shape)
)
if all(src >= dst for src, dst in zip(param.shape, target.shape)):
aligned = param[overlap].contiguous()
log_fn(
f"Cropped '{name}': checkpoint {tuple(param.shape)} "
f"-> model {tuple(aligned.shape)}"
)
return aligned
if all(src <= dst for src, dst in zip(param.shape, target.shape)):
aligned = target.detach().clone()
aligned[overlap] = param[overlap]
log_fn(
f"Padded '{name}': checkpoint {tuple(param.shape)} "
f"-> model {tuple(aligned.shape)} (tail keeps model init)"
)
return aligned
aligned = target.detach().clone()
aligned[overlap] = param[overlap]
log_fn(
f"Partially aligned '{name}': checkpoint {tuple(param.shape)} "
f"-> model {tuple(aligned.shape)} (non-overlap keeps model init)"
)
return aligned
def reshape_compatible_state_dict(
state_dict: dict, model_sd: dict, log_fn: Optional[Callable] = None
) -> dict:
"""Align checkpoint tensors to the target model shapes via crop / pad."""
log_fn = log_fn or _noop_log
out = {}
for name, param in state_dict.items():
if name not in model_sd:
log_fn(f"Not used parameter: {name}")
continue
target = model_sd[name]
if param.shape == target.shape:
out[name] = param
continue
aligned = _align_checkpoint_tensor(param, target, name, log_fn)
if aligned is not None:
out[name] = aligned
return out
def load_state_dict(checkpoint_path: str, model_class) -> dict:
"""Load a state dict from a checkpoint directory.
Supported formats:
- pytorch_model_fsdp.bin, optionally wrapped as {"state_dict": ...}
- model.safetensors
If the model class reports that the state dict is not fused, it is converted
through ``model_class.convert_to_fused``.
"""
fsdp_ckpt = os.path.join(checkpoint_path, "pytorch_model_fsdp.bin")
safetensor_ckpt = os.path.join(checkpoint_path, "model.safetensors")
if os.path.exists(fsdp_ckpt):
state_dict = torch.load(fsdp_ckpt, map_location="cpu")
if isinstance(state_dict, dict) and "state_dict" in state_dict:
state_dict = state_dict["state_dict"]
elif os.path.exists(safetensor_ckpt):
state_dict = load_file(safetensor_ckpt, device="cpu")
else:
raise FileNotFoundError(
"checkpoint contains neither pytorch_model_fsdp.bin nor model.safetensors: "
f"{checkpoint_path}"
)
if not model_class.is_fused(state_dict):
state_dict = model_class.convert_to_fused(state_dict)
return state_dict
def read_global_step(checkpoint_path: str) -> int | None:
"""Read ``global_step.pth`` when present."""
p = os.path.join(checkpoint_path, "global_step.pth")
if not os.path.exists(p):
return None
payload = torch.load(p)
return int(payload["global_step"])
def _dir_has_weights(path: str) -> bool:
return os.path.exists(
os.path.join(path, "pytorch_model_fsdp.bin")
) or os.path.exists(os.path.join(path, "model.safetensors"))
def resolve_checkpoint_dir(checkpoint_path: str) -> str:
"""Return a directory that directly contains model weights.
Training saves under a root such as ``libero6/`` with step subdirs
``libero6/0/``, ``libero6/3/``, etc. Inference callers may pass either the
root or a concrete step directory.
"""
if os.path.isfile(checkpoint_path):
checkpoint_path = os.path.dirname(checkpoint_path)
if _dir_has_weights(checkpoint_path):
return checkpoint_path
if not os.path.isdir(checkpoint_path):
raise FileNotFoundError(f"checkpoint path does not exist: {checkpoint_path}")
candidates: list[tuple[int, float, str]] = []
for entry in os.listdir(checkpoint_path):
sub = os.path.join(checkpoint_path, entry)
if not os.path.isdir(sub) or not _dir_has_weights(sub):
continue
step = read_global_step(sub)
sort_step = step if step is not None else -1
candidates.append((sort_step, os.path.getmtime(sub), sub))
if not candidates:
return checkpoint_path
candidates.sort()
resolved = candidates[-1][2]
if resolved != checkpoint_path:
import logging
logging.getLogger(__name__).info(
"Resolved checkpoint root %s -> %s (global_step=%s)",
checkpoint_path,
resolved,
read_global_step(resolved),
)
return resolved
+135
View File
@@ -0,0 +1,135 @@
"""Build action/proprio normalizers and resolve the effective norm key.
Public inference artifacts must carry their own normalization data. This module
uses checkpoint-local ``norm_stats.json`` first, then checkpoint-side normalizer
state dicts, and finally an explicit ``customized_action_statistic_dof`` path.
It does not fall back to internal default action statistics.
"""
from __future__ import annotations
import json
import logging
import os
import torch
from wall_x.data.backends.lerobot.utils import NormStats
from wall_x.model.core.action.normalizer import Normalizer, pad_normalizer_to_dim
from wall_x._vendor.harrix.utils.train_config import (
resolve_agent_pos_config,
resolve_dof_config,
)
logger = logging.getLogger(__name__)
def _load_norm_stats(norm_stats_path: str, action_key: str) -> NormStats:
with open(norm_stats_path, "r") as f:
norm_stats = json.load(f)
q01 = torch.tensor(norm_stats["norm_stats"][action_key]["q01"])
q99 = torch.tensor(norm_stats["norm_stats"][action_key]["q99"])
return NormStats(min=q01, max=q99, delta=q99 - q01)
def _load_custom_action_stats(train_config: dict) -> dict | None:
custom = train_config.get("customized_action_statistic_dof", None)
if not custom:
return None
with open(custom, "r") as f:
return json.load(f)
def _normalizer_from_stats(action_stats: dict, train_config: dict, key: str) -> Normalizer:
return Normalizer(
action_stats,
train_config[key],
min_key=train_config.get("min_key", "min"),
delta_key=train_config.get("delta_key", "delta"),
)
def _missing_normalizer_error(checkpoint_path: str, train_config: dict) -> FileNotFoundError:
custom = train_config.get("customized_action_statistic_dof", None)
return FileNotFoundError(
"Public inference requires normalization data. Expected one of: "
f"{os.path.join(checkpoint_path, 'norm_stats.json')}; checkpoint-side "
"normalizer_action.pth and normalizer_propri.pth; or an explicit "
f"customized_action_statistic_dof path. Current customized_action_statistic_dof={custom!r}."
)
def build_normalizers(
checkpoint_path: str,
train_config: dict,
norm_key: str,
) -> tuple[Normalizer, Normalizer, str]:
"""Return action/proprio normalizers and the resolved norm key."""
norm_stats_path = os.path.join(checkpoint_path, "norm_stats.json")
if os.path.exists(norm_stats_path):
propri_stats = _load_norm_stats(norm_stats_path, "observation.state")
action_stats = _load_norm_stats(norm_stats_path, "action")
normalizer_propri = Normalizer.from_lerobot_norm_stats(propri_stats, norm_key)
normalizer_action = Normalizer.from_lerobot_norm_stats(action_stats, norm_key)
else:
action_pth = os.path.join(checkpoint_path, "normalizer_action.pth")
propri_pth = os.path.join(checkpoint_path, "normalizer_propri.pth")
custom_stats = _load_custom_action_stats(train_config)
if custom_stats is None and (not os.path.exists(action_pth) or not os.path.exists(propri_pth)):
raise _missing_normalizer_error(checkpoint_path, train_config)
if os.path.exists(action_pth):
normalizer_action = Normalizer.from_ckpt(action_pth)
else:
normalizer_action = _normalizer_from_stats(custom_stats, train_config, "dof_config")
if os.path.exists(propri_pth):
normalizer_propri = Normalizer.from_ckpt(propri_pth)
else:
normalizer_propri = _normalizer_from_stats(custom_stats, train_config, "agent_pos_config")
action_dim = sum(resolve_dof_config(train_config).values())
propri_dim = sum(resolve_agent_pos_config(train_config).values())
pad_normalizer_to_dim(normalizer_action, action_dim, "action")
pad_normalizer_to_dim(normalizer_propri, propri_dim, "propri")
resolved = _resolve_norm_key(norm_key, normalizer_action, normalizer_propri)
return normalizer_action, normalizer_propri, resolved
def _resolve_norm_key(
norm_key: str,
normalizer_action: Normalizer,
normalizer_propri: Normalizer,
) -> str:
"""Resolve a requested norm key against normalizer keys."""
available = sorted(
set(normalizer_action.min.keys()) & set(normalizer_propri.min.keys())
)
if norm_key in available:
return norm_key
if not available:
return norm_key
prefix_matches = [k for k in available if k.startswith(f"{norm_key}_")]
if len(prefix_matches) == 1:
logger.warning(
"norm_key=%r not found; using prefix fallback %r",
norm_key,
prefix_matches[0],
)
return prefix_matches[0]
if len(available) == 1:
logger.warning(
"norm_key=%r not found; using the only available key %r",
norm_key,
available[0],
)
return available[0]
logger.warning(
"norm_key=%r not found; available=%s; returning the requested key unchanged",
norm_key,
available,
)
return norm_key
+23
View File
@@ -0,0 +1,23 @@
"""Seed helpers for driver and environment worker processes."""
from __future__ import annotations
import os
import random
import numpy as np
import torch
def set_seed_everywhere(seed: int) -> None:
"""Set random seeds and deterministic backend options."""
torch.manual_seed(seed)
torch.cuda.manual_seed_all(seed)
np.random.seed(seed)
random.seed(seed)
torch.backends.cudnn.deterministic = True
torch.backends.cudnn.benchmark = False
os.environ["PYTHONHASHSEED"] = str(seed)
# Deterministic cuBLAS workspace plus warning-only deterministic op checks.
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
torch.use_deterministic_algorithms(True, warn_only=True)
+371
View File
@@ -0,0 +1,371 @@
"""Load checkpoint-side train config and construct runtime config objects.
Inference adapters use this module to load the train config, apply checkpoint
overrides for moved processor/tokenizer files, build model config, and build the
data config required by preprocessing.
"""
from __future__ import annotations
import os
import logging
import yaml
logger = logging.getLogger(__name__)
_TASK_INFERENCE_KEYS = (
"dof_config",
"agent_pos_config",
"ar_dof_config",
"action_horizon",
"action_horizon_flow",
"noise_scheduler",
"use_state_string_representation",
)
_VIRTUAL_TAIL_KEYS = frozenset(("action_padding",))
def _move_virtual_keys_to_tail(layout: dict | None) -> dict | None:
"""Keep real LeRobot dims before virtual padding dims in inference layouts."""
if not isinstance(layout, dict):
return layout
head = {k: v for k, v in layout.items() if k not in _VIRTUAL_TAIL_KEYS}
tail = {k: v for k, v in layout.items() if k in _VIRTUAL_TAIL_KEYS}
if not tail:
return layout
return {**head, **tail}
def _canonicalize_task_layouts(target: dict) -> None:
if os.environ.get("WALLX_CANONICALIZE_VIRTUAL_DOF_ORDER", "1") == "0":
return
task = target.get("task")
data = target.get("data")
for key in ("dof_config", "agent_pos_config", "ar_dof_config"):
value = _move_virtual_keys_to_tail(target.get(key))
if value is not None:
target[key] = value
if isinstance(task, dict):
task_value = _move_virtual_keys_to_tail(task.get(key))
if task_value is not None:
task[key] = task_value
if isinstance(data, dict):
data_value = _move_virtual_keys_to_tail(data.get(key))
if data_value is not None:
data[key] = data_value
def resolve_use_state_string_representation(train_config: dict) -> bool:
"""Read ``use_state_string_representation`` with task YAML as authority."""
task = train_config.get("task") or {}
if task.get("use_state_string_representation") is not None:
return bool(task["use_state_string_representation"])
if train_config.get("use_state_string_representation") is not None:
return bool(train_config["use_state_string_representation"])
data = train_config.get("data") or {}
if data.get("use_state_string_representation") is not None:
return bool(data["use_state_string_representation"])
return False
def resolve_state_bins(train_config: dict, default: int = 256) -> int:
"""Read discretization bin count from flat or nested train config."""
if train_config.get("state_bins") is not None:
return int(train_config["state_bins"])
data = train_config.get("data") or {}
if data.get("state_bins") is not None:
return int(data["state_bins"])
return default
def resolve_agent_pos_config(train_config: dict) -> dict:
"""Return ``agent_pos_config`` from flat or ``task``-nested train YAML."""
task = train_config.get("task") or {}
agent_pos_config = train_config.get("agent_pos_config") or task.get(
"agent_pos_config"
)
if not agent_pos_config:
raise KeyError(
"agent_pos_config missing from train config "
"(expected top-level or task.agent_pos_config)"
)
return dict(agent_pos_config)
def resolve_dof_config(train_config: dict) -> dict:
"""Return ``dof_config`` from flat or ``task``-nested train YAML."""
task = train_config.get("task") or {}
dof_config = train_config.get("dof_config") or task.get("dof_config")
if not dof_config:
raise KeyError(
"dof_config missing from train config (expected top-level or task.dof_config)"
)
return dict(dof_config)
def resolve_cam_names_from_train_config(train_config: dict) -> list[str] | None:
"""Infer model camera keys from ``data.key_mappings.camera`` (e.g. LIBERO)."""
data = train_config.get("data") or {}
key_mappings = data.get("key_mappings") or {}
camera = key_mappings.get("camera") or {}
if not camera:
return None
names: list[str] = []
for value in camera.values():
name = str(value)
if name not in names:
names.append(name)
return names or None
def resolve_camera_label(cam_name: str, camera_name_mapping: dict | None = None) -> str:
"""Match training ``get_wallx_normal_text`` camera display names."""
mapping = camera_name_mapping or {}
return mapping.get(cam_name, cam_name)
def resolve_max_length(train_config: dict, default: int = 768) -> int:
"""Read ``max_length`` from flat or nested train YAML (collator default 768)."""
data = train_config.get("data") or {}
if train_config.get("max_length") is not None:
return int(train_config["max_length"])
if data.get("max_length") is not None:
return int(data["max_length"])
raw = train_config.get("_raw_data") or {}
if raw.get("max_length") is not None:
return int(raw["max_length"])
return default
# Fields ``load_wallx_processors`` and model construction read from a flat dict.
_ACTION_TOKENIZER_KEYS = (
"action_tokenizer_type",
"action_tokenizer_path",
"action_tokenizer_checkpoint_path",
"action_tokenizer_config_dir",
"action_tokenizer",
)
_INFERENCE_MODEL_KEYS = (
"processor_path",
"pretrained_path",
"config_path",
*_ACTION_TOKENIZER_KEYS,
"ar_loss_weight",
"attn_deterministic",
"flow_loss_weight",
"use_ema",
)
def _merge_model_fields(target: dict, source: dict, *, overwrite: bool = False) -> None:
"""Copy model-related keys from ``source`` into flat ``target``."""
if not source:
return
for key in _INFERENCE_MODEL_KEYS:
value = source.get(key)
if value is None:
continue
if overwrite or target.get(key) in (None, ""):
target[key] = value
def _mirror_task_fields(target: dict, task: dict) -> None:
"""Copy typed task fields into the flat/data legacy inference mirrors."""
if not isinstance(task, dict):
return
data = target.setdefault("data", {})
if not isinstance(data, dict):
data = {}
target["data"] = data
for key in _TASK_INFERENCE_KEYS:
value = task.get(key)
if value is None:
continue
target[key] = value
data[key] = value
def _load_ckpt_config_overlay(checkpoint_path: str) -> dict:
"""Read ``config.yml`` saved beside a training checkpoint."""
ckpt_yml = os.path.join(checkpoint_path, "config.yml")
if not os.path.exists(ckpt_yml):
return {}
with open(ckpt_yml, "r") as f:
raw = yaml.load(f, Loader=yaml.FullLoader) or {}
overlay: dict = {}
_merge_model_fields(overlay, raw.get("model") or {}, overwrite=True)
raw_yaml = raw.get("_raw_yaml") or {}
_merge_model_fields(overlay, raw_yaml.get("model") or {}, overwrite=False)
_merge_model_fields(overlay, raw, overwrite=False)
return overlay
def strip_action_tokenizer_fields(train_config: dict) -> None:
"""Drop action-tokenizer fields so flow inference can skip AR tokenizer setup."""
for key in _ACTION_TOKENIZER_KEYS:
train_config.pop(key, None)
model = train_config.get("model")
if isinstance(model, dict):
for key in _ACTION_TOKENIZER_KEYS:
model.pop(key, None)
def load_train_config_with_ckpt_overlay(
train_config_path: str,
checkpoint_path: str,
) -> dict:
"""Load train YAML and apply checkpoint-local processor/tokenizer overlays.
This lets a checkpoint remain portable when the original training-machine
processor or action-tokenizer paths are no longer available.
"""
with open(train_config_path, "r") as f:
train_config = yaml.load(f, Loader=yaml.FullLoader)
preprocessor_file = os.path.join(checkpoint_path, "preprocessor_config.json")
if os.path.exists(preprocessor_file):
train_config["processor_path"] = checkpoint_path
orig_action_tok = train_config.get("action_tokenizer_path", None)
if orig_action_tok is not None and not os.path.exists(orig_action_tok):
tokenizer_file = os.path.join(checkpoint_path, "tokenizer.json")
tokenizer_config_file = os.path.join(checkpoint_path, "tokenizer_config.json")
if os.path.exists(tokenizer_file) and os.path.exists(tokenizer_config_file):
train_config["action_tokenizer_path"] = checkpoint_path
ckpt_overlay = _load_ckpt_config_overlay(checkpoint_path)
_merge_model_fields(train_config, ckpt_overlay, overwrite=False)
if isinstance(train_config.get("model"), dict):
_merge_model_fields(train_config["model"], ckpt_overlay, overwrite=False)
return train_config
def normalize_train_config_for_inference(
train_config: dict,
train_config_path: str,
) -> dict:
"""Flatten typed TrainConfig YAML into the legacy dict inference expects.
Serving and ``update_model_config`` still read top-level ``dof_config`` and
nested ``data.*`` fields. New training yamls store those under ``task:``.
"""
try:
from wall_x.config.loader import load_config
typed_cfg = load_config(train_config_path)
except Exception as e:
logger.debug(
"Train config is not typed TrainConfig schema (%s): %s",
train_config_path,
e,
)
_mirror_task_fields(train_config, train_config.get("task") or {})
_canonicalize_task_layouts(train_config)
return train_config
import dataclasses
normalized = typed_cfg.build_data_loader_dict()
model_dict = dataclasses.asdict(typed_cfg.model)
_merge_model_fields(normalized, model_dict, overwrite=True)
normalized["data"] = dict(normalized.get("data") or {})
if isinstance(train_config.get("model"), dict):
_merge_model_fields(normalized, train_config["model"], overwrite=False)
for key, value in train_config.items():
if key in _INFERENCE_MODEL_KEYS or key == "qwen_vl_act_config_path":
if value is not None:
normalized[key] = value
elif key == "data" and isinstance(value, dict):
normalized["data"].update(value)
_mirror_task_fields(normalized, dataclasses.asdict(typed_cfg.task))
_canonicalize_task_layouts(normalized)
return normalized
def register_data_backend(train_config: dict) -> None:
"""Register the data backend before typed data config construction."""
from wall_x.data._registry import _set_data_backend
data_section = train_config.get("data", {})
dataset_type = train_config.get("dataset_type") or data_section.get(
"dataset_type", "lerobot"
)
_set_data_backend(dataset_type)
def build_model_config(
config_class,
checkpoint_path: str,
train_config: dict,
train_config_path: str | None = None,
):
"""Build the HF model config and inject train_config-derived fields.
``config_class`` is supplied by the variant adapter. If the checkpoint does
not include ``config.json``, this falls back to
``train_config["qwen_vl_act_config_path"]``.
"""
ckpt_config_path = os.path.join(checkpoint_path, "config.json")
if os.path.exists(ckpt_config_path):
resolved = ckpt_config_path
else:
resolved = train_config.get("qwen_vl_act_config_path")
if resolved is None or not os.path.exists(resolved):
raise ValueError(
f"cannot load model config: checkpoint file {ckpt_config_path} "
f"does not exist, and fallback qwen_vl_act_config_path={resolved!r} "
"does not exist either"
)
train_config["qwen_vl_act_config_path"] = resolved
if resolved.endswith(".json"):
model_config = config_class.from_json_file(resolved)
else:
model_config = config_class.from_pretrained(resolved)
legacy_train_config = (
normalize_train_config_for_inference(train_config, train_config_path)
if train_config_path
else train_config
)
model_config.update_model_config(legacy_train_config)
model_config._attn_implementation = "sdpa"
model_config.vision_config._attn_implementation = "flash_attention_2"
return model_config
def build_data_config(train_config_path: str, train_config: dict):
"""Build the data config used by image resizing and preprocessing.
New checkpoints use the typed schema. Older checkpoints may still use a flat
legacy schema, so this falls back to the active data backend.
"""
from wall_x.config.loader import load_config
from wall_x.data import data_backend
try:
typed_cfg = load_config(train_config_path)
backend = data_backend()
if backend.supports("load_trainer_data_config"):
return backend.load_trainer_data_config(typed_cfg)
except Exception as e:
logger.warning(
"typed TrainConfig loading failed; falling back to raw data config: %s",
e,
)
backend = data_backend()
if backend.supports("load_trainer_data_config_from_yaml_dict"):
return backend.load_trainer_data_config_from_yaml_dict(train_config)
raise RuntimeError(
f"active data backend {backend!r} cannot build a trainer data config"
)