Skip to content

feat(ruvllm): write GGUF F16 and Q8_0 from SafeTensors checkpoints - #1201

Open
ohdearquant wants to merge 3 commits into
ruvnet:mainfrom
ohdearquant:feat/gguf-safetensors-writer
Open

ohdearquant wants to merge 3 commits into
ruvnet:mainfrom
ohdearquant:feat/gguf-safetensors-writer

Conversation

@ohdearquant

@ohdearquant ohdearquant commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

Pull request opened by an AI agent on behalf of @ohdearquant.

Summary

ruvllm quantize writes real GGUF files from SafeTensors checkpoints instead of failing closed for every input. This covers the SafeTensors half of #968. GGUF input (gate 2) is not part of this change and still fails closed.

  • Input: a SafeTensors checkpoint as a single file or a sharded directory (model.safetensors.index.json), with F32, F16 or BF16 tensors, for the llama and qwen2 architectures, plus config.json and tokenizer.json.
  • Output: GGUF F16 or Q8_0, including model metadata, the BPE vocabulary, merges, token types, special-token ids and the chat template. Q8_0 quantizes the token embeddings by default, as llama.cpp's converter does; --keep-embed-fp16 keeps them in F16.
  • Compatibility with common checkpoints:
    • An NFC normalizer is accepted and writes the same output as no normalizer, as llama.cpp's converter does.
    • Llama 3 rope_scaling (rope_type: "llama3") is accepted and emits the rope_freqs.weight tensor that llama.cpp reads.
    • Added tokens that look special (<|...|> and similar) are typed CONTROL, using the converter's rule. Numeric BOS/EOS/PAD/UNK ids are written without changing a token's type, as the converter does.
    • The SmolLM2 pre-tokenizer (Digits with individual_digits, then ByteLevel) is accepted and written as smollm. Other Digits arrangements are refused with no output.
  • Safety: the file is written to a hidden temporary file beside the output, synced, then published with a hard link, so the requested path never holds a partial file and an existing output is never replaced. A failed run removes the temporary file; a process killed before publication can leave a hidden .<name>.part.* file. Unsupported architectures, tokenizer features, config fields and rope types are refused before any output exists. Q4_K_M and Q5_K_M still fail closed.

Real checkpoints against llama.cpp

Each model was converted as downloaded, by this change and by llama.cpp's convert_hf_to_gguf.py (llama.cpp f2cc7282). Both files were read with llama.cpp's gguf-py and run with llama.cpp on CPU (same commit).

Model Type Tensors identical to the converter's Token types PPL, this change PPL, converter Greedy text
Qwen2.5-0.5B-Instruct (c89bee90) F16 290 / 290 identical 6.0945 6.0945 identical
Qwen2.5-0.5B-Instruct Q8_0 290 / 290 identical 6.0985 6.0985 identical
Llama-3.2-1B-Instruct (unsloth mirror 5a8abab4) F16 147 / 147, incl. rope_freqs.weight identical 7.9321 7.9321 identical
Llama-3.2-1B-Instruct Q8_0 147 / 147, incl. rope_freqs.weight identical 7.9454 7.9454 identical
SmolLM2-135M-Instruct (12fd25f7) F16 272 / 272 identical 6.4572 6.4572 identical
SmolLM2-135M-Instruct Q8_0 272 / 272 identical 6.5010 6.5010 identical

