diff --git a/packaging/package_terminal.py b/packaging/package_terminal.py index 41fce5037..89a02646b 100644 --- a/packaging/package_terminal.py +++ b/packaging/package_terminal.py @@ -132,6 +132,8 @@ INSTALLER_TOOLS = ("strata_mcp", "iq_pack", "strata_pack", "pack_index", "mtp_fe #: tests/terminal/test_bundle_tools_paths.py reads the by-path references out of the source and fails when one is missing from the bundle. INSTALLER_SCRIPTS = ("dsv41/pack_gguf", "dsv41/smoke", "dsv41/tune", "nvfp4_local_pack", "strata_bench", "strata_inspect") INSTALLER_DATA = "data" # draft vocabularies, expert profiles: 2 MB +#: the Mac install's tools (setup.py's mac_control_setup runs them by path): Unsloth's many-shard sets become ONE GGUF for the Metal engine (stdlib only, 15 KB) +INSTALLER_METAL_TOOLS = ("metal_repack", "stream_experts") #: packages the bundle must import after the install (the API server's and the CLI's). PROBE = "import jinja2, yaml, regex, psutil, requests, numpy, PIL, tqdm; print('deps ok')" @@ -295,6 +297,10 @@ def add_installer(folder: Path, source: Path = ROOT) -> dict: (folder / "tools" / f"{name}.py").parent.mkdir(parents=True, exist_ok=True) shutil.copy2(source / "tools" / f"{name}.py", folder / "tools" / f"{name}.py") copied.append(f"tools/{name}.py") + (folder / "metal" / "tools").mkdir(parents=True, exist_ok=True) + for name in INSTALLER_METAL_TOOLS: + shutil.copy2(source / "metal" / "tools" / f"{name}.py", folder / "metal" / "tools" / f"{name}.py") + copied.append(f"metal/tools/{name}.py") if (folder / INSTALLER_DATA).exists(): shutil.rmtree(folder / INSTALLER_DATA) shutil.copytree(source / INSTALLER_DATA, folder / INSTALLER_DATA, ignore=shutil.ignore_patterns("__pycache__")) diff --git a/setup.py b/setup.py index c4eed485b..19d3a22e1 100644 --- a/setup.py +++ b/setup.py @@ -9572,6 +9572,88 @@ def prepare_pack_only(a) -> int: return 0 +MAC_PREPARE_LOCK = ".preparing.lock" # beside the repacked file, holds setup's pid from the repack to the end of the pack (metal/setup_mac.py PREPARE_LOCK) +MAC_EXPERTS_MARGIN_GB = 10.0 # what stays free on the disk after the experts' streaming copy (metal/setup_mac.py EXPERTS_MARGIN_GB) + + +def mac_tools_dir() -> Path: + """metal/tools: metal_repack.py (many Unsloth shards -> one GGUF for the GPU) and stream_experts.py; both ship in the installer's bundle.""" + return Path(ROOT, "metal", "tools") + + +def mac_ple_shard(files: list) -> Path: + """The shard that holds the n-gram table (per_layer_token_embd): the repacked file leaves it out and the engine reads it from there (--ple-gguf).""" + sys.path.insert(0, str(mac_tools_dir())) + import metal_repack as R + for f in files: + rd = R.Reader(f) + rd.f.close() + if any(t["name"] == R.PLE for t in rd.tensors): + return f + fail(f"no shard of {files[0].name} holds the n-gram table (per_layer_token_embd)") + + +def mac_engine_pids() -> list[int]: + """Pids of this user's running Metal engines (`strata`): a repack beside an engine of 70-90 GB froze the 128 GB M5 Max twice.""" + cmd = ["pgrep", "-x", "strata"] + if hasattr(os, "getuid"): + cmd[1:1] = ["-u", str(os.getuid())] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout + except (OSError, subprocess.SubprocessError): + return [] + return [int(x) for x in out.split() if x.isdigit() and int(x) != os.getpid()] + + +def mac_experts_file(native: Path) -> None: + """A streaming Mac: .experts beside the GPU's GGUF (metal/tools/stream_experts.py), written only when the disk has room (metal/setup_mac.py experts_file).""" + from strata_control import mac_fit as MF + out = native.with_name(native.name + ".experts") + if out.exists() and done(out): + ok(f"the experts' streaming copy: {out.name}") + return + sys.path.insert(0, str(mac_tools_dir())) + import stream_experts as X + need = X.size_bytes(native) / 1e9 + free = free_gb(native.parent) + if not os.access(native.parent, os.W_OK): + warn(f"{native.parent} cannot be written: the experts' streaming copy ({need:.0f} GB) is not written; the " + f"engine streams from the model file ({MF.STREAM_COPY_GAIN} slower)") + return + if free < need + MAC_EXPERTS_MARGIN_GB: + warn(f"the experts' streaming copy needs {need:.0f} GB and {MAC_EXPERTS_MARGIN_GB:.0f} GB to spare on the disk " + f"({free:.0f} GB free): not written; the engine streams from the model file ({MF.STREAM_COPY_GAIN} " + f"slower). Free some space and run ./setup.sh --setup again to add it") + return + say(f" Writing the experts in SSD-read order (one time, {need:.0f} GB; each expert is read with one request) ...") + run([sys.executable, str(mac_tools_dir() / "stream_experts.py"), "--gguf", str(native), "--verify"]) + mark(out) + ok(f"the experts' streaming copy: {out.name}") + + +def mac_prepare_lock(native: Path) -> Path: + """Take the prepare lock beside the repacked file (it holds our pid from the repack to the end of the pack: metal/setup_mac.py's start() and the engine look at it), + and never beside a running engine: an engine of 70-90 GB beside a repack froze the 128 GB M5 Max twice. The caller removes the lock.""" + running = mac_engine_pids() + if running: + fail(f"a Strata engine is running (pid {running[0]}): preparing a model beside it can freeze this Mac", + "stop the server (close its window), then run ./setup.sh --setup again") + native.parent.mkdir(parents=True, exist_ok=True) + lock = native.parent / MAC_PREPARE_LOCK + lock.write_text(str(os.getpid())) + return lock + + +def mac_repack(files: list, model: str, native: Path) -> None: + """Unsloth's many-shard set as ONE GGUF for the GPU (metal/tools/metal_repack.py --verify: every tensor hashed against its shard; the n-gram table stays + in its shard); the same step as metal/setup_mac.py.""" + from strata_control import mac_fit as MF + say(f" Writing the model's experts and weights into one file for the GPU (one time, " + f"{MF.MAC_MODELS[model]['shard1_gb']:.0f} GB) ...") + run([sys.executable, str(mac_tools_dir() / "metal_repack.py"), "--shard1", str(files[0]), "--out", str(native), "--verify"]) + mark(native) + + def mac_control_setup(a) -> int: """The app (and the Terminal's `strata launch`) on a Mac, STRATA_CONTROL_INSTALL=1: the same job as on Windows and Linux. It downloads and prepares the model files and writes a run config whose engine is "component:strata-engine"; it builds nothing @@ -9601,8 +9683,8 @@ def mac_control_setup(a) -> int: fail(problems[0]) step(2, "your choices") - app_sizes = [m for m, d in MF.MAC_MODELS.items() if d.get("family", "qwen") in ("qwen", "gyro")] # Gyro: one file, no repack - if a.family not in (None, "qwen", "gyro"): + app_sizes = [m for f in MF.APP_FAMILIES for m in MF.app_sizes(f)] # Gyro: one file, no repack; Unsloth's sets: the repack below (round 0214) + if a.family not in (None, *MF.APP_FAMILIES): fail(f"the app on a Mac runs the Qwen3.8-Flash-Next sizes ({', '.join(app_sizes)}); {a.family} is not one of them", "choose Qwen3.8-Flash-Next") rec, notes = MF.recommend_model(ram) @@ -9660,7 +9742,10 @@ def mac_control_setup(a) -> int: if problem: # files Strata cannot run, or another choice's files fail(*problem) mark_whole_unless_pinned(files, model_hf(fam, model) if fam.get("hf") else None, bool(a.gguf_dir)) # a whole file copied in by hand has no finish mark - need = (0 if a.gguf_dir or all(s.exists() and done(s) for s in files) else MODELS[model]["download_gb"]) + 6 + repacked = family == "unsloth" and len(files) > 2 # Unsloth's many-shard set: one GGUF for the GPU (mac_repack), the n-gram table left in its shard + native = data / "metal" / f"{tag.lower()}-metal.gguf" + need = MF.install_disk_gb(model, 0 if a.gguf_dir or all(s.exists() and done(s) for s in files) else MODELS[model]["download_gb"]) \ + - (MF.repacked_gb(model) if native.exists() and done(native) else 0.0) if free_gb(models_dir if models_dir.exists() else data) < need: warn(f"about {need:.0f} GB are needed on the disk: kept as you chose; it may run out") if not a.gguf_dir: @@ -9692,9 +9777,20 @@ def mac_control_setup(a) -> int: step(6, "preparing the model for Strata") pack = find_in(roots, f"packs/{tag.lower()}") or data / "packs" / tag.lower() env = dict(os.environ, STRATA_GGUF_PY=str(llama / "gguf-py")) - if not (pack / "native_experts.txt").exists() or not (pack / "tokenizer" / "vocab.json").exists(): - run([sys.executable, str(ROOT / "tools" / "iq_pack.py"), "--gguf", str(files[0]), "--out", str(pack), - *fam.get("pack_args", [])], env=env) # Gyro: --compat-bf16 + shards = [files[0], files[0]] if family == "gyro" else files # Gyro: one file, the model and its n-gram table + pack_todo = not (pack / "native_experts.txt").exists() or not (pack / "tokenizer" / "vocab.json").exists() + prep_lock = mac_prepare_lock(native) if repacked and (pack_todo or not (native.exists() and done(native))) else None + try: + if repacked: + if not (native.exists() and done(native)): + mac_repack(files, model, native) + shards = [native, mac_ple_shard(files)] + if pack_todo: + run([sys.executable, str(ROOT / "tools" / "iq_pack.py"), "--gguf", str(shards[0]), "--out", str(pack), + *fam.get("pack_args", [])], env=env) # Gyro and Unsloth: --compat-bf16 (the host's copies; the GPU reads the file) + finally: + if prep_lock is not None: + prep_lock.unlink(missing_ok=True) ok(f"model prepared: {pack}") use_mtp = MF.stream_mtp(stream, ram) mtp = (find_in(roots, "mtp/mtp-q2_0.gguf") or data / "mtp" / "mtp-q2_0.gguf").parent @@ -9719,11 +9815,12 @@ def mac_control_setup(a) -> int: rt.mkdir(parents=True, exist_ok=True) # the Metal engine reads mtp-q2_0.gguf beside it (no rt pack) refresh_draft_vocab(rt, a.draft_vocab or "cjk") ok(f"MTP draft layer: {mtp / 'mtp-q2_0.gguf'}") + if repacked and stream and ram < MF.STREAM_MTP_FROM_GB: + mac_experts_file(shards[0]) # a small Mac: its misses are many; a big Mac's arena holds most experts step(7, "writing the run settings") port = a.port or 8080 - args = MF.engine_args(pack, [files[0], files[0]] if family == "gyro" else files, rt, ctx, stream, ram, - ROOT / "data" / "expert-profile.bin", model) # Gyro: one file, the model and its n-gram table + args = MF.engine_args(pack, shards, rt, ctx, stream, ram, ROOT / "data" / "expert-profile.bin", model) # Unsloth: --native the repacked file, --ple-gguf the table's shard cfg = {"exe": engine_exe(Path("engine")), "args": args, "cwd": str(ROOT), "tokenizer": str(pack / "tokenizer"), "model_name": f"{fam['name']}-{model.lower()}", "log": str(home() / f"strata-{tag.lower()}.log"), "lib_dirs": [], "port": port, "backend": "metal", "env": {"STRATA_METAL_STREAM": "1" if stream else "0"}} diff --git a/strata_control/mac_fit.py b/strata_control/mac_fit.py index c065864df..d5a2baf25 100644 --- a/strata_control/mac_fit.py +++ b/strata_control/mac_fit.py @@ -183,6 +183,29 @@ def gpu_budget_gb(ram_gb: float) -> float: return ws +# The families the Mac app (setup.py's mac_control_setup and the app's model list) installs: the GSQ-RCO Qwen files, Unsloth's sets (several shards: +# metal/tools/metal_repack.py writes one GGUF for the GPU) and Gyro. A set with "missing" is left out (METAL_Q5_0_DOWN above). +APP_FAMILIES = ("qwen", "unsloth", "gyro") +# What the repack and the pack work in beside the shards (the Mac install path counts it; the repacked file is MAC_MODELS' shard1_gb). +REPACK_WORK_GB = 6.0 + + +def app_sizes(family: str) -> list[str]: + """The sizes the Mac app offers for a family: every MAC_MODELS entry of it that the Metal engine can run.""" + return [m for m, d in MAC_MODELS.items() if d.get("family", "qwen") == family and not d.get("missing")] + + +def repacked_gb(model: str) -> float: + """The disk the repacked GGUF takes (GB): shard1_gb for a many-shard Unsloth set, 0 for a file the engine maps as it is.""" + d = MAC_MODELS.get(model, {}) + return float(d["shard1_gb"]) if d.get("family") == "unsloth" and int(d.get("shards", 2)) > 2 else 0.0 + + +def install_disk_gb(model: str, download_left_gb: float) -> float: + """The free disk an install needs: what is still to download, the repacked file, and room to work.""" + return float(download_left_gb) + repacked_gb(model) + REPACK_WORK_GB + + def model_need_gb(model: str) -> float: return MAC_MODELS[model]["shard1_gb"] + OVERHEAD_GB diff --git a/tools/strata_mcp.py b/tools/strata_mcp.py index 0c1b5040b..4768e7151 100644 --- a/tools/strata_mcp.py +++ b/tools/strata_mcp.py @@ -1105,11 +1105,11 @@ class Strata: return hw def _mac_sizes(self, models: dict, family: str = "qwen") -> list: - """The sizes the app offers on a Mac: the GSQ-RCO files of the Qwen family (two shards, no repack) and agentionai's Gyro files (one - file, no repack). Unsloth's many-shard files need metal/tools/metal_repack.py, which the Terminal's setup uses; the app does not - offer them yet.""" + """The sizes the app offers on a Mac: the GSQ-RCO files of the Qwen family (two shards, no repack), agentionai's Gyro files (one + file, no repack) and, since round 0214, Unsloth's sets (many shards: setup.py's Mac install repacks them with + metal/tools/metal_repack.py, as the Terminal's setup does); a set the Metal engine cannot run yet is left out (mac_fit.MAC_MODELS "missing").""" mf = self.mac() - return [m for m, d in mf.MAC_MODELS.items() if d.get("family", "qwen") == family and m in models] if mf else [] + return [m for m in mf.app_sizes(family) if m in models] if mf else [] def _mac_verdict(self, model: str, ram: float) -> str: mf = self.mac() @@ -1153,7 +1153,7 @@ class Strata: ram = float(hw.get("ram_gb") or 0) have = {c.stem[len("strata-"):] for c in self.configs()} out = [] - for family in ("qwen", "gyro"): # Gyro after the regular sizes, as on a PC: never the default + for family in (self.mac().APP_FAMILIES if self.mac() else ("qwen", "gyro")): # Qwen, Unsloth's sets, then Gyro: never the default fd = families.get(family) if fd is None: continue @@ -2423,7 +2423,7 @@ class Tools: tag = (families[family].get("tag", "") + model) have_dir = target / "models" / tag partly = (have_dir.is_dir() and any(have_dir.glob("*.gguf*"))) or bool(gguf_dir) # a local folder: nothing to download - need = (8 if partly else md["download_gb"] + 8) + (1 if vision in ("yes", "gpu", "cpu") else 0) + need = (8 if partly else md["download_gb"] + 8) + (1 if vision in ("yes", "gpu", "cpu") else 0) + (s.mac().repacked_gb(model) if mac and s.mac() else 0) # a Mac repacks Unsloth's shards into one file for the GPU (mac_fit.repacked_gb) free = s.disk_free(target) short = None if free is not None and free < need: diff --git a/tools/test_setup_mac.py b/tools/test_setup_mac.py index 71cead99f..769d672a8 100644 --- a/tools/test_setup_mac.py +++ b/tools/test_setup_mac.py @@ -361,7 +361,7 @@ class ControlMode(unittest.TestCase): f.with_name(f.name + ".done").write_text("whole") self.files.append(f) - def run_setup(self, hw=M4, argv=("--yes", "--no-start", "--family", "qwen", "--model", "Q2_0", "--context", "8192"), clt_rc=0): + def run_setup(self, hw=M4, argv=("--yes", "--no-start", "--family", "qwen", "--model", "Q2_0", "--context", "8192"), clt_rc=0, tool=None, free=500.0): from strata_control import engine_component, mac_fit ran = [] @@ -379,7 +379,7 @@ class ControlMode(unittest.TestCase): mock.patch.object(setup, "load_settings", lambda: {}), mock.patch.object(setup, "save_settings", lambda s: None), mock.patch.object(setup, "get_llama_cpp", lambda: Path(self.tmp.name) / "llama"), mock.patch.object(setup, "whole_shard", lambda s: True), mock.patch.object(setup, "check_shards", lambda s: None), - mock.patch.object(setup, "run", fake_tool), mock.patch.object(setup, "free_gb", lambda p: 500.0), + mock.patch.object(setup, "run", tool or fake_tool), mock.patch.object(setup, "free_gb", lambda p: free), mock.patch.object(setup, "say", lambda *a, **k: None), mock.patch.object(setup, "ok", lambda *a, **k: None), mock.patch.object(setup, "step", lambda *a, **k: None), mock.patch.object(setup, "warn", lambda *a, **k: self.warnings.append(" ".join(str(x) for x in a))), @@ -427,11 +427,11 @@ class ControlMode(unittest.TestCase): self.assertEqual(cfg["env"], {"STRATA_METAL_STREAM": "0"}) self.assertEqual(cfg["args"][cfg["args"].index("--max-context") + 1], "65536") # the default context by memory - def test_no_command_line_tools_needed_and_the_unsloth_files_are_not_offered(self): + def test_no_command_line_tools_needed_and_ud_iq1_s_is_not_offered(self): rc, _ = self.run_setup(clt_rc=2) self.assertEqual(rc, 0) - with self.assertRaises(SystemExit): - self.run_setup(argv=("--yes", "--no-start", "--model", "UD-Q4_K_XL")) + with self.assertRaises(SystemExit): # UD-IQ1_S: never offered + self.run_setup(argv=("--yes", "--no-start", "--model", "UD-IQ1_S")) def test_gyro_is_offered_on_a_mac_as_one_file_with_a_compat_bf16_pack(self): fam = setup.FAMILIES["gyro"] @@ -469,6 +469,124 @@ class ControlMode(unittest.TestCase): self.assertIn('if sys.platform == "darwin" and CONTROL:', src) +class MacAppUnsloth(unittest.TestCase): + """Round 0214: the Mac app installs Unsloth's sets as the Terminal's setup_mac does: the shards -> ONE GGUF for the GPU (metal_repack.py --verify, the n-gram table + left out), the pack from that file, --native the repacked file, --ple-gguf the table's shard, --mmap-experts. Header-only fixtures (tests/fixtures/unsloth_v31): no + Mac, no weights; the repack and the pack tools are recorded, not run.""" + setUp = ControlMode.setUp + run_setup = ControlMode.run_setup + config = ControlMode.config + NEW = ("UD-IQ1_M", "UD-Q2_0", "UD-IQ2_XS", "UD-Q2_K_XL", "UD-IQ3_XXS", "UD-Q3_K_XL", "UD-IQ4_XS-v31") + + def shards(self, model): + sys.path.insert(0, str(ROOT / "tools")) + import unsloth_v31_fixture as FX + doc = FX.load() + hub = "UD-IQ4_XS" if model == "UD-IQ4_XS-v31" else model + for old in self.models.glob("*"): + old.unlink() + out = [] + for i, s in enumerate(doc["sets"][hub]["shards"]): + h = s["header"] or {"split": [0, len(doc["sets"][hub]["shards"]), 1224], "tensors": []} + p = FX.materialize({"header": h}, self.models / s["name"], first=(i == 0)) + p.with_name(p.name + ".done").write_text("whole") + out.append(p) + return out + + def tool(self, ran): + def fake(cmd, **kw): + ran.append([str(x) for x in cmd]) + if cmd[1].endswith("metal_repack.py"): + Path(cmd[cmd.index("--out") + 1]).write_bytes(b"x") + else: + pack = Path(cmd[cmd.index("--out") + 1]) + (pack / "tokenizer").mkdir(parents=True, exist_ok=True) + (pack / "native_experts.txt").write_text("x") + (pack / "tokenizer" / "vocab.json").write_text("{}") + return fake + + def install(self, model, hw=M5, **kw): + ran = [] + engines = kw.pop("engines", []) + with mock.patch.object(setup, "mac_engine_pids", lambda: engines): + rc, _ = self.run_setup(hw=hw, argv=("--yes", "--no-start", "--family", "unsloth", "--model", model, "--context", "8192", "--mtp", "off"), + tool=self.tool(ran), **kw) + return rc, ran + + def test_the_repack_the_pack_and_the_config(self): + for model in self.NEW: + with self.subTest(model=model): + files = self.shards(model) + shutil_native = self.data / "metal" / f"unsloth-{model.lower()}-metal.gguf" + if shutil_native.exists(): + shutil_native.unlink() + shutil_native.with_name(shutil_native.name + ".done").unlink(missing_ok=True) + rc, ran = self.install(model) + self.assertEqual(rc, 0) + self.assertEqual(len(ran), 2, ran) + repack, pack = ran + self.assertTrue(repack[1].endswith("metal_repack.py")) + self.assertEqual(repack[repack.index("--shard1") + 1], str(files[0])) + self.assertEqual(repack[repack.index("--out") + 1], str(shutil_native)) + self.assertIn("--verify", repack) + self.assertTrue(pack[1].endswith("iq_pack.py")) + self.assertEqual(pack[pack.index("--gguf") + 1], str(shutil_native)) # the pack is read from the repacked file + self.assertIn("--compat-bf16", pack) + cfg = self.config("unsloth-" + model.lower()) + a = cfg["args"] + self.assertEqual(cfg["exe"], "component:strata-engine") + self.assertEqual(a[a.index("--native") + 1], str(shutil_native)) + self.assertEqual(a[a.index("--native-dense-gguf") + 1], str(shutil_native)) + self.assertEqual(a[a.index("--ple-gguf") + 1], str(files[-1])) # the n-gram table: the LAST shard of these sets + self.assertIn("--mmap-experts", a) + self.assertEqual(cfg["env"]["STRATA_METAL_STREAM"], "1" if M.streams(model, 128.0) else "0") + self.assertEqual(cfg["tokenizer"], str(self.data / "packs" / ("unsloth-" + model.lower()) / "tokenizer")) + self.assertEqual(cfg["model_name"], "qwen3.8-flash-next-unsloth-" + model.lower()) + self.assertFalse((shutil_native.parent / ".preparing.lock").exists(), "the prepare lock is gone") + + def test_an_already_repacked_set_is_not_repacked_again(self): + model = "UD-IQ1_M" + self.shards(model) + native = self.data / "metal" / "unsloth-ud-iq1_m-metal.gguf" + native.parent.mkdir(parents=True) + native.write_bytes(b"x") + setup.mark(native) + rc, ran = self.install(model) + self.assertEqual(rc, 0) + self.assertEqual([r[1].rsplit("/", 1)[-1] for r in ran], ["iq_pack.py"]) + + def test_a_running_engine_stops_the_repack_before_anything_is_written(self): + self.shards("UD-IQ1_M") + with self.assertRaises(SystemExit): + self.install("UD-IQ1_M", engines=[4242]) + self.assertFalse((self.data / "metal").exists() and any((self.data / "metal").iterdir())) + + def test_the_disk_check_counts_the_repacked_file(self): + self.shards("UD-IQ1_M") + self.install("UD-IQ1_M", free=30.0) + said = [w for w in self.warnings if "are needed on the disk" in w] + self.assertEqual(len(said), 1, self.warnings) + self.assertIn("about 45 GB", said[0]) # nothing to download (the files are here), the 39.2 GB repacked file and 6 GB to work in + + def test_a_set_the_engine_cannot_run_is_not_offered(self): + from strata_control import mac_fit + self.shards("UD-Q3_K_XL") + with mock.patch.dict(mac_fit.MAC_MODELS, {"UD-Q3_K_XL": {**mac_fit.MAC_MODELS["UD-Q3_K_XL"], "missing": "the Q5_0 expert kernels"}}): + with self.assertRaises(SystemExit): + self.install("UD-Q3_K_XL") + + def test_the_bundle_carries_the_repack_tools(self): + sys.path.insert(0, str(ROOT / "packaging")) + import package_terminal as pt + self.assertEqual(pt.INSTALLER_METAL_TOOLS, ("metal_repack", "stream_experts")) + for n in pt.INSTALLER_METAL_TOOLS: + self.assertTrue((ROOT / "metal" / "tools" / f"{n}.py").is_file()) + with tempfile.TemporaryDirectory() as t: + out = pt.add_installer(Path(t)) + self.assertIn("metal/tools/metal_repack.py", out["files"]) + self.assertTrue((Path(t) / "metal" / "tools" / "stream_experts.py").is_file()) + + class UpdateFlow(unittest.TestCase): """update.sh asks the same questions as a setup run but does not download model files: a model chosen there must be named, not dropped in silence (an M1 Max picked IQ3_S in update.sh and got no model and no message).""" diff --git a/tools/test_strata_mcp.py b/tools/test_strata_mcp.py index a3adfb8a9..755967c81 100644 --- a/tools/test_strata_mcp.py +++ b/tools/test_strata_mcp.py @@ -769,11 +769,16 @@ class MacCatalog(unittest.TestCase): def test_the_model_list_is_the_macs_sizes_with_unified_memory_verdicts(self): s, hw = self.hw(32) fams = s.model_table(hw) - self.assertEqual([f["family"] for f in fams], ["qwen", "gyro", "deepseek41"], - "no Unsloth, Swift or NVFP4 on a Mac in the app; Gyro after the regular sizes, DeepSeek (0.2.7.1) last") + self.assertEqual([f["family"] for f in fams], ["qwen", "unsloth", "gyro", "deepseek41"], + "Unsloth's sets since round 0214 (the app repacks them), no Swift or NVFP4 on a Mac; Gyro after the regular sizes, DeepSeek (0.2.7.1) last") by = {x["model"]: x for x in fams[0]["sizes"]} self.assertEqual(list(by), ["Q2_0", "IQ3_XXS", "IQ3_S"]) - gy = {x["model"]: x for x in fams[1]["sizes"]} + un = {x["model"]: x for x in fams[1]["sizes"]} + self.assertEqual(list(un), ["UD-IQ1_M", "UD-Q2_0", "UD-IQ2_XS", "UD-Q2_K_XL", "UD-IQ3_XXS", "UD-Q3_K_XL", "UD-IQ4_XS-v31", "UD-Q4_K_XL", "UD-Q5_K_XL", "UD-Q6_K_XL"]) + self.assertEqual((un["UD-IQ1_M"]["download_gb"], un["UD-IQ1_M"]["experts_gb"]), (68.0, 34.8)) + self.assertTrue(all(x["on_this_pc"] and x["images"] and x["id"] == "unsloth-" + x["model"].lower() for x in un.values())) + self.assertTrue(un["UD-Q5_K_XL"]["on_this_pc"].startswith("slow here"), "32 GB: 104 GB to map does not fit, said not refused") + gy = {x["model"]: x for x in fams[2]["sizes"]} self.assertEqual(list(gy), ["Gyro-S", "Gyro-M"]) self.assertEqual(gy["Gyro-S"]["download_gb"], 58.5) self.assertTrue(all(v["on_this_pc"] and "SSD" in v["on_this_pc"] for v in gy.values()), "32 GB: both stream (said, not refused)") @@ -783,7 +788,11 @@ class MacCatalog(unittest.TestCase): self.assertTrue(by["IQ3_S"]["on_this_pc"].startswith("slow here")) self.assertTrue(all(x["images"] for x in by.values()), "the Mac engine carries the Metal picture reader: pictures are on") s, hw = self.hw(128) - self.assertEqual({x["on_this_pc"] for f in s.model_table(hw) if f["family"] != "deepseek41" for x in f["sizes"]}, {"fits"}) + fits = {x["model"]: x["on_this_pc"] for f in s.model_table(hw) if f["family"] != "deepseek41" for x in f["sizes"]} + from strata_control import mac_fit + for m, v in fits.items(): # the 128 GB Mac keeps ~96 GB resident: UD-Q5_K_XL and UD-Q6_K_XL (104 / 115 GB to map) stream, said not refused + self.assertEqual(v == "fits", not mac_fit.streams(m, 128.0), m) + self.assertEqual({m for m, v in fits.items() if v != "fits"}, {"UD-Q5_K_XL", "UD-Q6_K_XL"}) s, hw = self.hw(8) self.assertEqual({x["on_this_pc"] for f in s.model_table(hw) if f["family"] != "deepseek41" for x in f["sizes"]}, {"does not fit"}) self.assertTrue(all(x["on_this_pc"].startswith("does not fit") for f in s.model_table(hw) if f["family"] == "deepseek41" for x in f["sizes"])) @@ -864,6 +873,26 @@ class MacCatalog(unittest.TestCase): s2.hardware = lambda fresh=False: hw2 M.Tools(s2).install_plan(None, None, None, None, None, None, None, None, None, None, None) + def test_the_install_plan_counts_the_repacked_file_of_an_unsloth_set(self): + """Round 0214: the Mac app repacks Unsloth's shards into one GGUF: the disk it needs is the download, that file (mac_fit shard1_gb) and room to work.""" + s, hw = self.hw(128) + s.hardware = lambda fresh=False: hw + s.disk_free = lambda p: 1000.0 + with tempfile.TemporaryDirectory() as t: + target = Path(t) / "Strata-data" + target.mkdir() + plan, args = M.Tools(s).install_plan("unsloth", "UD-IQ1_M", None, "no", "int8", 0, None, None, "auto", None, str(target)) + plain, _ = M.Tools(s).install_plan("qwen", "Q2_0", None, "no", "int8", 0, None, None, "auto", None, str(target)) + self.assertEqual(plan["disk_needed_gb"], round(68.0 + 8 + 39.2)) + self.assertEqual(plain["disk_needed_gb"], round(plain["download_gb"] + 8)) # no repack for a GSQ-RCO file + self.assertEqual(args[:6], ["--yes", "--no-start", "--family", "unsloth", "--model", "UD-IQ1_M"]) + s.disk_free = lambda p: 100.0 + with tempfile.TemporaryDirectory() as t: + target = Path(t) / "Strata-data" + target.mkdir() + short, _ = M.Tools(s).install_plan("unsloth", "UD-IQ1_M", None, "no", "int8", 0, None, None, "auto", None, str(target)) + self.assertIn("not enough free disk space", short["disk_short"]) + def test_the_install_plan_for_deepseek_on_a_mac_is_text_only_at_8k(self): """(with an engine that has no Metal picture tower)""" s, hw = self.hw(128)