From 30649a0309a989e0513851fca86ef69315428a88 Mon Sep 17 00:00:00 2001 From: CaptainArni Date: Sat, 5 Sep 2026 21:25:46 +0200 Subject: [PATCH 1/2] gguf: let a finished GGUF be re-converted Re-quantising a GGUF that audiocpp_gguf produced fails today, and the error points at the wrong thing (#457): $ audiocpp_gguf --input ACE-Step1.5-XL-Turbo-bf16.gguf --type q8_0 --output test.gguf error: no GGUF sidecars were found ... Three things stand in the way. The conversion's namespaces come from the `namespace=` labels on the command line, so a GGUF whose tensors are already named `dit_xl_turbo_weights/...` presents as one unnamed namespace and matches nothing in its own family. With no `--family`, the catalog search then settles on whichever unrelated spec accepts a single unnamed namespace -- `sense_asr` -- and the run dies later, in sidecar embedding. When it does not die it is worse: re-converting the Kroko GGUF with any small text file beside it succeeds and writes `model_spec_family=sense_asr` into the output. And the sidecars the input already carries go unused, because collection only walks `--root`. So: read the namespaces back out of a GGUF input's tensor names, register its embedded model spec as the top-priority candidate and let it set the default family, and fall back to its embedded sidecars when neither `--root` nor `--sidecar` was given. A re-conversion also stops requiring every namespace the spec declares -- a package built with `--exclude-prefix` (an ACE-Step XL GGUF carries no turbo or base DiT) ships fewer than the spec lists, and the runtime loads it happily. docs/models/ace_step.md said building an XL GGUF "needs the other variants' safetensors on hand". It does not: the namespace check only reads the labels and `--exclude-prefix` drops those tensors before any data is touched, so a 76-byte placeholder works and the build needs 24 GB of downloads rather than 33 GB. The section now shows that, notes that only the two config.json files are genuinely required, and documents quantising the DiT alone -- q8_0's "planner sampling can fail" grade is about the planner LM, and keeping it at bf16 holds a 0.989 waveform correlation against the bf16 build at a fixed seed where a fully quantised build scores 0.09. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AYQxP9KPBeGmGRRhptbQWp --- app/gguf/main.cpp | 118 ++++++++++++++++++++++++++++++++++++---- docs/models/ace_step.md | 51 ++++++++++++++--- 2 files changed, 150 insertions(+), 19 deletions(-) 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..c0e14199e 100644 --- a/docs/models/ace_step.md +++ b/docs/models/ace_step.md @@ -235,16 +235,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 +``` From 174cabe3e8786a35699bca6b03de816688d8715a Mon Sep 17 00:00:00 2001 From: CaptainArni Date: Sat, 5 Sep 2026 21:34:51 +0200 Subject: [PATCH 2/2] ace_step: add package rows for the mixed-precision XL GGUFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ace_step_xl_turbo_q8dit` and `ace_step_xl_sft_q8dit` install the q8_0-DiT builds next to the bf16 ones, from the same repo the bf16 rows already point at. 9.97 GiB against 14.2 GiB, and at a fixed seed the output holds a 0.989 waveform correlation with the bf16 build (0.997 on a sung take, 0.999 for XL SFT) where a fully quantised build scores 0.09. `precision` is the validated enum, so these rows carry `q8_0` — the type the conversion was run at. What the planner LM, text encoder and VAE keep is in the id and display name instead. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AYQxP9KPBeGmGRRhptbQWp --- docs/models/ace_step.md | 7 +++++-- model_specs/ace_step.json | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/models/ace_step.md b/docs/models/ace_step.md index c0e14199e..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 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": [