mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-11 12:22:14 +08:00
236 lines
14 KiB
Python
236 lines
14 KiB
Python
from __future__ import annotations
|
|
import sys, argparse, codecs, typing, re, unicodedata, json, uuid, time, pathlib
|
|
from tinygrad import nn
|
|
from tinygrad.uop.ops import UOp, Ops
|
|
from tinygrad.helpers import partition, DEBUG, Timing, GlobalCounters, stderr_log, colored, Context, fetch, profile_marker
|
|
from tinygrad.viz.serve import TCPServerWithReuse, HTTPRequestHandler
|
|
from tinygrad.llm.model import Transformer
|
|
|
|
class SimpleTokenizer:
|
|
def __init__(self, normal_tokens:dict[str, int], special_tokens:dict[str, int], preset:str="llama3",
|
|
bos_id:int|None=None, eos_id:int=0, eot_id:int|None=None):
|
|
preset = {"qwen35":"qwen2","qwen35moe":"qwen2"}.get(preset, preset)
|
|
if preset not in ("llama3","llama-v3","llama-bpe","qwen2","olmo","kimi-k2","tekken","glm4"):
|
|
raise ValueError(f"Invalid tokenizer preset '{preset}'")
|
|
# https://github.com/openai/gpt-2/blob/9b63575ef42771a015060c964af2c3da4cf7c8ab/src/encoder.py#L9
|
|
bs = [*range(33, 127), *range(161, 173), *range(174, 256)] # bytes that map to themselves
|
|
self._byte_decoder = {chr(b): b for b in bs} | {chr(256+i): b for i,b in enumerate(b for b in range(256) if b not in bs)}
|
|
|
|
# https://github.com/ggml-org/llama.cpp/blob/94933c8c2eeaa9a7983e3f6c08af76bd86724094/src/llama-vocab.cpp#L286
|
|
# 0x323b0 is one past the max codepoint in unicode categories L/N/Z (0x323af is max L)
|
|
def ucat_range(pre: str): return "".join(re.escape(chr(cp)) for cp in range(0x323b0) if unicodedata.category(chr(cp)).startswith(pre))
|
|
r_ws, r_p_N, r_p_L = r"\t\n\x0b\x0c\r\x85" + ucat_range("Z"), ucat_range("N"), ucat_range("L")
|
|
self._split_to_word = re.compile("(?i:'s|'t|'re|'ve|'m|'ll|'d)|" + \
|
|
f"[^\\r\\n{r_p_N}{r_p_L}]?[{r_p_L}]+|[{r_p_N}]{{1,3}}| ?[^{r_ws}{r_p_N}{r_p_L}]+[\\r\\n]*|[{r_ws}]*[\\r\\n]+|[{r_ws}]+(?![^{r_ws}])|[{r_ws}]+")
|
|
self._split_to_sentence = re.compile("|".join(re.escape(tok) for tok in special_tokens.keys()) if special_tokens else r"(?!)")
|
|
|
|
self._normal_tokens = {bytes(self._byte_decoder[c] for c in tok): tid for tok, tid in normal_tokens.items()}
|
|
self._special_tokens = special_tokens
|
|
self._tok2bytes = {tid: tok for tok, tid in self._normal_tokens.items()} | {tid: tok.encode() for tok, tid in self._special_tokens.items()}
|
|
self.preset = preset
|
|
self.bos_id, self.eos_id, self.eot_id = bos_id, eos_id, eot_id
|
|
|
|
@staticmethod
|
|
def from_gguf_kv(kv:dict):
|
|
# https://github.com/ggml-org/llama.cpp/blob/94933c8c2eeaa9a7983e3f6c08af76bd86724094/src/llama-vocab.cpp#L1818-L1820
|
|
vocab: typing.Iterable[tuple[str, int]] = ((tok, idx) for idx, tok in enumerate(kv["tokenizer.ggml.tokens"]))
|
|
normal_tokens, special_tokens = partition(vocab, lambda e: kv["tokenizer.ggml.token_type"][e[1]] == 1)
|
|
return SimpleTokenizer(dict(normal_tokens), dict(special_tokens), kv["tokenizer.ggml.pre"],
|
|
bos_id=kv.get('tokenizer.ggml.bos_token_id') if kv.get('tokenizer.ggml.add_bos_token', True) else None,
|
|
eos_id=kv.get('tokenizer.ggml.eos_token_id', 0), eot_id=kv.get('tokenizer.ggml.eot_token_id'))
|
|
|
|
def _encode_word(self, word:bytes) -> list[int]:
|
|
if (early_token:=self._normal_tokens.get(word)) is not None: return [early_token]
|
|
parts = [bytes([b]) for b in word]
|
|
# greedily merge any parts that we can
|
|
while True:
|
|
i = min([(sys.maxsize, -1)] + [(self._normal_tokens.get(parts[j]+parts[j+1], sys.maxsize), j) for j in range(len(parts)-1)])[1]
|
|
if i == -1: break
|
|
parts[i:i+2] = [parts[i] + parts[i+1]]
|
|
try: return [self._normal_tokens[p] for p in parts]
|
|
except KeyError: raise RuntimeError("token not found")
|
|
def _encode_sentence(self, chunk:str) -> list[int]:
|
|
return [tok for word in self._split_to_word.findall(chunk) for tok in self._encode_word(word.encode())]
|
|
def encode(self, text:str) -> list[int]:
|
|
tokens: list[int] = []
|
|
pos = 0
|
|
for match in self._split_to_sentence.finditer(text):
|
|
tokens.extend(self._encode_sentence(text[pos:match.start(0)]) + [self._special_tokens[text[match.start(0):match.end(0)]]])
|
|
pos = match.end(0)
|
|
return tokens + self._encode_sentence(text[pos:])
|
|
|
|
def decode(self, ids:list[int]) -> str: return b''.join(self._tok2bytes[tid] for tid in ids).decode(errors='replace')
|
|
def stream_decoder(self) -> typing.Callable[..., str]:
|
|
dec = codecs.getincrementaldecoder('utf-8')('replace')
|
|
def _decode(tid:int|None=None) -> str: return dec.decode(self._tok2bytes[tid]) if tid is not None else dec.decode(b'', final=True)
|
|
return _decode
|
|
def role(self, role:str):
|
|
if self.preset == 'olmo': return self.encode("<|" + role + "|>\n") # OLMoE Instruct format
|
|
if self.preset == 'kimi-k2': return self.encode("<|im_" + role + "|>" + role + "<|im_middle|>")
|
|
if self.preset == 'qwen2': return self.encode("<|im_start|>" + role + "\n")
|
|
if self.preset == 'glm4': return self.encode("<|" + role + "|>")
|
|
if self.preset == 'tekken':
|
|
if role == 'user': return self.encode("[INST]")
|
|
if role == 'assistant': return []
|
|
raise ValueError(f"Unsupported role '{role}' for tokenizer preset '{self.preset}'")
|
|
return self.encode("<|start_header_id|>" + role + "<|end_header_id|>\n\n")
|
|
def end_turn(self):
|
|
if self.preset == 'olmo': return self.encode("\n")
|
|
if self.preset == 'kimi-k2': return [self.eos_id]
|
|
if self.preset == 'qwen2': return [self.eos_id] + self.encode("\n")
|
|
if self.preset == 'glm4': return []
|
|
if self.preset == 'tekken': return self.encode("[/INST]")
|
|
return [self.eos_id]
|
|
def prefix(self) -> list[int]:
|
|
return ([] if self.bos_id is None else [self.bos_id]) + (self.encode("<sop>") if self.preset == 'glm4' else [])
|
|
def is_end(self, token_id:int) -> bool: return token_id in (self.eos_id, self.eot_id)
|
|
|
|
models = {
|
|
"llama3.2:1b": "https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q6_K.gguf",
|
|
"llama3.2:1b-q4": "https://huggingface.co/bartowski/Llama-3.2-1B-Instruct-GGUF/resolve/main/Llama-3.2-1B-Instruct-Q4_K_M.gguf",
|
|
"llama3.2:3b": "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-Q6_K.gguf",
|
|
"llama3.2:3b-f16": "https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF/resolve/main/Llama-3.2-3B-Instruct-f16.gguf",
|
|
"llama3.1:8b": "https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q8_0.gguf",
|
|
"qwen3:0.6b": "https://huggingface.co/Qwen/Qwen3-0.6B-GGUF/resolve/main/Qwen3-0.6B-Q8_0.gguf",
|
|
"qwen3:1.7b": "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf",
|
|
"qwen3:8b": "https://huggingface.co/Qwen/Qwen3-8B-GGUF/resolve/main/Qwen3-8B-Q4_K_M.gguf",
|
|
"qwen3:30b-a3b": "https://huggingface.co/Qwen/Qwen3-30B-A3B-GGUF/resolve/main/Qwen3-30B-A3B-Q4_K_M.gguf",
|
|
"qwen3.5:0.8b": "https://huggingface.co/unsloth/Qwen3.5-0.8B-GGUF/resolve/main/Qwen3.5-0.8B-Q8_0.gguf",
|
|
"qwen3.5:4b": "https://huggingface.co/unsloth/Qwen3.5-4B-GGUF/resolve/main/Qwen3.5-4B-Q4_K_M.gguf",
|
|
"qwen3.5:9b": "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF/resolve/main/Qwen3.5-9B-Q4_K_M.gguf",
|
|
"qwen3.5:27b": "https://huggingface.co/unsloth/Qwen3.5-27B-GGUF/resolve/main/Qwen3.5-27B-Q4_K_M.gguf",
|
|
"qwen3.5:35b-a3b": "https://huggingface.co/unsloth/Qwen3.5-35B-A3B-GGUF/resolve/main/Qwen3.5-35B-A3B-Q4_K_M.gguf",
|
|
"olmoe": "https://huggingface.co/allenai/OLMoE-1B-7B-0924-Instruct-GGUF/resolve/main/olmoe-1b-7b-0924-instruct-q4_k_m.gguf",
|
|
"moonlight": "https://huggingface.co/gabriellarson/Moonlight-16B-A3B-Instruct-GGUF/resolve/main/Moonlight-16B-A3B-Instruct-Q4_K_M.gguf",
|
|
"glm-4.7-flash": "https://huggingface.co/unsloth/GLM-4.7-Flash-GGUF/resolve/main/GLM-4.7-Flash-Q4_K_M.gguf",
|
|
}
|
|
|
|
# *** simple OpenAI API compatible server with web interface on http://localhost:8000/ ***
|
|
|
|
class Handler(HTTPRequestHandler):
|
|
server: LLMServer
|
|
def log_request(self, code='-', size='-'): pass
|
|
def do_GET(self):
|
|
if self.path == "/v1/models": self.send_data(json.dumps({"object":"list","data":[{"id":self.server.model_name,"object":"model"}]}).encode())
|
|
else: self.send_data((pathlib.Path(__file__).parent / "chat.html").read_bytes(), content_type="text/html")
|
|
def run_model(self, ids:list[int], model_name:str, include_usage=False, max_tokens:int|None=None, temperature:float=0.0):
|
|
model, tok = self.server.model, self.server.tok
|
|
cache_start_pos = model.get_start_pos(ids)
|
|
stderr_log(f"{self.path} {colored('--', 'BLACK')} "
|
|
f"in:{colored(f'{cache_start_pos:5d}', 'green')} +{len(ids)-cache_start_pos:5d} {colored('--', 'BLACK')} ")
|
|
tmpl = {"id":f"chatcmpl-{uuid.uuid4().hex[:24]}", "object":"chat.completion.chunk", "created":int(time.time()), "model":model_name}
|
|
yield {"choices": [{"index":0, "delta":{"role":"assistant","content":""}, "finish_reason":None}], **tmpl}
|
|
out: list[int] = []
|
|
finish_reason = "stop"
|
|
st = time.perf_counter()
|
|
dec = tok.stream_decoder()
|
|
for next_id in model.generate(ids, temperature=temperature):
|
|
if len(out) == 0: stderr_log(f"prefill:{(len(ids)-cache_start_pos)/((pt:=time.perf_counter())-st):4.0f} tok/s {colored('--', 'BLACK')} ")
|
|
if tok.is_end(next_id): break
|
|
out.append(next_id)
|
|
yield {"choices": [{"index":0, "delta":{"content":dec(next_id)}, "finish_reason":None}], **tmpl}
|
|
if max_tokens is not None and len(out) >= max_tokens:
|
|
finish_reason = "length"
|
|
break
|
|
if (tail := dec()): yield {"choices": [{"index":0, "delta":{"content":tail}, "finish_reason":None}], **tmpl}
|
|
yield {"choices": [{"index":0, "delta":{},"finish_reason":finish_reason}], **tmpl}
|
|
if include_usage:
|
|
yield {"choices": [], "usage": {"prompt_tokens": len(ids), "completion_tokens": len(out), "total_tokens": len(ids) + len(out)}, **tmpl}
|
|
et = time.perf_counter()
|
|
stderr_log(f"gen:{len(out)/(et-pt) if len(out) > 1 else 0:4.0f} tok/s {colored('--', 'BLACK')} "
|
|
f"out:{len(out):5d} {colored('--', 'BLACK')} total:{et-st:6.2f}s\n")
|
|
|
|
def do_POST(self):
|
|
tok = self.server.tok
|
|
raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
|
|
body: dict[str, typing.Any] = json.loads(raw_body.decode("utf-8"))
|
|
if DEBUG >= 1: print(json.dumps(body, indent=2))
|
|
if self.path == "/v1/chat/completions":
|
|
# extract tokens, last assistant message is treated as prefill
|
|
ids: list[int] = tok.prefix()
|
|
for i, msg in enumerate(body["messages"]):
|
|
ids += tok.role(msg["role"])
|
|
content = msg["content"]
|
|
if isinstance(content, str): ids += tok.encode(content)
|
|
elif isinstance(content, list):
|
|
for c in content:
|
|
if c["type"] == "text": ids += tok.encode(c["text"])
|
|
else: raise RuntimeError(f"unhandled type: {c['type']}")
|
|
else: raise RuntimeError(f"unknown content type: {type(content)}")
|
|
if msg["role"] == "assistant" and i == len(body["messages"]) - 1: break
|
|
ids += tok.end_turn()
|
|
else: ids += tok.role("assistant")
|
|
|
|
# reply
|
|
max_tokens = body.get("max_completion_tokens") or body.get("max_tokens")
|
|
chunks = self.run_model(ids, body["model"], not body.get("stream") or body.get("stream_options",{}).get("include_usage", False),
|
|
max_tokens=max_tokens, temperature=float(body.get("temperature", 0.0)))
|
|
if body.get("stream"): self.stream_json(chunks)
|
|
else:
|
|
out, finish_reason = [], "stop"
|
|
for c in chunks:
|
|
if c["choices"] and c["choices"][0].get("delta", {}).get("content"): out.append(c["choices"][0]["delta"]["content"])
|
|
if c["choices"] and c["choices"][0].get("finish_reason"): finish_reason = c["choices"][0]["finish_reason"]
|
|
self.send_data(json.dumps({**c, "object":"chat.completion",
|
|
"choices":[{"index":0, "message":{"role":"assistant","content":"".join(out)}, "finish_reason":finish_reason}]}).encode())
|
|
else:
|
|
raise RuntimeError(f"unhandled path {self.path}")
|
|
|
|
class LLMServer(TCPServerWithReuse):
|
|
def __init__(self, server_address:tuple, model:Transformer, model_name:str, tok:SimpleTokenizer):
|
|
self.model, self.model_name, self.tok = model, model_name, tok
|
|
super().__init__(server_address, Handler)
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--model", "-m", default=list(models.keys())[0], help=f"Model choice ({', '.join(models.keys())}) or path to a local GGUF file")
|
|
parser.add_argument("--max_context", type=int, default=4096, help="Max Context Length")
|
|
parser.add_argument("--serve", nargs='?', type=int, const=8000, metavar="PORT", help="Run OpenAI compatible API (optional port, default 8000)")
|
|
parser.add_argument("--warmup", action="store_true", help="warmup the JIT")
|
|
parser.add_argument("--benchmark", nargs='?', type=int, const=20, metavar="COUNT", help="Benchmark tok/s (optional count, default 20)")
|
|
args = parser.parse_args()
|
|
|
|
# load the model
|
|
model, kv = Transformer.from_gguf(fetch(models.get(args.model, args.model)), args.max_context)
|
|
model_name = kv.get('general.name') or kv.get('general.basename') or args.model
|
|
file_sizes = [y.nbytes() for y in UOp.sink(*[x.uop for x in nn.state.get_parameters(model)]).toposort() if y.op is Ops.BUFFER]
|
|
print(f"using model \"{model_name}\" with {sum(file_sizes):,} bytes and {sum(x.numel() for x in nn.state.get_parameters(model)):,} params")
|
|
|
|
# get tokenizer
|
|
tok = SimpleTokenizer.from_gguf_kv(kv)
|
|
|
|
# warmup the JIT
|
|
if args.warmup or args.serve:
|
|
# run 2 tokens through the model twice to capture the JIT before serving
|
|
with Context(DEBUG=max(DEBUG.value, 1)):
|
|
for _ in range(2): list(zip(range(2), model.generate([0])))
|
|
|
|
# start server
|
|
if args.serve: LLMServer(('', args.serve), model, model_name, tok).serve_forever()
|
|
|
|
# do benchmark
|
|
if args.benchmark is not None:
|
|
gen = model.generate(toks:=[tok.bos_id or 0])
|
|
for i in range(args.benchmark):
|
|
profile_marker(f"decode @ {i}")
|
|
GlobalCounters.reset()
|
|
with Timing(on_exit=lambda x: f", {1e9/x:6.2f} tok/s, {GlobalCounters.global_mem/x:7.2f} GB/s,"
|
|
f" {GlobalCounters.global_mem//1000000}/{GlobalCounters.mem_used//1000000} MB -- "+\
|
|
tok.decode(toks).replace("\n", "\\n")): next(gen)
|
|
exit(0)
|
|
|
|
# interactive chat
|
|
ids: list[int] = tok.prefix()
|
|
while 1:
|
|
try:
|
|
ids += tok.role("user") + tok.encode(input('>>> ')) + tok.end_turn() + tok.role("assistant")
|
|
except EOFError:
|
|
break
|
|
dec = tok.stream_decoder()
|
|
for next_id in model.generate(ids):
|
|
sys.stdout.write(dec(next_id) if not tok.is_end(next_id) else dec() + "\n\n")
|
|
sys.stdout.flush()
|
|
if tok.is_end(next_id): break
|
|
|
|
if __name__ == "__main__": main()
|