File size: 5,618 Bytes
ce94aa4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3
"""
Merge Qwen3.5-MoE vision_tower (dir B) into the finetuned
language-only quantized model (dir A).

Verified clean namespace split:
  language_model.*  -> LM (from A, keeps A's quantization map)
  vision_tower.*    -> vision (from B, unquantized bf16)

Config: LM + quantization from A (finetune), vision fields from B.
Chat template: from B by default (vision-aware).
"""

import argparse
import json
import shutil
from pathlib import Path

import mlx.core as mx


def load_all(model_dir: Path) -> dict:
    """Load every tensor from all safetensors shards, natively in MLX.

    mx.load reads safetensors directly and preserves bfloat16 (unlike a
    NumPy round-trip, which has no native bf16 dtype).
    """
    weights = {}
    files = sorted(model_dir.glob("*.safetensors"))
    if not files:
        raise FileNotFoundError(f"No safetensors in {model_dir}")
    for f in files:
        weights.update(mx.load(str(f)))
    return weights


def is_lm_key(k: str) -> bool:
    return k.startswith("language_model.")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--finetune", "-a", required=True,
                    help="Dir A: finetuned language-only quantized model")
    ap.add_argument("--base", "-b", required=True,
                    help="Dir B: base VLM with vision_tower")
    ap.add_argument("--out", "-o", required=True,
                    help="Output directory for merged model")
    ap.add_argument("--template-from", choices=["a", "b"], default="a",
                    help="Which model's chat template to use "
                         "(default b: vision-aware).")
    args = ap.parse_args()

    dir_a, dir_b, out = Path(args.finetune), Path(args.base), Path(args.out)
    out.mkdir(parents=True, exist_ok=True)

    print("Loading A (finetuned LM)…")
    a = load_all(dir_a)
    print("Loading B (base VLM)…")
    b = load_all(dir_b)

    lm_weights = {k: v for k, v in a.items() if is_lm_key(k)}
    vision_weights = {k: v for k, v in b.items() if not is_lm_key(k)}

    # Verify the vision split is what we expect (all vision_tower.*).
    non_vision_b = [k for k in vision_weights
                    if not k.startswith("vision_tower.")]
    if non_vision_b:
        print("WARNING: B has non-LM keys that are NOT vision_tower.*:")
        for k in non_vision_b[:20]:
            print("   ", k)
        print("   -> Decide whether these belong in the merge. "
              "Currently they WILL be included.")

    stray_a = [k for k in a if not is_lm_key(k)]
    if stray_a:
        print(f"NOTE: A has {len(stray_a)} non-LM keys (ignored). "
              f"e.g. {stray_a[:5]}")

    # Drop MTP tensors if present in B (A config sets mtp layers = 0).
    mtp = [k for k in vision_weights if "mtp" in k.lower()]
    for k in mtp:
        vision_weights.pop(k)
    if mtp:
        print(f"Dropped {len(mtp)} MTP tensors from B.")

    print(f"LM tensors (A):     {len(lm_weights)}")
    print(f"Vision tensors (B): {len(vision_weights)}")
    if not vision_weights:
        print("ERROR: no vision tensors. Aborting.")
        return

    merged = {**lm_weights, **vision_weights}
    print(f"Total merged:       {len(merged)}")

    mx.save_safetensors(str(out / "model.safetensors"),
                        merged, metadata={"format": "mlx"})
    print("Saved model.safetensors")

    build_config(dir_a, dir_b, out)
    copy_aux(dir_a, dir_b, out, template_from=args.template_from)

    print("\nDone. Test:")
    print(f"  python -m mlx_vlm.generate --model {out} "
          f"--prompt 'Hi' --max-tokens 30")
    print(f"  python -m mlx_vlm.generate --model {out} "
          f"--image test.jpg --prompt 'Describe this.' --max-tokens 100")


def build_config(dir_a: Path, dir_b: Path, out: Path):
    ca = json.loads((dir_a / "config.json").read_text())
    cb = json.loads((dir_b / "config.json").read_text())

    # A is authoritative for LM: it has the CORRECT quantization map
    # (A and B were quantized differently) and text_config (mtp=0).
    merged = dict(ca)

    # Graft vision-specific fields from B.
    for field in ("vision_config", "image_token_id", "video_token_id",
                  "vision_start_token_id", "vision_end_token_id"):
        if field in cb:
            merged[field] = cb[field]
            print(f"config: added '{field}' from B")

    (out / "config.json").write_text(json.dumps(merged, indent=2))
    print("config: wrote merged config.json "
          "(LM/quant from A, vision from B)")


def copy_aux(dir_a: Path, dir_b: Path, out: Path, template_from="a"):
    tmpl_dir = dir_b if template_from == "b" else dir_a
    print(f"aux: chat template / tokenizer_config from '{template_from}'")

    # Vocab files: identical between A and B; take from A.
    for n in ["tokenizer.json", "vocab.json", "merges.txt",
              "special_tokens_map.json", "added_tokens.json",
              "generation_config.json", "optiq_metadata.json"]:
        if (dir_a / n).exists():
            shutil.copy(dir_a / n, out / n)

    # Template-bearing files: from chosen source (default B, vision-aware).
    for n in ["tokenizer_config.json", "chat_template.jinja"]:
        if (tmpl_dir / n).exists():
            shutil.copy(tmpl_dir / n, out / n)

    # Vision preprocessing: always from B.
    for n in ["preprocessor_config.json", "processor_config.json",
              "image_processor_config.json", "video_processor_config.json"]:
        if (dir_b / n).exists():
            shutil.copy(dir_b / n, out / n)
            print(f"aux: copied {n} from B")


if __name__ == "__main__":
    main()