ec436095dd
Book-CI / test (macos-latest) (push) Has been cancelled
Book-CI / test (ubuntu-latest) (push) Has been cancelled
Book-CI / test (windows-latest) (push) Has been cancelled
Release Fake Tag / publish (push) Has been cancelled
Deploy / deploy (macos-latest) (push) Has been cancelled
Deploy / deploy (ubuntu-latest) (push) Has been cancelled
Deploy / deploy (windows-latest) (push) Has been cancelled
Release to PyPI / Build & publish sglang-kt (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.11) (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.12) (push) Has been cancelled
Release to PyPI / Publish kt-kernel to PyPI (push) Has been cancelled
478 lines
15 KiB
Python
478 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Convert KT fused expert LoRA checkpoints into an SGLang adapter directory."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
from pathlib import Path
|
|
from typing import Dict, Iterable, Mapping
|
|
|
|
import torch
|
|
from safetensors.torch import load_file, save_file
|
|
|
|
|
|
FUSED_EXPERT_LORA_FILE = "fused_expert_lora.safetensors"
|
|
ADAPTER_MODEL_FILE = "adapter_model.safetensors"
|
|
ADAPTER_CONFIG_FILE = "adapter_config.json"
|
|
|
|
KT_NAME_MAP = {
|
|
"gate_lora_a": ("gate_proj", "lora_A", 1),
|
|
"gate_lora_b": ("gate_proj", "lora_B", 2),
|
|
"up_lora_a": ("up_proj", "lora_A", 1),
|
|
"up_lora_b": ("up_proj", "lora_B", 2),
|
|
"down_lora_a": ("down_proj", "lora_A", 1),
|
|
"down_lora_b": ("down_proj", "lora_B", 2),
|
|
}
|
|
|
|
TARGET_MODULE_ORDER = [
|
|
"q_proj",
|
|
"k_proj",
|
|
"v_proj",
|
|
"o_proj",
|
|
"gate_proj",
|
|
"up_proj",
|
|
"down_proj",
|
|
"in_proj_qkv",
|
|
"in_proj_z",
|
|
"in_proj_b",
|
|
"in_proj_a",
|
|
"out_proj",
|
|
"embed_tokens",
|
|
"lm_head",
|
|
]
|
|
|
|
KT_FUSED_KEY_RE = re.compile(r"^layers\.(\d+)\.experts\.([^.]+)$")
|
|
|
|
|
|
def _load_json(path: Path) -> dict:
|
|
with path.open("r", encoding="utf-8") as f:
|
|
return json.load(f)
|
|
|
|
|
|
def _write_json(path: Path, data: Mapping) -> None:
|
|
with path.open("w", encoding="utf-8") as f:
|
|
json.dump(data, f, indent=2, sort_keys=True)
|
|
f.write("\n")
|
|
|
|
|
|
def _clean_adapter_key(key: str) -> str:
|
|
"""Match the existing SGLang converter's PEFT key cleanup."""
|
|
key = key.replace("base_model.model.", "")
|
|
key = key.replace(".orig_module", "")
|
|
return key
|
|
|
|
|
|
def _ordered_target_modules(modules: Iterable[str]) -> list[str]:
|
|
seen = set(modules)
|
|
ordered = [name for name in TARGET_MODULE_ORDER if name in seen]
|
|
ordered.extend(sorted(seen.difference(ordered)))
|
|
return ordered
|
|
|
|
|
|
def _infer_target_module_from_key(key: str) -> str | None:
|
|
if "lora_embedding_A" in key or "lora_embedding_B" in key:
|
|
if "embed_tokens" in key:
|
|
return "embed_tokens"
|
|
if "lm_head" in key or "unembed_tokens" in key:
|
|
return "lm_head"
|
|
|
|
marker = ".lora_"
|
|
if marker not in key:
|
|
return None
|
|
prefix = key.split(marker, 1)[0]
|
|
if "." not in prefix:
|
|
return prefix
|
|
return prefix.rsplit(".", 1)[-1]
|
|
|
|
|
|
def _merge_tensor(tensors: Dict[str, torch.Tensor], key: str, value: torch.Tensor) -> None:
|
|
if key in tensors:
|
|
raise ValueError(f"Duplicate output tensor key: {key}")
|
|
tensors[key] = value.detach().cpu()
|
|
|
|
|
|
def _load_existing_adapter(input_dir: Path) -> tuple[dict[str, torch.Tensor], set[str]]:
|
|
adapter_path = input_dir / ADAPTER_MODEL_FILE
|
|
if not adapter_path.exists():
|
|
return {}, set()
|
|
|
|
tensors: dict[str, torch.Tensor] = {}
|
|
target_modules: set[str] = set()
|
|
for key, value in load_file(str(adapter_path)).items():
|
|
cleaned_key = _clean_adapter_key(key)
|
|
_merge_tensor(tensors, cleaned_key, value)
|
|
target_module = _infer_target_module_from_key(cleaned_key)
|
|
if target_module is not None:
|
|
target_modules.add(target_module)
|
|
return tensors, target_modules
|
|
|
|
|
|
def _convert_fused_expert_lora(
|
|
fused_path: Path,
|
|
) -> tuple[dict[str, torch.Tensor], int, set[str]]:
|
|
if not fused_path.exists():
|
|
raise FileNotFoundError(f"Missing {FUSED_EXPERT_LORA_FILE}: {fused_path}")
|
|
|
|
output: dict[str, torch.Tensor] = {}
|
|
ranks: set[int] = set()
|
|
expert_counts: set[int] = set()
|
|
target_modules: set[str] = set()
|
|
|
|
for key, tensor in sorted(load_file(str(fused_path)).items()):
|
|
match = KT_FUSED_KEY_RE.match(key)
|
|
if match is None:
|
|
raise ValueError(f"Unexpected key in {FUSED_EXPERT_LORA_FILE}: {key}")
|
|
|
|
layer_idx, kt_name = match.groups()
|
|
if kt_name not in KT_NAME_MAP:
|
|
raise ValueError(f"Unsupported KT fused expert LoRA tensor: {key}")
|
|
if tensor.dim() != 3:
|
|
raise ValueError(f"{key} must be 3D [E, ...], got shape {tuple(tensor.shape)}")
|
|
|
|
proj_name, lora_name, rank_dim = KT_NAME_MAP[kt_name]
|
|
expert_count = int(tensor.shape[0])
|
|
rank = int(tensor.shape[rank_dim])
|
|
expert_counts.add(expert_count)
|
|
ranks.add(rank)
|
|
target_modules.add(proj_name)
|
|
|
|
for expert_idx in range(expert_count):
|
|
output_key = (
|
|
f"model.layers.{layer_idx}.mlp.experts.{expert_idx}."
|
|
f"{proj_name}.{lora_name}.weight"
|
|
)
|
|
_merge_tensor(output, output_key, tensor[expert_idx].contiguous())
|
|
|
|
if not output:
|
|
raise ValueError(f"No tensors found in {fused_path}")
|
|
if len(expert_counts) != 1:
|
|
raise ValueError(f"Inconsistent expert counts in {FUSED_EXPERT_LORA_FILE}: {sorted(expert_counts)}")
|
|
if len(ranks) != 1:
|
|
raise ValueError(f"Inconsistent LoRA ranks in {FUSED_EXPERT_LORA_FILE}: {sorted(ranks)}")
|
|
|
|
return output, next(iter(ranks)), target_modules
|
|
|
|
|
|
def _build_adapter_config(
|
|
input_dir: Path,
|
|
rank: int,
|
|
target_modules: set[str],
|
|
base_model_name_or_path: str,
|
|
lora_alpha: float | None,
|
|
*,
|
|
include_input_target_modules: bool = True,
|
|
) -> dict:
|
|
config_path = input_dir / ADAPTER_CONFIG_FILE
|
|
config = _load_json(config_path) if config_path.exists() else {}
|
|
|
|
if "lora_alpha" in config:
|
|
final_alpha = config["lora_alpha"]
|
|
elif lora_alpha is not None:
|
|
final_alpha = lora_alpha
|
|
else:
|
|
raise ValueError(
|
|
f"No {ADAPTER_CONFIG_FILE} with lora_alpha found in {input_dir}; "
|
|
"pass --lora-alpha to preserve runtime scaling."
|
|
)
|
|
|
|
existing_targets = config.get("target_modules", [])
|
|
if include_input_target_modules and isinstance(existing_targets, list):
|
|
target_modules.update(str(name).split(".")[-1] for name in existing_targets)
|
|
|
|
config["peft_type"] = config.get("peft_type", "LORA")
|
|
config["r"] = rank
|
|
config["lora_alpha"] = final_alpha
|
|
config["target_modules"] = _ordered_target_modules(target_modules)
|
|
config["bias"] = config.get("bias", "none")
|
|
config["task_type"] = config.get("task_type", "CAUSAL_LM")
|
|
config["base_model_name_or_path"] = base_model_name_or_path
|
|
|
|
return config
|
|
|
|
|
|
def _paths_have_ancestor_relationship(left: Path, right: Path) -> bool:
|
|
if left == right:
|
|
return True
|
|
try:
|
|
left.relative_to(right)
|
|
return True
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
right.relative_to(left)
|
|
return True
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def _validate_no_ancestor_paths(
|
|
paths: Iterable[Path],
|
|
*,
|
|
label: str,
|
|
) -> None:
|
|
resolved = list(paths)
|
|
for i, left in enumerate(resolved):
|
|
for right in resolved[i + 1 :]:
|
|
if _paths_have_ancestor_relationship(left, right):
|
|
raise ValueError(
|
|
f"{label} cannot have ancestor/descendant relationships: "
|
|
f"{left} and {right}."
|
|
)
|
|
|
|
|
|
def _prepare_output_dir(output_path: Path, input_path: Path, overwrite: bool) -> None:
|
|
_validate_output_dir(output_path, input_path, overwrite)
|
|
if output_path.exists() and any(output_path.iterdir()):
|
|
shutil.rmtree(output_path)
|
|
output_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def _validate_output_dir(output_path: Path, input_path: Path, overwrite: bool) -> None:
|
|
if output_path == input_path:
|
|
raise ValueError("Output directory must be different from input directory.")
|
|
if _paths_have_ancestor_relationship(output_path, input_path):
|
|
raise ValueError(
|
|
"Output and input directories cannot be ancestor/descendant of each other: "
|
|
f"output={output_path}, input={input_path}."
|
|
)
|
|
if output_path.exists() and not output_path.is_dir():
|
|
raise FileExistsError(f"Output path exists and is not a directory: {output_path}")
|
|
if output_path.exists() and any(output_path.iterdir()):
|
|
if not overwrite:
|
|
raise FileExistsError(f"Output directory is not empty: {output_path}")
|
|
|
|
|
|
def _infer_lora_rank_from_tensor(key: str, tensor: torch.Tensor) -> int | None:
|
|
if ".lora_A." in key:
|
|
return int(tensor.shape[0])
|
|
if ".lora_B." in key:
|
|
return int(tensor.shape[1])
|
|
return None
|
|
|
|
|
|
def _validate_nonexpert_rank(
|
|
existing_tensors: Mapping[str, torch.Tensor],
|
|
expert_rank: int,
|
|
input_dir: Path,
|
|
) -> None:
|
|
if not existing_tensors:
|
|
return
|
|
|
|
config_path = input_dir / ADAPTER_CONFIG_FILE
|
|
if config_path.exists():
|
|
config_rank = _load_json(config_path).get("r")
|
|
if config_rank is not None and int(config_rank) != expert_rank:
|
|
raise ValueError(
|
|
f"Non-expert adapter rank mismatch: adapter_config.json r={config_rank}, "
|
|
f"but fused expert LoRA rank={expert_rank}."
|
|
)
|
|
|
|
for key, tensor in existing_tensors.items():
|
|
tensor_rank = _infer_lora_rank_from_tensor(key, tensor)
|
|
if tensor_rank is None:
|
|
continue
|
|
if tensor_rank != expert_rank:
|
|
raise ValueError(
|
|
f"Non-expert adapter tensor rank mismatch for {key}: "
|
|
f"tensor rank={tensor_rank}, fused expert LoRA rank={expert_rank}."
|
|
)
|
|
|
|
|
|
def _write_adapter(
|
|
output_path: Path,
|
|
input_path: Path,
|
|
tensors: dict[str, torch.Tensor],
|
|
config: Mapping,
|
|
*,
|
|
overwrite: bool,
|
|
) -> None:
|
|
_prepare_output_dir(output_path, input_path, overwrite)
|
|
save_file(tensors, str(output_path / ADAPTER_MODEL_FILE), metadata={"format": "pt"})
|
|
_write_json(output_path / ADAPTER_CONFIG_FILE, config)
|
|
|
|
|
|
def convert_kt_to_sglang_adapter(
|
|
input_dir: str | os.PathLike,
|
|
output_dir: str | os.PathLike,
|
|
*,
|
|
base_model_name_or_path: str,
|
|
lora_alpha: float | None = None,
|
|
overwrite: bool = False,
|
|
expert_output_dir: str | os.PathLike | None = None,
|
|
nonexpert_output_dir: str | os.PathLike | None = None,
|
|
) -> dict:
|
|
input_path = Path(input_dir).expanduser().resolve()
|
|
output_path = Path(output_dir).expanduser().resolve()
|
|
expert_output_path = (
|
|
Path(expert_output_dir).expanduser().resolve()
|
|
if expert_output_dir is not None
|
|
else None
|
|
)
|
|
nonexpert_output_path = (
|
|
Path(nonexpert_output_dir).expanduser().resolve()
|
|
if nonexpert_output_dir is not None
|
|
else None
|
|
)
|
|
|
|
if not input_path.is_dir():
|
|
raise FileNotFoundError(f"Input directory not found: {input_path}")
|
|
|
|
output_paths = [output_path]
|
|
output_paths.extend(path for path in (expert_output_path, nonexpert_output_path) if path is not None)
|
|
if len(set(output_paths)) != len(output_paths):
|
|
raise ValueError("Merged, expert, and non-expert output directories must be distinct.")
|
|
_validate_no_ancestor_paths(
|
|
output_paths,
|
|
label="Merged/expert/non-expert output directories",
|
|
)
|
|
for path in output_paths:
|
|
_validate_output_dir(path, input_path, overwrite)
|
|
|
|
existing_tensors, existing_targets = _load_existing_adapter(input_path)
|
|
fused_tensors, rank, fused_targets = _convert_fused_expert_lora(input_path / FUSED_EXPERT_LORA_FILE)
|
|
_validate_nonexpert_rank(existing_tensors, rank, input_path)
|
|
if nonexpert_output_path is not None and not existing_tensors:
|
|
raise ValueError(
|
|
f"Cannot write non-expert adapter: no {ADAPTER_MODEL_FILE} found in {input_path}."
|
|
)
|
|
|
|
tensors: dict[str, torch.Tensor] = {}
|
|
for key, value in existing_tensors.items():
|
|
_merge_tensor(tensors, key, value)
|
|
for key, value in fused_tensors.items():
|
|
_merge_tensor(tensors, key, value)
|
|
|
|
target_modules = set(existing_targets)
|
|
target_modules.update(fused_targets)
|
|
config = _build_adapter_config(
|
|
input_path,
|
|
rank,
|
|
target_modules,
|
|
base_model_name_or_path,
|
|
lora_alpha,
|
|
)
|
|
|
|
_write_adapter(output_path, input_path, tensors, config, overwrite=overwrite)
|
|
|
|
split_outputs: dict[str, dict] = {}
|
|
if expert_output_path is not None:
|
|
expert_config = _build_adapter_config(
|
|
input_path,
|
|
rank,
|
|
set(fused_targets),
|
|
base_model_name_or_path,
|
|
lora_alpha,
|
|
include_input_target_modules=False,
|
|
)
|
|
_write_adapter(
|
|
expert_output_path,
|
|
input_path,
|
|
fused_tensors,
|
|
expert_config,
|
|
overwrite=overwrite,
|
|
)
|
|
split_outputs["expert"] = {
|
|
"output_dir": str(expert_output_path),
|
|
"tensor_count": len(fused_tensors),
|
|
"target_modules": expert_config["target_modules"],
|
|
}
|
|
|
|
if nonexpert_output_path is not None:
|
|
nonexpert_config = _build_adapter_config(
|
|
input_path,
|
|
rank,
|
|
set(existing_targets),
|
|
base_model_name_or_path,
|
|
lora_alpha,
|
|
include_input_target_modules=False,
|
|
)
|
|
_write_adapter(
|
|
nonexpert_output_path,
|
|
input_path,
|
|
existing_tensors,
|
|
nonexpert_config,
|
|
overwrite=overwrite,
|
|
)
|
|
split_outputs["nonexpert"] = {
|
|
"output_dir": str(nonexpert_output_path),
|
|
"tensor_count": len(existing_tensors),
|
|
"target_modules": nonexpert_config["target_modules"],
|
|
}
|
|
|
|
return {
|
|
"input_dir": str(input_path),
|
|
"output_dir": str(output_path),
|
|
"tensor_count": len(tensors),
|
|
"rank": rank,
|
|
"target_modules": config["target_modules"],
|
|
"lora_alpha": config["lora_alpha"],
|
|
"split_outputs": split_outputs,
|
|
}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Convert KT fused expert LoRA weights to an SGLang adapter directory."
|
|
)
|
|
parser.add_argument("input_dir", help="Directory containing fused_expert_lora.safetensors.")
|
|
parser.add_argument("output_dir", help="Destination adapter directory.")
|
|
parser.add_argument(
|
|
"--base-model-name-or-path",
|
|
required=True,
|
|
help="Base model path/name to write into adapter_config.json.",
|
|
)
|
|
parser.add_argument(
|
|
"--lora-alpha",
|
|
type=float,
|
|
default=None,
|
|
help="LoRA alpha to use when input adapter_config.json is absent.",
|
|
)
|
|
parser.add_argument(
|
|
"--overwrite",
|
|
action="store_true",
|
|
help="Remove and recreate output_dir if it already contains files.",
|
|
)
|
|
parser.add_argument(
|
|
"--expert-output-dir",
|
|
default=None,
|
|
help="Optional destination for a split expert-only adapter directory.",
|
|
)
|
|
parser.add_argument(
|
|
"--nonexpert-output-dir",
|
|
default=None,
|
|
help="Optional destination for a split non-expert-only adapter directory.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
summary = convert_kt_to_sglang_adapter(
|
|
args.input_dir,
|
|
args.output_dir,
|
|
base_model_name_or_path=args.base_model_name_or_path,
|
|
lora_alpha=args.lora_alpha,
|
|
overwrite=args.overwrite,
|
|
expert_output_dir=args.expert_output_dir,
|
|
nonexpert_output_dir=args.nonexpert_output_dir,
|
|
)
|
|
print(
|
|
"Converted KT fused expert LoRA adapter: "
|
|
f"{summary['tensor_count']} tensors, rank={summary['rank']}, "
|
|
f"target_modules={summary['target_modules']}"
|
|
)
|
|
for name, split_summary in summary["split_outputs"].items():
|
|
print(
|
|
f"Wrote {name} adapter: {split_summary['tensor_count']} tensors, "
|
|
f"target_modules={split_summary['target_modules']}, "
|
|
f"output_dir={split_summary['output_dir']}"
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|