"""Modal entrypoint for MiniCPM5-1B Actor LoRA/QLoRA training. Run from the repo root with Modal installed and authenticated: modal run finetune/modal_train_actor_lora.py::smoke_test modal run finetune/modal_train_actor_lora.py::train_full """ from __future__ import annotations import os from pathlib import Path APP_NAME = "ai-puppet-theater-actor-lora" VOLUME_NAME = "ai-puppet-theater-finetune" REMOTE_WORKDIR = Path("/root/ai-puppet-theater") REMOTE_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-lora") REMOTE_V1_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-lora-v1") REMOTE_MERGED_OUTPUT_DIR = Path("/vol/outputs/minicpm5-actor-merged") REMOTE_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_lora_eval.jsonl") REMOTE_V1_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_lora_v1_eval.jsonl") REMOTE_MERGED_EVAL_OUTPUT_FILE = Path("/vol/eval_outputs/minicpm5_actor_merged_v0_eval.jsonl") REMOTE_CACHE_DIR = Path("/vol/cache") try: import modal except ImportError: # Keeps local imports clean when Modal is not installed. modal = None def _require_modal(): if modal is None: raise RuntimeError("modal is not installed. Install it with `pip install modal` and run `modal setup`.") return modal if modal is not None: app = modal.App(APP_NAME) volume = modal.Volume.from_name(VOLUME_NAME, create_if_missing=True) hf_secret_name = os.getenv("MODAL_HF_SECRET_NAME") hf_secrets = [modal.Secret.from_name(hf_secret_name)] if hf_secret_name else [] image = ( modal.Image.debian_slim(python_version="3.11") .apt_install("git") .pip_install_from_requirements("finetune/requirements-train.txt") .env( { "HF_HOME": str(REMOTE_CACHE_DIR / "huggingface"), "TRANSFORMERS_CACHE": str(REMOTE_CACHE_DIR / "huggingface" / "transformers"), "HF_HUB_CACHE": str(REMOTE_CACHE_DIR / "huggingface" / "hub"), } ) .add_local_dir("finetune", remote_path=str(REMOTE_WORKDIR / "finetune")) ) else: app = None volume = None image = None def training_args( *, max_train_samples: int | None = None, max_eval_samples: int | None = None, epochs: float = 2.0, output_subdir: str = "minicpm5-actor-lora", dataset_version: str = "v0", extra_args: list[str] | None = None, ) -> list[str]: output_dir = REMOTE_OUTPUT_DIR.parent / output_subdir args = [ "--model_name", os.getenv("MODEL_NAME", "openbmb/MiniCPM5-1B"), "--train_file", str(REMOTE_WORKDIR / f"finetune/data/actor_sft_{dataset_version}_train.jsonl"), "--val_file", str(REMOTE_WORKDIR / f"finetune/data/actor_sft_{dataset_version}_val.jsonl"), "--output_dir", str(output_dir), "--epochs", str(epochs), "--max_seq_length", os.getenv("MAX_SEQ_LENGTH", "1024"), "--per_device_train_batch_size", os.getenv("PER_DEVICE_TRAIN_BATCH_SIZE", "2"), "--gradient_accumulation_steps", os.getenv("GRADIENT_ACCUMULATION_STEPS", "8"), ] if max_train_samples is not None: args.extend(["--max_train_samples", str(max_train_samples)]) if max_eval_samples is not None: args.extend(["--max_eval_samples", str(max_eval_samples)]) if extra_args: args.extend(extra_args) return args if modal is not None: @app.function( image=image, gpu=os.getenv("MODAL_GPU", "A10"), timeout=60 * 60 * 6, volumes={"/vol": volume}, secrets=hf_secrets, ) def run_training(args: list[str]) -> str: import subprocess import sys cmd = [sys.executable, str(REMOTE_WORKDIR / "finetune/scripts/train_minicpm5_actor_lora.py"), *args] subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True) volume.commit() output_dir = args[args.index("--output_dir") + 1] return output_dir @app.local_entrypoint() def smoke_test() -> None: output_dir = run_training.remote( training_args( max_train_samples=20, max_eval_samples=10, epochs=float(os.getenv("SMOKE_EPOCHS", "0.05")), output_subdir="minicpm5-actor-lora-smoke", extra_args=["--save_strategy", "no", "--eval_strategy", "no"], ) ) print(f"Smoke-test adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}") @app.local_entrypoint() def train_full() -> None: output_dir = run_training.remote(training_args()) print(f"Adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}") @app.local_entrypoint() def train_v1() -> None: output_dir = run_training.remote( training_args( epochs=float(os.getenv("EPOCHS", "2.0")), output_subdir="minicpm5-actor-lora-v1", dataset_version="v1", ) ) print(f"Actor v1 adapter output saved in Modal Volume {VOLUME_NAME}: {output_dir}") @app.function( image=image, gpu=os.getenv("MODAL_GPU", "A10"), timeout=60 * 60, volumes={"/vol": volume}, secrets=hf_secrets, ) def run_eval(limit: int | None = None) -> str: return run_eval_for_adapter( adapter_dir=REMOTE_WORKDIR / "finetune/minicpm5-actor-lora", output_file=REMOTE_EVAL_OUTPUT_FILE, limit=limit, ) @app.function( image=image, gpu=os.getenv("MODAL_GPU", "A10"), timeout=60 * 60, volumes={"/vol": volume}, secrets=hf_secrets, ) def run_eval_v1(limit: int | None = None) -> str: return run_eval_for_adapter( adapter_dir=REMOTE_V1_OUTPUT_DIR, output_file=REMOTE_V1_EVAL_OUTPUT_FILE, limit=limit, ) @app.function( image=image, gpu=os.getenv("MODAL_GPU", "A10"), timeout=60 * 60, volumes={"/vol": volume}, secrets=hf_secrets, ) def run_eval_merged_v0(limit: int | None = None) -> str: import subprocess import sys cmd = [ sys.executable, str(REMOTE_WORKDIR / "finetune/scripts/eval_minicpm5_actor_lora.py"), "--base_model", str(REMOTE_MERGED_OUTPUT_DIR), "--merged_model", "--eval_file", str(REMOTE_WORKDIR / "finetune/data_samples/actor_eval_prompts.jsonl"), "--output_file", str(REMOTE_MERGED_EVAL_OUTPUT_FILE), ] if limit is not None: cmd.extend(["--limit", str(limit)]) subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True) volume.commit() return str(REMOTE_MERGED_EVAL_OUTPUT_FILE) def run_eval_for_adapter(adapter_dir: Path, output_file: Path, limit: int | None = None) -> str: import subprocess import sys cmd = [ sys.executable, str(REMOTE_WORKDIR / "finetune/scripts/eval_minicpm5_actor_lora.py"), "--adapter_dir", str(adapter_dir), "--eval_file", str(REMOTE_WORKDIR / "finetune/data_samples/actor_eval_prompts.jsonl"), "--output_file", str(output_file), ] if limit is not None: cmd.extend(["--limit", str(limit)]) subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True) volume.commit() return str(output_file) @app.local_entrypoint() def eval_adapter(limit: int | None = None) -> None: output_file = run_eval.remote(limit) print(f"Eval output saved in Modal Volume {VOLUME_NAME}: {output_file}") @app.local_entrypoint() def eval_adapter_v1(limit: int | None = None) -> None: output_file = run_eval_v1.remote(limit) print(f"Actor v1 eval output saved in Modal Volume {VOLUME_NAME}: {output_file}") @app.local_entrypoint() def eval_merged_v0(limit: int | None = None) -> None: output_file = run_eval_merged_v0.remote(limit) print(f"Merged Actor v0 eval output saved in Modal Volume {VOLUME_NAME}: {output_file}") @app.function( image=image, gpu=os.getenv("MODAL_GPU", "A10"), timeout=60 * 60 * 2, volumes={"/vol": volume}, secrets=hf_secrets, ) def run_merge_v0() -> str: import subprocess import sys cmd = [ sys.executable, str(REMOTE_WORKDIR / "finetune/scripts/merge_actor_lora.py"), "--base_model", os.getenv("MODEL_NAME", "openbmb/MiniCPM5-1B"), "--adapter_dir", str(REMOTE_OUTPUT_DIR), "--output_dir", str(REMOTE_MERGED_OUTPUT_DIR), ] subprocess.run(cmd, cwd=str(REMOTE_WORKDIR), check=True) volume.commit() return str(REMOTE_MERGED_OUTPUT_DIR) @app.local_entrypoint() def merge_v0() -> None: output_dir = run_merge_v0.remote() print(f"Merged Actor v0 model saved in Modal Volume {VOLUME_NAME}: {output_dir}") else: def smoke_test() -> None: _require_modal() def train_full() -> None: _require_modal() def train_v1() -> None: _require_modal() def eval_adapter(limit: int | None = None) -> None: _require_modal() def eval_adapter_v1(limit: int | None = None) -> None: _require_modal() def eval_merged_v0(limit: int | None = None) -> None: _require_modal() def merge_v0() -> None: _require_modal()