chore: import upstream snapshot with attribution
Book-CI / test (macos-latest) (push) Has been cancelled
Book-CI / test (ubuntu-latest) (push) Has been cancelled
Book-CI / test (windows-latest) (push) Has been cancelled
Release Fake Tag / publish (push) Has been cancelled
Deploy / deploy (macos-latest) (push) Has been cancelled
Deploy / deploy (ubuntu-latest) (push) Has been cancelled
Deploy / deploy (windows-latest) (push) Has been cancelled
Release to PyPI / Build & publish sglang-kt (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.11) (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.12) (push) Has been cancelled
Release to PyPI / Publish kt-kernel to PyPI (push) Has been cancelled
Book-CI / test (macos-latest) (push) Has been cancelled
Book-CI / test (ubuntu-latest) (push) Has been cancelled
Book-CI / test (windows-latest) (push) Has been cancelled
Release Fake Tag / publish (push) Has been cancelled
Deploy / deploy (macos-latest) (push) Has been cancelled
Deploy / deploy (ubuntu-latest) (push) Has been cancelled
Deploy / deploy (windows-latest) (push) Has been cancelled
Release to PyPI / Build & publish sglang-kt (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.11) (push) Has been cancelled
Release to PyPI / Build kt-kernel (Python 3.12) (push) Has been cancelled
Release to PyPI / Publish kt-kernel to PyPI (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,454 @@
|
||||
# Weight Quantization Tools
|
||||
|
||||
KT-Kernel provides weight conversion tools for CPU-GPU hybrid inference (e.g., integrating KTransformers with SGLang). Both tools work together to enable heterogeneous expert placement:
|
||||
|
||||
- **CPU Weights (`convert_cpu_weights.py`)**: Quantize weights to INT4/INT8 with AMX optimization for CPU-resident "cold" experts
|
||||
- **GPU Weights (`convert_gpu_weights.py`)**: Apply GPTQ/RTN quantization (W4A16/W8A16) for GPU-resident "hot" experts
|
||||
- **KT Fused Expert LoRA (`convert_kt_to_sglang_adapter.py`)**: Convert KT SFT fused expert LoRA checkpoints into adapter-only SafeTensors directories
|
||||
|
||||
---
|
||||
|
||||
## KT Fused Expert LoRA Adapter Conversion
|
||||
|
||||
KT SFT fused expert LoRA saves MoE expert LoRA tensors in `fused_expert_lora.safetensors` using compact 3D tensors:
|
||||
|
||||
```
|
||||
layers.{L}.experts.gate_lora_a
|
||||
layers.{L}.experts.gate_lora_b
|
||||
layers.{L}.experts.up_lora_a
|
||||
layers.{L}.experts.up_lora_b
|
||||
layers.{L}.experts.down_lora_a
|
||||
layers.{L}.experts.down_lora_b
|
||||
```
|
||||
|
||||
Use `convert_kt_to_sglang_adapter.py` to convert raw KT SFT output into one merged SGLang adapter directory:
|
||||
|
||||
```bash
|
||||
python scripts/convert_kt_to_sglang_adapter.py /path/to/kt_adapter /path/to/sglang_adapter \
|
||||
--base-model-name-or-path /path/to/base_model \
|
||||
--lora-alpha 16 \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
sglang_adapter/
|
||||
├── adapter_config.json
|
||||
└── adapter_model.safetensors
|
||||
```
|
||||
|
||||
The converter merges the existing non-expert `adapter_model.safetensors` with expanded expert tensors from `fused_expert_lora.safetensors`. Pass this merged directory to SGLang with:
|
||||
|
||||
```bash
|
||||
--enable-lora \
|
||||
--lora-paths my_lora=/path/to/sglang_adapter
|
||||
```
|
||||
|
||||
The KTransformers SGLang fork will auto-split the merged adapter internally at server startup. Users do not need to pass separate expert and non-expert adapter paths in the normal workflow.
|
||||
|
||||
Optional split outputs for debugging:
|
||||
|
||||
```bash
|
||||
python scripts/convert_kt_to_sglang_adapter.py /path/to/kt_adapter /path/to/sglang_adapter \
|
||||
--base-model-name-or-path /path/to/base_model \
|
||||
--expert-output-dir /path/to/expert_adapter \
|
||||
--nonexpert-output-dir /path/to/nonexpert_adapter \
|
||||
--overwrite
|
||||
```
|
||||
|
||||
Existing PEFT prefixes such as `base_model.model.` are stripped to match SGLang's loader. Scaling is not folded into the LoRA B tensors. Runtime scaling remains `lora_alpha / r`; if the input directory has no `adapter_config.json`, pass `--lora-alpha` explicitly.
|
||||
|
||||
This script only converts adapter files. Serving compatibility depends on the KTransformers SGLang runtime branch being used.
|
||||
|
||||
### Optional Integration Validation
|
||||
|
||||
The unit tests use synthetic tensors and run without model files. To validate a real KT adapter directory, set these environment variables:
|
||||
|
||||
```bash
|
||||
export KT_LORA_ADAPTER_DIR=/path/to/kt_adapter
|
||||
export KT_LORA_BASE_MODEL=/path/to/base_model
|
||||
export KT_LORA_ALPHA=16 # required only if the input has no adapter_config.json
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
python -m pytest kt-kernel/test/per_commit/test_convert_kt_to_sglang_adapter_integration.py -q
|
||||
```
|
||||
|
||||
To run a large adapter conversion smoke test, also set:
|
||||
|
||||
```bash
|
||||
export KT_LORA_LARGE_ADAPTER_DIR=/path/to/large_kt_adapter
|
||||
```
|
||||
|
||||
These integration tests check real fused tensor splitting, optional `adapter_model.safetensors` merging, `adapter_config.json` compatibility with `sglang.srt.lora.lora_config.LoRAConfig`, and large-file readability. They intentionally do not start an SGLang server or validate runtime `FusedMoE` LoRA application.
|
||||
|
||||
---
|
||||
|
||||
## CPU Weight Quantization
|
||||
|
||||
Convert weights to INT4/INT8 format optimized for AMX inference on CPU. These quantized weights are used for "cold" experts (less frequently accessed) that run on CPU in hybrid inference scenarios.
|
||||
|
||||
### Quantization Methods
|
||||
|
||||
- **INT4**: 4-bit quantization for maximum memory efficiency
|
||||
- **INT8**: 8-bit quantization for better accuracy
|
||||
|
||||
### Supported Input Formats
|
||||
|
||||
- **FP8**: 8-bit floating point with automatic dequantization
|
||||
- **FP16**: 16-bit floating point
|
||||
- **BF16**: BFloat16 format
|
||||
|
||||
> **⚠️ Precision Warning:** Quantizing directly from FP8 to INT4/INT8 may cause significant accuracy degradation. For best results, use the original **BF16** model as the source for INT4/INT8 quantization.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Quantize BF16 model to INT4
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /path/to/bf16/model \
|
||||
--input-type bf16 \
|
||||
--output /path/to/output \
|
||||
--quant-method int4
|
||||
```
|
||||
|
||||
### Quantize FP16 model to INT8
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /path/to/fp16/model \
|
||||
--input-type fp16 \
|
||||
--output /path/to/output \
|
||||
--quant-method int8
|
||||
```
|
||||
|
||||
### Quantize FP8 model to INT4
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /path/to/fp8/model \
|
||||
--input-type fp8 \
|
||||
--output /path/to/output \
|
||||
--quant-method int4
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
By default, the converted weights are saved in SafeTensors format with NUMA-aware layout:
|
||||
|
||||
```
|
||||
output_dir/
|
||||
├── model-00001-of-00050.safetensors
|
||||
├── model-00002-of-00050.safetensors
|
||||
├── ...
|
||||
├── config.json
|
||||
└── tokenizer files...
|
||||
```
|
||||
|
||||
Each expert's weights are split across NUMA nodes for optimal memory access:
|
||||
- `blk.{layer}.ffn_{proj}_exps.{expert}.numa.{numa_idx}.weight`: Quantized weights
|
||||
- `blk.{layer}.ffn_{proj}_exps.{expert}.numa.{numa_idx}.scale`: Quantization scales
|
||||
|
||||
## Advanced Options
|
||||
|
||||
### Low Memory Mode
|
||||
|
||||
For systems with insufficient memory to complete full model quantization, use the `--no-merge-safetensor` flag to keep weights in layer folder structure without merging into safetensor files:
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /path/to/model \
|
||||
--input-type bf16 \
|
||||
--output /path/to/output \
|
||||
--quant-method int4 \
|
||||
--no-merge-safetensor
|
||||
```
|
||||
|
||||
This will save quantized weights in the following folder structure:
|
||||
|
||||
```
|
||||
output_dir/
|
||||
├── _layer_0/
|
||||
│ ├── _numa_0/
|
||||
│ │ ├── INT4_down_0_*.kt
|
||||
│ │ ├── INT4_gate_0_*.kt
|
||||
│ │ └── INT4_up_0_*.kt
|
||||
│ └── _numa_1/
|
||||
│ └── ...
|
||||
├── _layer_1/
|
||||
│ └── ...
|
||||
└── ...
|
||||
```
|
||||
|
||||
**When to use `--no-merge-safetensor`:**
|
||||
- Machine runs out of memory during the merge step
|
||||
- Need to process very large models on memory-constrained systems
|
||||
- Want to preserve intermediate layer-wise quantized weights
|
||||
|
||||
### Resume Layer
|
||||
|
||||
For memory-constrained systems that are unable to complete quantization despite enabling low memory mode with `--no-merge-safetensor`, restart the script with the `--resume-layer` arg to specify the layer from which to continue the conversion process. In the example below, we skip layers 0-11 and resume conversion starting with layer 12.
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /path/to/model \
|
||||
--input-type bf16 \
|
||||
--output /path/to/output \
|
||||
--quant-method int4 \
|
||||
--no-merge-safetensor
|
||||
--resume-layer 12
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: Quantize DeepSeek-V3.1 (FP8 → INT4)
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /mnt/data/models/DeepSeek-V3.1 \
|
||||
--input-type fp8 \
|
||||
--output /mnt/data/models/DeepSeek-V3.1-INT4 \
|
||||
--quant-method int4 \
|
||||
--cpuinfer-threads 60 \
|
||||
--threadpool-count 2
|
||||
```
|
||||
|
||||
### Example 2: Quantize Qwen3-Next-80B (BF16 → INT4, Low Memory)
|
||||
|
||||
```bash
|
||||
python scripts/convert_cpu_weights.py \
|
||||
--input-path /mnt/data/models/Qwen3-Next-80B-A3B-Instruct \
|
||||
--input-type bf16 \
|
||||
--output /mnt/data/models/Qwen3-Next-80B-A3B-Instruct-INT4 \
|
||||
--quant-method int4 \
|
||||
--cpuinfer-threads 60 \
|
||||
--threadpool-count 2 \
|
||||
--no-merge-safetensor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GPU Weight Quantization
|
||||
|
||||
### Prerequisites
|
||||
|
||||
GPU weight quantization requires additional dependencies. Install them before proceeding:
|
||||
|
||||
```bash
|
||||
pip install accelerate transformers llmcompressor datasets
|
||||
```
|
||||
|
||||
**Required packages:**
|
||||
- `accelerate`: For distributed model loading and device mapping
|
||||
- `transformers`: For model and tokenizer loading
|
||||
- `llmcompressor`: For quantization (supports GPTQ and RTN methods)
|
||||
- `datasets`: For calibration data loading (GPTQ only)
|
||||
|
||||
**Documentation:** This tool is based on llmcompressor. For more details, see [llmcompressor quantization guide](https://docs.vllm.ai/projects/llm-compressor/en/latest/getting-started/compress/#select-a-quantization-method-and-scheme).
|
||||
|
||||
### Overview
|
||||
|
||||
Apply weight quantization to model weights for GPU-resident "hot" experts (frequently accessed) in CPU-GPU hybrid inference. This tool works together with `convert_cpu_weights.py` to enable heterogeneous expert placement:
|
||||
|
||||
- **GPU-resident experts** ("hot" experts) use GPTQ/RTN quantization (this tool) for efficient GPU memory usage
|
||||
- **CPU-resident experts** ("cold" experts) use AMX-optimized INT4/INT8 quantization (convert_cpu_weights.py)
|
||||
- **Attention layers, gates, and shared experts** remain in higher precision
|
||||
|
||||
This approach maximizes throughput and resource utilization by intelligently distributing experts across CPUs and GPUs.
|
||||
|
||||
### Quantization Methods
|
||||
|
||||
#### 1. GPTQ (Calibration-based, Default)
|
||||
**Pros:**
|
||||
- Higher accuracy through calibration-based quantization
|
||||
- Recommended for production deployments
|
||||
|
||||
**Cons:**
|
||||
- Requires calibration dataset
|
||||
- Slower quantization process
|
||||
- Higher memory requirements (needs Hessian matrix)
|
||||
|
||||
#### 2. RTN (Round-To-Nearest)
|
||||
**Pros:**
|
||||
- Fast quantization (no calibration needed)
|
||||
- Lower memory requirements
|
||||
- Good for quick testing and prototyping
|
||||
|
||||
**Cons:**
|
||||
- Slightly lower accuracy compared to GPTQ
|
||||
- No calibration optimization
|
||||
|
||||
### Quantization Types
|
||||
|
||||
- **W4A16**: 4-bit weights, 16-bit activations (INT4)
|
||||
- **W8A16**: 8-bit weights, 16-bit activations (INT8)
|
||||
|
||||
### Basic Usage
|
||||
|
||||
#### GPTQ Quantization (Recommended for Production)
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /path/to/model \
|
||||
--output_dir /path/to/output \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W4A16
|
||||
```
|
||||
|
||||
#### RTN Quantization (Fast, for Testing)
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /path/to/model \
|
||||
--output_dir /path/to/output \
|
||||
--quant_method RTN \
|
||||
--quant_type W4A16
|
||||
```
|
||||
|
||||
### Memory Requirements
|
||||
|
||||
Understanding memory requirements is crucial for successful quantization. The requirements differ significantly between RTN and GPTQ methods.
|
||||
|
||||
#### RTN Memory Requirements
|
||||
|
||||
RTN only requires memory for quantization parameters (scales/zero-points):
|
||||
|
||||
| Component | Requirement |
|
||||
|-----------|-------------|
|
||||
| **DRAM (CPU Memory)** | ≥ Total model parameters |
|
||||
| **VRAM (GPU Memory)** | ≥ Single layer parameters |
|
||||
|
||||
**Example: DeepSeek-R1-0528-BF16 (684B parameters)**
|
||||
- DRAM: ~1368 GB (684B params × 2 bytes)
|
||||
- VRAM: ~22.4 GB (1 layer)
|
||||
|
||||
#### GPTQ Memory Requirements
|
||||
|
||||
GPTQ requires additional memory for Hessian matrices during calibration:
|
||||
|
||||
| Component | Requirement |
|
||||
|-----------|-------------|
|
||||
| **DRAM (CPU Memory)** | ≥ Total model parameters |
|
||||
| **VRAM (GPU Memory)** | ≥ Single layer parameters × 2 |
|
||||
|
||||
The Hessian matrix is approximately the same size as the layer weights and is used to increase accuracy recovery.
|
||||
|
||||
**Example: DeepSeek-R1-0528-BF16 (684B parameters)**
|
||||
- DRAM: ~1368 GB (684B params × 2 bytes)
|
||||
- VRAM: ~44.8 GB (1 layer × 2 for Hessian matrix)
|
||||
|
||||
#### Method Comparison
|
||||
|
||||
| Method | Speed | VRAM | Accuracy | Use Case |
|
||||
|--------|-------|------|----------|----------|
|
||||
| **RTN** | Fast | Low (~22GB) | Good | Testing, prototyping |
|
||||
| **GPTQ** | Slow | High (~45GB) | Better | Production deployment |
|
||||
|
||||
### Advanced Options
|
||||
|
||||
#### Calibration Configuration (GPTQ Only)
|
||||
|
||||
For GPTQ quantization, control the calibration process for better quantization quality:
|
||||
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /path/to/model \
|
||||
--output_dir /path/to/output \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W4A16 \
|
||||
--num_calibration_samples 512 \
|
||||
--max_sequence_length 2048 \
|
||||
--dataset HuggingFaceH4/ultrachat_200k \
|
||||
--dataset_split train_sft
|
||||
```
|
||||
|
||||
**Options (GPTQ only):**
|
||||
- `--num_calibration_samples`: Number of samples for calibration (default: 512)
|
||||
- `--max_sequence_length`: Maximum sequence length (default: 2048)
|
||||
- `--dataset`: HuggingFace dataset for calibration
|
||||
- `--dataset_split`: Dataset split to use
|
||||
- `--dampening_frac`: Dampening fraction to reduce quantization noise (default: 0.1)
|
||||
|
||||
#### Memory Management
|
||||
|
||||
Use `--max_gpu_memory` to limit GPU memory usage and offload remaining layers to CPU:
|
||||
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /path/to/model \
|
||||
--output_dir /path/to/output \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W4A16 \
|
||||
--max_gpu_memory "40GiB"
|
||||
```
|
||||
|
||||
**Recommended settings for GPTQ:**
|
||||
|
||||
| GPU VRAM | Suggested `--max_gpu_memory` | Notes |
|
||||
|----------|------------------------------|-------|
|
||||
| 24 GiB | 10-12 GiB | Reserve ~50% for Hessian |
|
||||
| 48 GiB | 24-30 GiB | Reserve ~40% for Hessian |
|
||||
| 80 GiB | 40-50 GiB | Reserve ~40% for Hessian |
|
||||
|
||||
**Recommended settings for RTN:**
|
||||
|
||||
| GPU VRAM | Suggested `--max_gpu_memory` | Notes |
|
||||
|----------|------------------------------|-------|
|
||||
| 24 GiB | 18-20 GiB | No Hessian needed |
|
||||
| 48 GiB | 40-45 GiB | No Hessian needed |
|
||||
| 80 GiB | 70-75 GiB | No Hessian needed |
|
||||
|
||||
**Options:**
|
||||
- `--max_gpu_memory`: Maximum GPU memory for model weights per device (e.g., '40GiB')
|
||||
- `--max_cpu_memory`: Maximum CPU memory (default: 1000GiB when `--max_gpu_memory` is set)
|
||||
|
||||
**Important:** llmcompressor does not support disk offloading. Ensure your machine has enough GPU + CPU memory to load the entire model. If you still encounter OOM:
|
||||
1. Use RTN instead of GPTQ (requires less memory)
|
||||
2. Reduce `--num_calibration_samples` (GPTQ only, e.g., 256)
|
||||
3. Reduce `--max_sequence_length` (GPTQ only, e.g., 1024)
|
||||
4. Use `--force_cpu` to run entirely on CPU (slower but avoids GPU OOM)
|
||||
|
||||
### Examples
|
||||
|
||||
#### Example 1: GPTQ Quantization for Production (Qwen3-Next-80B, W4A16)
|
||||
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /mnt/data/models/Qwen3-Next-80B-A3B-Instruct \
|
||||
--output_dir /mnt/data/models/Qwen3-Next-80B-A3B-Instruct-GPTQ-W4A16 \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W4A16 \
|
||||
--num_calibration_samples 512 \
|
||||
--max_sequence_length 2048 \
|
||||
--max_gpu_memory "40GiB" \
|
||||
--trust_remote_code
|
||||
```
|
||||
|
||||
#### Example 2: RTN Quantization for Fast Testing (DeepSeek-R1, W4A16)
|
||||
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /mnt/data/models/DeepSeek-R1-0528-BF16 \
|
||||
--output_dir /mnt/data/models/DeepSeek-R1-0528-RTN-W4A16 \
|
||||
--quant_method RTN \
|
||||
--quant_type W4A16 \
|
||||
--max_gpu_memory "70GiB" \
|
||||
--trust_remote_code
|
||||
```
|
||||
|
||||
#### Example 3: GPTQ with Custom Calibration Dataset (GLM-4.5-Air, W8A16)
|
||||
|
||||
```bash
|
||||
python scripts/convert_gpu_weights.py \
|
||||
--model_id /mnt/data/models/GLM-4.5-Air \
|
||||
--output_dir /mnt/data/models/GLM-4.5-Air-GPTQ-W8A16 \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W8A16 \
|
||||
--dataset "tatsu-lab/alpaca" \
|
||||
--dataset_split "train" \
|
||||
--num_calibration_samples 256 \
|
||||
--max_gpu_memory "40GiB" \
|
||||
--trust_remote_code
|
||||
```
|
||||
@@ -0,0 +1,277 @@
|
||||
import os
|
||||
|
||||
# insert the path of the project
|
||||
import sys
|
||||
|
||||
# sys.path.insert(0, "/home/azure/ktransformers")
|
||||
import argparse
|
||||
import torch
|
||||
from safetensors import safe_open
|
||||
from safetensors.torch import save_file
|
||||
import re
|
||||
from collections import defaultdict
|
||||
import itertools
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
|
||||
tensor_from_amx = [".mlp.experts."] # todo: add keys in gguf that should be used in the final tensor
|
||||
|
||||
|
||||
def safe_open_binary_to_tensor(file_path):
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
if not os.access(file_path, os.R_OK):
|
||||
raise PermissionError(f"没有权限读取文件: {file_path}")
|
||||
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
binary_data = f.read()
|
||||
|
||||
np_array = np.frombuffer(binary_data, dtype=np.int8)
|
||||
|
||||
tensor = torch.from_numpy(np_array)
|
||||
|
||||
return tensor
|
||||
|
||||
except Exception as e:
|
||||
raise IOError(f"file process error: {str(e)}")
|
||||
|
||||
|
||||
def read_safetensor_keys_from_folder(folder_path) -> dict:
|
||||
"""
|
||||
:param folder_path: folder path
|
||||
:return: key_to_file_map
|
||||
"""
|
||||
# check if the folder path is exist
|
||||
if not os.path.exists(folder_path):
|
||||
raise FileNotFoundError(f"GGUF dir not found: {folder_path}")
|
||||
if os.path.isfile(folder_path):
|
||||
folder_path = os.path.dirname(folder_path)
|
||||
|
||||
key_to_file_map = {}
|
||||
|
||||
found_safetensor = False
|
||||
for root, dirs, files in os.walk(folder_path):
|
||||
# sort files
|
||||
files = sorted(files)
|
||||
for file in files:
|
||||
if file.endswith(".safetensors"):
|
||||
found_safetensor = True
|
||||
file_path = os.path.join(root, file)
|
||||
try:
|
||||
with safe_open(file_path, framework="pt") as f:
|
||||
for key in f.keys():
|
||||
if "model.layers.61" in key:
|
||||
# skip MTP layer
|
||||
continue
|
||||
# try:
|
||||
# if int(key.split('.')[2]) > 4:
|
||||
# continue
|
||||
# except:
|
||||
# pass
|
||||
key_to_file_map[key] = file_path
|
||||
except Exception as e:
|
||||
print(f"Error reading Safetensor file {file_path}: {e}")
|
||||
|
||||
if not found_safetensor:
|
||||
raise FileNotFoundError(f"No Safetensor files found in {folder_path}")
|
||||
|
||||
return key_to_file_map
|
||||
|
||||
|
||||
def read_amx_tensor_from_folder(folder_path, keys) -> dict:
|
||||
layer_list = [f"_layer_{i}" for i in range(3, 61)]
|
||||
numa_list = ["_numa_0", "_numa_1"]
|
||||
|
||||
down_list = [f"INT4_down_{i}_quant_.kt" for i in range(256)]
|
||||
gate_list = [f"INT4_gate_{i}_quant_.kt" for i in range(256)]
|
||||
up_list = [f"INT4_up_{i}_quant_.kt" for i in range(256)]
|
||||
down_scale_list = [f"INT4_down_{i}_scale_.kt" for i in range(256)]
|
||||
gate_scale_list = [f"INT4_gate_{i}_scale_.kt" for i in range(256)]
|
||||
up_scale_list = [f"INT4_up_{i}_scale_.kt" for i in range(256)]
|
||||
target = ["ffn_up_exps", "ffn_down_exps", "ffn_gate_exps"]
|
||||
tensor_file_map = {}
|
||||
for key in keys:
|
||||
layer = int(key.split(".")[1])
|
||||
if layer < 3:
|
||||
continue
|
||||
layer_path = f"_layer_{layer}"
|
||||
# concatenate the path layer/numa/(down|gate|up)_(0-255)_3670016Byte_quant_.kt
|
||||
# store the path in the tensor_file_map
|
||||
# key = key+'.idx.weight'
|
||||
# scale_key = key+'.idx.scale'
|
||||
for numa_idx, numa in enumerate(numa_list):
|
||||
# TODO: 256 should be a variable
|
||||
for i in range(256):
|
||||
prefix_key = ".".join(key.split(".")[:-1])
|
||||
|
||||
experts_key = prefix_key + f".{i}.numa.{numa_idx}.weight"
|
||||
scale_key = prefix_key + f".{i}.numa.{numa_idx}.scale"
|
||||
if "down" in experts_key:
|
||||
tensor_file_map[experts_key] = os.path.join(folder_path, layer_path, numa, down_list[i])
|
||||
tensor_file_map[scale_key] = os.path.join(folder_path, layer_path, numa, down_scale_list[i])
|
||||
elif "gate" in experts_key:
|
||||
tensor_file_map[experts_key] = os.path.join(folder_path, layer_path, numa, gate_list[i])
|
||||
tensor_file_map[scale_key] = os.path.join(folder_path, layer_path, numa, gate_scale_list[i])
|
||||
elif "up" in experts_key:
|
||||
tensor_file_map[experts_key] = os.path.join(folder_path, layer_path, numa, up_list[i])
|
||||
tensor_file_map[scale_key] = os.path.join(folder_path, layer_path, numa, up_scale_list[i])
|
||||
return tensor_file_map
|
||||
|
||||
|
||||
# def translate_name(name:str)->str:
|
||||
# """
|
||||
# :param name: name of the tensor
|
||||
# :return: translated name
|
||||
# """
|
||||
# name = translate_name_to_gguf(name)
|
||||
# name = name.replace(".up_proj.", ".ffn_up_exps.")
|
||||
# name = name.replace(".down_proj.", ".ffn_down_exps.")
|
||||
# name = name.replace(".gate_proj.", ".ffn_gate_exps.")
|
||||
# name = name.replace(".ffn_gate_inp.e_score_correction_bias", ".exp_probs_b.bias")
|
||||
# return name
|
||||
|
||||
|
||||
def _clean_keys(keys):
|
||||
keys = list(keys)
|
||||
target = ["ffn_up_exps", "ffn_down_exps", "ffn_gate_exps"]
|
||||
# only keep the keys that contain the target
|
||||
keys = [key for key in keys if any(target_key in key for target_key in target) and "ggml_type" not in key]
|
||||
return keys
|
||||
|
||||
|
||||
def combine_tensor_sources(safetensor_path, amx_path):
|
||||
safetensor_tensor_file_map = read_safetensor_keys_from_folder(safetensor_path)
|
||||
|
||||
keys = _clean_keys(safetensor_tensor_file_map.keys())
|
||||
|
||||
amx_tensor_file_map = read_amx_tensor_from_folder(amx_path, keys)
|
||||
target_tensor_map = {}
|
||||
for key in safetensor_tensor_file_map.keys():
|
||||
if "_exps." in key:
|
||||
continue
|
||||
|
||||
target_tensor_map[key] = safetensor_tensor_file_map[key]
|
||||
|
||||
for key in amx_tensor_file_map.keys():
|
||||
target_tensor_map[key] = amx_tensor_file_map[key]
|
||||
|
||||
return target_tensor_map
|
||||
|
||||
|
||||
def write_combined_tensor(target_tensor_map: dict, output_path: str):
|
||||
# Ensure output directory exists
|
||||
os.makedirs(output_path, exist_ok=True)
|
||||
|
||||
# Cache for safetensor file handles and GGUF loaders
|
||||
safetensors_cache = {}
|
||||
amx_cache = {}
|
||||
|
||||
# Group tensors by layer
|
||||
layer_groups = defaultdict(list)
|
||||
non_layer_keys = []
|
||||
layer_pattern = re.compile(r"blk\.(\d+)\.")
|
||||
|
||||
for key in target_tensor_map:
|
||||
match = layer_pattern.search(key)
|
||||
if match:
|
||||
layer_groups[int(match.group(1))].append(key)
|
||||
else:
|
||||
non_layer_keys.append(key)
|
||||
|
||||
# Calculate the number of shards
|
||||
total_shards = len(layer_groups) + (1 if non_layer_keys else 0) - 1
|
||||
|
||||
shard_idx = 0
|
||||
# Save non-layer tensors to the first shard if they exist
|
||||
if non_layer_keys:
|
||||
tensors = {}
|
||||
for key in non_layer_keys:
|
||||
file_path = target_tensor_map[key]
|
||||
tensor = None
|
||||
ggml_type = None
|
||||
if file_path.endswith(".safetensors"):
|
||||
if file_path not in safetensors_cache:
|
||||
safetensors_cache[file_path] = safe_open(file_path, framework="pt")
|
||||
f = safetensors_cache[file_path]
|
||||
tensor = f.get_tensor(key)
|
||||
elif file_path.endswith(".kt"):
|
||||
tensor = safe_open_binary_to_tensor(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file format: {file_path}")
|
||||
tensors[key] = tensor
|
||||
|
||||
output_file = os.path.join(output_path, f"model-{shard_idx:05}-of-{total_shards:05}.safetensors")
|
||||
print(f"Saving non-layer tensors to {output_file}")
|
||||
save_file(tensors, output_file)
|
||||
shard_idx += 1
|
||||
|
||||
# Save each layer's tensors to subsequent shards
|
||||
for layer_num in sorted(layer_groups.keys()):
|
||||
layer_keys = layer_groups[layer_num]
|
||||
tensors = {}
|
||||
for key in layer_keys:
|
||||
file_path = target_tensor_map[key]
|
||||
tensor = None
|
||||
ggml_type = None
|
||||
if file_path.endswith(".safetensors"):
|
||||
if file_path not in safetensors_cache:
|
||||
safetensors_cache[file_path] = safe_open(file_path, framework="pt")
|
||||
f = safetensors_cache[file_path]
|
||||
tensor = f.get_tensor(key)
|
||||
tensor_info = tensor.shape
|
||||
elif file_path.endswith(".kt"):
|
||||
tensor = safe_open_binary_to_tensor(file_path)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file format: {file_path}")
|
||||
tensors[key] = tensor
|
||||
|
||||
output_file = os.path.join(output_path, f"model-{shard_idx:05}-of-{total_shards:05}.safetensors")
|
||||
print(f"Saving layer {layer_num} to {output_file}")
|
||||
save_file(tensors, output_file)
|
||||
shard_idx += 1
|
||||
return
|
||||
|
||||
|
||||
def main():
|
||||
# 输入已经处理过的混合模型路径,提前处理好的amx路径,输出路径
|
||||
parser = argparse.ArgumentParser(description="Read parameters from Safetensor and GGUF files")
|
||||
parser.add_argument(
|
||||
"--safetensor_path",
|
||||
type=str,
|
||||
help="Path to the Safetensor file",
|
||||
default="/mnt/data/models/DeepSeek-R1-GGML-FP8-Hybrid/DeepSeek-R1-IQ1S-FP8",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--amx_path", type=str, help="Path to the GGUF file", default="/mnt/data/models/DeepSeek-R1-INT4"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output_path",
|
||||
type=str,
|
||||
help="Path to the output file",
|
||||
default="/mnt/data/models/DeepSeek-R1-GGML-FP8-Hybrid/DeepSeek-R1-AMXQ4-FP8",
|
||||
)
|
||||
|
||||
# print all the arguments
|
||||
print("All the arguments:")
|
||||
print(parser.parse_args())
|
||||
|
||||
# 解析命令行参数
|
||||
args = parser.parse_args()
|
||||
|
||||
safetensor_path = args.safetensor_path
|
||||
amx_path = args.amx_path
|
||||
output_path = args.output_path
|
||||
|
||||
target_tensor_map = combine_tensor_sources(safetensor_path, amx_path)
|
||||
for key, value in target_tensor_map.items():
|
||||
print(f"{key}: {value}")
|
||||
write_combined_tensor(target_tensor_map, output_path)
|
||||
|
||||
return
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+120
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CPU feature detection script for kt-kernel.
|
||||
|
||||
This script checks if your CPU supports the required instruction sets for FP8 MoE:
|
||||
- AVX512F (foundation)
|
||||
- AVX512_BF16 (BF16 dot product)
|
||||
- AVX512_VNNI (VNNI instructions)
|
||||
- AVX512_VBMI (byte permutation)
|
||||
|
||||
Usage:
|
||||
python3 scripts/check_cpu_features.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def check_cpuinfo():
|
||||
"""Check CPU features via /proc/cpuinfo."""
|
||||
try:
|
||||
with open("/proc/cpuinfo", "r") as f:
|
||||
cpuinfo = f.read().lower()
|
||||
return cpuinfo
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("KT-Kernel CPU Feature Detection")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
cpuinfo = check_cpuinfo()
|
||||
|
||||
if cpuinfo is None:
|
||||
print("❌ /proc/cpuinfo not found (not on Linux?)")
|
||||
print(" Cannot detect CPU features automatically.")
|
||||
sys.exit(1)
|
||||
|
||||
# Extract CPU model
|
||||
for line in cpuinfo.split("\n"):
|
||||
if "model name" in line:
|
||||
model = line.split(":")[1].strip()
|
||||
print(f"CPU Model: {model}")
|
||||
break
|
||||
print()
|
||||
|
||||
# Check AMX support
|
||||
print("AMX Support (Intel Sapphire Rapids+):")
|
||||
amx_flags = ["amx_tile", "amx_int8", "amx_bf16"]
|
||||
amx_status = {}
|
||||
for flag in amx_flags:
|
||||
has_flag = flag in cpuinfo
|
||||
amx_status[flag] = has_flag
|
||||
status = "✅" if has_flag else "❌"
|
||||
print(f" {status} {flag.upper()}")
|
||||
|
||||
has_amx = all(amx_status.values())
|
||||
print(f"\n Overall AMX Support: {'✅ YES' if has_amx else '❌ NO'}")
|
||||
print()
|
||||
|
||||
# Check AVX512 support
|
||||
print("AVX512 Support (required for FP8 MoE):")
|
||||
avx512_flags = ["avx512f", "avx512_bf16", "avx512_vnni", "avx512_vbmi"]
|
||||
avx512_status = {}
|
||||
for flag in avx512_flags:
|
||||
has_flag = flag in cpuinfo
|
||||
avx512_status[flag] = has_flag
|
||||
status = "✅" if has_flag else "❌"
|
||||
flag_desc = {
|
||||
"avx512f": "AVX512F (foundation)",
|
||||
"avx512_bf16": "AVX512_BF16 (BF16 dot product)",
|
||||
"avx512_vnni": "AVX512_VNNI (VNNI instructions)",
|
||||
"avx512_vbmi": "AVX512_VBMI (byte permutation)",
|
||||
}
|
||||
print(f" {status} {flag_desc.get(flag, flag.upper())}")
|
||||
|
||||
has_avx512_full = all(avx512_status.values())
|
||||
print(f"\n Overall AVX512 Support: {'✅ YES' if has_avx512_full else '❌ NO'}")
|
||||
|
||||
if not has_avx512_full and avx512_status["avx512f"]:
|
||||
missing = [f for f in avx512_flags if not avx512_status[f]]
|
||||
print(f" ⚠️ Warning: AVX512F detected but missing: {', '.join(missing)}")
|
||||
print(f" kt-kernel will fall back to AVX2 mode")
|
||||
print()
|
||||
|
||||
# Check AVX2 support
|
||||
print("AVX2 Support (fallback):")
|
||||
has_avx2 = "avx2" in cpuinfo
|
||||
status = "✅" if has_avx2 else "❌"
|
||||
print(f" {status} AVX2")
|
||||
print()
|
||||
|
||||
# Recommendation
|
||||
print("=" * 70)
|
||||
print("Recommendation:")
|
||||
print("=" * 70)
|
||||
if has_amx:
|
||||
print("✅ Your CPU supports AMX - you can use the highest performance mode!")
|
||||
print(" Build with: -DKTRANSFORMERS_CPU_USE_AMX_AVX512=ON -DKTRANSFORMERS_CPU_USE_AMX=ON")
|
||||
elif has_avx512_full:
|
||||
print("✅ Your CPU supports full AVX512 (F/BF16/VNNI/VBMI) - FP8 MoE will work!")
|
||||
print(" Build with: -DKTRANSFORMERS_CPU_USE_AMX_AVX512=ON")
|
||||
elif avx512_status.get("avx512f", False):
|
||||
print("⚠️ Your CPU has AVX512F but missing required extensions.")
|
||||
print(" FP8 MoE will NOT work. kt-kernel will fall back to AVX2 mode.")
|
||||
print(" Missing extensions:", ", ".join([f for f in avx512_flags if not avx512_status.get(f, False)]))
|
||||
elif has_avx2:
|
||||
print("ℹ️ Your CPU supports AVX2 only - basic compatibility mode.")
|
||||
print(" FP8 MoE will NOT be available, but other features will work.")
|
||||
else:
|
||||
print("❌ Your CPU does not support the minimum required instruction set (AVX2).")
|
||||
print(" kt-kernel may not work on this system.")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Compare two sets of quantized weights generated by convert_cpu_weights.py
|
||||
|
||||
This script supports comparing:
|
||||
- Two safetensor format weights (merged)
|
||||
- Two .kt format weights (layer folder structure)
|
||||
- One safetensor and one .kt format (cross-format comparison)
|
||||
|
||||
Usage:
|
||||
python compare_weights.py --path1 /path/to/weights1 --path2 /path/to/weights2
|
||||
python compare_weights.py --path1 /path/to/weights1 --path2 /path/to/weights2 --tolerance 1e-5
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import glob
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors import safe_open
|
||||
from typing import Dict, Tuple
|
||||
from collections import defaultdict
|
||||
|
||||
|
||||
def unpack_awq_int32_to_int8(packed: np.ndarray, bits: int = 4) -> np.ndarray:
|
||||
"""Unpack AWQ int32 packed format to int8
|
||||
|
||||
AWQ uses INT4 quantization: 8 x 4-bit values packed into 1 x 32-bit integer
|
||||
|
||||
Args:
|
||||
packed: Packed int32 array
|
||||
bits: Number of bits per element (default: 4)
|
||||
|
||||
Returns:
|
||||
Unpacked int8 array
|
||||
"""
|
||||
if packed.dtype != np.int32:
|
||||
# Try to reinterpret as int32
|
||||
packed = packed.view(np.int32)
|
||||
|
||||
pack_num = 32 // bits # 8 for INT4
|
||||
unpacked_size = packed.size * pack_num
|
||||
|
||||
unpacked = np.empty(unpacked_size, dtype=np.int8)
|
||||
|
||||
for i in range(pack_num):
|
||||
shift = i * bits
|
||||
mask = (1 << bits) - 1 # 0x0F for 4-bit
|
||||
unpacked[i::pack_num] = ((packed >> shift) & mask).astype(np.int8)
|
||||
|
||||
return unpacked
|
||||
|
||||
|
||||
def normalize_tensor_dtype(tensor: np.ndarray, tensor_name: str, is_awq: bool = False) -> np.ndarray:
|
||||
"""Normalize tensor to consistent dtype based on tensor type
|
||||
|
||||
Args:
|
||||
tensor: Input tensor
|
||||
tensor_name: Name of the tensor (used to determine type)
|
||||
is_awq: Whether this is AWQ format (requires unpacking)
|
||||
|
||||
Returns:
|
||||
Normalized tensor with consistent dtype
|
||||
"""
|
||||
# Determine tensor type from name
|
||||
is_scale = "scale" in tensor_name
|
||||
is_weight = "weight" in tensor_name
|
||||
is_qzeros = "qzeros" in tensor_name
|
||||
|
||||
if is_scale:
|
||||
# Scale should be float32
|
||||
if tensor.dtype != np.float32:
|
||||
# Try to reinterpret bytes as float32
|
||||
tensor = tensor.view(np.float32)
|
||||
return tensor
|
||||
|
||||
elif is_weight or is_qzeros:
|
||||
# Weight/qzeros should be int8
|
||||
if is_awq and tensor.dtype == np.int32:
|
||||
# AWQ format: unpack int32 to int8
|
||||
tensor = unpack_awq_int32_to_int8(tensor)
|
||||
elif tensor.dtype == np.float32:
|
||||
# Two cases for float32:
|
||||
# Case 1: Values look like int8 values (e.g., [37., 73., -70.])
|
||||
# -> use astype to convert values
|
||||
# Case 2: Values are large scientific notation (e.g., [2.6e34, ...])
|
||||
# -> use view to reinterpret bytes
|
||||
|
||||
# Check if values are in int8 range (-128 to 127)
|
||||
if len(tensor) > 0:
|
||||
sample_size = min(100, len(tensor))
|
||||
sample_values = tensor.flat[:sample_size]
|
||||
|
||||
# If most values are in int8 range and have no decimal parts
|
||||
in_int8_range = np.all((sample_values >= -128) & (sample_values <= 127))
|
||||
is_integer_valued = np.all(sample_values == np.round(sample_values))
|
||||
|
||||
if in_int8_range and is_integer_valued:
|
||||
# Case 1: Direct value conversion
|
||||
tensor = tensor.astype(np.int8)
|
||||
else:
|
||||
# Case 2: Byte reinterpretation (4 bytes -> 4 int8s)
|
||||
tensor = tensor.view(np.int8)
|
||||
else:
|
||||
tensor = tensor.astype(np.int8)
|
||||
|
||||
elif tensor.dtype == np.int32:
|
||||
# Reinterpret int32 as int8 (4x more elements)
|
||||
tensor = tensor.view(np.int8)
|
||||
elif tensor.dtype != np.int8:
|
||||
# Other types: try to convert
|
||||
tensor = tensor.astype(np.int8)
|
||||
|
||||
return tensor
|
||||
|
||||
else:
|
||||
# Unknown type, return as-is
|
||||
return tensor
|
||||
|
||||
|
||||
def load_kt_binary(file_path: str) -> np.ndarray:
|
||||
"""Load .kt format binary tensor file
|
||||
|
||||
Args:
|
||||
file_path: Path to .kt binary file
|
||||
|
||||
Returns:
|
||||
numpy array with the loaded tensor
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
binary_data = f.read()
|
||||
|
||||
# Determine dtype based on file name
|
||||
if "scale" in file_path:
|
||||
dtype = np.float32
|
||||
else:
|
||||
dtype = np.int8
|
||||
|
||||
return np.frombuffer(binary_data, dtype=dtype)
|
||||
|
||||
|
||||
def detect_weight_format(path: str) -> str:
|
||||
"""Detect if weights are in safetensor or .kt format
|
||||
|
||||
Args:
|
||||
path: Path to weight directory
|
||||
|
||||
Returns:
|
||||
'safetensor' or 'kt' or 'unknown'
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"Path not found: {path}")
|
||||
|
||||
# Check for safetensor files
|
||||
safetensor_files = glob.glob(os.path.join(path, "*.safetensors"))
|
||||
if safetensor_files:
|
||||
return "safetensor"
|
||||
|
||||
# Check for layer folder structure
|
||||
layer_folders = glob.glob(os.path.join(path, "_layer_*"))
|
||||
if layer_folders:
|
||||
return "kt"
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def detect_awq_format(weights_sample: Dict[str, np.ndarray]) -> bool:
|
||||
"""Detect if weights are in AWQ format
|
||||
|
||||
AWQ format characteristics:
|
||||
- Has 'qzeros' tensors
|
||||
- Weight tensors are int32 dtype (packed)
|
||||
|
||||
Args:
|
||||
weights_sample: Sample of loaded weights
|
||||
|
||||
Returns:
|
||||
True if AWQ format detected
|
||||
"""
|
||||
has_qzeros = any("qzeros" in key for key in weights_sample.keys())
|
||||
|
||||
if not has_qzeros:
|
||||
return False
|
||||
|
||||
# Check if weight tensors are int32
|
||||
for key, tensor in weights_sample.items():
|
||||
if "weight" in key and tensor.dtype == np.int32:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def load_safetensor_weights(path: str) -> Dict[str, np.ndarray]:
|
||||
"""Load all weights from safetensor format
|
||||
|
||||
Args:
|
||||
path: Path to directory containing safetensor files
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tensor names to numpy arrays (dtype normalized)
|
||||
"""
|
||||
weights = {}
|
||||
|
||||
safetensor_files = sorted(glob.glob(os.path.join(path, "*.safetensors")))
|
||||
if not safetensor_files:
|
||||
raise FileNotFoundError(f"No safetensor files found in {path}")
|
||||
|
||||
print(f"Loading safetensor files from {path}")
|
||||
|
||||
# First pass: load all tensors
|
||||
for file in safetensor_files:
|
||||
with safe_open(file, framework="pt") as f:
|
||||
for key in f.keys():
|
||||
# Only load MoE expert weights for comparison
|
||||
if ".ffn_" in key and "_exps." in key:
|
||||
tensor = f.get_tensor(key)
|
||||
weights[key] = tensor.cpu().numpy()
|
||||
|
||||
# Detect AWQ format
|
||||
is_awq = detect_awq_format(weights)
|
||||
print(f" Format detected: {'AWQ' if is_awq else 'INT4/INT8'}")
|
||||
|
||||
# Second pass: normalize dtypes
|
||||
print(f" Normalizing dtypes...")
|
||||
for key in list(weights.keys()):
|
||||
original_dtype = weights[key].dtype
|
||||
original_shape = weights[key].shape
|
||||
weights[key] = normalize_tensor_dtype(weights[key], key, is_awq=is_awq)
|
||||
|
||||
if weights[key].shape != original_shape or weights[key].dtype != original_dtype:
|
||||
print(f" {key}: {original_dtype}{original_shape} -> {weights[key].dtype}{weights[key].shape}")
|
||||
|
||||
print(f" Loaded {len(weights)} tensors from safetensor format")
|
||||
return weights
|
||||
|
||||
|
||||
def load_kt_weights(path: str) -> Dict[str, np.ndarray]:
|
||||
"""Load all weights from .kt format (layer folder structure)
|
||||
|
||||
Args:
|
||||
path: Path to directory containing _layer_* folders
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tensor names to numpy arrays
|
||||
"""
|
||||
weights = {}
|
||||
|
||||
layer_folders = sorted(glob.glob(os.path.join(path, "_layer_*")))
|
||||
if not layer_folders:
|
||||
raise FileNotFoundError(f"No _layer_* folders found in {path}")
|
||||
|
||||
print(f"Loading .kt files from {path}")
|
||||
|
||||
for layer_folder in layer_folders:
|
||||
# Extract layer index from folder name
|
||||
layer_idx = int(os.path.basename(layer_folder).split("_")[-1])
|
||||
|
||||
# Find all NUMA folders
|
||||
numa_folders = sorted(glob.glob(os.path.join(layer_folder, "_numa_*")))
|
||||
|
||||
for numa_folder in numa_folders:
|
||||
# Extract NUMA index
|
||||
numa_idx = int(os.path.basename(numa_folder).split("_")[-1])
|
||||
|
||||
# Find all .kt files
|
||||
kt_files = glob.glob(os.path.join(numa_folder, "*.kt"))
|
||||
|
||||
for kt_file in kt_files:
|
||||
filename = os.path.basename(kt_file)
|
||||
|
||||
# Parse filename to extract metadata
|
||||
# Format: {METHOD}_{proj}_{expert}_{size}Byte_{type}_.kt
|
||||
parts = filename.replace(".kt", "").split("_")
|
||||
|
||||
if len(parts) >= 5:
|
||||
method = parts[0] # INT4, INT8, etc.
|
||||
proj = parts[1] # down, gate, up
|
||||
expert = parts[2] # expert ID
|
||||
tensor_type = parts[4] # quant or scale
|
||||
|
||||
# Map proj names
|
||||
proj_map = {"down": "ffn_down_exps", "gate": "ffn_gate_exps", "up": "ffn_up_exps"}
|
||||
|
||||
proj_key = proj_map.get(proj, proj)
|
||||
|
||||
# Build key matching safetensor format
|
||||
if tensor_type == "quant":
|
||||
key = f"blk.{layer_idx}.{proj_key}.{expert}.numa.{numa_idx}.weight"
|
||||
else: # scale
|
||||
key = f"blk.{layer_idx}.{proj_key}.{expert}.numa.{numa_idx}.scale"
|
||||
|
||||
# Load tensor
|
||||
weights[key] = load_kt_binary(kt_file)
|
||||
|
||||
# Normalize dtypes (.kt format is never AWQ)
|
||||
print(f" Normalizing dtypes...")
|
||||
for key in list(weights.keys()):
|
||||
original_dtype = weights[key].dtype
|
||||
original_shape = weights[key].shape
|
||||
weights[key] = normalize_tensor_dtype(weights[key], key, is_awq=False)
|
||||
|
||||
if weights[key].shape != original_shape or weights[key].dtype != original_dtype:
|
||||
print(f" {key}: {original_dtype}{original_shape} -> {weights[key].dtype}{weights[key].shape}")
|
||||
|
||||
print(f" Loaded {len(weights)} tensors from .kt format")
|
||||
return weights
|
||||
|
||||
|
||||
def normalize_key(key: str) -> Tuple[int, str, int, str]:
|
||||
"""Normalize tensor key to extract layer, projection, expert, and type
|
||||
|
||||
Args:
|
||||
key: Tensor key like "blk.0.ffn_up_exps.5.weight" or "blk.0.ffn_up_exps.5.numa.0.weight"
|
||||
|
||||
Returns:
|
||||
Tuple of (layer_idx, proj_name, expert_idx, tensor_type)
|
||||
"""
|
||||
parts = key.split(".")
|
||||
|
||||
layer_idx = int(parts[1])
|
||||
proj_name = parts[2]
|
||||
expert_idx = int(parts[3])
|
||||
|
||||
# Handle both formats: with and without numa
|
||||
if "numa" in key:
|
||||
tensor_type = parts[6] # weight or scale
|
||||
else:
|
||||
tensor_type = parts[4] # weight, scale, or qzeros
|
||||
|
||||
return (layer_idx, proj_name, expert_idx, tensor_type)
|
||||
|
||||
|
||||
def compare_weights(
|
||||
weights1: Dict[str, np.ndarray], weights2: Dict[str, np.ndarray], tolerance: float = 1e-6
|
||||
) -> Tuple[bool, Dict[str, Dict]]:
|
||||
"""Compare two sets of weights
|
||||
|
||||
Args:
|
||||
weights1: First set of weights
|
||||
weights2: Second set of weights
|
||||
tolerance: Numerical tolerance for comparison
|
||||
|
||||
Returns:
|
||||
Tuple of (all_match, differences_dict)
|
||||
"""
|
||||
print("\n" + "=" * 80)
|
||||
print("WEIGHT COMPARISON")
|
||||
print("=" * 80)
|
||||
|
||||
# Group keys by normalized form (ignoring numa index)
|
||||
def group_by_base_key(weights):
|
||||
groups = defaultdict(list)
|
||||
for key in weights.keys():
|
||||
try:
|
||||
layer, proj, expert, ttype = normalize_key(key)
|
||||
base_key = f"blk.{layer}.{proj}.{expert}.{ttype}"
|
||||
groups[base_key].append(key)
|
||||
except:
|
||||
# Skip keys that don't match expected format
|
||||
pass
|
||||
return groups
|
||||
|
||||
groups1 = group_by_base_key(weights1)
|
||||
groups2 = group_by_base_key(weights2)
|
||||
|
||||
all_base_keys = sorted(set(groups1.keys()) | set(groups2.keys()))
|
||||
|
||||
all_match = True
|
||||
differences = {}
|
||||
|
||||
total_comparisons = 0
|
||||
matching_comparisons = 0
|
||||
|
||||
for base_key in all_base_keys:
|
||||
keys1 = groups1.get(base_key, [])
|
||||
keys2 = groups2.get(base_key, [])
|
||||
|
||||
if not keys1:
|
||||
print(f"❌ Missing in weights1: {base_key}")
|
||||
differences[base_key] = {"status": "missing_in_weights1"}
|
||||
all_match = False
|
||||
continue
|
||||
|
||||
if not keys2:
|
||||
print(f"❌ Missing in weights2: {base_key}")
|
||||
differences[base_key] = {"status": "missing_in_weights2"}
|
||||
all_match = False
|
||||
continue
|
||||
|
||||
# For kt format, we may have multiple keys (one per NUMA node)
|
||||
# We need to concatenate them for comparison
|
||||
if len(keys1) > 1 or len(keys2) > 1:
|
||||
# Concatenate tensors from all NUMA nodes
|
||||
tensor1 = np.concatenate([weights1[k] for k in sorted(keys1)])
|
||||
tensor2 = np.concatenate([weights2[k] for k in sorted(keys2)])
|
||||
else:
|
||||
tensor1 = weights1[keys1[0]]
|
||||
tensor2 = weights2[keys2[0]]
|
||||
|
||||
total_comparisons += 1
|
||||
|
||||
# Debug: print dtype and shape info
|
||||
if tensor1.dtype != tensor2.dtype:
|
||||
print(f"⚠️ Dtype mismatch for {base_key}: {tensor1.dtype} vs {tensor2.dtype}")
|
||||
print(f" This should have been normalized. Shape: {tensor1.shape} vs {tensor2.shape}")
|
||||
|
||||
# Compare shapes
|
||||
if tensor1.shape != tensor2.shape:
|
||||
print(f"❌ Shape mismatch for {base_key}:")
|
||||
print(f" Shape1: {tensor1.shape} (dtype: {tensor1.dtype})")
|
||||
print(f" Shape2: {tensor2.shape} (dtype: {tensor2.dtype})")
|
||||
differences[base_key] = {
|
||||
"status": "shape_mismatch",
|
||||
"shape1": tensor1.shape,
|
||||
"shape2": tensor2.shape,
|
||||
"dtype1": str(tensor1.dtype),
|
||||
"dtype2": str(tensor2.dtype),
|
||||
}
|
||||
all_match = False
|
||||
continue
|
||||
|
||||
# Compare dtypes (should be consistent after normalization)
|
||||
if tensor1.dtype != tensor2.dtype:
|
||||
print(f"❌ Dtype mismatch for {base_key} after normalization:")
|
||||
print(f" Dtype1: {tensor1.dtype}")
|
||||
print(f" Dtype2: {tensor2.dtype}")
|
||||
differences[base_key] = {
|
||||
"status": "dtype_mismatch",
|
||||
"dtype1": str(tensor1.dtype),
|
||||
"dtype2": str(tensor2.dtype),
|
||||
}
|
||||
all_match = False
|
||||
continue
|
||||
|
||||
# Compare values
|
||||
if np.allclose(tensor1, tensor2, atol=tolerance, rtol=tolerance):
|
||||
matching_comparisons += 1
|
||||
else:
|
||||
max_diff = np.max(np.abs(tensor1 - tensor2))
|
||||
mean_diff = np.mean(np.abs(tensor1 - tensor2))
|
||||
|
||||
print(f"❌ Value mismatch for {base_key}:")
|
||||
print(f" Max difference: {max_diff:.2e}")
|
||||
print(f" Mean difference: {mean_diff:.2e}")
|
||||
print(f" Tolerance: {tolerance:.2e}")
|
||||
|
||||
differences[base_key] = {
|
||||
"status": "value_mismatch",
|
||||
"max_diff": float(max_diff),
|
||||
"mean_diff": float(mean_diff),
|
||||
"tolerance": tolerance,
|
||||
}
|
||||
all_match = False
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Total comparisons: {total_comparisons}")
|
||||
print(f"Matching: {matching_comparisons}")
|
||||
print(f"Mismatching: {total_comparisons - matching_comparisons}")
|
||||
print(f"Missing tensors: {len(differences) - (total_comparisons - matching_comparisons)}")
|
||||
|
||||
if all_match:
|
||||
print("\n✅ All weights match!")
|
||||
else:
|
||||
print(f"\n❌ Found {len(differences)} differences")
|
||||
|
||||
return all_match, differences
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Compare two sets of quantized weights")
|
||||
parser.add_argument("--path1", type=str, required=True, help="Path to first weight directory")
|
||||
parser.add_argument("--path2", type=str, required=True, help="Path to second weight directory")
|
||||
parser.add_argument(
|
||||
"--tolerance", type=float, default=1e-6, help="Numerical tolerance for comparison (default: 1e-6)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate paths
|
||||
if not os.path.exists(args.path1):
|
||||
print(f"Error: Path1 does not exist: {args.path1}")
|
||||
return 1
|
||||
|
||||
if not os.path.exists(args.path2):
|
||||
print(f"Error: Path2 does not exist: {args.path2}")
|
||||
return 1
|
||||
|
||||
# Detect formats
|
||||
print("Detecting weight formats...")
|
||||
format1 = detect_weight_format(args.path1)
|
||||
format2 = detect_weight_format(args.path2)
|
||||
|
||||
print(f"Path1 format: {format1}")
|
||||
print(f"Path2 format: {format2}")
|
||||
|
||||
if format1 == "unknown":
|
||||
print(f"Error: Unable to detect weight format in {args.path1}")
|
||||
return 1
|
||||
|
||||
if format2 == "unknown":
|
||||
print(f"Error: Unable to detect weight format in {args.path2}")
|
||||
return 1
|
||||
|
||||
# Load weights based on format
|
||||
print("\nLoading weights...")
|
||||
|
||||
if format1 == "safetensor":
|
||||
weights1 = load_safetensor_weights(args.path1)
|
||||
else:
|
||||
weights1 = load_kt_weights(args.path1)
|
||||
|
||||
if format2 == "safetensor":
|
||||
weights2 = load_safetensor_weights(args.path2)
|
||||
else:
|
||||
weights2 = load_kt_weights(args.path2)
|
||||
|
||||
# Compare weights
|
||||
all_match, differences = compare_weights(weights1, weights2, args.tolerance)
|
||||
|
||||
return 0 if all_match else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,488 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
GPU Weight Quantization Tool for KTransformers
|
||||
|
||||
This script quantizes model weights for CPU-GPU hybrid inference when integrating
|
||||
KTransformers with SGLang. It supports multiple quantization methods (GPTQ, RTN) and
|
||||
applies selective quantization to GPU-resident layers while preserving certain
|
||||
components (e.g., attention, gates, shared experts) in higher precision.
|
||||
|
||||
Usage:
|
||||
python convert_gpu_weights.py --model_id /path/to/model --output_dir /path/to/output --quant_method GPTQ --quant_type W4A16
|
||||
|
||||
Example (GPTQ with calibration for best accuracy):
|
||||
python convert_gpu_weights.py \
|
||||
--model_id /mnt/data2/models/Qwen3-Next-80B-A3B-Instruct \
|
||||
--output_dir /mnt/data2/models/Qwen3-Next-80B-A3B-Instruct-GPU-weight \
|
||||
--quant_method GPTQ \
|
||||
--quant_type W4A16
|
||||
|
||||
Example (RTN for fast quantization without calibration):
|
||||
python convert_gpu_weights.py \
|
||||
--model_id /mnt/data/models/GLM-4.5-Air \
|
||||
--output_dir /mnt/data/models/GLM-4.5-Air-GPU-weights-rtn \
|
||||
--quant_method RTN \
|
||||
--quant_type W4A16
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
import argparse
|
||||
|
||||
# IMPORTANT: Parse force_cpu argument BEFORE importing torch
|
||||
# CUDA_VISIBLE_DEVICES must be set before torch initializes CUDA
|
||||
if __name__ == "__main__":
|
||||
# Quick check for --force_cpu flag before full argument parsing
|
||||
if "--force_cpu" in sys.argv:
|
||||
os.environ["CUDA_VISIBLE_DEVICES"] = ""
|
||||
warnings.filterwarnings("ignore", message="Can't initialize NVML")
|
||||
print("🔧 Forced CPU-only mode (CUDA_VISIBLE_DEVICES set before torch import)")
|
||||
|
||||
# Now it's safe to import torch and other GPU-dependent libraries
|
||||
import torch
|
||||
from accelerate import init_empty_weights, infer_auto_device_map
|
||||
from transformers import AutoModelForCausalLM, AutoTokenizer, AutoConfig
|
||||
from llmcompressor import oneshot
|
||||
from llmcompressor.modifiers.quantization.gptq import GPTQModifier
|
||||
from llmcompressor.modifiers.quantization import QuantizationModifier
|
||||
from datasets import load_dataset
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Quantize MoE models with selective quantization")
|
||||
|
||||
# Required arguments
|
||||
parser.add_argument("--model_id", type=str, required=True, help="Path to the input model directory")
|
||||
parser.add_argument("--output_dir", type=str, required=True, help="Path to save the quantized model")
|
||||
|
||||
# Optional arguments
|
||||
parser.add_argument(
|
||||
"--quant_method",
|
||||
type=str,
|
||||
choices=["GPTQ", "RTN"],
|
||||
default="GPTQ",
|
||||
help="Quantization method: GPTQ (calibration-based) or RTN (round-to-nearest, no calibration). Default: GPTQ",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quant_type",
|
||||
type=str,
|
||||
choices=["W4A16", "W8A16"],
|
||||
default="W8A16",
|
||||
help="Quantization type: W4A16 (INT4) or W8A16 (INT8). Default: W8A16",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--num_calibration_samples",
|
||||
type=int,
|
||||
default=512,
|
||||
help="Number of calibration samples (GPTQ only). Default: 512",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_sequence_length",
|
||||
type=int,
|
||||
default=2048,
|
||||
help="Maximum sequence length for calibration (GPTQ only). Default: 2048",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dampening_frac",
|
||||
type=float,
|
||||
default=0.1,
|
||||
help="Dampening fraction to mitigate quantization noise (GPTQ only). Default: 0.1",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset",
|
||||
type=str,
|
||||
default="HuggingFaceH4/ultrachat_200k",
|
||||
help="Dataset for calibration (GPTQ only). Default: HuggingFaceH4/ultrachat_200k",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dataset_split", type=str, default="train_sft", help="Dataset split to use (GPTQ only). Default: train_sft"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force_cpu", action="store_true", help="Force all computations to CPU (sets CUDA_VISIBLE_DEVICES='')"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ignore_patterns",
|
||||
type=str,
|
||||
nargs="*",
|
||||
default=[
|
||||
"lm_head",
|
||||
r"re:.*\.mlp\.gate$",
|
||||
r"re:.*\.self_attn\..*$",
|
||||
r"re:.*\.shared_expert\..*$",
|
||||
r"re:.*\.shared_experts\..*$",
|
||||
r"re:.*\.mlp\.shared_expert_gate$",
|
||||
r"re:.*\.linear_attn\..*$",
|
||||
],
|
||||
help="Regex patterns for layers to ignore during quantization",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--torch_dtype",
|
||||
type=str,
|
||||
choices=["bfloat16", "float16", "float32"],
|
||||
default="bfloat16",
|
||||
help="PyTorch dtype for model loading. Default: bfloat16",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--trust_remote_code", action="store_true", help="Allow loading of remote code (required for some models)"
|
||||
)
|
||||
parser.add_argument("--random_seed", type=int, default=42, help="Random seed for dataset shuffling. Default: 42")
|
||||
parser.add_argument(
|
||||
"--max_gpu_memory",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Maximum GPU memory for model weights per device (e.g., '40GiB'). "
|
||||
"GPTQ quantization requires additional GPU memory for Hessian matrix computation, "
|
||||
"so reserve 40-50%% of total VRAM. For example, use '40GiB' on 80GB GPUs. "
|
||||
"Remaining layers will be offloaded to CPU. Default: use all available",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max_cpu_memory",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Maximum CPU memory to use (e.g., '100GiB'). Default: use all available",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def setup_environment(force_cpu=False):
|
||||
"""
|
||||
Verify environment setup (actual setup happens before torch import).
|
||||
|
||||
Args:
|
||||
force_cpu: If True, was requested to force CPU-only mode
|
||||
|
||||
Note:
|
||||
CUDA_VISIBLE_DEVICES must be set BEFORE importing torch.
|
||||
The actual environment setup is done at module import time.
|
||||
"""
|
||||
if force_cpu:
|
||||
# Verify the environment variable was set correctly
|
||||
cuda_visible = os.environ.get("CUDA_VISIBLE_DEVICES", None)
|
||||
if cuda_visible != "":
|
||||
print("⚠️ Warning: force_cpu was requested but CUDA_VISIBLE_DEVICES is not empty")
|
||||
print(f" Current value: '{cuda_visible}'")
|
||||
print(" This may happen if imported as a module. Recommend running as script.")
|
||||
else:
|
||||
print("✅ CPU-only mode verified (CUDA_VISIBLE_DEVICES is empty)")
|
||||
|
||||
|
||||
def get_torch_dtype(dtype_str):
|
||||
"""
|
||||
Convert string to torch dtype.
|
||||
|
||||
Args:
|
||||
dtype_str: String representation of dtype ("bfloat16", "float16", "float32")
|
||||
|
||||
Returns:
|
||||
torch.dtype: Corresponding PyTorch dtype
|
||||
"""
|
||||
dtype_map = {"bfloat16": torch.bfloat16, "float16": torch.float16, "float32": torch.float32}
|
||||
return dtype_map[dtype_str]
|
||||
|
||||
|
||||
def check_dense_layers_and_update_ignore(model_id, ignore_patterns, trust_remote_code=False):
|
||||
"""
|
||||
Check if the model has dense layers (first_k_dense_replace parameter) and add them to ignore list.
|
||||
|
||||
Some MoE models have dense MLP layers in the first few layers instead of MoE layers.
|
||||
These dense layers should not be quantized using the same scheme as expert layers.
|
||||
|
||||
Args:
|
||||
model_id: Path to the model
|
||||
ignore_patterns: List of existing ignore patterns
|
||||
trust_remote_code: Whether to trust remote code
|
||||
|
||||
Returns:
|
||||
Updated ignore_patterns list with dense layer patterns added
|
||||
"""
|
||||
print("🔍 Checking model configuration for dense layers...")
|
||||
|
||||
try:
|
||||
# Load model configuration
|
||||
config = AutoConfig.from_pretrained(model_id, trust_remote_code=trust_remote_code)
|
||||
|
||||
# Check if the model has first_k_dense_replace parameter
|
||||
first_k_dense_replace = getattr(config, "first_k_dense_replace", None)
|
||||
|
||||
if first_k_dense_replace is not None and first_k_dense_replace > 0:
|
||||
print(f"✅ Found dense layers configuration: first_k_dense_replace = {first_k_dense_replace}")
|
||||
print(f" Adding first {first_k_dense_replace} layers to ignore list...")
|
||||
|
||||
# Create regex pattern for dense layers (layers 0 to first_k_dense_replace-1)
|
||||
if first_k_dense_replace == 1:
|
||||
dense_pattern = r"re:model\.layers\.0\.mlp\..*$"
|
||||
else:
|
||||
# For multiple layers, use range pattern
|
||||
layer_range = f"[0-{first_k_dense_replace-1}]"
|
||||
dense_pattern = f"re:model\\.layers\\.{layer_range}\\.mlp\\..*$"
|
||||
|
||||
# Add the dense layer pattern to ignore list
|
||||
updated_ignore_patterns = ignore_patterns + [dense_pattern]
|
||||
|
||||
print(f" Dense layer pattern added: {dense_pattern}")
|
||||
print(f" This will ignore MLP components in layers 0-{first_k_dense_replace-1}")
|
||||
|
||||
return updated_ignore_patterns
|
||||
else:
|
||||
print("ℹ️ No dense layers detected (first_k_dense_replace not found or is 0)")
|
||||
return ignore_patterns
|
||||
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not check model config for dense layers: {e}")
|
||||
print(" Proceeding with original ignore patterns...")
|
||||
return ignore_patterns
|
||||
|
||||
|
||||
def load_and_prepare_dataset(dataset_name, dataset_split, num_samples, max_length, tokenizer, seed=42):
|
||||
"""
|
||||
Load and prepare calibration dataset for GPTQ quantization.
|
||||
|
||||
GPTQ requires calibration data to compute optimal quantization parameters.
|
||||
This function loads a conversation dataset, applies chat template, and tokenizes it.
|
||||
|
||||
Args:
|
||||
dataset_name: HuggingFace dataset name
|
||||
dataset_split: Dataset split to use (e.g., "train_sft")
|
||||
num_samples: Number of samples to use for calibration
|
||||
max_length: Maximum sequence length for tokenization
|
||||
tokenizer: Model tokenizer
|
||||
seed: Random seed for shuffling
|
||||
|
||||
Returns:
|
||||
Dataset with tokenized calibration samples
|
||||
"""
|
||||
print(f"📊 Loading dataset: {dataset_name}")
|
||||
|
||||
# Load dataset
|
||||
ds = load_dataset(dataset_name, split=f"{dataset_split}[:{num_samples}]")
|
||||
ds = ds.shuffle(seed=seed)
|
||||
|
||||
# Preprocess the data into the format the model is trained with
|
||||
def preprocess(example):
|
||||
return {"text": tokenizer.apply_chat_template(example["messages"], tokenize=False)}
|
||||
|
||||
ds = ds.map(preprocess)
|
||||
|
||||
# Tokenize the data
|
||||
def tokenize(sample):
|
||||
return tokenizer(
|
||||
sample["text"], padding=False, max_length=max_length, truncation=True, add_special_tokens=False
|
||||
)
|
||||
|
||||
ds = ds.map(tokenize, remove_columns=ds.column_names)
|
||||
print(f"✅ Dataset prepared with {len(ds)} samples")
|
||||
|
||||
return ds
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
Main function for GPU weight quantization.
|
||||
|
||||
This performs weight quantization on model weights intended for GPU execution
|
||||
in CPU-GPU hybrid inference scenarios. Supports two quantization methods:
|
||||
|
||||
1. GPTQ (default): Calibration-based quantization for better accuracy
|
||||
- Requires calibration dataset
|
||||
- Higher accuracy but slower
|
||||
- Recommended for production use
|
||||
|
||||
2. RTN (Round-To-Nearest): Fast quantization without calibration
|
||||
- No calibration dataset needed
|
||||
- Faster but may have lower accuracy
|
||||
- Good for quick testing or prototyping
|
||||
|
||||
The quantization is selective:
|
||||
- Expert MLP weights are quantized to INT4/INT8
|
||||
- Attention layers, gates, and shared experts remain in original precision
|
||||
- Dense layers (if present) are excluded from quantization
|
||||
|
||||
The quantized model can be used with SGLang+KTransformers for heterogeneous
|
||||
inference, where "hot" experts run on GPU and "cold" experts run on CPU.
|
||||
"""
|
||||
args = parse_args()
|
||||
|
||||
# Setup environment
|
||||
setup_environment(args.force_cpu)
|
||||
|
||||
# Convert torch dtype
|
||||
torch_dtype = get_torch_dtype(args.torch_dtype)
|
||||
|
||||
print(f"🚀 Starting quantization process")
|
||||
print(f" Model: {args.model_id}")
|
||||
print(f" Output: {args.output_dir}")
|
||||
print(f" Quantization method: {args.quant_method}")
|
||||
print(f" Quantization type: {args.quant_type}")
|
||||
if args.quant_method == "GPTQ":
|
||||
print(f" Calibration samples: {args.num_calibration_samples}")
|
||||
print(f" Max sequence length: {args.max_sequence_length}")
|
||||
else:
|
||||
print(f" Calibration: Not required for {args.quant_method}")
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# 0) Check for dense layers and update ignore patterns
|
||||
# Dense layers in the first few layers should not be quantized
|
||||
updated_ignore_patterns = check_dense_layers_and_update_ignore(
|
||||
args.model_id, args.ignore_patterns, args.trust_remote_code
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# 1) Build a dummy model (no weights) to infer a device map
|
||||
# This determines optimal device placement for each module
|
||||
if args.force_cpu:
|
||||
# In force_cpu mode, directly get module names without calling infer_auto_device_map
|
||||
# to avoid GPU memory allocation
|
||||
print("🔍 Building CPU-only device map...")
|
||||
with init_empty_weights():
|
||||
dummy = AutoModelForCausalLM.from_pretrained(
|
||||
args.model_id, torch_dtype=torch_dtype, trust_remote_code=args.trust_remote_code
|
||||
)
|
||||
device_map = {name: "cpu" for name, _ in dummy.named_modules() if name}
|
||||
del dummy
|
||||
else:
|
||||
print("🔍 Inferring device map...")
|
||||
with init_empty_weights():
|
||||
dummy = AutoModelForCausalLM.from_pretrained(
|
||||
args.model_id, torch_dtype=torch_dtype, trust_remote_code=args.trust_remote_code
|
||||
)
|
||||
# Build max_memory dict if specified
|
||||
max_memory = None
|
||||
if args.max_gpu_memory or args.max_cpu_memory:
|
||||
max_memory = {}
|
||||
if args.max_gpu_memory:
|
||||
# Apply to all available GPUs
|
||||
num_gpus = torch.cuda.device_count()
|
||||
for i in range(num_gpus):
|
||||
max_memory[i] = args.max_gpu_memory
|
||||
print(f" GPU memory limit: {args.max_gpu_memory} per device ({num_gpus} GPUs)")
|
||||
|
||||
# Always set CPU memory when max_memory is used
|
||||
# Otherwise infer_auto_device_map may trigger disk offloading
|
||||
if args.max_cpu_memory:
|
||||
max_memory["cpu"] = args.max_cpu_memory
|
||||
print(f" CPU memory limit: {args.max_cpu_memory}")
|
||||
else:
|
||||
# Use a very large value to allow using all available CPU memory
|
||||
# This prevents disk offloading when user has enough RAM
|
||||
max_memory["cpu"] = "1000GiB"
|
||||
print(f" CPU memory limit: 1000GiB (default, to prevent disk offloading)")
|
||||
|
||||
device_map = infer_auto_device_map(
|
||||
dummy, no_split_module_classes=dummy._no_split_modules, max_memory=max_memory
|
||||
)
|
||||
|
||||
# Check if disk offloading was triggered (not supported by llmcompressor)
|
||||
disk_modules = [k for k, v in device_map.items() if v == "disk"]
|
||||
if disk_modules:
|
||||
print(f"❌ Error: {len(disk_modules)} modules would be offloaded to disk.")
|
||||
print(" llmcompressor does not support disk offloading.")
|
||||
print(" Solutions:")
|
||||
print(" 1. Increase --max_gpu_memory to use more GPU memory")
|
||||
print(" 2. Add --max_cpu_memory with higher value (e.g., '200GiB')")
|
||||
print(" 3. Ensure your machine has enough GPU + CPU memory")
|
||||
raise RuntimeError(
|
||||
"Disk offloading is not supported by llmcompressor. "
|
||||
"Please ensure you have enough GPU + CPU memory."
|
||||
)
|
||||
|
||||
del dummy
|
||||
# --------------------------------------------------------------------
|
||||
# 2) Load the full model weights with device mapping
|
||||
# Note: offload_folder=None disables disk offloading (not supported by llmcompressor)
|
||||
print("📥 Loading model...")
|
||||
try:
|
||||
model = AutoModelForCausalLM.from_pretrained(
|
||||
args.model_id,
|
||||
device_map=device_map,
|
||||
torch_dtype=torch_dtype,
|
||||
trust_remote_code=args.trust_remote_code,
|
||||
offload_folder=None, # Disable disk offloading (not supported by llmcompressor)
|
||||
)
|
||||
except Exception as e:
|
||||
if "disk" in str(e).lower() or "offload" in str(e).lower():
|
||||
print(f"❌ Error: Not enough GPU + CPU memory to load the model.")
|
||||
print(" llmcompressor does not support disk offloading.")
|
||||
print(" Solutions:")
|
||||
print(" 1. Increase --max_gpu_memory to use more GPU memory")
|
||||
print(" 2. Ensure you have enough CPU RAM for remaining layers")
|
||||
print(" 3. Use a machine with more memory")
|
||||
raise
|
||||
raise
|
||||
|
||||
tokenizer = AutoTokenizer.from_pretrained(args.model_id)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# 3) Prepare calibration dataset
|
||||
# GPTQ needs calibration data to compute optimal quantization parameters
|
||||
if args.quant_method == "GPTQ":
|
||||
ds = load_and_prepare_dataset(
|
||||
args.dataset,
|
||||
args.dataset_split,
|
||||
args.num_calibration_samples,
|
||||
args.max_sequence_length,
|
||||
tokenizer,
|
||||
args.random_seed,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# 4) Create quantization recipe with selective layer exclusion
|
||||
print(f"⚙️ Setting up {args.quant_method} {args.quant_type} quantization recipe...")
|
||||
if args.quant_method == "GPTQ":
|
||||
# GPTQ: calibration-based quantization for better accuracy
|
||||
recipe = GPTQModifier(
|
||||
targets="Linear", # Target all Linear layers
|
||||
scheme=args.quant_type, # W4A16 or W8A16
|
||||
ignore=updated_ignore_patterns, # Exclude specific patterns
|
||||
dampening_frac=args.dampening_frac,
|
||||
)
|
||||
elif args.quant_method == "RTN":
|
||||
# RTN (Round-To-Nearest): fast quantization without calibration
|
||||
recipe = QuantizationModifier(
|
||||
targets="Linear", # Target all Linear layers
|
||||
scheme=args.quant_type, # W4A16 or W8A16
|
||||
ignore=updated_ignore_patterns, # Exclude specific patterns
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported quantization method: {args.quant_method}")
|
||||
|
||||
print("🔧 Ignoring the following patterns from quantization:")
|
||||
for i, pattern in enumerate(updated_ignore_patterns):
|
||||
marker = "🆕" if i >= len(args.ignore_patterns) else " "
|
||||
print(f" {marker} {pattern}")
|
||||
|
||||
# --------------------------------------------------------------------
|
||||
# 5) Perform one-shot quantization
|
||||
# GPTQ: calibration-based quantization to minimize accuracy loss
|
||||
# RTN: fast round-to-nearest quantization without calibration
|
||||
print("🎯 Starting one-shot quantization...")
|
||||
if args.quant_method == "GPTQ":
|
||||
# GPTQ requires calibration dataset
|
||||
oneshot(
|
||||
model=model,
|
||||
dataset=ds,
|
||||
recipe=recipe,
|
||||
output_dir=args.output_dir,
|
||||
max_seq_length=args.max_sequence_length,
|
||||
num_calibration_samples=args.num_calibration_samples,
|
||||
)
|
||||
elif args.quant_method == "RTN":
|
||||
# RTN does not require calibration dataset
|
||||
oneshot(
|
||||
model=model,
|
||||
recipe=recipe,
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Unsupported quantization method: {args.quant_method}")
|
||||
|
||||
print(f"\n✅ Quantized model written to: {args.output_dir}")
|
||||
print(f" Quantization method: {args.quant_method}")
|
||||
print(f" Quantization type: {args.quant_type}")
|
||||
print(f" Ignored patterns remain in {args.torch_dtype}")
|
||||
print("🎉 Quantization completed successfully!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,98 @@
|
||||
import os
|
||||
import json
|
||||
from argparse import ArgumentParser
|
||||
from glob import glob
|
||||
from tqdm import tqdm
|
||||
|
||||
import torch
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
import gc
|
||||
|
||||
|
||||
def weight_dequant_cpu(x: torch.Tensor, s: torch.Tensor, block_size: int = 128) -> torch.Tensor:
|
||||
assert x.dim() == 2 and s.dim() == 2, "Expect 2D tensors for x and s"
|
||||
M, N = x.shape
|
||||
n_m = (M + block_size - 1) // block_size
|
||||
n_n = (N + block_size - 1) // block_size
|
||||
|
||||
y = torch.empty((M, N), dtype=torch.bfloat16, device="cpu")
|
||||
for bm in range(n_m):
|
||||
m0 = bm * block_size
|
||||
m1 = min(m0 + block_size, M)
|
||||
for bn in range(n_n):
|
||||
n0 = bn * block_size
|
||||
n1 = min(n0 + block_size, N)
|
||||
scale = s[bm, bn].item()
|
||||
sub = x[m0:m1, n0:n1].to(torch.float32) * scale
|
||||
y[m0:m1, n0:n1] = sub.to(torch.bfloat16)
|
||||
return y
|
||||
|
||||
|
||||
def main(fp8_path, bf16_path):
|
||||
torch.set_default_dtype(torch.bfloat16)
|
||||
os.makedirs(bf16_path, exist_ok=True)
|
||||
model_index_file = os.path.join(fp8_path, "model.safetensors.index.json")
|
||||
with open(model_index_file, "r") as f:
|
||||
model_index = json.load(f)
|
||||
weight_map = model_index["weight_map"]
|
||||
|
||||
loaded_files = {}
|
||||
fp8_weight_names = []
|
||||
|
||||
def get_tensor(tensor_name):
|
||||
file_name = weight_map[tensor_name]
|
||||
if file_name not in loaded_files:
|
||||
file_path = os.path.join(fp8_path, file_name)
|
||||
loaded_files[file_name] = load_file(file_path, device="cpu")
|
||||
return loaded_files[file_name][tensor_name]
|
||||
|
||||
safetensor_files = list(glob(os.path.join(fp8_path, "*.safetensors")))
|
||||
safetensor_files.sort()
|
||||
for safetensor_file in tqdm(safetensor_files, desc="weight file convert"):
|
||||
file_name = os.path.basename(safetensor_file)
|
||||
current_state_dict = load_file(safetensor_file, device="cpu")
|
||||
loaded_files[file_name] = current_state_dict
|
||||
|
||||
new_state_dict = {}
|
||||
for weight_name, weight in current_state_dict.items():
|
||||
if weight_name.endswith("_scale_inv"):
|
||||
continue
|
||||
elif weight.element_size() == 1:
|
||||
scale_inv_name = f"{weight_name}_scale_inv"
|
||||
try:
|
||||
scale_inv = get_tensor(scale_inv_name)
|
||||
fp8_weight_names.append(weight_name)
|
||||
new_state_dict[weight_name] = weight_dequant_cpu(weight, scale_inv)
|
||||
except KeyError:
|
||||
print(f"Warning: {weight_name}loss scale factor")
|
||||
new_state_dict[weight_name] = weight
|
||||
else:
|
||||
new_state_dict[weight_name] = weight
|
||||
|
||||
new_safetensor_file = os.path.join(bf16_path, file_name)
|
||||
save_file(new_state_dict, new_safetensor_file)
|
||||
|
||||
if len(loaded_files) > 2:
|
||||
oldest_file = next(iter(loaded_files))
|
||||
del loaded_files[oldest_file]
|
||||
gc.collect()
|
||||
if torch.cuda.is_available():
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
new_model_index_file = os.path.join(bf16_path, "model.safetensors.index.json")
|
||||
for weight_name in fp8_weight_names:
|
||||
scale_inv_name = f"{weight_name}_scale_inv"
|
||||
if scale_inv_name in weight_map:
|
||||
weight_map.pop(scale_inv_name)
|
||||
with open(new_model_index_file, "w") as f:
|
||||
json.dump({"metadata": {}, "weight_map": weight_map}, f, indent=2)
|
||||
print(f"Finish, Result in: {bf16_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("--input-fp8-hf-path", type=str, required=True, help="Kimi-K2 FP8 model")
|
||||
parser.add_argument("--output-bf16-hf-path", type=str, required=True, help="BF16 model (After convert)")
|
||||
args = parser.parse_args()
|
||||
main(args.input_fp8_hf_path, args.output_bf16_hf_path)
|
||||
@@ -0,0 +1,477 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Convert KT fused expert LoRA checkpoints into an SGLang adapter directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterable, Mapping
|
||||
|
||||
import torch
|
||||
from safetensors.torch import load_file, save_file
|
||||
|
||||
|
||||
FUSED_EXPERT_LORA_FILE = "fused_expert_lora.safetensors"
|
||||
ADAPTER_MODEL_FILE = "adapter_model.safetensors"
|
||||
ADAPTER_CONFIG_FILE = "adapter_config.json"
|
||||
|
||||
KT_NAME_MAP = {
|
||||
"gate_lora_a": ("gate_proj", "lora_A", 1),
|
||||
"gate_lora_b": ("gate_proj", "lora_B", 2),
|
||||
"up_lora_a": ("up_proj", "lora_A", 1),
|
||||
"up_lora_b": ("up_proj", "lora_B", 2),
|
||||
"down_lora_a": ("down_proj", "lora_A", 1),
|
||||
"down_lora_b": ("down_proj", "lora_B", 2),
|
||||
}
|
||||
|
||||
TARGET_MODULE_ORDER = [
|
||||
"q_proj",
|
||||
"k_proj",
|
||||
"v_proj",
|
||||
"o_proj",
|
||||
"gate_proj",
|
||||
"up_proj",
|
||||
"down_proj",
|
||||
"in_proj_qkv",
|
||||
"in_proj_z",
|
||||
"in_proj_b",
|
||||
"in_proj_a",
|
||||
"out_proj",
|
||||
"embed_tokens",
|
||||
"lm_head",
|
||||
]
|
||||
|
||||
KT_FUSED_KEY_RE = re.compile(r"^layers\.(\d+)\.experts\.([^.]+)$")
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict:
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _write_json(path: Path, data: Mapping) -> None:
|
||||
with path.open("w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
|
||||
|
||||
def _clean_adapter_key(key: str) -> str:
|
||||
"""Match the existing SGLang converter's PEFT key cleanup."""
|
||||
key = key.replace("base_model.model.", "")
|
||||
key = key.replace(".orig_module", "")
|
||||
return key
|
||||
|
||||
|
||||
def _ordered_target_modules(modules: Iterable[str]) -> list[str]:
|
||||
seen = set(modules)
|
||||
ordered = [name for name in TARGET_MODULE_ORDER if name in seen]
|
||||
ordered.extend(sorted(seen.difference(ordered)))
|
||||
return ordered
|
||||
|
||||
|
||||
def _infer_target_module_from_key(key: str) -> str | None:
|
||||
if "lora_embedding_A" in key or "lora_embedding_B" in key:
|
||||
if "embed_tokens" in key:
|
||||
return "embed_tokens"
|
||||
if "lm_head" in key or "unembed_tokens" in key:
|
||||
return "lm_head"
|
||||
|
||||
marker = ".lora_"
|
||||
if marker not in key:
|
||||
return None
|
||||
prefix = key.split(marker, 1)[0]
|
||||
if "." not in prefix:
|
||||
return prefix
|
||||
return prefix.rsplit(".", 1)[-1]
|
||||
|
||||
|
||||
def _merge_tensor(tensors: Dict[str, torch.Tensor], key: str, value: torch.Tensor) -> None:
|
||||
if key in tensors:
|
||||
raise ValueError(f"Duplicate output tensor key: {key}")
|
||||
tensors[key] = value.detach().cpu()
|
||||
|
||||
|
||||
def _load_existing_adapter(input_dir: Path) -> tuple[dict[str, torch.Tensor], set[str]]:
|
||||
adapter_path = input_dir / ADAPTER_MODEL_FILE
|
||||
if not adapter_path.exists():
|
||||
return {}, set()
|
||||
|
||||
tensors: dict[str, torch.Tensor] = {}
|
||||
target_modules: set[str] = set()
|
||||
for key, value in load_file(str(adapter_path)).items():
|
||||
cleaned_key = _clean_adapter_key(key)
|
||||
_merge_tensor(tensors, cleaned_key, value)
|
||||
target_module = _infer_target_module_from_key(cleaned_key)
|
||||
if target_module is not None:
|
||||
target_modules.add(target_module)
|
||||
return tensors, target_modules
|
||||
|
||||
|
||||
def _convert_fused_expert_lora(
|
||||
fused_path: Path,
|
||||
) -> tuple[dict[str, torch.Tensor], int, set[str]]:
|
||||
if not fused_path.exists():
|
||||
raise FileNotFoundError(f"Missing {FUSED_EXPERT_LORA_FILE}: {fused_path}")
|
||||
|
||||
output: dict[str, torch.Tensor] = {}
|
||||
ranks: set[int] = set()
|
||||
expert_counts: set[int] = set()
|
||||
target_modules: set[str] = set()
|
||||
|
||||
for key, tensor in sorted(load_file(str(fused_path)).items()):
|
||||
match = KT_FUSED_KEY_RE.match(key)
|
||||
if match is None:
|
||||
raise ValueError(f"Unexpected key in {FUSED_EXPERT_LORA_FILE}: {key}")
|
||||
|
||||
layer_idx, kt_name = match.groups()
|
||||
if kt_name not in KT_NAME_MAP:
|
||||
raise ValueError(f"Unsupported KT fused expert LoRA tensor: {key}")
|
||||
if tensor.dim() != 3:
|
||||
raise ValueError(f"{key} must be 3D [E, ...], got shape {tuple(tensor.shape)}")
|
||||
|
||||
proj_name, lora_name, rank_dim = KT_NAME_MAP[kt_name]
|
||||
expert_count = int(tensor.shape[0])
|
||||
rank = int(tensor.shape[rank_dim])
|
||||
expert_counts.add(expert_count)
|
||||
ranks.add(rank)
|
||||
target_modules.add(proj_name)
|
||||
|
||||
for expert_idx in range(expert_count):
|
||||
output_key = (
|
||||
f"model.layers.{layer_idx}.mlp.experts.{expert_idx}."
|
||||
f"{proj_name}.{lora_name}.weight"
|
||||
)
|
||||
_merge_tensor(output, output_key, tensor[expert_idx].contiguous())
|
||||
|
||||
if not output:
|
||||
raise ValueError(f"No tensors found in {fused_path}")
|
||||
if len(expert_counts) != 1:
|
||||
raise ValueError(f"Inconsistent expert counts in {FUSED_EXPERT_LORA_FILE}: {sorted(expert_counts)}")
|
||||
if len(ranks) != 1:
|
||||
raise ValueError(f"Inconsistent LoRA ranks in {FUSED_EXPERT_LORA_FILE}: {sorted(ranks)}")
|
||||
|
||||
return output, next(iter(ranks)), target_modules
|
||||
|
||||
|
||||
def _build_adapter_config(
|
||||
input_dir: Path,
|
||||
rank: int,
|
||||
target_modules: set[str],
|
||||
base_model_name_or_path: str,
|
||||
lora_alpha: float | None,
|
||||
*,
|
||||
include_input_target_modules: bool = True,
|
||||
) -> dict:
|
||||
config_path = input_dir / ADAPTER_CONFIG_FILE
|
||||
config = _load_json(config_path) if config_path.exists() else {}
|
||||
|
||||
if "lora_alpha" in config:
|
||||
final_alpha = config["lora_alpha"]
|
||||
elif lora_alpha is not None:
|
||||
final_alpha = lora_alpha
|
||||
else:
|
||||
raise ValueError(
|
||||
f"No {ADAPTER_CONFIG_FILE} with lora_alpha found in {input_dir}; "
|
||||
"pass --lora-alpha to preserve runtime scaling."
|
||||
)
|
||||
|
||||
existing_targets = config.get("target_modules", [])
|
||||
if include_input_target_modules and isinstance(existing_targets, list):
|
||||
target_modules.update(str(name).split(".")[-1] for name in existing_targets)
|
||||
|
||||
config["peft_type"] = config.get("peft_type", "LORA")
|
||||
config["r"] = rank
|
||||
config["lora_alpha"] = final_alpha
|
||||
config["target_modules"] = _ordered_target_modules(target_modules)
|
||||
config["bias"] = config.get("bias", "none")
|
||||
config["task_type"] = config.get("task_type", "CAUSAL_LM")
|
||||
config["base_model_name_or_path"] = base_model_name_or_path
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def _paths_have_ancestor_relationship(left: Path, right: Path) -> bool:
|
||||
if left == right:
|
||||
return True
|
||||
try:
|
||||
left.relative_to(right)
|
||||
return True
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
right.relative_to(left)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _validate_no_ancestor_paths(
|
||||
paths: Iterable[Path],
|
||||
*,
|
||||
label: str,
|
||||
) -> None:
|
||||
resolved = list(paths)
|
||||
for i, left in enumerate(resolved):
|
||||
for right in resolved[i + 1 :]:
|
||||
if _paths_have_ancestor_relationship(left, right):
|
||||
raise ValueError(
|
||||
f"{label} cannot have ancestor/descendant relationships: "
|
||||
f"{left} and {right}."
|
||||
)
|
||||
|
||||
|
||||
def _prepare_output_dir(output_path: Path, input_path: Path, overwrite: bool) -> None:
|
||||
_validate_output_dir(output_path, input_path, overwrite)
|
||||
if output_path.exists() and any(output_path.iterdir()):
|
||||
shutil.rmtree(output_path)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def _validate_output_dir(output_path: Path, input_path: Path, overwrite: bool) -> None:
|
||||
if output_path == input_path:
|
||||
raise ValueError("Output directory must be different from input directory.")
|
||||
if _paths_have_ancestor_relationship(output_path, input_path):
|
||||
raise ValueError(
|
||||
"Output and input directories cannot be ancestor/descendant of each other: "
|
||||
f"output={output_path}, input={input_path}."
|
||||
)
|
||||
if output_path.exists() and not output_path.is_dir():
|
||||
raise FileExistsError(f"Output path exists and is not a directory: {output_path}")
|
||||
if output_path.exists() and any(output_path.iterdir()):
|
||||
if not overwrite:
|
||||
raise FileExistsError(f"Output directory is not empty: {output_path}")
|
||||
|
||||
|
||||
def _infer_lora_rank_from_tensor(key: str, tensor: torch.Tensor) -> int | None:
|
||||
if ".lora_A." in key:
|
||||
return int(tensor.shape[0])
|
||||
if ".lora_B." in key:
|
||||
return int(tensor.shape[1])
|
||||
return None
|
||||
|
||||
|
||||
def _validate_nonexpert_rank(
|
||||
existing_tensors: Mapping[str, torch.Tensor],
|
||||
expert_rank: int,
|
||||
input_dir: Path,
|
||||
) -> None:
|
||||
if not existing_tensors:
|
||||
return
|
||||
|
||||
config_path = input_dir / ADAPTER_CONFIG_FILE
|
||||
if config_path.exists():
|
||||
config_rank = _load_json(config_path).get("r")
|
||||
if config_rank is not None and int(config_rank) != expert_rank:
|
||||
raise ValueError(
|
||||
f"Non-expert adapter rank mismatch: adapter_config.json r={config_rank}, "
|
||||
f"but fused expert LoRA rank={expert_rank}."
|
||||
)
|
||||
|
||||
for key, tensor in existing_tensors.items():
|
||||
tensor_rank = _infer_lora_rank_from_tensor(key, tensor)
|
||||
if tensor_rank is None:
|
||||
continue
|
||||
if tensor_rank != expert_rank:
|
||||
raise ValueError(
|
||||
f"Non-expert adapter tensor rank mismatch for {key}: "
|
||||
f"tensor rank={tensor_rank}, fused expert LoRA rank={expert_rank}."
|
||||
)
|
||||
|
||||
|
||||
def _write_adapter(
|
||||
output_path: Path,
|
||||
input_path: Path,
|
||||
tensors: dict[str, torch.Tensor],
|
||||
config: Mapping,
|
||||
*,
|
||||
overwrite: bool,
|
||||
) -> None:
|
||||
_prepare_output_dir(output_path, input_path, overwrite)
|
||||
save_file(tensors, str(output_path / ADAPTER_MODEL_FILE), metadata={"format": "pt"})
|
||||
_write_json(output_path / ADAPTER_CONFIG_FILE, config)
|
||||
|
||||
|
||||
def convert_kt_to_sglang_adapter(
|
||||
input_dir: str | os.PathLike,
|
||||
output_dir: str | os.PathLike,
|
||||
*,
|
||||
base_model_name_or_path: str,
|
||||
lora_alpha: float | None = None,
|
||||
overwrite: bool = False,
|
||||
expert_output_dir: str | os.PathLike | None = None,
|
||||
nonexpert_output_dir: str | os.PathLike | None = None,
|
||||
) -> dict:
|
||||
input_path = Path(input_dir).expanduser().resolve()
|
||||
output_path = Path(output_dir).expanduser().resolve()
|
||||
expert_output_path = (
|
||||
Path(expert_output_dir).expanduser().resolve()
|
||||
if expert_output_dir is not None
|
||||
else None
|
||||
)
|
||||
nonexpert_output_path = (
|
||||
Path(nonexpert_output_dir).expanduser().resolve()
|
||||
if nonexpert_output_dir is not None
|
||||
else None
|
||||
)
|
||||
|
||||
if not input_path.is_dir():
|
||||
raise FileNotFoundError(f"Input directory not found: {input_path}")
|
||||
|
||||
output_paths = [output_path]
|
||||
output_paths.extend(path for path in (expert_output_path, nonexpert_output_path) if path is not None)
|
||||
if len(set(output_paths)) != len(output_paths):
|
||||
raise ValueError("Merged, expert, and non-expert output directories must be distinct.")
|
||||
_validate_no_ancestor_paths(
|
||||
output_paths,
|
||||
label="Merged/expert/non-expert output directories",
|
||||
)
|
||||
for path in output_paths:
|
||||
_validate_output_dir(path, input_path, overwrite)
|
||||
|
||||
existing_tensors, existing_targets = _load_existing_adapter(input_path)
|
||||
fused_tensors, rank, fused_targets = _convert_fused_expert_lora(input_path / FUSED_EXPERT_LORA_FILE)
|
||||
_validate_nonexpert_rank(existing_tensors, rank, input_path)
|
||||
if nonexpert_output_path is not None and not existing_tensors:
|
||||
raise ValueError(
|
||||
f"Cannot write non-expert adapter: no {ADAPTER_MODEL_FILE} found in {input_path}."
|
||||
)
|
||||
|
||||
tensors: dict[str, torch.Tensor] = {}
|
||||
for key, value in existing_tensors.items():
|
||||
_merge_tensor(tensors, key, value)
|
||||
for key, value in fused_tensors.items():
|
||||
_merge_tensor(tensors, key, value)
|
||||
|
||||
target_modules = set(existing_targets)
|
||||
target_modules.update(fused_targets)
|
||||
config = _build_adapter_config(
|
||||
input_path,
|
||||
rank,
|
||||
target_modules,
|
||||
base_model_name_or_path,
|
||||
lora_alpha,
|
||||
)
|
||||
|
||||
_write_adapter(output_path, input_path, tensors, config, overwrite=overwrite)
|
||||
|
||||
split_outputs: dict[str, dict] = {}
|
||||
if expert_output_path is not None:
|
||||
expert_config = _build_adapter_config(
|
||||
input_path,
|
||||
rank,
|
||||
set(fused_targets),
|
||||
base_model_name_or_path,
|
||||
lora_alpha,
|
||||
include_input_target_modules=False,
|
||||
)
|
||||
_write_adapter(
|
||||
expert_output_path,
|
||||
input_path,
|
||||
fused_tensors,
|
||||
expert_config,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
split_outputs["expert"] = {
|
||||
"output_dir": str(expert_output_path),
|
||||
"tensor_count": len(fused_tensors),
|
||||
"target_modules": expert_config["target_modules"],
|
||||
}
|
||||
|
||||
if nonexpert_output_path is not None:
|
||||
nonexpert_config = _build_adapter_config(
|
||||
input_path,
|
||||
rank,
|
||||
set(existing_targets),
|
||||
base_model_name_or_path,
|
||||
lora_alpha,
|
||||
include_input_target_modules=False,
|
||||
)
|
||||
_write_adapter(
|
||||
nonexpert_output_path,
|
||||
input_path,
|
||||
existing_tensors,
|
||||
nonexpert_config,
|
||||
overwrite=overwrite,
|
||||
)
|
||||
split_outputs["nonexpert"] = {
|
||||
"output_dir": str(nonexpert_output_path),
|
||||
"tensor_count": len(existing_tensors),
|
||||
"target_modules": nonexpert_config["target_modules"],
|
||||
}
|
||||
|
||||
return {
|
||||
"input_dir": str(input_path),
|
||||
"output_dir": str(output_path),
|
||||
"tensor_count": len(tensors),
|
||||
"rank": rank,
|
||||
"target_modules": config["target_modules"],
|
||||
"lora_alpha": config["lora_alpha"],
|
||||
"split_outputs": split_outputs,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Convert KT fused expert LoRA weights to an SGLang adapter directory."
|
||||
)
|
||||
parser.add_argument("input_dir", help="Directory containing fused_expert_lora.safetensors.")
|
||||
parser.add_argument("output_dir", help="Destination adapter directory.")
|
||||
parser.add_argument(
|
||||
"--base-model-name-or-path",
|
||||
required=True,
|
||||
help="Base model path/name to write into adapter_config.json.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--lora-alpha",
|
||||
type=float,
|
||||
default=None,
|
||||
help="LoRA alpha to use when input adapter_config.json is absent.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Remove and recreate output_dir if it already contains files.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--expert-output-dir",
|
||||
default=None,
|
||||
help="Optional destination for a split expert-only adapter directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--nonexpert-output-dir",
|
||||
default=None,
|
||||
help="Optional destination for a split non-expert-only adapter directory.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
summary = convert_kt_to_sglang_adapter(
|
||||
args.input_dir,
|
||||
args.output_dir,
|
||||
base_model_name_or_path=args.base_model_name_or_path,
|
||||
lora_alpha=args.lora_alpha,
|
||||
overwrite=args.overwrite,
|
||||
expert_output_dir=args.expert_output_dir,
|
||||
nonexpert_output_dir=args.nonexpert_output_dir,
|
||||
)
|
||||
print(
|
||||
"Converted KT fused expert LoRA adapter: "
|
||||
f"{summary['tensor_count']} tensors, rank={summary['rank']}, "
|
||||
f"target_modules={summary['target_modules']}"
|
||||
)
|
||||
for name, split_summary in summary["split_outputs"].items():
|
||||
print(
|
||||
f"Wrote {name} adapter: {split_summary['tensor_count']} tensors, "
|
||||
f"target_modules={split_summary['target_modules']}, "
|
||||
f"output_dir={split_summary['output_dir']}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,193 @@
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from typing import Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
import torch
|
||||
from safetensors.torch import save_file, safe_open
|
||||
|
||||
from compressed_tensors.compressors import unpack_from_int32
|
||||
|
||||
|
||||
def _load_config(model_dir: str, config_path: Optional[str]) -> Tuple[int, int, int]:
|
||||
cfg_path = config_path or os.path.join(model_dir, "config.json")
|
||||
with open(cfg_path, "r") as f:
|
||||
cfg = json.load(f)
|
||||
hidden_size = int(cfg.get("hidden_size"))
|
||||
inter_size = int(cfg.get("moe_intermediate_size"))
|
||||
group_size = int(
|
||||
cfg.get("quantization_config", {})
|
||||
.get("config_groups", {})
|
||||
.get("group_0", {})
|
||||
.get("weights", {})
|
||||
.get("group_size", 32)
|
||||
)
|
||||
return hidden_size, inter_size, group_size
|
||||
|
||||
|
||||
def _dequantize_tensor(
|
||||
weight_packed: torch.Tensor,
|
||||
weight_scale: torch.Tensor,
|
||||
weight_shape: torch.Tensor,
|
||||
group_size: int,
|
||||
) -> torch.Tensor:
|
||||
if isinstance(weight_shape, torch.Tensor):
|
||||
shape = tuple(int(v) for v in weight_shape.view(-1).tolist())
|
||||
else:
|
||||
shape = tuple(weight_shape)
|
||||
weight = unpack_from_int32(weight_packed, 4, shape)
|
||||
if group_size > 0:
|
||||
scale = weight_scale.to(torch.float32)
|
||||
if scale.dim() == 1:
|
||||
scale = scale.unsqueeze(1)
|
||||
scales = torch.repeat_interleave(scale, repeats=group_size, dim=1)
|
||||
else:
|
||||
scales = weight_scale.to(torch.float32)
|
||||
if scales.shape != weight.shape:
|
||||
if scales.numel() == weight.numel():
|
||||
scales = scales.reshape_as(weight)
|
||||
else:
|
||||
raise ValueError(f"Scale shape {scales.shape} incompatible with weight shape {weight.shape}")
|
||||
bf16 = (weight.to(torch.float32) * scales).to(torch.bfloat16)
|
||||
return bf16.contiguous()
|
||||
|
||||
|
||||
def _is_quantized_weight_key(key: str) -> bool:
|
||||
if ".mlp.experts." not in key or ".shared_experts." in key:
|
||||
return False
|
||||
suffixes = ("weight_packed", "weight_scale", "weight_shape")
|
||||
for proj in ("gate_proj", "up_proj", "down_proj"):
|
||||
for suffix in suffixes:
|
||||
if key.endswith(f".{proj}.{suffix}"):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def convert_file(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
group_size: int,
|
||||
skip_existing: bool = True,
|
||||
):
|
||||
if skip_existing and os.path.exists(output_path):
|
||||
print(f"[skip] {output_path} already exists.")
|
||||
return
|
||||
|
||||
tensors: Dict[str, torch.Tensor] = {}
|
||||
expert_buffers: Dict[str, Dict[str, Dict[str, torch.Tensor]]] = defaultdict(lambda: defaultdict(dict))
|
||||
|
||||
with safe_open(input_path, framework="pt") as reader:
|
||||
keys = list(reader.keys())
|
||||
for key in keys:
|
||||
tensor = reader.get_tensor(key).detach().cpu()
|
||||
|
||||
if not _is_quantized_weight_key(key):
|
||||
tensors[key] = tensor
|
||||
continue
|
||||
|
||||
parts = key.split(".")
|
||||
try:
|
||||
expert_idx = parts.index("experts")
|
||||
except ValueError:
|
||||
tensors[key] = tensor
|
||||
continue
|
||||
|
||||
prefix = ".".join(parts[: expert_idx + 2])
|
||||
project = parts[-2]
|
||||
suffix = parts[-1]
|
||||
expert_buffers[prefix][project][suffix] = tensor
|
||||
|
||||
stats = {
|
||||
"converted": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
for prefix, components in expert_buffers.items():
|
||||
for proj_name in ["gate_proj", "up_proj", "down_proj"]:
|
||||
proj_data = components.get(proj_name, {})
|
||||
required = {"weight_packed", "weight_scale", "weight_shape"}
|
||||
if not required.issubset(proj_data.keys()):
|
||||
print(f"[warn] Missing components for {prefix}.{proj_name}, keeping quantized tensors.")
|
||||
for suffix, value in proj_data.items():
|
||||
tensors[f"{prefix}.{proj_name}.{suffix}"] = value
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
bf16_weight = _dequantize_tensor(
|
||||
proj_data["weight_packed"].to(torch.int32),
|
||||
proj_data["weight_scale"].to(torch.float32),
|
||||
proj_data["weight_shape"],
|
||||
group_size,
|
||||
)
|
||||
tensors[f"{prefix}.{proj_name}.weight"] = bf16_weight.to(torch.bfloat16)
|
||||
stats["converted"] += 1
|
||||
print(f" converted {prefix}.{proj_name}.weight -> bf16")
|
||||
|
||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||
save_file(tensors, output_path)
|
||||
print(f"[done] wrote {output_path} (converted={stats['converted']}, skipped={stats['skipped']})")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Convert MoE experts to BF16 weights.")
|
||||
parser.add_argument("--model-dir", required=True, help="Directory containing safetensors checkpoints.")
|
||||
parser.add_argument(
|
||||
"--output-dir",
|
||||
default=None,
|
||||
help="Destination directory for converted checkpoints (default: <model-dir>_bf16).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--files",
|
||||
nargs="+",
|
||||
default=None,
|
||||
help="Specific safetensor filenames to convert (relative to model-dir). Convert all if omitted.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--config-path",
|
||||
default=None,
|
||||
help="Path to config.json for extracting group_size (default: model-dir/config.json).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--overwrite",
|
||||
action="store_true",
|
||||
help="Rewrite output files even if they already exist.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
model_dir = os.path.abspath(args.model_dir)
|
||||
output_dir = os.path.abspath(args.output_dir or f"{model_dir}_bf16")
|
||||
|
||||
if not os.path.isdir(model_dir):
|
||||
raise FileNotFoundError(f"Model directory not found: {model_dir}")
|
||||
|
||||
_, _, group_size = _load_config(model_dir, args.config_path)
|
||||
|
||||
if args.files:
|
||||
targets = [os.path.join(model_dir, fname) for fname in args.files]
|
||||
else:
|
||||
targets = [
|
||||
os.path.join(model_dir, name) for name in sorted(os.listdir(model_dir)) if name.endswith(".safetensors")
|
||||
]
|
||||
|
||||
if not targets:
|
||||
print("No safetensors checkpoints found.")
|
||||
return
|
||||
|
||||
total = len(targets)
|
||||
|
||||
for idx, path in enumerate(targets, start=1):
|
||||
if not os.path.isfile(path):
|
||||
print(f"[skip] {path} is not a file.")
|
||||
continue
|
||||
rel = os.path.relpath(path, model_dir)
|
||||
output_path = os.path.join(output_dir, rel)
|
||||
print(f"[{idx}/{total}] converting {rel}")
|
||||
convert_file(path, output_path, group_size, skip_existing=not args.overwrite)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+57
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env sh
|
||||
# Install git hooks from kt-kernel/.githooks into the monorepo's .git/hooks by
|
||||
# creating symlinks (or copying if symlink fails).
|
||||
|
||||
set -eu
|
||||
|
||||
# This script lives in kt-kernel/scripts/, so REPO_ROOT = kt-kernel
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
HOOKS_SRC="$REPO_ROOT/.githooks"
|
||||
|
||||
# Detect the top-level Git worktree (the monorepo root: ktransformers)
|
||||
GIT_TOP="$(git rev-parse --show-toplevel 2>/dev/null || true)"
|
||||
if [ -z "$GIT_TOP" ] || [ ! -d "$GIT_TOP/.git" ]; then
|
||||
echo "[install-git-hooks] Not inside a git worktree; skipping hooks installation." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
GIT_DIR="$GIT_TOP/.git"
|
||||
HOOKS_DEST="$GIT_DIR/hooks"
|
||||
|
||||
if [ ! -d "$HOOKS_SRC" ]; then
|
||||
echo "[install-git-hooks] No .githooks directory found at $HOOKS_SRC" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[install-git-hooks] Installing git hooks from $HOOKS_SRC to $HOOKS_DEST (repo: $GIT_TOP)"
|
||||
|
||||
# Ensure all source hook files are executable so that even if copied (not symlinked) they run.
|
||||
for src_hook in "$HOOKS_SRC"/*; do
|
||||
[ -f "$src_hook" ] || continue
|
||||
if [ ! -x "$src_hook" ]; then
|
||||
chmod +x "$src_hook" || true
|
||||
fi
|
||||
done
|
||||
|
||||
for hook in "$HOOKS_SRC"/*; do
|
||||
[ -e "$hook" ] || continue
|
||||
name=$(basename "$hook")
|
||||
dest="$HOOKS_DEST/$name"
|
||||
|
||||
# Remove existing hook if it's our symlink or a file
|
||||
if [ -L "$dest" ] || [ -f "$dest" ]; then
|
||||
rm -f "$dest"
|
||||
fi
|
||||
|
||||
# Try symlink first
|
||||
if ln -s "$hook" "$dest" 2>/dev/null; then
|
||||
echo "linked $name"
|
||||
else
|
||||
# Fall back to copying and preserve executable bit
|
||||
cp "$hook" "$dest"
|
||||
chmod +x "$dest"
|
||||
echo "copied $name"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[install-git-hooks] Done. Hooks installed."
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import glob
|
||||
import numpy as np
|
||||
import torch
|
||||
from safetensors.torch import save_file
|
||||
import gc
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
def discover_layers(input_path: str):
|
||||
"""Discover all layer folders in the input directory."""
|
||||
layer_folders = []
|
||||
for item in os.listdir(input_path):
|
||||
if item.startswith("_layer_"):
|
||||
try:
|
||||
layer_idx = int(item.split("_")[-1])
|
||||
layer_folders.append((layer_idx, item))
|
||||
except ValueError:
|
||||
continue
|
||||
layer_folders.sort(key=lambda x: x[0])
|
||||
return layer_folders
|
||||
|
||||
|
||||
def discover_numa_folders(layer_path: str):
|
||||
"""Discover all NUMA folders within a layer folder."""
|
||||
numa_folders = []
|
||||
for item in os.listdir(layer_path):
|
||||
if item.startswith("_numa_"):
|
||||
try:
|
||||
numa_idx = int(item.split("_")[-1])
|
||||
numa_folders.append((numa_idx, item))
|
||||
except ValueError:
|
||||
continue
|
||||
numa_folders.sort(key=lambda x: x[0])
|
||||
return numa_folders
|
||||
|
||||
|
||||
def detect_quant_method(layer_path: str):
|
||||
"""Detect quantization method from file names (INT4 vs INT8)."""
|
||||
for root, _, files in os.walk(layer_path):
|
||||
for f in files:
|
||||
if f.startswith("MOE_INT4_"):
|
||||
return "moe_int4", "MOE_INT4"
|
||||
elif f.startswith("MOE_INT8_"):
|
||||
return "moe_int8", "MOE_INT8"
|
||||
elif f.startswith("INT4_"):
|
||||
return "int4", "INT4"
|
||||
elif f.startswith("INT8_"):
|
||||
return "int8", "INT8"
|
||||
raise ValueError(f"Could not detect quant method in {layer_path}")
|
||||
|
||||
|
||||
def load_binary_tensor(file_path: str) -> torch.Tensor:
|
||||
"""Load .kt format binary tensor file."""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
with open(file_path, "rb") as f:
|
||||
binary_data = f.read()
|
||||
|
||||
if "scale" in file_path:
|
||||
np_array = np.frombuffer(binary_data, dtype=np.float32)
|
||||
else:
|
||||
np_array = np.frombuffer(binary_data, dtype=np.int8)
|
||||
|
||||
return torch.from_numpy(np_array.copy())
|
||||
|
||||
|
||||
def process_layer(layer_path: str, amx_prefix: str, layer_idx: int) -> dict:
|
||||
"""Process a single layer folder and return all tensors."""
|
||||
tensors = {}
|
||||
numa_folders = discover_numa_folders(layer_path)
|
||||
|
||||
if not numa_folders:
|
||||
print(f" Warning: No NUMA folders found in {layer_path}", file=sys.stderr)
|
||||
return tensors
|
||||
|
||||
proj_mappings = [
|
||||
("down", "ffn_down_exps"),
|
||||
("gate", "ffn_gate_exps"),
|
||||
("up", "ffn_up_exps"),
|
||||
]
|
||||
|
||||
for numa_idx, numa_folder in numa_folders:
|
||||
numa_path = os.path.join(layer_path, numa_folder)
|
||||
|
||||
for proj_name, proj_key in proj_mappings:
|
||||
quant_pattern = os.path.join(numa_path, f"{amx_prefix}_{proj_name}_*Byte_quant_.kt")
|
||||
scale_pattern = os.path.join(numa_path, f"{amx_prefix}_{proj_name}_*Byte_scale_.kt")
|
||||
|
||||
quant_files = sorted(glob.glob(quant_pattern))
|
||||
scale_files = sorted(glob.glob(scale_pattern))
|
||||
|
||||
for quant_file in quant_files:
|
||||
filename = os.path.basename(quant_file)
|
||||
remainder = filename[len(f"{amx_prefix}_{proj_name}_"):]
|
||||
try:
|
||||
expert_idx = int(remainder.split("_")[0])
|
||||
except (ValueError, IndexError):
|
||||
print(f" Warning: Could not parse expert index from {filename}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
weight_key = f"blk.{layer_idx}.{proj_key}.{expert_idx}.numa.{numa_idx}.weight"
|
||||
tensors[weight_key] = load_binary_tensor(quant_file)
|
||||
|
||||
for scale_file in scale_files:
|
||||
filename = os.path.basename(scale_file)
|
||||
remainder = filename[len(f"{amx_prefix}_{proj_name}_"):]
|
||||
try:
|
||||
expert_idx = int(remainder.split("_")[0])
|
||||
except (ValueError, IndexError):
|
||||
print(f" Warning: Could not parse expert index from {filename}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
scale_key = f"blk.{layer_idx}.{proj_key}.{expert_idx}.numa.{numa_idx}.scale"
|
||||
tensors[scale_key] = load_binary_tensor(scale_file)
|
||||
|
||||
return tensors
|
||||
|
||||
|
||||
def write_shards(accumulated_tensors: dict, output_path: str, shard_counter: dict, keep_remainder: bool = True):
|
||||
"""Write accumulated tensors to one or more shard files.
|
||||
|
||||
Args:
|
||||
accumulated_tensors: Dict of tensors to write
|
||||
output_path: Output directory
|
||||
shard_counter: Dict with 'shard' and 'max_tensors' keys
|
||||
keep_remainder: If True, keep leftover tensors in accumulator for next batch
|
||||
"""
|
||||
if not accumulated_tensors:
|
||||
return
|
||||
|
||||
max_tensors = shard_counter["max_tensors"]
|
||||
current_shard = shard_counter["shard"]
|
||||
total_tensors = len(accumulated_tensors)
|
||||
|
||||
if total_tensors <= max_tensors:
|
||||
if not keep_remainder:
|
||||
output_file = os.path.join(output_path, f"model-{current_shard:05d}.safetensors")
|
||||
save_file(accumulated_tensors, output_file)
|
||||
print(f" Saved {total_tensors} tensors to {output_file}")
|
||||
shard_counter["shard"] = current_shard + 1
|
||||
accumulated_tensors.clear()
|
||||
else:
|
||||
pass # Keep accumulating until we hit max_tensors
|
||||
else:
|
||||
full_shards = total_tensors // max_tensors
|
||||
remainder = total_tensors % max_tensors
|
||||
|
||||
items = list(accumulated_tensors.items())
|
||||
|
||||
# Write full shards
|
||||
for i in range(full_shards):
|
||||
batch = dict(items[i * max_tensors : (i + 1) * max_tensors])
|
||||
output_file = os.path.join(output_path, f"model-{current_shard:05d}.safetensors")
|
||||
save_file(batch, output_file)
|
||||
print(f" Saved {len(batch)} tensors to {output_file}")
|
||||
current_shard += 1
|
||||
|
||||
# Keep remainder for next batch if enabled
|
||||
if keep_remainder and remainder > 0:
|
||||
remainder_items = dict(items[full_shards * max_tensors:])
|
||||
accumulated_tensors.clear()
|
||||
accumulated_tensors.update(remainder_items)
|
||||
print(f" Rolled over {remainder} tensors to next batch")
|
||||
elif remainder > 0:
|
||||
# Write remainder as final shard
|
||||
batch = dict(items[full_shards * max_tensors:])
|
||||
output_file = os.path.join(output_path, f"model-{current_shard:05d}.safetensors")
|
||||
save_file(batch, output_file)
|
||||
print(f" Saved {len(batch)} tensors to {output_file}")
|
||||
current_shard += 1
|
||||
accumulated_tensors.clear()
|
||||
|
||||
shard_counter["shard"] = current_shard
|
||||
|
||||
|
||||
def copy_config_files(original_path: str, output_path: str):
|
||||
"""Copy config and tokenizer files from original model folder."""
|
||||
config_files = [
|
||||
"config.json",
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"special_tokens_map.json",
|
||||
]
|
||||
|
||||
for config_file in config_files:
|
||||
src_path = os.path.join(original_path, config_file)
|
||||
if os.path.exists(src_path):
|
||||
dst_path = os.path.join(output_path, config_file)
|
||||
shutil.copy2(src_path, dst_path)
|
||||
print(f"Copied: {config_file}")
|
||||
else:
|
||||
print(f"Warning: {config_file} not found in {original_path}, skipping", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Merge CPU-optimized weights from nested folder structure to sharded safetensors"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--input-path", "-i", required=True, help="Input directory with nested _layer_* folders"
|
||||
)
|
||||
parser.add_argument("--output", "-o", required=True, help="Output directory for merged safetensors")
|
||||
parser.add_argument(
|
||||
"--original-path",
|
||||
"-r",
|
||||
default=None,
|
||||
help="Original model folder with config.json and tokenizer files to copy",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-tensors",
|
||||
type=int,
|
||||
default=3000,
|
||||
help="Maximum tensors per safetensors shard (default: 3000)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.input_path):
|
||||
print(f"Error: Input path does not exist: {args.input_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
os.makedirs(args.output, exist_ok=True)
|
||||
|
||||
print("Discovering layer folders...")
|
||||
layer_folders = discover_layers(args.input_path)
|
||||
if not layer_folders:
|
||||
print(f"Error: No _layer_* folders found in {args.input_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Found {len(layer_folders)} layer folders")
|
||||
|
||||
print("Detecting quantization method...")
|
||||
first_layer_path = os.path.join(args.input_path, layer_folders[0][1])
|
||||
quant_method, amx_prefix = detect_quant_method(first_layer_path)
|
||||
print(f"Detected quant method: {quant_method} (prefix: {amx_prefix})")
|
||||
|
||||
print(f"\nProcessing layers (max {args.max_tensors} tensors per shard)...")
|
||||
|
||||
accumulated_tensors = {}
|
||||
shard_counter = {"shard": 1, "max_tensors": args.max_tensors}
|
||||
|
||||
for layer_idx, layer_folder in layer_folders:
|
||||
layer_path = os.path.join(args.input_path, layer_folder)
|
||||
print(f"Processing layer {layer_idx} ({layer_folder})...")
|
||||
|
||||
layer_tensors = process_layer(layer_path, amx_prefix, layer_idx)
|
||||
print(f" Loaded {len(layer_tensors)} tensors from this layer")
|
||||
|
||||
accumulated_tensors.update(layer_tensors)
|
||||
|
||||
if len(accumulated_tensors) >= args.max_tensors:
|
||||
print(f" Accumulator has {len(accumulated_tensors)} tensors, flushing to shard(s)...")
|
||||
write_shards(accumulated_tensors, args.output, shard_counter, keep_remainder=True)
|
||||
|
||||
gc.collect()
|
||||
|
||||
if accumulated_tensors:
|
||||
print(f"Flushing remaining {len(accumulated_tensors)} tensors to final shard(s)...")
|
||||
write_shards(accumulated_tensors, args.output, shard_counter, keep_remainder=False)
|
||||
|
||||
if args.original_path:
|
||||
print(f"\nCopying config files from {args.original_path}...")
|
||||
copy_config_files(args.original_path, args.output)
|
||||
|
||||
total_shards = shard_counter["shard"] - 1
|
||||
print(f"\nConversion completed! Created {total_shards} shard(s) in {args.output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exit(main())
|
||||
Reference in New Issue
Block a user