211 lines
8.9 KiB
Python
211 lines
8.9 KiB
Python
"""Publish a finalized adapter to the Hugging Face Hub, with a card that matches it.
|
|
|
|
The card is generated from the file rather than written by hand, so the tensor counts,
|
|
rank, and sizes it advertises cannot drift from what is actually in the repo. Run
|
|
``finalize_adapter.py`` first: this script refuses an adapter that still carries bare
|
|
``<param>.weight`` keys, because those are the ones a loader silently drops.
|
|
|
|
python scripts/lora_extraction/upload_adapter.py \\
|
|
--adapter-dir /models/fasth3-loras-publish/FastH3-4-step-v1.1 \\
|
|
--repo-id FastVideo/FastH3-4-step-v1.1-LoRA --dry-run
|
|
|
|
Drop ``--dry-run`` to actually push. Authentication comes from the usual
|
|
``huggingface-cli login`` / ``HF_TOKEN`` sources; no token is read from the command line
|
|
so it cannot end up in a shell history.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
from pathlib import Path
|
|
|
|
from safetensors import safe_open
|
|
|
|
LOG = logging.getLogger("upload_adapter")
|
|
|
|
CARD = """---
|
|
license: other
|
|
license_name: minimax-h3-community
|
|
license_link: LICENSE
|
|
base_model: MiniMaxAI/MiniMax-H3
|
|
tags:
|
|
- video-generation
|
|
- minimax-h3
|
|
- lora
|
|
- distillation
|
|
- few-step
|
|
- fastvideo
|
|
---
|
|
|
|
# {title}
|
|
|
|
LoRA extraction of [{finetuned}](https://huggingface.co/{finetuned}), a four-step DMD2
|
|
distillation of [MiniMax-H3](https://huggingface.co/MiniMaxAI/MiniMax-H3){attention_clause}.
|
|
|
|
## What is in the file
|
|
|
|
{variant_table}
|
|
|
|
This is not a purely low-rank adapter, because the distillation it captures is not a
|
|
purely low-rank change:
|
|
|
|
| kind | keys | what it is |
|
|
|---|---|---|
|
|
| low-rank factors | `<module>.lora_A/.lora_B` | attention, feed-forward and AdaLN projections |
|
|
| exact deltas | `<module>.diff`, `<module>.diff_b` | norms and biases, where a rank-r factorization of a length-n vector would cost more than the vector |
|
|
{gate_row}
|
|
{gate_note}
|
|
## Usage (FastVideo)
|
|
|
|
```bash
|
|
python examples/inference/lora/minimax_h3_lora_inference.py \\
|
|
--model-path MiniMaxAI/MiniMax-H3 \\
|
|
--lora-path {repo_id} \\
|
|
--prompt "your prompt here"{usage_flags}
|
|
```
|
|
|
|
The adapter must be supplied when the pipeline is constructed, not swapped in later: a
|
|
parameter the base model lacks has to arrive while weights are still unsharded.
|
|
Sample with **4 steps** (5 sigma-grid points) and **cfg 1.0** — the checkpoint is
|
|
guidance-distilled.{sampling_note}
|
|
|
|
## Fidelity
|
|
|
|
Reconstruction against the checkpoint this was extracted from, as relative Frobenius
|
|
error per parameter family:
|
|
|
|
{fidelity}
|
|
|
|
`.diff` and `.set_weight` families are stored exactly. The low-rank families carry the
|
|
SVD truncation tail, which is what choosing a rank buys or costs.
|
|
|
|
## License
|
|
|
|
MiniMax H3 Community License, inherited from the base model. Review its territory and
|
|
acceptable-use terms before use or redistribution.
|
|
"""
|
|
|
|
|
|
def inspect(path: Path) -> dict:
|
|
"""Counts, rank, and size of one adapter file, read from its header."""
|
|
with safe_open(path, framework="pt") as handle:
|
|
meta = handle.metadata() or {}
|
|
keys = list(handle.keys())
|
|
ranks = {handle.get_slice(k).get_shape()[0] for k in keys if ".lora_A" in k}
|
|
return {
|
|
"path": path,
|
|
"metadata": meta,
|
|
"n_low_rank": sum(1 for k in keys if ".lora_" in k),
|
|
"n_diff": sum(1 for k in keys if k.endswith((".diff", ".diff_b"))),
|
|
"n_set": sum(1 for k in keys if k.endswith(".set_weight")),
|
|
"n_bare": sum(1 for k in keys if k.endswith((".weight", ".bias")) and ".lora_" not in k),
|
|
"rank": max(ranks) if ranks else None,
|
|
"size_gib": path.stat().st_size / 2**30,
|
|
}
|
|
|
|
|
|
def build_card(repo_id: str, variants: list[dict], fidelity: str) -> str:
|
|
"""Render the card from what the files contain.
|
|
|
|
The VSA paragraphs are conditional on the adapter actually carrying gates: the dense
|
|
variants of these checkpoints have none, and a card that describes a payload the repo
|
|
does not hold is exactly the drift generating the card was supposed to prevent.
|
|
"""
|
|
finetuned = next((v["metadata"].get("finetuned_model") for v in variants if v["metadata"].get("finetuned_model")),
|
|
"")
|
|
rows = ["| file | rank | low-rank | .diff | .set_weight | size |", "|---|---|---|---|---|---|"]
|
|
for v in sorted(variants, key=lambda x: x["rank"] or 0):
|
|
rel = v["path"].relative_to(v["path"].parents[1])
|
|
rows.append(f"| `{rel}` | {v['rank']} | {v['n_low_rank']} | {v['n_diff']} | "
|
|
f"{v['n_set']} | {v['size_gib']:.2f} GiB |")
|
|
|
|
has_gates = any(v["n_set"] for v in variants)
|
|
gate_row = ("| whole parameters | `attn.to_gate_compress.set_weight` | the VSA compression gate, which "
|
|
"**does not exist in base MiniMax-H3** |\n" if has_gates else "")
|
|
gate_note = ("\nThe `.set_weight` keys are why this needs a loader that understands them. "
|
|
"`to_gate_compress` is created only under the VSA attention backend, and is zero-initialized "
|
|
"when a checkpoint does not carry it — so a loader that ignores those keys silently produces a "
|
|
"model with the compression branch switched off.\n" if has_gates else "")
|
|
|
|
return CARD.format(
|
|
title=repo_id.split("/")[-1],
|
|
finetuned=finetuned,
|
|
repo_id=repo_id,
|
|
variant_table="\n".join(rows),
|
|
attention_clause=(" under video sparse attention (VSA)" if has_gates else
|
|
" with dense attention (no VSA gates in this adapter)"),
|
|
gate_row=gate_row,
|
|
gate_note=gate_note,
|
|
usage_flags=" \\\n --vsa" if has_gates else " \\\n --no-vsa",
|
|
sampling_note=(" Enable the VSA backend: without it the gate has no module to load into and the "
|
|
"loader will refuse." if has_gates else ""),
|
|
fidelity=fidelity or "_not measured for this build_",
|
|
)
|
|
|
|
|
|
def main() -> None:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--adapter-dir", required=True, help="directory holding rank-*/adapter_model.safetensors")
|
|
parser.add_argument("--repo-id", required=True)
|
|
parser.add_argument("--rank",
|
|
type=int,
|
|
action="append",
|
|
default=None,
|
|
help="publish only this rank; repeat for several. Default: every rank present.")
|
|
parser.add_argument("--fidelity-md", default=None, help="markdown fragment of measured reconstruction error")
|
|
parser.add_argument("--private", action="store_true")
|
|
parser.add_argument("--dry-run", action="store_true", help="write the card locally and stop")
|
|
parser.add_argument("--allow-bare-weights",
|
|
action="store_true",
|
|
help="upload even when the adapter still carries unconvertible bare .weight keys")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(args.adapter_dir)
|
|
files = sorted(root.glob("rank-*/adapter_model.safetensors"))
|
|
if args.rank:
|
|
wanted = {f"rank-{r}" for r in args.rank}
|
|
files = [f for f in files if f.parent.name in wanted]
|
|
if not files:
|
|
raise SystemExit(f"no matching rank-*/adapter_model.safetensors under {root}")
|
|
|
|
variants = [inspect(f) for f in files]
|
|
variants_paths = [v["path"] for v in variants]
|
|
for v in variants:
|
|
LOG.info("%s: rank=%s low_rank=%d diff=%d set_weight=%d bare=%d %.2f GiB", v["path"].parent.name, v["rank"],
|
|
v["n_low_rank"], v["n_diff"], v["n_set"], v["n_bare"], v["size_gib"])
|
|
if v["n_bare"] and not args.allow_bare_weights:
|
|
raise SystemExit(f"{v['path']} still has {v['n_bare']} bare .weight/.bias keys. Those are the keys "
|
|
f"loaders drop silently -- run finalize_adapter.py first, or pass "
|
|
f"--allow-bare-weights if you really mean it.")
|
|
|
|
fidelity = Path(args.fidelity_md).read_text().strip() if args.fidelity_md else ""
|
|
|
|
card_path = root / "README.md"
|
|
card_path.write_text(build_card(args.repo_id, variants, fidelity))
|
|
LOG.info("wrote %s", card_path)
|
|
|
|
if args.dry_run:
|
|
LOG.info("dry run: not uploading. Review %s, then rerun without --dry-run.", card_path)
|
|
return
|
|
|
|
from huggingface_hub import HfApi
|
|
api = HfApi()
|
|
api.create_repo(repo_id=args.repo_id, repo_type="model", private=args.private, exist_ok=True)
|
|
LOG.info("uploading %s -> %s", root, args.repo_id)
|
|
api.upload_folder(
|
|
folder_path=str(root),
|
|
repo_id=args.repo_id,
|
|
repo_type="model",
|
|
# Only the ranks selected above, plus the card. Bookkeeping from the finalize
|
|
# step stays local; the card already carries the numbers that matter.
|
|
allow_patterns=[f"{f.parent.name}/*" for f in variants_paths] + ["README.md"],
|
|
)
|
|
LOG.info("done: https://huggingface.co/%s", args.repo_id)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|