Qwen-Compress / server.py
Gavin-chen's picture
Upload 2 files
c8adb34 verified
Raw
History Blame Contribute Delete
25.2 kB
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
import uvicorn
import threading
import time
import uuid
import json
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import JSONResponse
from fastapi.responses import StreamingResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field, ConfigDict
from typing import List, Optional, Union, Dict, Any
from transformers import AutoTokenizer, AutoModelForCausalLM, TextIteratorStreamer
from transformers import BitsAndBytesConfig
from peft import LoraConfig, get_peft_model, prepare_model_for_kbit_training
from torch.utils.checkpoint import checkpoint
from transformers.modeling_outputs import CausalLMOutputWithPast
import asyncio
# ==========================================
# 1. 初始化 FastAPI App 與 跨域(CORS) 設定
# ==========================================
app = FastAPI(title="OpenAI-Compatible FrankenQwen Server", version="1.0")
# 開啟 CORS,讓前端 UI (如 Open WebUI) 可以順利連線
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
MAX_CONCURRENT_REQUESTS = 4
gpu_semaphore = asyncio.Semaphore(MAX_CONCURRENT_REQUESTS)
def get_vram_status():
import torch
if not torch.cuda.is_available():
return {"status": "CUDA 不可用"}
# 轉換為 GiB (除以 1024^3)
allocated = torch.cuda.memory_allocated() / (1024 ** 3)
reserved = torch.cuda.memory_reserved() / (1024 ** 3)
peak = torch.cuda.max_memory_allocated() / (1024 ** 3)
return {
"current_tensor_used_gib": round(allocated, 2),
"pytorch_reserved_gib": round(reserved, 2),
"peak_vram_used_gib": round(peak, 2)
}
# ==========================================
# 2. 原始模型組件與邏輯 (保持不變)
# ==========================================
class HiddenStateCompressor(nn.Module):
def __init__(self, dim, ratio=4):
super().__init__()
self.ratio = ratio
self.wgate = nn.Linear(dim, 1, bias=False)
self.ape = nn.Parameter(torch.empty(ratio, 1))
nn.init.normal_(self.ape, std=0.02)
def forward(self, x):
B, L, D = x.shape
assert L % self.ratio == 0, "輸入長度必須是壓縮倍率的整數倍"
orig_dtype = x.dtype
target_dtype = self.wgate.weight.dtype
x_reshaped = x.view(B, L // self.ratio, self.ratio, D).to(target_dtype)
scores = self.wgate(x_reshaped) + self.ape.view(1, 1, self.ratio, 1)
scores_fp32 = scores.to(torch.float32)
scores_probs = F.softmax(scores_fp32, dim=2).to(target_dtype)
compressed_x = (x_reshaped * scores_probs).sum(dim=2)
return compressed_x.to(orig_dtype)
def get_custom_qwen_forward(compressor_module, window_size=128, ratio=4):
from collections.abc import Callable
from transformers.models.qwen3_5.modeling_qwen3_5 import ALL_ATTENTION_FUNCTIONS, apply_rotary_pos_emb, eager_attention_forward, FlashAttentionKwargs
from transformers.processing_utils import Unpack
from transformers.cache_utils import Cache
def forward2(
self, hidden_states: torch.Tensor, position_embeddings: tuple[torch.Tensor, torch.Tensor],
attention_mask: torch.Tensor | None, past_key_values: Cache | None = None, **kwargs: Unpack[FlashAttentionKwargs],
) -> tuple[torch.Tensor, torch.Tensor | None]:
cos, sin = position_embeddings
bsz, q_len, _ = hidden_states.size()
input_shape = hidden_states.shape[:-1]
hidden_shape = (*input_shape, -1, self.head_dim)
hidden_states = hidden_states.contiguous()
query_states, gate = torch.chunk(
self.q_proj(hidden_states).view(*input_shape, -1, self.head_dim * 2), 2, dim=-1
)
gate = gate.contiguous().reshape(*input_shape, -1)
query_states = self.q_norm(query_states.contiguous().view(hidden_shape)).transpose(1, 2)
key_states, value_states = None, None
do_compress = getattr(self, "compress_enabled", True)
if do_compress:
if q_len > 1: # Prefill
window = min(window_size, q_len)
global_len = q_len - window
cutoff = global_len - (global_len % ratio)
compressed = compressor_module(hidden_states[:, :cutoff, :]) if cutoff > 0 else hidden_states[:, :0, :]
residual = hidden_states[:, cutoff:global_len, :] if global_len > cutoff else hidden_states[:, :0, :]
tail = hidden_states[:, global_len:, :]
kv_hidden = torch.cat([compressed, residual, tail], dim=1).contiguous()
compressed_pos = torch.arange(ratio - 1, cutoff, ratio, device=hidden_states.device, dtype=torch.long) if cutoff > 0 else torch.arange(0, device=hidden_states.device, dtype=torch.long)
residual_pos = torch.arange(cutoff, global_len, device=hidden_states.device, dtype=torch.long)
tail_pos = torch.arange(global_len, q_len, device=hidden_states.device, dtype=torch.long)
kv_positions = torch.cat([compressed_pos, residual_pos, tail_pos], dim=0)
kv_seq_len = kv_positions.size(0)
q_pos = torch.arange(q_len, device=hidden_states.device).view(q_len, 1)
k_pos = kv_positions.view(1, kv_seq_len)
custom_causal_mask = (q_pos >= k_pos)
additive_mask = torch.zeros((q_len, kv_seq_len), dtype=hidden_states.dtype, device=hidden_states.device)
additive_mask.masked_fill_(~custom_causal_mask, torch.finfo(hidden_states.dtype).min)
additive_mask = additive_mask.view(1, 1, q_len, kv_seq_len).expand(bsz, 1, q_len, kv_seq_len)
attention_mask = (attention_mask[..., kv_positions] + additive_mask) if attention_mask is not None else additive_mask
kv_hidden_shape = (bsz, kv_seq_len, -1, self.head_dim)
key_states = self.k_norm(self.k_proj(kv_hidden).view(kv_hidden_shape)).transpose(1, 2).contiguous()
value_states = self.v_proj(kv_hidden).view(kv_hidden_shape).transpose(1, 2).contiguous()
query_states, _ = apply_rotary_pos_emb(query_states, query_states, cos, sin)
k_cos = cos[:, kv_positions, :] if cos.dim() == 3 else cos[kv_positions, :]
k_sin = sin[:, kv_positions, :] if sin.dim() == 3 else sin[kv_positions, :]
_, key_states = apply_rotary_pos_emb(key_states, key_states, k_cos, k_sin)
if past_key_values is not None:
key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx)
else: # Decode
new_key = self.k_norm(self.k_proj(hidden_states).view(bsz, 1, -1, self.head_dim)).transpose(1, 2)
new_value = self.v_proj(hidden_states).view(bsz, 1, -1, self.head_dim).transpose(1, 2)
query_states, _ = apply_rotary_pos_emb(query_states, query_states, cos, sin)
_, new_key = apply_rotary_pos_emb(new_key, new_key, cos, sin)
target_dtype = hidden_states.dtype
new_key = new_key.to(target_dtype).contiguous()
new_value = new_value.to(target_dtype).contiguous()
if past_key_values is not None:
if not hasattr(past_key_values, "uncompressed_buffer_k"):
past_key_values.uncompressed_buf_h = {}
past_key_values.uncompressed_buffer_k = {}
past_key_values.uncompressed_buffer_v = {}
layer_idx = self.layer_idx
buf_k = past_key_values.uncompressed_buffer_k.get(layer_idx, torch.empty((bsz, self.config.num_key_value_heads, 0, self.head_dim), device=new_key.device, dtype=target_dtype))
buf_v = past_key_values.uncompressed_buffer_v.get(layer_idx, torch.empty((bsz, self.config.num_key_value_heads, 0, self.head_dim), device=new_value.device, dtype=target_dtype))
buf_h = past_key_values.uncompressed_buf_h.get(layer_idx, torch.empty((bsz, 0, hidden_states.size(-1)), device=hidden_states.device, dtype=hidden_states.dtype))
buf_h = torch.cat([buf_h, hidden_states], dim=1)
buf_k = torch.cat([buf_k, new_key], dim=2)
buf_v = torch.cat([buf_v, new_value], dim=2)
buffer_len = buf_k.shape[2]
if getattr(self, "compress_enabled", True) and buffer_len >= (window_size + ratio):
to_compress_h = buf_h[:, :ratio, :]
compressed_h = compressor_module(to_compress_h)
compressed_k = self.k_norm(self.k_proj(compressed_h).view(bsz, 1, -1, self.head_dim)).transpose(1, 2)
compressed_v = self.v_proj(compressed_h).view(bsz, 1, -1, self.head_dim).transpose(1, 2)
past_key_values.update(compressed_k, compressed_v, layer_idx)
buf_h = buf_h[:, ratio:, :]
buf_k = buf_k[:, :, ratio:, :]
buf_v = buf_v[:, :, ratio:, :]
past_key_values.uncompressed_buffer_k[layer_idx] = buf_k
past_key_values.uncompressed_buffer_v[layer_idx] = buf_v
past_key_values.uncompressed_buf_h[layer_idx] = buf_h
layer_cache = past_key_values.layers[layer_idx] if hasattr(past_key_values, "layers") else None
history_k = layer_cache.keys if layer_cache else past_key_values.key_cache[layer_idx]
history_v = layer_cache.values if layer_cache else past_key_values.value_cache[layer_idx]
key_states = torch.cat([history_k, buf_k], dim=2) if history_k is not None and history_k.shape[2] > 0 else buf_k
value_states = torch.cat([history_v, buf_v], dim=2) if history_v is not None and history_v.shape[2] > 0 else buf_v
kv_seq_len = past_key_values.get_seq_length(self.layer_idx) + buf_k.shape[2]
else:
key_states, value_states = new_key, new_value
else:
kv_hidden_shape = (bsz, q_len, -1, self.head_dim)
key_states = self.k_norm(self.k_proj(hidden_states).view(kv_hidden_shape)).transpose(1, 2).contiguous()
value_states = self.v_proj(hidden_states).view(kv_hidden_shape).transpose(1, 2).contiguous()
query_states, key_states = apply_rotary_pos_emb(query_states, key_states, cos, sin)
if past_key_values is not None:
key_states, value_states = past_key_values.update(key_states, value_states, self.layer_idx)
kv_seq_len = key_states.shape[2]
target_dtype = hidden_states.dtype
query_states, key_states, value_states = query_states.to(target_dtype).contiguous(), key_states.to(target_dtype).contiguous(), value_states.to(target_dtype).contiguous()
attention_interface: Callable = ALL_ATTENTION_FUNCTIONS.get_interface(self.config._attn_implementation, eager_attention_forward)
attn_output, attn_weights = attention_interface(
self, query_states, key_states, value_states, attention_mask,
dropout=0.0 if not self.training else self.attention_dropout, scaling=self.scaling, **kwargs,
)
attn_output = attn_output.reshape(*input_shape, -1).contiguous()
attn_output = attn_output * torch.sigmoid(gate)
return self.o_proj(attn_output), attn_weights
return forward2
def get_chunked_causal_forward(chunk_size=512):
def custom_causal_forward(self, input_ids=None, attention_mask=None, position_ids=None, past_key_values=None, inputs_embeds=None, labels=None, use_cache=None, output_attentions=None, output_hidden_states=None, return_dict=None, **kwargs):
outputs = self.model(input_ids=input_ids, attention_mask=attention_mask, position_ids=position_ids, past_key_values=past_key_values, inputs_embeds=inputs_embeds, use_cache=use_cache, output_attentions=output_attentions, output_hidden_states=output_hidden_states, return_dict=return_dict, **kwargs)
hidden_states = outputs[0]
loss, logits = None, None
if labels is not None:
shift_hidden_states = hidden_states[..., :-1, :].contiguous()
shift_labels = labels[..., 1:].contiguous()
seq_len = shift_hidden_states.shape[1]
total_loss = 0.0
active_tokens = (shift_labels != -100).sum().clamp(min=1)
def compute_chunk_loss(h_chunk, lbl_chunk):
c_logits = self.lm_head(h_chunk).float()
return F.cross_entropy(c_logits.reshape(-1, c_logits.size(-1)), lbl_chunk.reshape(-1), reduction='sum')
for i in range(0, seq_len, chunk_size):
end_i = min(i + chunk_size, seq_len)
c_loss = checkpoint(compute_chunk_loss, shift_hidden_states[:, i:end_i, :], shift_labels[:, i:end_i], use_reentrant=False)
total_loss += c_loss
loss = total_loss / active_tokens
else:
logits = self.lm_head(hidden_states)
return CausalLMOutputWithPast(loss=loss, logits=logits, past_key_values=outputs.past_key_values, hidden_states=outputs.hidden_states, attentions=outputs.attentions)
return custom_causal_forward
def load_custom_checkpoint(model, filepath="/vault/franken_qwen2B_compressor_checkpoint.pt"):
if not os.path.exists(filepath):
print(f"⚠️ 找不到權重檔 {filepath}!將使用原始權重。")
return model
print(f"\n📂 正在載入客製化權重: {filepath}")
trainable_state_dict = torch.load(filepath, map_location="cpu", weights_only=True)
clean_state_dict = { (k[7:] if k.startswith('module.') else k): v for k, v in trainable_state_dict.items() }
model.load_state_dict(clean_state_dict, strict=False)
print("✅ 客製化權重完美掛載!")
return model
def build_franken_qwen(qwen_id="Qwen/Qwen3.6-27B", device="cuda"):
quantization_config = BitsAndBytesConfig(load_in_4bit=True, bnb_4bit_compute_dtype=torch.bfloat16)
qwen_model = AutoModelForCausalLM.from_pretrained(qwen_id, dtype=torch.bfloat16, device_map="auto", quantization_config=quantization_config)
#qwen_model = prepare_model_for_kbit_training(qwen_model)
qwen_model.compressors = nn.ModuleList()
for i, layer in enumerate(qwen_model.model.layers):
if i % 4 == 3:
compressor = HiddenStateCompressor(dim=qwen_model.config.hidden_size, ratio=4).to(device, dtype=torch.bfloat16)
qwen_model.compressors.append(compressor)
custom_fw = get_custom_qwen_forward(compressor, window_size=128, ratio=4)
layer.self_attn.forward = custom_fw.__get__(layer.self_attn, type(layer.self_attn))
qwen_model.forward = get_chunked_causal_forward(chunk_size=512).__get__(qwen_model, type(qwen_model))
for param in qwen_model.parameters(): param.requires_grad = False
for compressor in qwen_model.compressors:
for param in compressor.parameters(): param.requires_grad = True
if qwen_id=="Qwen/Qwen3.6-27B":
lora_config = LoraConfig(r=16, lora_alpha=32, target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj"], lora_dropout=0.05, bias="none", task_type="CAUSAL_LM")
qwen_model = get_peft_model(qwen_model, lora_config)
return load_custom_checkpoint(qwen_model, filepath="./Downloads/franken_qwen27B_compressor_lora.pt")
elif qwen_id=="Qwen/Qwen3.5-2B":
return load_custom_checkpoint(qwen_model, filepath="./Downloads/franken_qwen2B_compressor_checkpoint.pt")
qwen_model.enable_input_require_grads()
return load_custom_checkpoint(qwen_model, filepath="/vault/franken_qwen27B_compressor_lora.pt")
# 全域模型初始化
torch.backends.cuda.matmul.allow_tf32 = True
torch.backends.cudnn.allow_tf32 = True
MODEL_ID = "Qwen/Qwen3.5-2B"
tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
model = build_franken_qwen(qwen_id=MODEL_ID, device="cuda")
model=model.get_base_model() if hasattr(model, "get_base_model") else model
model=torch.compile(model)
model.eval()
print(get_vram_status())
# ==========================================
# 3. OpenAI 官方相容的 Pydantic 資料模型定義
# ==========================================
class ChatMessage(BaseModel):
role: str
content: str
class ChatCompletionRequest(BaseModel):
# 🌟 關鍵優化:extra="allow" 允許 lm-eval 傳入額外的 OpenAI 參數 (如 n, logprobs) 而不報錯
model_config = ConfigDict(extra="allow")
model: str
messages: List[ChatMessage]
temperature: Optional[float] = 0.7
top_p: Optional[float] = 0.9
max_tokens: Optional[int] = Field(default=512, alias="max_tokens")
stream: Optional[bool] = False
stop: Optional[Union[str, List[str]]] = None # 🌟 接收 lm-eval 的停止符號
class CompletionRequest(BaseModel):
# 🌟 針對標準文字補全的 Request Schema
model_config = ConfigDict(extra="allow")
model: str
prompt: Union[str, List[str]]
temperature: Optional[float] = 0.7
top_p: Optional[float] = 0.9
max_tokens: Optional[int] = Field(default=512, alias="max_tokens")
stream: Optional[bool] = False
stop: Optional[Union[str, List[str]]] = None
# ==========================================
# 4. OpenAI 相容核心端點 (/v1/chat/completions)
# ==========================================
@app.exception_handler(Exception)
async def universal_exception_handler(request: Request, exc: Exception):
return JSONResponse(
status_code=500,
content={"error": {"message": f"Internal Server Error: {str(exc)}", "type": "server_error"}}
)
def prepare_gen_config(request, inputs):
"""公用生成參數建立函式"""
gen_config = {
**inputs,
"max_new_tokens": request.max_tokens,
"do_sample": True if request.temperature > 0 else False,
"temperature": request.temperature if request.temperature > 0 else None,
"top_p": request.top_p,
"repetition_penalty": 1.05,
"use_cache": True,
"pad_token_id": tokenizer.eos_token_id,
"eos_token_id": [tokenizer.eos_token_id, tokenizer.convert_tokens_to_ids("<|im_end|>")]
}
# 🌟 處理 lm-eval 傳過來的 stop 截斷字串
if request.stop:
gen_config["stop_strings"] = [request.stop] if isinstance(request.stop, str) else request.stop
gen_config["tokenizer"] = tokenizer
return gen_config
@app.post("/v1/chat/completions")
async def chat_completions_endpoint(request: ChatCompletionRequest):
async with gpu_semaphore: # 佇列鎖管控
formatted_messages = [{"role": msg.role, "content": msg.content} for msg in request.messages]
input_text = tokenizer.apply_chat_template(formatted_messages, tokenize=False, add_generation_prompt=True)
inputs = tokenizer(input_text, return_tensors="pt", add_special_tokens=False).to(model.device)
gen_config = prepare_gen_config(request, inputs)
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
gen_config["streamer"] = streamer
threading.Thread(target=lambda: torch.no_grad()(model.generate)(**gen_config)).start()
if not request.stream:
# 🌟 優化:將同步阻塞的 "".join 丟進線程池,解放 Event Loop
full_text = await asyncio.to_thread(lambda: "".join([new_text for new_text in streamer]))
return {
"id": f"chatcmpl-{uuid.uuid4()}", "object": "chat.completion", "created": int(time.time()), "model": request.model,
"choices": [{"index": 0, "message": {"role": "assistant", "content": full_text}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": inputs["input_ids"].shape[1], "completion_tokens": len(tokenizer.encode(full_text)), "total_tokens": inputs["input_ids"].shape[1] + len(tokenizer.encode(full_text))}
}
else:
async def openai_chat_stream():
chunk_id, created_time = f"chatcmpl-{uuid.uuid4()}", int(time.time())
yield f"data: {json.dumps({'id': chunk_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': request.model, 'choices': [{'index': 0, 'delta': {'role': 'assistant'}, 'finish_reason': None}]}, ensure_ascii=False)}\n\n"
# 🌟 優化:建立安全不阻塞事件循環的取 Token 函式
def get_next_token():
try:
return next(streamer), False
except StopIteration:
return "", True
while True:
# 🌟 關鍵:每次拿 Token 都讓出主執行緒,讓監控端點可以插隊進來執行
new_text, is_done = await asyncio.to_thread(get_next_token)
if is_done:
break
if new_text:
yield f"data: {json.dumps({'id': chunk_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': request.model, 'choices': [{'index': 0, 'delta': {'content': new_text}, 'finish_reason': None}]}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'id': chunk_id, 'object': 'chat.completion.chunk', 'created': created_time, 'model': request.model, 'choices': [{'index': 0, 'delta': {}, 'finish_reason': 'stop'}]}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(openai_chat_stream(), media_type="text/event-stream")
# ----- 端點 B:文字接龍 (/v1/completions) -----
@app.post("/v1/completions")
async def completions_endpoint(request: CompletionRequest):
async with gpu_semaphore: # 佇列鎖管控
input_text = request.prompt[0] if isinstance(request.prompt, list) else request.prompt
inputs = tokenizer(input_text, return_tensors="pt", add_special_tokens=False).to(model.device)
gen_config = prepare_gen_config(request, inputs)
streamer = TextIteratorStreamer(tokenizer, skip_prompt=True, skip_special_tokens=True)
gen_config["streamer"] = streamer
threading.Thread(target=lambda: torch.no_grad()(model.generate)(**gen_config)).start()
if not request.stream:
full_text = "".join([new_text for new_text in streamer])
return {
"id": f"cmpl-{uuid.uuid4()}", "object": "text_completion", "created": int(time.time()), "model": request.model,
"choices": [{"text": full_text, "index": 0, "logprobs": None, "finish_reason": "stop"}],
"usage": {"prompt_tokens": inputs["input_ids"].shape[1], "completion_tokens": len(tokenizer.encode(full_text)), "total_tokens": inputs["input_ids"].shape[1] + len(tokenizer.encode(full_text))}
}
else:
async def openai_text_stream():
chunk_id, created_time = f"cmpl-{uuid.uuid4()}", int(time.time())
for new_text in streamer:
if new_text:
yield f"data: {json.dumps({'id': chunk_id, 'object': 'text_completion.chunk', 'created': created_time, 'model': request.model, 'choices': [{'text': new_text, 'index': 0, 'logprobs': None, 'finish_reason': None}]}, ensure_ascii=False)}\n\n"
yield f"data: {json.dumps({'id': chunk_id, 'object': 'text_completion.chunk', 'created': created_time, 'model': request.model, 'choices': [{'text': '', 'index': 0, 'logprobs': None, 'finish_reason': 'stop'}]}, ensure_ascii=False)}\n\n"
yield "data: [DONE]\n\n"
return StreamingResponse(openai_text_stream(), media_type="text/event-stream")
# ==========================================
# 5. 補齊 OpenAI 的 /v1/models 查詢端點 (許多 UI 啟動時會先呼叫這個)
# ==========================================
@app.get("/v1/models")
async def list_models():
return {
"object": "list",
"data": [
{
"id": MODEL_ID,
"object": "model",
"created": int(time.time()),
"owned_by": "custom"
}
]
}
@app.get("/v1/debug/vram")
async def vram_monitor():
"""隨時動態查詢當前 GPU 記憶體狀態"""
return {
"object": "vram_status",
"data": get_vram_status()
}
if __name__ == "__main__":
uvicorn.run(app, host="127.0.0.1", port=8001)