diff --git a/app/gguf/main.cpp b/app/gguf/main.cpp index a9e06f34a..5296f0d67 100644 --- a/app/gguf/main.cpp +++ b/app/gguf/main.cpp @@ -38,6 +38,35 @@ std::string lower_ascii(std::string value) { return value; } +bool is_gguf_path(const std::filesystem::path & path) { + return lower_ascii(path.extension().string()) == ".gguf"; +} + +// A GGUF this tool wrote carries its conversion namespaces in the tensor names +// ("/"), so re-converting one — requantising a published +// bf16 package to q8_0, say — can recover them instead of asking the caller to +// spell out namespaces the file already knows. +std::set gguf_tensor_namespaces(const std::filesystem::path & path) { + std::set namespaces; + const auto source = engine::assets::open_tensor_source(path); + for (const auto & tensor : source->tensors()) { + const auto separator = tensor.name.find('/'); + namespaces.insert(separator == std::string::npos ? std::string() : tensor.name.substr(0, separator)); + } + return namespaces; +} + +std::set input_namespaces(const engine::assets::TensorSourceInput & input) { + if (!input.tensor_prefix.empty()) + return {input.tensor_prefix}; + if (is_gguf_path(input.path)) { + auto namespaces = gguf_tensor_namespaces(input.path); + if (!namespaces.empty()) + return namespaces; + } + return {std::string()}; +} + bool excluded_sidecar(const std::filesystem::path & path, const std::filesystem::path & output) { const std::string extension = lower_ascii(path.extension().string()); return extension == ".safetensors" || extension == ".gguf" || extension == ".bin" || extension == ".pt" || @@ -304,7 +333,8 @@ std::optional required_destination(const json::Value & source, cons std::vector validate_candidate(const PackageSpecCandidate & candidate, const std::set & actual_prefixes, - const std::set & sidecars) { + const std::set & sidecars, + bool reconversion) { std::vector errors; try { const auto spec = json::parse(candidate.spec.json); @@ -327,10 +357,18 @@ std::vector validate_candidate(const PackageSpecCandidate & candida optional_prefixes.insert(tensor_prefix(value)); } } - for (const auto & prefix : expected_prefixes) { - if (actual_prefixes.find(prefix) == actual_prefixes.end()) { - errors.push_back("missing tensor namespace '" + (prefix.empty() ? std::string("") : prefix) + - "'"); + // Requiring every namespace catches a fresh conversion that forgot an + // input. Re-converting a finished package proves nothing of the sort: + // its namespaces are already exactly what that package ships, and a + // package legitimately built with `--exclude-prefix` (an ACE-Step XL + // GGUF carries no turbo or base DiT) would otherwise be impossible to + // re-quantise even though the runtime loads it happily. + if (!reconversion) { + for (const auto & prefix : expected_prefixes) { + if (actual_prefixes.find(prefix) == actual_prefixes.end()) { + errors.push_back("missing tensor namespace '" + (prefix.empty() ? std::string("") : prefix) + + "'"); + } } } for (const auto & prefix : actual_prefixes) { @@ -354,22 +392,78 @@ std::vector validate_candidate(const PackageSpecCandidate & candida return errors; } +// Where the conversion looks for the small files (configs, tokenizers) that +// belong in the output. `--root` wins, and so does an explicit `--sidecar` +// set. Otherwise a GGUF input's own embedded copies are used — for a re-encode +// they are exactly the files that package ships, where the directory the file +// happens to sit in is only a guess. Everything else keeps using that +// directory, which is what every safetensors conversion does. +std::filesystem::path resolve_sidecar_root(const std::filesystem::path & requested, + const std::vector & inputs, + const std::vector & explicit_sidecars, + bool embed_sidecars) { + if (!requested.empty()) + return std::filesystem::weakly_canonical(requested); + const auto parent = std::filesystem::weakly_canonical(inputs.front().path.parent_path()); + if (!embed_sidecars || !explicit_sidecars.empty()) + return parent; + for (const auto & input : inputs) { + if (!is_gguf_path(input.path) || !engine::assets::gguf_has_embedded_sidecars(input.path)) + continue; + const auto materialized = engine::assets::materialize_gguf_sidecars(input.path); + std::cerr << "note: reusing the sidecars embedded in " << input.path.string() + << "; pass --root to override\n"; + return materialized; + } + return parent; +} + PackageSpecCandidate select_package_spec(const std::vector & inputs, const std::filesystem::path & model_root, const std::filesystem::path & output, const std::vector & explicit_sidecars, const std::optional & requested_spec, std::optional family, bool embed_sidecars) { + // Every input is a GGUF this tool wrote for one family: the conversion is a + // re-encode of a finished package rather than an assembly of a new one. + const bool reconversion = + !inputs.empty() && std::all_of(inputs.begin(), inputs.end(), + [](const engine::assets::TensorSourceInput & input) { + return is_gguf_path(input.path) && + engine::assets::read_gguf_embedded_model_spec(input.path).has_value(); + }); std::vector candidates; if (requested_spec.has_value()) { add_spec_path(candidates, *requested_spec, family, 0); } else { const size_t config_candidate_count = candidates.size(); + // A GGUF input states its own family and package spec. Trusting that is + // both more accurate than guessing from the catalog and safer: without + // it, a single-namespace GGUF matches whichever unrelated family's spec + // happens to accept one unnamed namespace, and the conversion is + // silently stamped with that family. + for (const auto & input : inputs) { + if (!is_gguf_path(input.path)) + continue; + const auto embedded = engine::assets::read_gguf_embedded_model_spec(input.path); + if (!embedded.has_value()) + continue; + add_candidate(candidates, + parse_package_spec(embedded->json, "embedded:" + input.path.string(), 0)); + if (!family.has_value()) + family = embedded->family; + } const auto config_family = add_model_config_spec(candidates, model_root); if (!family.has_value()) family = config_family; - if (candidates.size() == config_candidate_count) { + // An explicitly requested family that none of those specs describes still + // falls through to the catalog, the way it did before they existed. + const bool describes_requested_family = + std::any_of(candidates.begin(), candidates.end(), [&family](const PackageSpecCandidate & candidate) { + return !family.has_value() || candidate.spec.family == *family; + }); + if (candidates.size() == config_candidate_count || !describes_requested_family) { if (engine::io::is_existing_file(model_root / "model_spec.json")) { add_spec_file(candidates, model_root / "model_spec.json", 2); } @@ -389,9 +483,11 @@ PackageSpecCandidate select_package_spec(const std::vector prefixes; for (const auto & input : inputs) { - if (!prefixes.insert(input.tensor_prefix).second) { - throw std::runtime_error("duplicate tensor namespace in conversion inputs: '" + - (input.tensor_prefix.empty() ? std::string("") : input.tensor_prefix) + "'"); + for (const auto & prefix : input_namespaces(input)) { + if (!prefixes.insert(prefix).second) { + throw std::runtime_error("duplicate tensor namespace in conversion inputs: '" + + (prefix.empty() ? std::string("") : prefix) + "'"); + } } } const auto sidecars = planned_sidecar_destinations(model_root, output, explicit_sidecars, embed_sidecars); @@ -412,7 +508,7 @@ PackageSpecCandidate select_package_spec(const std::vector embedded_model_spec; if (!allow_missing_model_spec) { embedded_model_spec = diff --git a/docs/models/ace_step.md b/docs/models/ace_step.md index 168e54ac7..0d3f418e5 100644 --- a/docs/models/ace_step.md +++ b/docs/models/ace_step.md @@ -215,8 +215,11 @@ The two differ only in `is_turbo`: XL Turbo is guidance-distilled and ignores Their dimensions, encoder group and head configuration are identical. `ace_step_xl_turbo_bf16` and `ace_step_xl_sft_bf16` install them as GGUFs -(14.2 GB each), self-contained the way the Turbo and Base GGUFs are — XL DiT, -planner LM, text encoder and VAE in one file: +(14.2 GiB each), self-contained the way the Turbo and Base GGUFs are — XL DiT, +planner LM, text encoder and VAE in one file. `ace_step_xl_turbo_q8dit` and +`ace_step_xl_sft_q8dit` are the same packages with the DiT at q8_0 and the +planner LM, text encoder and VAE left at bf16 (9.97 GiB); see the measurements +at the end of this section for what that costs: ```bash audiocpp_cli --task gen --family ace_step --model models/ACE-Step1.5-GGUF/xl-turbo --backend cuda --task-route text2music --text "warm lo-fi hip hop with a soft rhodes piano" --duration-seconds 60 --load-option ace_step.dit_model_path=acestep-v15-xl-turbo --out song.wav @@ -235,16 +238,22 @@ warm in the page cache: 87 s from safetensors at `native`, 25 s from safetensors at `bf16`, 15 s from the bf16 GGUF, both variants alike (turbo, for reference: 11 s). Reading the weights off disk adds roughly 10 s either way. -Building an XL GGUF yourself needs the other variants' safetensors on hand, -because `audiocpp_gguf` checks the conversion against the spec's required -namespaces; exclude them from the output: +Building an XL GGUF yourself still names every namespace the spec requires, +because `audiocpp_gguf` validates the conversion against all of them — but the +turbo and base entries only have to *exist*. `--exclude-prefix` drops their +tensors before any data is read, so a 76-byte placeholder stands in for the 9 GB +of weights that would otherwise be downloaded and thrown away: + +```bash +python -c "import json,struct; h=json.dumps({'x':{'dtype':'F32','shape':[1,1],'data_offsets':[0,4]}}).encode(); h+=b' '*((8-len(h)%8)%8); open('placeholder.safetensors','wb').write(struct.pack('/silence_latent.pt` -converts it. +converts it. Of the turbo and base snapshots only `config.json` is genuinely +needed — those are required sidecars, a few KB each. + +`ace_step` is graded `No (planner sampling can fail)` for q8_0 in +[gguf.md](../gguf.md), and that grade is about the planner LM, not the DiT: at a +fixed seed a fully quantised build samples a different token path and returns an +unrelated song. Quantising the DiT alone keeps the planner exact, which +`--keep-type` expresses: + +```bash + --keep-type "lm_weights*=bf16" \ + --keep-type "text_encoder_weights*=bf16" \ + --keep-type "vae_weights*=bf16" \ + --keep-type "dit_xl_turbo_silence_latent*=bf16" \ + --type q8_0 --output ace-step-1.5-xl-turbo-q8dit.gguf +``` + +Measured on an RTX 5090 against the bf16 build at the same seed and prompt, +20 s of audio: 9.97 GiB against 14.2 GiB and 9.3 s against 14.2 s, with a 0.989 +waveform correlation against the bf16 output — 0.997 on a sung 40 s take, 0.999 +for XL SFT. A fully quantised build of the same weights correlates 0.09, and its +ASR transcript is a different lyric line. + +Re-quantising a finished GGUF works too, since a GGUF input carries its own +namespaces, package spec and sidecars: + +```bash +audiocpp_gguf --input ace-step-1.5-xl-turbo-bf16.gguf --type q8_0 \ + --keep-type "lm_weights*=bf16" --output ace-step-1.5-xl-turbo-q8dit.gguf +``` diff --git a/model_specs/ace_step.json b/model_specs/ace_step.json index b06e42014..b1c094c68 100644 --- a/model_specs/ace_step.json +++ b/model_specs/ace_step.json @@ -123,6 +123,36 @@ "kind": "huggingface_snapshot", "repo": "CaptainArni/audio.cpp-gguf" } + }, + { + "id": "ace_step_xl_turbo_q8dit", + "display_name": "ACE-Step 1.5 XL Turbo Q8_0 DiT GGUF (BF16 planner)", + "format": "gguf", + "precision": "q8_0", + "target_directory": "ACE-Step1.5-GGUF", + "files": [ + "ACE-Step1.5-GGUF/xl-turbo-q8dit/ace-step-1.5-xl-turbo-q8dit.gguf" + ], + "strip_prefix": "ACE-Step1.5-GGUF", + "download": { + "kind": "huggingface_snapshot", + "repo": "CaptainArni/audio.cpp-gguf" + } + }, + { + "id": "ace_step_xl_sft_q8dit", + "display_name": "ACE-Step 1.5 XL SFT Q8_0 DiT GGUF (BF16 planner)", + "format": "gguf", + "precision": "q8_0", + "target_directory": "ACE-Step1.5-GGUF", + "files": [ + "ACE-Step1.5-GGUF/xl-sft-q8dit/ace-step-1.5-xl-sft-q8dit.gguf" + ], + "strip_prefix": "ACE-Step1.5-GGUF", + "download": { + "kind": "huggingface_snapshot", + "repo": "CaptainArni/audio.cpp-gguf" + } } ], "sources": [