"Identical" means the same name, type, shape and bytes for every tensor. PPL is llama-perplexity on a fixed 51,851-byte text (llama.cpp's README plus docs/build.md), with -c 512 --chunks 6. Greedy text is 24 tokens from llama-simple for "The three primary colors are".

Metadata differs only in optional keys; every key both files write has the same value. Keys only one side writes:

  • Qwen2.5: this change also writes general.alignment, qwen2.rope.dimension_count, qwen2.vocab_size and tokenizer.ggml.add_space_prefix; the converter also writes general.name, general.size_label, general.type and general.sampling.*.
  • Llama-3.2: this change also writes general.alignment and tokenizer.ggml.add_space_prefix; the converter also writes general.name, general.size_label, general.type, general.sampling.*, llama.attention.key_length, llama.attention.value_length and tokenizer.ggml.add_sep_token.
  • SmolLM2: this change also writes general.alignment and tokenizer.ggml.add_eos_token; the converter also writes general.name, general.size_label, general.type, llama.attention.key_length and llama.attention.value_length.

None of these changed the results above.

Gate 4 bindings at this commit

Linux x86_64, 16 CPUs, rustc 1.98.0, release build of ruvllm-cli at this commit (3d51ab3), /usr/bin/time -v:

Input model.safetensors (sha256) Type Output (sha256) Bytes Wall Peak RSS
Qwen2.5-0.5B-Instruct fdf756fa7fcbe7404d5c60e26bff1a0c8b8aa1f72ced49e7dd0210fe288fb7fe F16 0a46b1687ee045642403d5cdb1cc30cc8e746ada05ad3d5cabdd5d20b3ddce4c 994,156,320 9.6 s 341 MiB
same Q8_0 82d260e58a3e37dabd1c4d33e3a7f6dee0e0c5bca38b012f92a9ab8643146d92 531,067,680 8.4 s 341 MiB
Llama-3.2-1B-Instruct 1ff795ff6a07e6a68085d206fb84417da2f083f68391c2843cd2b8ac6df8538f F16 8b3ec19869adfb6d1515041b928720764ef894e287396b3e269935d0add84334 2,479,594,976 24.0 s 641 MiB
same Q8_0 48d614953a561000d040c053f474732a57840f7ab64eaebac935c5adb6c187d7 1,321,082,336 21.6 s 640 MiB
SmolLM2-135M-Instruct 5af571cbf074e6d21a03528d2330792e532ca608f24ac70a143f6b369968ab8c F16 e83fd8cb757228fe3dd7222015ba1d3a15479a706faa106b470c38c8e7320951 270,885,664 3.2 s 87 MiB
same Q8_0 b565cdb1046ed2bc9959f6a09f33cf4d9174df519c560a6b0c395fbca59905ae 144,810,784 3.1 s 86 MiB

The Qwen2.5 and Llama-3.2 files are byte-identical to the ones measured in the table above (and to the previous commit's output), and the SmolLM2 rows of that table were measured on these two SmolLM2 files, so all of those results apply to this commit. Other jobs may have been running on the machine, so the wall times are indicative only.

Tests

At this commit, on the same machine:

Command Result
cargo fmt --all -- --check pass
cargo test --locked -p ruvllm --lib gguf::writer:: 43 passed
cargo test --locked -p ruvllm-cli --bin ruvllm commands::quantize:: 7 passed
cargo clippy --locked -p ruvllm -p ruvllm-cli --all-targets -- -W warnings pass, no warnings in the changed files

The writer is a new ruvllm::gguf::writer module. Its unit tests cover:

  • NFC and null normalizers producing byte-identical files, and other normalizers being refused with no output;
  • rope_freqs.weight matching llama.cpp's values bit for bit, legacy type / default fields, and unsupported rope types or unknown keys being refused with no output;
  • added-token typing, including the six Qwen FIM/repo tokens (CONTROL) and the tool-call tags (USER_DEFINED), and numeric special-token ids keeping their token type;
  • the SmolLM2 pre-tokenizer and a full conversion of a reduced SmolLM2-shaped checkpoint, and other Digits arrangements being refused with no output;
  • Q8_0 token embeddings by default, with explicit F16 retention through both the library options and the CLI flag;
  • input authentication, sharded checkpoints, truncated or malformed input, and atomic publication.

For the normalizer, rope, token-type, embedding and SmolLM2 rules, ten of these tests were checked against sixteen individual code changes they cover: with each change reverted its test failed, and with it restored the test passed.

CI

The red jobs on recent pull requests (Tests (core-and-rest), Tests (core-platform), Tests (core-and-rest-heavy)) also fail on main and are tracked in #1178. cargo check -p ruvllm --no-default-features fails on main with the same Tokio error before and after this change; #1200 fixes that.

Refs #968

`ruvllm quantize` now converts a SafeTensors checkpoint (a single file
or a sharded directory, F32/F16/BF16) of the llama and qwen2
architectures to GGUF F16 or Q8_0, with model and tokenizer metadata.
NFC normalizers and llama3 rope scaling are accepted; the latter adds
the rope_freqs.weight tensor llama.cpp expects. Added tokens that look
special are marked CONTROL, and Q8_0 output quantizes the token
embeddings by default (--keep-embed-fp16 keeps them in F16).

The output is written to a temporary file and published atomically,
so a failed run leaves no output. Unsupported architectures, tokenizers,
config features and formats are refused before any output exists;
GGUF input and the K-quant formats still fail closed.

Refs ruvnet#968
Accept the pre-tokenizer sequence SmolLM2 uses (Digits with
individual_digits, then ByteLevel) and write it as the smollm
pre-tokenizer. Any other Digits arrangement is still refused before
output exists.

Numeric BOS/EOS/PAD/UNK token ids no longer overwrite the token type
taken from the vocabulary, matching llama.cpp's converter. This keeps
an added special unknown token as CONTROL; Qwen2.5 and Llama-3.2
outputs are unchanged.

Refs ruvnet#968
@ruvnet

ruvnet commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Held back for review, not merged. It combines cleanly with the other ruvllm PRs and the suite passes (1656 lib tests including the 1380 lines of writer tests), but it adds ~8k lines of new GGUF writer code and has no linked issue, so I did not merge it unreviewed. Note clippy on rustc 1.98 flags is_multiple_of (writer/mod.rs:463) and two chunks_exact constant-size uses (writer/tests.rs:277,466) under -D warnings; CI clippy uses -W warnings, and is_multiple_of is newer than the Rust 1.88 baseline accepted in #1245, so those should be rewritten rather than switched to the new API.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants