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
14 changes: 10 additions & 4 deletions crates/skippy-model-package/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1189,16 +1189,22 @@ fn write_package_artifact(
);
let path = out_dir.join(&spec.relative_path);
write_stage_artifact(source, &stage, &path)?;
let relative_path = spec.relative_path.display().to_string();
run_artifact_hook(artifact_hook, &path, &relative_path)?;
let artifact_info = ModelInfo::open(&path)
.with_context(|| format!("open package artifact {}", path.display()))?;
let artifact_tensors = artifact_info
.tensors()
.with_context(|| format!("read package artifact tensors {}", path.display()))?;
let metadata = fs::metadata(&path)
.with_context(|| format!("read artifact metadata {}", path.display()))?;
let artifact = PackageArtifact {
path: spec.relative_path.display().to_string(),
tensor_count: stage.tensor_count,
tensor_bytes: stage.tensor_bytes,
path: relative_path,
tensor_count: artifact_tensors.len(),
tensor_bytes: artifact_tensors.iter().map(|tensor| tensor.byte_size).sum(),
artifact_bytes: metadata.len(),
sha256: file_sha256(&path)?,
};
run_artifact_hook(artifact_hook, &path, &artifact.path)?;
Ok(artifact)
}

Expand Down
53 changes: 49 additions & 4 deletions crates/skippy-quantize/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -406,10 +406,10 @@ Important quantization flags:
## Recipes

Top-level quantization modes intentionally mirror the pinned llama.cpp quant
table. Custom profile labels such as `UD-Q3_K_S` and `Q4_K_XL` are accepted as
recipe aliases when paired with `--tensor-type-file`. They resolve to the
corresponding base llama quant for backend execution while preserving the recipe
label in default output and sidecar names.
table. Custom profile names such as `Q2_K-MTP-Q8`, `UD-Q3_K_S`, or `Q4_K_XL`
belong in artifact names such as `--target-prefix` and `--output-basename`, not
in `--quant`. Pass the base llama quant with `--quant` and express any
per-tensor policy with `--tensor-type-file` or repeated `--tensor-type`.

The tensor recipe format is one override per line:

Expand All @@ -426,6 +426,51 @@ skippy-quantize list-quants --json
skippy-quantize list-tensor-types --json
```

## BF16 to layer package

For lab workflows, keep one reusable split BF16 GGUF artifact as the durable
source of truth, then build quantized layer packages from it. The quantized
GGUF shards can be treated as disposable staging once the package preflight
passes:

```bash
skippy-quantize quantize-layer-package \
--source /Users/lab/glm52-work/bf16-gguf \
--source-prefix BF16 \
--target /Users/lab/glm52-work/quantized \
--target-prefix Q2_K-MTP-Q8 \
--manifest /Users/lab/glm52-work/work/q2-k-mtp-q8-package/quant-manifest.json \
--package-dir /Users/lab/glm52-work/packages/GLM-5.2-Q2_K-MTP-Q8-layers \
--package-model-id meshllm/GLM-5.2-Q2_K-MTP-Q8-GGUF:Q2_K-MTP-Q8 \
--package-source-repo meshllm/GLM-5.2-Q2_K-MTP-Q8-GGUF \
--package-source-revision local \
--work-dir /Users/lab/glm52-work/work/q2-k-mtp-q8-package/native-work \
--spool-dir /Users/lab/glm52-work/work/q2-k-mtp-q8-package/spool \
--record-dir /Users/lab/glm52-work/work/q2-k-mtp-q8-package/records \
--json-event-file /Users/lab/glm52-work/work/q2-k-mtp-q8-package/status.json \
--quant Q2_K \
--tensor-type-file /Users/lab/glm52-work/recipes/glm-5.2-q2-k-mtp-q8.tensor-types.txt \
--output-basename GLM-5.2-Q2_K-MTP-Q8 \
--stages 2 \
--replace-package \
--watchdog-seconds 120
```

Build prerequisites:

```bash
just skippy-quantize-standalone-release-build
cargo build --release --locked -p skippy-model-package
```

The command validates the source split, writes the package artifacts from the
BF16 GGUF source, quantizes each artifact in place, then runs package preflight.
It does not materialize a complete quantized GGUF repo first. By default, the
temporary quant scratch directory is deleted after package preflight passes;
pass `--keep-quant` to retain it. It does not pass `--max-memory` to
quantization because the unpatched llama API quant backend does not expose a
memory-budget knob.

## Validation

Useful checks:
Expand Down
20 changes: 9 additions & 11 deletions crates/skippy-quantize/src/direct_quantize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -413,12 +413,13 @@ mod tests {
}

#[test]
fn parses_recipe_quant_label_for_direct_quantize() {
let parsed = parse_direct_quantize_positionals(&["UD-Q3_K_S".to_string()]).unwrap();
fn rejects_profile_quant_label_for_direct_quantize() {
let error = parse_direct_quantize_positionals(&["UD-Q3_K_S".to_string()]).unwrap_err();

assert_eq!(parsed.output, None);
assert_eq!(parsed.quant.base_quant(), QuantType::Q3KS);
assert_eq!(parsed.quant.output_name(), "UD-Q3_K_S");
assert!(
error.to_string().contains("custom tensor-type recipes"),
"profile labels should not be accepted as quant modes: {error}"
);
}

#[test]
Expand Down Expand Up @@ -611,12 +612,9 @@ mod tests {
PathBuf::from("ggml-model-Q2_K_S.gguf")
);
assert_eq!(
default_output_path(
Path::new("/repo/BF16/model.gguf"),
&"UD-Q3_K_S".parse::<QuantSpec>().unwrap()
)
.unwrap(),
PathBuf::from("/repo/BF16/ggml-model-UD-Q3_K_S.gguf")
default_output_path(Path::new("/repo/BF16/model.gguf"), &QuantType::Q3KS.into())
.unwrap(),
PathBuf::from("/repo/BF16/ggml-model-Q3_K_S.gguf")
);
}

Expand Down
115 changes: 115 additions & 0 deletions crates/skippy-quantize/src/gguf_template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ pub(crate) fn metadata_from_hf_config_with_options(
];
push_common_llm_metadata(&mut metadata, arch, &config, options)?;
push_attention_metadata(&mut metadata, arch, &config)?;
push_glm_dsa_indexer_metadata(&mut metadata, arch, &config)?;
push_moe_metadata(&mut metadata, arch, &config);
push_tokenizer_metadata(&mut metadata, source, &config)?;
if options.include_mtp {
Expand Down Expand Up @@ -220,6 +221,38 @@ fn push_attention_metadata(metadata: &mut Vec<GgufKv>, arch: &str, config: &Valu
Ok(())
}

fn push_glm_dsa_indexer_metadata(
metadata: &mut Vec<GgufKv>,
arch: &str,
config: &Value,
) -> Result<()> {
if arch != "glm-dsa" {
return Ok(());
}
push_required_first_u32(
metadata,
arch,
"attention.indexer.head_count",
config,
&["index_n_heads", "indexer_n_head"],
)?;
push_required_first_u32(
metadata,
arch,
"attention.indexer.key_length",
config,
&["index_head_dim", "indexer_head_size"],
)?;
push_required_first_u32(
metadata,
arch,
"attention.indexer.top_k",
config,
&["index_topk", "indexer_top_k"],
)?;
Ok(())
}

fn push_moe_metadata(metadata: &mut Vec<GgufKv>, arch: &str, config: &Value) {
push_first_u32(
metadata,
Expand Down Expand Up @@ -271,6 +304,26 @@ fn push_moe_metadata(metadata: &mut Vec<GgufKv>, arch: &str, config: &Value) {
}
}

fn push_required_first_u32(
metadata: &mut Vec<GgufKv>,
arch: &str,
gguf_suffix: &str,
config: &Value,
config_keys: &[&str],
) -> Result<()> {
for config_key in config_keys {
if let Some(value) = optional_u32(config, config_key) {
ensure!(
value > 0,
"config value {config_key:?} must be greater than zero"
);
metadata.push(GgufKv::u32(&format!("{arch}.{gguf_suffix}"), value));
return Ok(());
}
}
anyhow::bail!("config missing one of {config_keys:?}")
}

fn push_first_u32(
metadata: &mut Vec<GgufKv>,
arch: &str,
Expand Down Expand Up @@ -439,6 +492,68 @@ mod tests {
fs::remove_dir_all(root).unwrap();
}

#[test]
fn builds_glm_dsa_indexer_metadata_from_config() {
let root = unique_temp_dir();
fs::create_dir_all(&root).unwrap();
fs::write(
root.join("config.json"),
r#"{
"model_type": "glm_moe_dsa",
"vocab_size": 154880,
"max_position_embeddings": 1048576,
"hidden_size": 6144,
"intermediate_size": 12288,
"num_hidden_layers": 78,
"num_nextn_predict_layers": 1,
"num_attention_heads": 64,
"num_key_value_heads": 64,
"qk_nope_head_dim": 192,
"qk_rope_head_dim": 64,
"v_head_dim": 256,
"q_lora_rank": 2048,
"kv_lora_rank": 512,
"index_n_heads": 32,
"index_head_dim": 128,
"index_topk": 2048,
"n_routed_experts": 256,
"num_experts_per_tok": 8,
"n_shared_experts": 1,
"moe_intermediate_size": 2048,
"first_k_dense_replace": 3,
"routed_scaling_factor": 2.5,
"norm_topk_prob": true,
"rms_norm_eps": 1e-5
}"#,
)
.unwrap();

let metadata = metadata_from_hf_config(&root, 3).unwrap();

assert!(metadata.iter().any(|kv| {
matches!(
kv,
GgufKv::U32 { key, value }
if key == "glm-dsa.attention.indexer.head_count" && *value == 32
)
}));
assert!(metadata.iter().any(|kv| {
matches!(
kv,
GgufKv::U32 { key, value }
if key == "glm-dsa.attention.indexer.key_length" && *value == 128
)
}));
assert!(metadata.iter().any(|kv| {
matches!(
kv,
GgufKv::U32 { key, value }
if key == "glm-dsa.attention.indexer.top_k" && *value == 2048
)
}));
fs::remove_dir_all(root).unwrap();
}

#[test]
fn builds_llama_metadata_from_config() {
let root = unique_temp_dir();
Expand Down
75 changes: 62 additions & 13 deletions crates/skippy-quantize/src/gguf_writer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ fn prepare_raw_safetensors_gguf(
source.display()
);
let total_tensor_count = tensors.len();
let tensors = select_split_tensors(tensors, options.split)?;
let mut tensors = select_split_tensors(tensors, options.split)?;
assign_gguf_offsets(&mut tensors)?;
let metadata = options
.metadata
.clone()
Expand Down Expand Up @@ -161,10 +162,9 @@ fn select_split_tensors(
);
let split_index =
usize::try_from(split.split_index).context("split_index does not fit usize")?;
let split_count =
usize::try_from(split.split_count).context("split_count does not fit usize")?;
let start = (split_index - 1) * total_tensors / split_count;
let end = split_index * total_tensors / split_count;
let boundaries = byte_balanced_split_boundaries(&tensors, split)?;
let start = boundaries[split_index - 1];
let end = boundaries[split_index];
ensure!(
start < end,
"split {} of {} would contain no tensors",
Expand All @@ -178,6 +178,63 @@ fn select_split_tensors(
.collect())
}

fn byte_balanced_split_boundaries(
tensors: &[TensorSource],
split: GgufSplit,
) -> Result<Vec<usize>> {
split.validate()?;
let split_count =
usize::try_from(split.split_count).context("split_count does not fit usize")?;
ensure!(
split_count <= tensors.len(),
"split_count {} cannot exceed tensor count {}",
split.split_count,
tensors.len()
);
let total_bytes = tensors
.iter()
.try_fold(0_u128, |acc, tensor| {
acc.checked_add(tensor.byte_len as u128)
})
.context("split tensor byte total overflow")?;
let mut boundaries = vec![0_usize];
let mut accumulated = 0_u128;
for (index, tensor) in tensors.iter().enumerate() {
accumulated = accumulated
.checked_add(tensor.byte_len as u128)
.context("split tensor byte total overflow")?;
let remaining_tensors = tensors.len() - (index + 1);
let remaining_splits = split_count - boundaries.len();
if boundaries.len() < split_count && remaining_tensors >= remaining_splits {
let target = total_bytes
.checked_mul(boundaries.len() as u128)
.context("split target byte overflow")?
/ split_count as u128;
if accumulated >= target {
boundaries.push(index + 1);
}
}
}
while boundaries.len() < split_count {
let next = boundaries.last().copied().unwrap_or(0) + 1;
boundaries.push(next);
}
boundaries.push(tensors.len());
Ok(boundaries)
}

fn assign_gguf_offsets(tensors: &mut [TensorSource]) -> Result<()> {
let mut offset = 0_u64;
for tensor in tensors {
offset = align_to(offset, GGUF_ALIGNMENT);
tensor.gguf_offset = offset;
offset = offset
.checked_add(tensor.byte_len)
.with_context(|| format!("GGUF data offset overflow after {}", tensor.name))?;
}
Ok(())
}

fn split_metadata(
mut metadata: Vec<GgufKv>,
split: Option<GgufSplit>,
Expand Down Expand Up @@ -269,14 +326,6 @@ fn collect_tensor_sources(
tensors.push(group.into_tensor_source()?);
}
tensors.sort_by(|a, b| a.name.cmp(&b.name));
let mut offset = 0_u64;
for tensor in &mut tensors {
offset = align_to(offset, GGUF_ALIGNMENT);
tensor.gguf_offset = offset;
offset = offset
.checked_add(tensor.byte_len)
.with_context(|| format!("GGUF data offset overflow after {}", tensor.name))?;
}
Ok(tensors)
}

Expand Down
Loading
Loading