apoapps commited on
Commit
55317d1
·
verified ·
1 Parent(s): 2c7171f

Upload export_apochat_litert.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. export_apochat_litert.py +318 -0
export_apochat_litert.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Export the Apochat-tuned Gemma 4 E2B MLX model to a LiteRT .litertlm artifact.
3
+
4
+ This script is intentionally **not** run on the 16 GB local Mac. It is designed for a
5
+ machine with at least 32 GB of CPU RAM or a GPU with 24 GB+ VRAM (e.g. a Hugging Face
6
+ Space/Notebook with GPU upgrade).
7
+
8
+ Pipeline:
9
+ 1. Load the public MLX-q4 fused snapshot from Hugging Face.
10
+ 2. Dequantize weights to bfloat16 and save as PyTorch-format safetensors shards.
11
+ 3. Patch the config so transformers sees a normal bf16 checkpoint.
12
+ 4. Run `litert convert` with weight-only int4 quantization to produce .litertlm.
13
+ 5. Upload the resulting artifact to a Hugging Face model repo.
14
+
15
+ Usage (on a high-memory machine / HF Space):
16
+ pip install -r scripts/requirements_litert_export.txt
17
+ python scripts/export_apochat_litert.py \
18
+ --mlx-repo apoapps/apochat-gemma4-e2b-apochat-tuned-v1 \
19
+ --output-dir ./apochat-litert-build \
20
+ --upload-repo apoapps/apochat-gemma4-e2b-apochat-tuned-v1-litert
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import shutil
29
+ import subprocess
30
+ import sys
31
+ import tempfile
32
+ from pathlib import Path
33
+ from typing import Any
34
+
35
+ import mlx.core as mx
36
+ import numpy as np
37
+ from huggingface_hub import HfApi, create_repo, hf_hub_download, upload_file, upload_folder
38
+ from safetensors.torch import save_file
39
+
40
+
41
+ def parse_args() -> argparse.Namespace:
42
+ parser = argparse.ArgumentParser(description="Export Apochat-tuned Gemma 4 E2B to LiteRT")
43
+ parser.add_argument(
44
+ "--mlx-repo",
45
+ default="apoapps/apochat-gemma4-e2b-apochat-tuned-v1",
46
+ help="Hugging Face repo id containing the fused MLX-q4 model",
47
+ )
48
+ parser.add_argument(
49
+ "--revision",
50
+ default=None,
51
+ help="Optional git revision for the MLX repo",
52
+ )
53
+ parser.add_argument(
54
+ "--output-dir",
55
+ default="./apochat-litert-build",
56
+ help="Local directory for intermediate PyTorch checkpoint and final .litertlm",
57
+ )
58
+ parser.add_argument(
59
+ "--upload-repo",
60
+ default="apoapps/apochat-gemma4-e2b-apochat-tuned-v1-litert",
61
+ help="HF repo id where the final .litertlm will be uploaded",
62
+ )
63
+ parser.add_argument(
64
+ "--upload-private",
65
+ action="store_true",
66
+ help="Make the upload repo private",
67
+ )
68
+ parser.add_argument(
69
+ "--skip-upload",
70
+ action="store_true",
71
+ help="Keep the output local; do not upload to HF",
72
+ )
73
+ parser.add_argument(
74
+ "--prefill-lengths",
75
+ default="256",
76
+ help="LiteRT prefill signature lengths (comma separated)",
77
+ )
78
+ parser.add_argument(
79
+ "--cache-length",
80
+ type=int,
81
+ default=1024,
82
+ help="LiteRT KV-cache length",
83
+ )
84
+ parser.add_argument(
85
+ "--quantize-recipe",
86
+ default="weight_only_wi4_afp32",
87
+ help="LiteRT quantization recipe",
88
+ )
89
+ parser.add_argument(
90
+ "--shard-size",
91
+ type=int,
92
+ default=5_000_000_000,
93
+ help="Target size in bytes per PyTorch safetensors shard",
94
+ )
95
+ return parser.parse_args()
96
+
97
+
98
+ def download_repo_files(repo_id: str, revision: str | None, local_dir: Path) -> None:
99
+ """Download all non-weight files from the MLX repo into local_dir."""
100
+ print(f"Downloading aux files from {repo_id} ...")
101
+ local_dir.mkdir(parents=True, exist_ok=True)
102
+ api = HfApi()
103
+ files = api.list_repo_files(repo_id, repo_type="model", revision=revision)
104
+ for fname in files:
105
+ if fname.endswith(".safetensors"):
106
+ continue
107
+ print(f" {fname}")
108
+ hf_hub_download(
109
+ repo_id=repo_id,
110
+ filename=fname,
111
+ repo_type="model",
112
+ revision=revision,
113
+ local_dir=str(local_dir),
114
+ local_dir_use_symlinks=False,
115
+ )
116
+
117
+
118
+ def patch_config_for_pytorch(config_path: Path) -> None:
119
+ """Remove MLX quantization config and ensure torch_dtype is bfloat16."""
120
+ with open(config_path, "r", encoding="utf-8") as f:
121
+ config: dict[str, Any] = json.load(f)
122
+
123
+ config.pop("quantization_config", None)
124
+ text_config = config.get("text_config")
125
+ if isinstance(text_config, dict):
126
+ text_config.pop("quantization_config", None)
127
+ config["torch_dtype"] = "bfloat16"
128
+
129
+ with open(config_path, "w", encoding="utf-8") as f:
130
+ json.dump(config, f, indent=2)
131
+
132
+
133
+ def dequantize_mlx_to_pytorch(
134
+ mlx_repo: str,
135
+ revision: str | None,
136
+ output_dir: Path,
137
+ shard_size_bytes: int,
138
+ ) -> None:
139
+ """Load MLX-q4 weights, dequantize, and write PyTorch safetensors shards."""
140
+ print("Loading MLX-q4 weights ...")
141
+ api = HfApi()
142
+ files = api.list_repo_files(mlx_repo, repo_type="model", revision=revision)
143
+ safetensors_files = [f for f in files if f.endswith(".safetensors")]
144
+ weights: dict[str, mx.array] = {}
145
+ for fname in safetensors_files:
146
+ print(f" {fname}")
147
+ local_path = hf_hub_download(
148
+ repo_id=mlx_repo,
149
+ filename=fname,
150
+ repo_type="model",
151
+ revision=revision,
152
+ )
153
+ part = mx.load(local_path)
154
+ if isinstance(part, dict):
155
+ weights.update(part)
156
+ else:
157
+ raise RuntimeError(f"Unexpected MLX load result for {fname}: {type(part)}")
158
+ print(f"Total tensors: {len(weights)}")
159
+
160
+ # Identify quantized triples: weight + scales + biases.
161
+ quantized: set[str] = set()
162
+ for name in list(weights.keys()):
163
+ if name.endswith(".scales"):
164
+ base = name[: -len(".scales")]
165
+ if f"{base}.biases" in weights:
166
+ quantized.add(base)
167
+
168
+ print(f"Quantized groups: {len(quantized)}")
169
+
170
+ current_shard: dict[str, Any] = {}
171
+ current_shard_bytes = 0
172
+ shard_index = 0
173
+
174
+ def flush_shard() -> None:
175
+ nonlocal current_shard, current_shard_bytes, shard_index
176
+ if not current_shard:
177
+ return
178
+ shard_path = output_dir / f"model-{shard_index:05d}-of-?????.safetensors"
179
+ save_file(current_shard, str(shard_path))
180
+ print(f" Saved {shard_path.name} ({len(current_shard)} tensors, {current_shard_bytes / 1e9:.2f} GB)")
181
+ current_shard = {}
182
+ current_shard_bytes = 0
183
+ shard_index += 1
184
+
185
+ for name, arr in weights.items():
186
+ # Skip scale/bias metadata; we'll consume them with the base weight.
187
+ if name.endswith(".scales") or name.endswith(".biases"):
188
+ continue
189
+
190
+ base = name
191
+ is_quantized = base in quantized
192
+
193
+ if is_quantized:
194
+ scales = weights[f"{base}.scales"]
195
+ biases = weights[f"{base}.biases"]
196
+ # Dequantize to bfloat16 on the MLX device.
197
+ arr = mx.dequantize(arr, scales, biases, group_size=64, bits=4).astype(mx.bfloat16)
198
+ elif arr.dtype != mx.bfloat16:
199
+ arr = arr.astype(mx.bfloat16)
200
+
201
+ torch_tensor = mlx_bfloat16_to_torch(arr)
202
+ current_shard[name] = torch_tensor
203
+ current_shard_bytes += torch_tensor.nbytes
204
+
205
+ if current_shard_bytes >= shard_size_bytes:
206
+ flush_shard()
207
+
208
+ flush_shard()
209
+
210
+ # Rewrite the final shard names with the actual count.
211
+ shards = sorted(output_dir.glob("model-?????-of-?????.safetensors"))
212
+ total = len(shards)
213
+ for i, old in enumerate(shards):
214
+ new = old.with_name(f"model-{i:05d}-of-{total:05d}.safetensors")
215
+ old.rename(new)
216
+
217
+ print(f"Wrote {total} safetensors shard(s) to {output_dir}")
218
+
219
+
220
+ def mlx_bfloat16_to_torch(arr: mx.array) -> Any:
221
+ """Convert an MLX bfloat16 array to a contiguous torch bfloat16 tensor."""
222
+ import torch
223
+
224
+ # MLX bfloat16 cannot be read directly by numpy; bridge via uint16.
225
+ u16 = np.array(arr.astype(mx.uint16))
226
+ if not u16.flags.c_contiguous:
227
+ u16 = np.ascontiguousarray(u16)
228
+ return torch.from_numpy(u16).view(torch.bfloat16)
229
+
230
+
231
+ def run_litert_convert(
232
+ checkpoint_dir: Path,
233
+ output_dir: Path,
234
+ prefill_lengths: str,
235
+ cache_length: int,
236
+ quantize_recipe: str,
237
+ ) -> Path:
238
+ """Run `litert convert` on the dequantized checkpoint."""
239
+ print("Running litert convert ...")
240
+ cmd = [
241
+ "litert",
242
+ "convert",
243
+ str(checkpoint_dir),
244
+ "--output",
245
+ str(output_dir),
246
+ "--quantize-recipe",
247
+ quantize_recipe,
248
+ "--prefill-lengths",
249
+ prefill_lengths,
250
+ "--cache-length",
251
+ str(cache_length),
252
+ "--bundle-litert-lm",
253
+ ]
254
+ subprocess.run(cmd, check=True)
255
+
256
+ litertlm_files = list(output_dir.glob("*.litertlm"))
257
+ if not litertlm_files:
258
+ raise RuntimeError(f"No .litertlm file found in {output_dir}")
259
+ return litertlm_files[0]
260
+
261
+
262
+ def upload_litert_model(repo_id: str, litertlm_path: Path, private: bool) -> str:
263
+ """Upload the .litertlm file to HF and return the git revision."""
264
+ print(f"Uploading {litertlm_path.name} to {repo_id} ...")
265
+ create_repo(repo_id, repo_type="model", private=private, exist_ok=True)
266
+ upload_file(
267
+ repo_id=repo_id,
268
+ repo_type="model",
269
+ path_in_repo=litertlm_path.name,
270
+ path_or_fileobj=str(litertlm_path),
271
+ )
272
+ # Get the new revision.
273
+ api = HfApi()
274
+ info = api.repo_info(repo_id, repo_type="model")
275
+ print(f"Uploaded. Revision: {info.sha}")
276
+ return info.sha
277
+
278
+
279
+ def main() -> int:
280
+ args = parse_args()
281
+ output_dir = Path(args.output_dir).resolve()
282
+ output_dir.mkdir(parents=True, exist_ok=True)
283
+
284
+ # Stage 1: prepare a transformers-compatible checkpoint.
285
+ pytorch_dir = output_dir / "pytorch_checkpoint"
286
+ pytorch_dir.mkdir(parents=True, exist_ok=True)
287
+
288
+ download_repo_files(args.mlx_repo, args.revision, pytorch_dir)
289
+ patch_config_for_pytorch(pytorch_dir / "config.json")
290
+
291
+ dequantize_mlx_to_pytorch(
292
+ args.mlx_repo,
293
+ args.revision,
294
+ pytorch_dir,
295
+ shard_size_bytes=args.shard_size,
296
+ )
297
+
298
+ # Stage 2: convert to LiteRT.
299
+ litert_dir = output_dir / "litert_out"
300
+ litert_dir.mkdir(parents=True, exist_ok=True)
301
+ litertlm_path = run_litert_convert(
302
+ pytorch_dir,
303
+ litert_dir,
304
+ args.prefill_lengths,
305
+ args.cache_length,
306
+ args.quantize_recipe,
307
+ )
308
+ print(f"LiteRT artifact: {litertlm_path}")
309
+
310
+ # Stage 3: upload.
311
+ if not args.skip_upload:
312
+ upload_litert_model(args.upload_repo, litertlm_path, args.upload_private)
313
+
314
+ return 0
315
+
316
+
317
+ if __name__ == "__main__":
318
+ sys.exit(main())