GGUF Export & Hugging Face Hub¶
OneComp can convert Hugging Face checkpoints (including OneComp save directories with dequantized FP16 weights) into the GGUF v3 format used by llama.cpp-based runtimes such as Ollama, and publish save directories to the Hugging Face Hub with a generated model card.
The GGUF writer is implemented inside OneComp with the standard library and
numpy only, so no extra dependency (such as the gguf package) is required.
Supported architectures¶
| Architecture | Tokenizer | Status |
|---|---|---|
| Llama (e.g. TinyLlama, Llama-2) | SentencePiece (tokenizer.model) |
✅ Verified (TinyLlama-1.1B, Llama-2-7B) |
| Llama-3 style | BPE (tokenizer.json, pre = llama-bpe) |
✅ Supported (tested with synthetic checkpoints) |
| Qwen2 (e.g. Qwen2.5) | BPE (tokenizer.json) |
✅ Verified (Qwen2.5-0.5B-Instruct) |
Llama-family checkpoints use tokenizer.model (SentencePiece) when it is
present next to the weights; otherwise the BPE vocabulary and merges are
extracted from tokenizer.json (Llama-3 style checkpoints ship only the
fast tokenizer). Qwen2 always uses the tokenizer.json path. The
SentencePiece and Qwen2 paths have been validated against the original
Hugging Face tokenizers (vocabulary, merges, and token-id round trips).
When config.json declares a list of eos_token_id (e.g. Llama-3.1's
<|end_of_text|> / <|eom_id|> / <|eot_id|>), the extra stop ids are
mapped onto tokenizer.ggml.eot_token_id / eom_token_id so llama.cpp
still terminates turns correctly. Tokenizers that define more tokens than
the model's embedding table (config.json's vocab_size) are rejected
instead of producing a GGUF that mis-tokenizes.
Only FP16/FP32 (and BF16, converted to F16) safetensors checkpoints can be
exported. For quantized OneComp models, save dequantized weights first
(e.g. with Runner.save_dequantized_model); checkpoints containing packed
quantized tensors are rejected.
Installation¶
pip install 'onecomp[llamacpp]' # installs gguf + llama-cpp-python
# or with uv:
uv sync --extra cpu --extra llamacpp
llama-cpp-python provides prebuilt CPU wheels, so no C++ toolchain is required
for inference. The direct export path additionally uses llama.cpp's pure-Python
convert_hf_to_gguf.py to build the model metadata/tokenizer; it is fetched
automatically (a shallow git clone) or taken from $LLAMA_CPP_DIR if set.
Python API¶
from onecomp import GGUFExportConfig, export_gguf
export_gguf(
"./TinyLlama-1.1B-Chat-v1.0", # HF model directory
GGUFExportConfig(out_path="./tinyllama-f16.gguf"),
)
GGUFExportConfig fields:
| Field | Default | Description |
|---|---|---|
out_path |
(required) | Output GGUF file path |
dtype |
"f16" |
Weight matrix dtype ("f16" or "f32"; 1-D tensors are always F32) |
architecture |
None |
"llama" / "qwen2", auto-detected from config.json when None |
name |
None |
general.name metadata, defaults to the directory name |
CLI¶
# Quantize, save, and additionally export a GGUF F16 file into the save dir
onecomp TinyLlama/TinyLlama-1.1B-Chat-v1.0 --save-dir ./tinyllama-quant --format gguf
# Quantize, save, and push to the Hugging Face Hub (private repo)
onecomp TinyLlama/TinyLlama-1.1B-Chat-v1.0 --save-dir ./tinyllama-quant \
--push-to-hub your-name/tinyllama-onecomp
Both options require an explicit --save-dir. The GGUF file is written as
<save-dir>/<save-dir-name>-f16.gguf.
With --format gguf, the CLI first writes dequantized FP16 weights to a
temporary directory (the save directory itself contains packed quantized
tensors, which GGUF F16 export cannot consume), converts them to GGUF, and
removes the temporary directory again. Expect transient disk usage of one
extra FP16 copy of the model during the conversion.
Using the exported model with vLLM¶
vLLM can serve the exported single-file GGUF directly. Since vLLM
0.24 the GGUF support lives in the out-of-tree
vllm-gguf-plugin
(pip install vllm-gguf-plugin); earlier versions bundle it in-tree.
Pass the original Hugging Face model directory as the tokenizer (the
tokenizer conversion from GGUF metadata is slow and less reliable),
and use dtype="float16" — the GGUF quantization method supports
FP16/FP32 only:
from vllm import LLM, SamplingParams
llm = LLM(
model="./tinyllama-f16.gguf", # absolute path recommended
tokenizer="./TinyLlama-1.1B-Chat-v1.0", # original HF directory
dtype="float16",
)
outputs = llm.generate(["The capital of France is"], SamplingParams(temperature=0.0))
Or with the OpenAI-compatible server:
Verified on vLLM 0.24.0 + vllm-gguf-plugin 0.0.2 (B200): TinyLlama-1.1B
F16 GGUF greedy generation matches the original HF checkpoint token for
token; Qwen2.5-0.5B F16 loads and generates equivalent-quality text
(exact token match is not expected across the vLLM and HF runtimes).
Known plugin issue (0.0.2, with vLLM 0.24): models whose embedding
layer is constructed without a module prefix (Llama, Qwen2) fail with
KeyError: 'embed_tokens.weight' because the F16 token_embd
tensor cannot be matched against unquantized_modules; force the
unquantized embedding method until the fix lands upstream:
from vllm.model_executor.layers.vocab_parallel_embedding import (
UnquantizedEmbeddingMethod,
VocabParallelEmbedding,
)
from vllm_gguf_plugin.quantization.config import GGUFConfig
_original = GGUFConfig.get_quant_method
def _patched(self, layer, prefix):
if isinstance(layer, VocabParallelEmbedding) and not prefix:
return UnquantizedEmbeddingMethod()
return _original(self, layer, prefix)
GGUFConfig.get_quant_method = _patched
Second plugin limitation (0.0.2, with vLLM 0.24), llama architecture
only: llama GGUF files store the Q/K projection rows in the interleaved
("NORM") RoPE order — the same permutation convert_hf_to_gguf.py and
export_gguf apply — but vLLM hardcodes neox-style RoPE for llama and
the plugin does not un-permute the rows on load, which degrades
generations. Switch the rotary embedding to interleaved style until
the plugin handles the permutation (qwen2 uses NEOX RoPE and needs no
patch):
from vllm.model_executor.layers.rotary_embedding import get_rope
from vllm.model_executor.models import llama
def _rotary_patched(self, config, quant_config):
self.rotary_emb = get_rope(
self.head_dim,
max_position=self.max_position_embeddings,
rope_parameters=getattr(config, "rope_parameters", None),
is_neox_style=False, # match the GGUF interleaved layout
)
llama.LlamaAttention._init_rotary_emb = _rotary_patched
With both patches applied, TinyLlama-1.1B F16 GGUF greedy generation under vLLM matches the original HF checkpoint token for token (verified on vLLM 0.24.0 + vllm-gguf-plugin 0.0.2, B200).
Using the exported model with Ollama¶
Create a Modelfile next to the exported GGUF file:
Then register and run the model:
Publishing to the Hugging Face Hub¶
from onecomp import generate_model_card, push_to_hub
card = generate_model_card(
"TinyLlama/TinyLlama-1.1B-Chat-v1.0",
recipe={"method": "AutoBit + QEP", "wbits": 4, "groupsize": 128},
results={"perplexity (wikitext2)": 8.12, "accuracy (lambada)": 0.65},
)
push_to_hub("./tinyllama-quant", "your-name/tinyllama-onecomp", model_card=card)
generate_model_card produces a Markdown card with YAML frontmatter
(license, base_model, tags: [onecomp, quantized, gptq]), a quantization
recipe table, and an evaluation results table. push_to_hub writes the card
as README.md (unless one already exists), creates the repository (private
by default), and uploads the whole save directory. Authentication uses the
token argument, the cached huggingface-cli login, or the HF_TOKEN
environment variable.
Validating an exported file¶
A lightweight reader is included for validation:
from onecomp.export import GGUFReader
reader = GGUFReader("./tinyllama-f16.gguf")
print(reader.metadata["general.architecture"]) # "llama"
print(len(reader.tensors)) # e.g. 201
print(reader.tensor("token_embd.weight").shape) # (32000, 2048)
Loading a GGUF file back into transformers¶
F16/F32 GGUF files produced by OneComp can be restored to a Hugging Face state dict, e.g. to double-check a conversion end to end:
import torch
from transformers import AutoConfig, AutoModelForCausalLM
from onecomp.export import load_gguf_state_dict
state_dict = load_gguf_state_dict("./tinyllama-f16.gguf")
config = AutoConfig.from_pretrained("./TinyLlama-1.1B-Chat-v1.0")
model = AutoModelForCausalLM.from_config(config).to(torch.float16)
model.load_state_dict(state_dict, strict=False) # lm_head is absent when tied
The restore is exact: every tensor is bit-identical to the original
checkpoint cast to float16 (verified for TinyLlama-1.1B and
Qwen2.5-0.5B, including greedy-generation token equality). For models
with tie_word_embeddings the file has no output.weight, so load
with strict=False and call model.tie_weights().
Implementation Notes¶
This section documents the internals of the export pipeline for contributors and for anyone auditing the produced files.
GGUF v3 file layout¶
The writer produces the standard little-endian GGUF v3 layout (specification):
offset 0
+----------------------------------------------------------+
| magic "GGUF" (u32 = 0x46554747) | version (u32 = 3) |
| tensor_count (u64) | metadata_kv_count (u64) |
+----------------------------------------------------------+
| metadata KV pairs (repeated kv_count times) |
| key: string (u64 length + UTF-8 bytes, no NUL) |
| value_type (u32) + payload |
| arrays: element_type (u32) + count (u64) + elements |
+----------------------------------------------------------+
| tensor infos (repeated tensor_count times) |
| name: string |
| n_dims (u32) + dims[n_dims] (u64, ne order: |
| fastest-varying dimension first) |
| ggml_type (u32: 0 = F32, 1 = F16) |
| offset (u64, relative to the data section start, |
| multiple of general.alignment) |
+----------------------------------------------------------+
| zero padding up to the next multiple of general.alignment |
+----------------------------------------------------------+
| tensor data, each tensor padded to general.alignment |
+----------------------------------------------------------+
Key points that are easy to get wrong and are covered by tests:
- Metadata value-type codes follow the specification exactly
(
UINT8 = 0...FLOAT64 = 12; notablyBOOL = 7,STRING = 8,ARRAY = 9, and the 64-bit types 10-12 afterARRAY). - Strings are a
u64byte length followed by UTF-8 bytes with no NUL terminator, both for metadata keys/values and tensor names. - Tensor dimensions are stored in
neorder (fastest-varying first), i.e. the reverse of the numpy/PyTorch shape: a(32000, 2048)HF embedding is written asdims = [2048, 32000]. - Tensor offsets are relative to the start of the data section, which
begins at the first multiple of
general.alignment(default 32) after the last tensor info. Every tensor is zero-padded to the alignment.
Why a built-in writer and reader¶
The writer (onecomp/export/gguf_writer.py, ~280 lines) and the
validation reader (onecomp/export/gguf_reader.py) only use struct
and numpy, so onecomp gains GGUF interoperability without adding the
gguf package (and its version churn) as a dependency. Only the F32
and F16 tensor types are implemented because OneComp exports
dequantized checkpoints; quantized GGUF types (Q4_K, i-quants, ...)
are produced by re-quantizing the F16 file with llama-quantize.
The reader is intentionally minimal (header, metadata, tensor directory, on-demand tensor loads with offset/alignment validation) and is used by the test-suite; it is not a general-purpose GGUF loader.
SentencePiece parser coverage¶
Llama-family vocabularies are read directly from the serialized
sentencepiece.ModelProto (tokenizer.model) with a minimal protobuf
wire-format parser, so the sentencepiece package is not required.
The parser understands all four wire types used by protobuf
(varint, fixed64, length-delimited, fixed32), skips unknown fields, and
decodes only ModelProto.pieces (field 1) with its piece (string),
score (float, default 0.0), and type (varint, default NORMAL)
subfields. Piece types map 1:1 to GGUF token types
(NORMAL = 1, UNKNOWN = 2, CONTROL = 3, USER_DEFINED = 4,
UNUSED = 5, BYTE = 6), so byte-fallback tokens (<0x00>...<0xFF>)
keep type BYTE.
Added tokens that extend the base SentencePiece vocabulary
(added_tokens in tokenizer.json, or added_tokens.json) are merged
by id before padding; ids already covered by the SentencePiece model
are left untouched. Remaining ids up to config.vocab_size are filled
with [PAD<n>] placeholders of type UNUSED.
Tokenizer metadata by architecture¶
| Metadata key | Llama (SentencePiece) | Qwen2 (BPE) |
|---|---|---|
tokenizer.ggml.model |
llama |
gpt2 |
tokenizer.ggml.pre |
(not written) | qwen2 |
tokenizer.ggml.tokens |
pieces by id, padded to vocab_size |
vocab by id + added tokens, padded to vocab_size (e.g. 151936 vs 151665 real tokens for Qwen2.5) |
tokenizer.ggml.scores |
piece scores | (not written) |
tokenizer.ggml.token_type |
piece types from the model | NORMAL, added specials CONTROL, others USER_DEFINED |
tokenizer.ggml.merges |
(not written) | "left right" strings in tokenizer.json order |
tokenizer.ggml.unknown_token_id |
id of the UNKNOWN piece |
(not written) |
tokenizer.ggml.bos_token_id / eos_token_id |
from config.json |
from config.json (first entry if eos_token_id is a list) |
tokenizer.ggml.add_bos_token / add_eos_token |
from tokenizer_config.json when boolean |
same |
tokenizer.chat_template |
from tokenizer_config.json when present |
same |
Tensor name mapping¶
| Hugging Face name | GGUF name |
|---|---|
model.embed_tokens.weight |
token_embd.weight |
model.norm.weight |
output_norm.weight |
lm_head.weight |
output.weight (omitted when tie_word_embeddings is true; llama.cpp falls back to token_embd.weight) |
model.layers.<i>.self_attn.{q,k,v}_proj.{weight,bias} |
blk.<i>.attn_{q,k,v}.{weight,bias} |
model.layers.<i>.self_attn.o_proj.weight |
blk.<i>.attn_output.weight |
model.layers.<i>.mlp.{gate,up,down}_proj.weight |
blk.<i>.ffn_{gate,up,down}.weight |
model.layers.<i>.input_layernorm.weight |
blk.<i>.attn_norm.weight |
model.layers.<i>.post_attention_layernorm.weight |
blk.<i>.ffn_norm.weight |
*rotary_emb.inv_freq |
skipped (recomputed by the runtime) |
Any other tensor name raises a ValueError instead of being silently
dropped, so unsupported architectures fail fast. 1-D tensors (norms,
biases) are always stored as F32; 2-D weight matrices use the
configured dtype. BF16 sources are clamped to the finite F16 range
(±65504) before the narrowing cast so that no ±inf values reach the
file.
Known limitations and future work¶
- Only F16/F32 output types; no direct quantized GGUF output
(
Q4_K_M,Q8_0, i-quants, ...). Usellama-quantizeon the exported F16 file. - Architectures:
llamaandqwen2only. Notably Qwen3 (q_norm/k_normtensors) and multimodal models are not mapped yet. - RoPE scaling metadata (
rope.scaling.*) is not exported; models relying on YaRN/linear scaling will use their base context window (a warning is logged whenconfig.jsondeclaresrope_scaling). - Packed OneComp checkpoints (GPTQ
qweight/qzerosetc.) are rejected with a pointer toRunner.save_dequantized_model; the CLI handles this automatically via a temporary dequantized copy.