Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 107 additions & 11 deletions app/gguf/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ("<namespace>/<tensor>"), 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<std::string> gguf_tensor_namespaces(const std::filesystem::path & path) {
std::set<std::string> 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<std::string> 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" ||
Expand Down Expand Up @@ -304,7 +333,8 @@ std::optional<std::string> required_destination(const json::Value & source, cons

std::vector<std::string> validate_candidate(const PackageSpecCandidate & candidate,
const std::set<std::string> & actual_prefixes,
const std::set<std::string> & sidecars) {
const std::set<std::string> & sidecars,
bool reconversion) {
std::vector<std::string> errors;
try {
const auto spec = json::parse(candidate.spec.json);
Expand All @@ -327,10 +357,18 @@ std::vector<std::string> 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("<root>") : 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("<root>") : prefix) +
"'");
}
}
}
for (const auto & prefix : actual_prefixes) {
Expand All @@ -354,22 +392,78 @@ std::vector<std::string> 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<engine::assets::TensorSourceInput> & inputs,
const std::vector<engine::assets::GgufEmbeddedFile> & 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<engine::assets::TensorSourceInput> & inputs,
const std::filesystem::path & model_root, const std::filesystem::path & output,
const std::vector<engine::assets::GgufEmbeddedFile> & explicit_sidecars,
const std::optional<std::filesystem::path> & requested_spec,
std::optional<std::string> 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<PackageSpecCandidate> 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);
}
Expand All @@ -389,9 +483,11 @@ PackageSpecCandidate select_package_spec(const std::vector<engine::assets::Tenso

std::set<std::string> 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("<root>") : 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("<root>") : prefix) + "'");
}
}
}
const auto sidecars = planned_sidecar_destinations(model_root, output, explicit_sidecars, embed_sidecars);
Expand All @@ -412,7 +508,7 @@ PackageSpecCandidate select_package_spec(const std::vector<engine::assets::Tenso
for (const auto & candidate : candidates) {
if ((family.has_value() && candidate.spec.family != *family) || candidate.priority != *selected_priority)
continue;
auto errors = validate_candidate(candidate, prefixes, sidecars);
auto errors = validate_candidate(candidate, prefixes, sidecars, reconversion);
if (errors.empty())
matches.push_back({candidate, {}});
else
Expand Down Expand Up @@ -584,7 +680,7 @@ int main(int argc, char ** argv) {
}
const auto storage_type = engine::assets::parse_tensor_storage_type(type);
const auto resolved_sidecar_root =
std::filesystem::weakly_canonical(sidecar_root.empty() ? inputs.front().path.parent_path() : sidecar_root);
resolve_sidecar_root(sidecar_root, inputs, sidecars, embed_sidecars);
std::optional<engine::assets::GgufEmbeddedModelSpec> embedded_model_spec;
if (!allow_missing_model_spec) {
embedded_model_spec =
Expand Down
58 changes: 48 additions & 10 deletions docs/models/ace_step.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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('<Q',len(h))+h+bytes(4))"
```

```bash
audiocpp_gguf --root models/Ace-Step1.5 --family ace_step \
--input dit_turbo_weights=models/Ace-Step1.5/acestep-v15-turbo/model.safetensors \
--input dit_turbo_silence_latent=models/Ace-Step1.5/acestep-v15-turbo/silence_latent.safetensors \
--input dit_base_weights=models/Ace-Step1.5/acestep-v15-base/model.safetensors \
--input dit_base_silence_latent=models/Ace-Step1.5/acestep-v15-base/silence_latent.safetensors \
--input dit_turbo_weights=placeholder.safetensors \
--input dit_turbo_silence_latent=placeholder.safetensors \
--input dit_base_weights=placeholder.safetensors \
--input dit_base_silence_latent=placeholder.safetensors \
--input dit_xl_turbo_weights=models/Ace-Step1.5/acestep-v15-xl-turbo/model.safetensors.index.json \
--input dit_xl_turbo_silence_latent=models/Ace-Step1.5/acestep-v15-xl-turbo/silence_latent.safetensors \
--input lm_weights=models/Ace-Step1.5/acestep-5Hz-lm-1.7B/model.safetensors \
Expand All @@ -257,4 +266,33 @@ audiocpp_gguf --root models/Ace-Step1.5 --family ace_step \
Swap `dit_xl_turbo_*` for `dit_xl_sft_*` to build the SFT one. Upstream ships
`silence_latent.pt` where the spec wants safetensors;
`tests/ace_step/convert_silence_latent.py --input <variant>/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
```
30 changes: 30 additions & 0 deletions model_specs/ace_step.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
Loading