Text Generation
LiteRT-LM
English
custom
hermes-edge
mobile-ai
on-device
ios
iphone-16
apple-neural-engine
deepseek
dspark
speculative-decoding
hermes-agent
tool-calling
raven-ecosystem
Instructions to use bclermo/hermes-edge with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- LiteRT-LM
How to use bclermo/hermes-edge with LiteRT-LM:
# LiteRT-LM runs on various platforms (Android, iOS, Windows, Linux, macOS, IoT, Web/WASM) # and supports many APIs (C++, Python, Kotlin, Swift, JavaScript, Flutter). # For platform-specific integration guides, please refer to the official developer website: # https://ai.google.dev/edge/litert-lm # To try LiteRT-LM, the easiest way is to use our CLI tool. # 1. Install the LiteRT-LM CLI tool: pip install -U litert-lm # 2. Download and run this model locally: # See: https://ai.google.dev/edge/litert-lm/cli litert-lm run \ --from-huggingface-repo=bclermo/hermes-edge \ --prompt="Write me a poem"
- Notebooks
- Google Colab
- Kaggle
Upload folder using huggingface_hub
Browse files- __init__.py +16 -0
- agent.py +196 -0
- chat_template.py +102 -0
- config.py +210 -0
- inference.py +334 -0
- kv_cache.py +311 -0
- litert_model.py +176 -0
- model.py +248 -0
- quantization.py +301 -0
__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hermes Edge — Package Init
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
__version__ = "0.2.0"
|
| 6 |
+
__author__ = "Barry Clerjuste"
|
| 7 |
+
__email__ = "bclerjuste@gmail.com"
|
| 8 |
+
|
| 9 |
+
from hermes.config import HermesConfig, get_config, PRESETS
|
| 10 |
+
from hermes.chat_template import build_prompt, Message
|
| 11 |
+
from hermes.litert_model import LiteRTModel
|
| 12 |
+
|
| 13 |
+
try:
|
| 14 |
+
from hermes.agent import HermesAgent, AgentConfig
|
| 15 |
+
except ImportError:
|
| 16 |
+
pass
|
agent.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hermes Edge Agent — On-Device AI Agent Framework
|
| 3 |
+
|
| 4 |
+
Combines DeepSeek-style reasoning + Hermes tool calling + LiteRT-LM runtime
|
| 5 |
+
into a coherent agent loop for on-device inference.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
from hermes.agent import HermesAgent
|
| 9 |
+
from hermes.tools import ToolRegistry
|
| 10 |
+
from hermes.litert_model import LiteRTModel
|
| 11 |
+
|
| 12 |
+
model = LiteRTModel("/path/to/model.litertlm")
|
| 13 |
+
agent = HermesAgent(model)
|
| 14 |
+
response = agent.run("What's the weather?")
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
import logging
|
| 18 |
+
import time
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
+
|
| 21 |
+
from hermes.chat_template import build_prompt, Message
|
| 22 |
+
from scripts.deepseek_reasoning_template import ReasoningPipeline, ReasoningResult
|
| 23 |
+
from scripts.hermes_tool_format import ToolRegistry, HermesToolFormatter
|
| 24 |
+
from scripts.dspark_draft import DSparkDraftEngine, DSparkConfig, NGramDraftModel
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass
|
| 30 |
+
class AgentConfig:
|
| 31 |
+
max_tool_rounds: int = 5
|
| 32 |
+
max_tokens: int = 512
|
| 33 |
+
temperature: float = 0.7
|
| 34 |
+
top_k: int = 40
|
| 35 |
+
use_reasoning: bool = True
|
| 36 |
+
use_speculative_decoding: bool = True
|
| 37 |
+
draft_k: int = 4
|
| 38 |
+
system_prompt: str = ""
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
DEFAULT_SYSTEM = (
|
| 42 |
+
"You are Hermes Edge, an on-device AI agent powered by Raven AI ecosystem. "
|
| 43 |
+
"You run fully offline via LiteRT-LM on iPhone 16 / Android. "
|
| 44 |
+
"You have access to tools and can reason step by step. "
|
| 45 |
+
"Always prefer local computation. Be helpful, concise, and accurate."
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@dataclass
|
| 50 |
+
class AgentTurn:
|
| 51 |
+
user_input: str = ""
|
| 52 |
+
assistant_response: str = ""
|
| 53 |
+
thinking: str = ""
|
| 54 |
+
tool_calls: list[dict] = field(default_factory=list)
|
| 55 |
+
tool_results: list[dict] = field(default_factory=list)
|
| 56 |
+
latency_ms: float = 0.0
|
| 57 |
+
tokens_used: int = 0
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass
|
| 61 |
+
class Conversation:
|
| 62 |
+
messages: list[Message] = field(default_factory=list)
|
| 63 |
+
turns: list[AgentTurn] = field(default_factory=list)
|
| 64 |
+
|
| 65 |
+
def add_user(self, text: str) -> None:
|
| 66 |
+
self.messages.append(Message(role="user", content=text))
|
| 67 |
+
|
| 68 |
+
def add_assistant(self, text: str) -> None:
|
| 69 |
+
self.messages.append(Message(role="assistant", content=text))
|
| 70 |
+
|
| 71 |
+
def add_tool_result(self, name: str, content: str) -> None:
|
| 72 |
+
self.messages.append(Message(role="tool", content=f"<tool_response>{name}: {content}</tool_response>"))
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class HermesAgent:
|
| 76 |
+
"""Full agent loop combining reasoning, tool calling, and speculative decoding."""
|
| 77 |
+
|
| 78 |
+
def __init__(
|
| 79 |
+
self,
|
| 80 |
+
model=None,
|
| 81 |
+
tool_registry: ToolRegistry | None = None,
|
| 82 |
+
config: AgentConfig | None = None,
|
| 83 |
+
):
|
| 84 |
+
self.model = model
|
| 85 |
+
self.config = config or AgentConfig()
|
| 86 |
+
self.tools = tool_registry or ToolRegistry()
|
| 87 |
+
self.conversation = Conversation()
|
| 88 |
+
self.reasoning = ReasoningPipeline(use_reasoning=self.config.use_reasoning)
|
| 89 |
+
self.tool_formatter = HermesToolFormatter()
|
| 90 |
+
self.draft_engine: DSparkDraftEngine | None = None
|
| 91 |
+
self._init_draft_engine()
|
| 92 |
+
|
| 93 |
+
def _init_draft_engine(self) -> None:
|
| 94 |
+
if self.config.use_speculative_decoding and self.model is not None:
|
| 95 |
+
vocab_size = getattr(self.model, "vocab_size", 32000)
|
| 96 |
+
draft = NGramDraftModel(vocab_size=vocab_size, max_order=3)
|
| 97 |
+
dconfig = DSparkConfig(
|
| 98 |
+
draft_k=self.config.draft_k,
|
| 99 |
+
temperature=self.config.temperature,
|
| 100 |
+
top_k=self.config.top_k,
|
| 101 |
+
)
|
| 102 |
+
self.draft_engine = DSparkDraftEngine(self.model, draft, dconfig)
|
| 103 |
+
|
| 104 |
+
def set_model(self, model) -> None:
|
| 105 |
+
self.model = model
|
| 106 |
+
self._init_draft_engine()
|
| 107 |
+
|
| 108 |
+
def register_tool(self, name: str, description: str, func, parameters: dict | None = None) -> None:
|
| 109 |
+
self.tools.register(name, description, func, parameters)
|
| 110 |
+
|
| 111 |
+
def run(self, user_input: str, context: str | None = None) -> str:
|
| 112 |
+
"""Process a user input through the full agent pipeline."""
|
| 113 |
+
if not self.model:
|
| 114 |
+
return "Error: No model loaded."
|
| 115 |
+
|
| 116 |
+
turn = AgentTurn(user_input=user_input)
|
| 117 |
+
start = time.perf_counter()
|
| 118 |
+
|
| 119 |
+
if self.config.use_reasoning:
|
| 120 |
+
prompt = self.reasoning.build_reasoning_prompt(user_input, context)
|
| 121 |
+
else:
|
| 122 |
+
tool_defs = self.tools.get_defs()
|
| 123 |
+
self.tool_formatter.set_tools(tool_defs)
|
| 124 |
+
prompt = self.tool_formatter.build_tool_prompt(user_input, context=context)
|
| 125 |
+
|
| 126 |
+
raw_output = self._generate(prompt)
|
| 127 |
+
turn.tokens_used = len(raw_output) // 4
|
| 128 |
+
|
| 129 |
+
parsed = self.reasoning.parse_response(raw_output)
|
| 130 |
+
turn.thinking = parsed.thinking
|
| 131 |
+
turn.assistant_response = parsed.answer
|
| 132 |
+
turn.tool_calls = parsed.tool_calls
|
| 133 |
+
|
| 134 |
+
tool_round = 0
|
| 135 |
+
while parsed.tool_calls and tool_round < self.config.max_tool_rounds:
|
| 136 |
+
tool_round += 1
|
| 137 |
+
for call in parsed.tool_calls:
|
| 138 |
+
name = call.get("name", "")
|
| 139 |
+
args = call.get("arguments", {})
|
| 140 |
+
result = self.tools.execute(name, args)
|
| 141 |
+
turn.tool_results.append({"name": name, "content": result.content, "success": result.success})
|
| 142 |
+
self.conversation.add_tool_result(name, result.content)
|
| 143 |
+
|
| 144 |
+
tool_prompt = self.reasoning.build_tool_result_prompt(
|
| 145 |
+
tool_name=name if parsed.tool_calls else "unknown",
|
| 146 |
+
tool_content=result.content if parsed.tool_calls else "",
|
| 147 |
+
original_prompt=prompt,
|
| 148 |
+
)
|
| 149 |
+
raw_output = self._generate(tool_prompt)
|
| 150 |
+
parsed = self.reasoning.parse_response(raw_output)
|
| 151 |
+
turn.assistant_response += "\n" + parsed.answer
|
| 152 |
+
turn.tool_calls.extend(parsed.tool_calls)
|
| 153 |
+
|
| 154 |
+
turn.latency_ms = (time.perf_counter() - start) * 1000
|
| 155 |
+
self.conversation.turns.append(turn)
|
| 156 |
+
self.conversation.add_user(user_input)
|
| 157 |
+
self.conversation.add_assistant(turn.assistant_response)
|
| 158 |
+
|
| 159 |
+
log.info(
|
| 160 |
+
"Agent turn: %d ms, %d tokens, %d tool calls, reasoning=%s",
|
| 161 |
+
turn.latency_ms,
|
| 162 |
+
turn.tokens_used,
|
| 163 |
+
len(turn.tool_calls),
|
| 164 |
+
bool(turn.thinking),
|
| 165 |
+
)
|
| 166 |
+
return turn.assistant_response
|
| 167 |
+
|
| 168 |
+
def _generate(self, prompt: str) -> str:
|
| 169 |
+
"""Generate text using the model, optionally with speculative decoding."""
|
| 170 |
+
try:
|
| 171 |
+
if self.draft_engine and self.model:
|
| 172 |
+
prompt_ids = self._encode(prompt)
|
| 173 |
+
result = self.draft_engine.speculative_generate(
|
| 174 |
+
prompt_ids=prompt_ids,
|
| 175 |
+
max_tokens=self.config.max_tokens,
|
| 176 |
+
tokenizer=getattr(self.model, "tokenizer", None),
|
| 177 |
+
)
|
| 178 |
+
if result.text:
|
| 179 |
+
return result.text
|
| 180 |
+
except Exception as exc:
|
| 181 |
+
log.warning("Speculative decoding failed, falling back: %s", exc)
|
| 182 |
+
|
| 183 |
+
if hasattr(self.model, "generate"):
|
| 184 |
+
return self.model.generate(prompt, max_tokens=self.config.max_tokens)
|
| 185 |
+
return f"[Model would generate response for: {prompt[:50]}...]"
|
| 186 |
+
|
| 187 |
+
@staticmethod
|
| 188 |
+
def _encode(text: str) -> list[int]:
|
| 189 |
+
return list(text.encode("utf-8")[:256])
|
| 190 |
+
|
| 191 |
+
def get_conversation_summary(self) -> str:
|
| 192 |
+
"""Get a summary of the conversation."""
|
| 193 |
+
turns = len(self.conversation.turns)
|
| 194 |
+
total_tokens = sum(t.tokens_used for t in self.conversation.turns)
|
| 195 |
+
total_latency = sum(t.latency_ms for t in self.conversation.turns)
|
| 196 |
+
return f"{turns} turns, ~{total_tokens} tokens, ~{total_latency:.0f}ms total"
|
chat_template.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hermes chat + tool-calling prompt format.
|
| 2 |
+
|
| 3 |
+
The format follows the ChatML-style convention used by the original Hermes
|
| 4 |
+
models (`<|im_start|>role ... <|im_end|>`) and adds explicit tool-call markers
|
| 5 |
+
so the on-device model can emit structured function calls that the Google AI
|
| 6 |
+
Edge Gallery Agent Skills runtime can parse and dispatch.
|
| 7 |
+
|
| 8 |
+
A tool call is emitted as::
|
| 9 |
+
|
| 10 |
+
<tool_call>{"name": "calculator", "arguments": {"expression": "2+2"}}</tool_call>
|
| 11 |
+
|
| 12 |
+
Constrained decoding in LiteRT-LM can be anchored on the ``<tool_call>`` /
|
| 13 |
+
``</tool_call>`` sentinels to guarantee well-formed JSON.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
from dataclasses import dataclass
|
| 20 |
+
from typing import Any, Dict, List, Optional
|
| 21 |
+
|
| 22 |
+
IM_START = "<|im_start|>"
|
| 23 |
+
IM_END = "<|im_end|>"
|
| 24 |
+
TOOL_CALL_START = "<tool_call>"
|
| 25 |
+
TOOL_CALL_END = "</tool_call>"
|
| 26 |
+
TOOL_RESPONSE_START = "<tool_response>"
|
| 27 |
+
TOOL_RESPONSE_END = "</tool_response>"
|
| 28 |
+
|
| 29 |
+
DEFAULT_SYSTEM_PROMPT = (
|
| 30 |
+
"You are Hermes, a helpful on-device AI agent. You can call tools when "
|
| 31 |
+
"they help answer the user. To call a tool, respond ONLY with a "
|
| 32 |
+
"<tool_call> block containing JSON: "
|
| 33 |
+
'{"name": <tool_name>, "arguments": <json_args>}. '
|
| 34 |
+
"After receiving a <tool_response>, use it to answer the user."
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass
|
| 39 |
+
class Message:
|
| 40 |
+
role: str # "system" | "user" | "assistant" | "tool"
|
| 41 |
+
content: str
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def render_tools(tools: List[Dict[str, Any]]) -> str:
|
| 45 |
+
"""Render available tool schemas into the system context."""
|
| 46 |
+
if not tools:
|
| 47 |
+
return ""
|
| 48 |
+
lines = ["You have access to the following tools:"]
|
| 49 |
+
for tool in tools:
|
| 50 |
+
lines.append(json.dumps(tool, ensure_ascii=False))
|
| 51 |
+
return "\n".join(lines)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def build_prompt(
|
| 55 |
+
messages: List[Message],
|
| 56 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 57 |
+
system_prompt: str = DEFAULT_SYSTEM_PROMPT,
|
| 58 |
+
add_generation_prompt: bool = True,
|
| 59 |
+
) -> str:
|
| 60 |
+
"""Render a list of messages into the Hermes ChatML training/inference string."""
|
| 61 |
+
parts: List[str] = []
|
| 62 |
+
|
| 63 |
+
system_content = system_prompt
|
| 64 |
+
tool_block = render_tools(tools or [])
|
| 65 |
+
if tool_block:
|
| 66 |
+
system_content = f"{system_prompt}\n\n{tool_block}"
|
| 67 |
+
parts.append(f"{IM_START}system\n{system_content}{IM_END}")
|
| 68 |
+
|
| 69 |
+
for msg in messages:
|
| 70 |
+
if msg.role == "tool":
|
| 71 |
+
body = f"{TOOL_RESPONSE_START}\n{msg.content}\n{TOOL_RESPONSE_END}"
|
| 72 |
+
parts.append(f"{IM_START}tool\n{body}{IM_END}")
|
| 73 |
+
else:
|
| 74 |
+
parts.append(f"{IM_START}{msg.role}\n{msg.content}{IM_END}")
|
| 75 |
+
|
| 76 |
+
if add_generation_prompt:
|
| 77 |
+
parts.append(f"{IM_START}assistant\n")
|
| 78 |
+
return "\n".join(parts)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def format_tool_call(name: str, arguments: Dict[str, Any]) -> str:
|
| 82 |
+
"""Serialize a tool call into the sentinel-wrapped JSON the model emits."""
|
| 83 |
+
payload = json.dumps({"name": name, "arguments": arguments}, ensure_ascii=False)
|
| 84 |
+
return f"{TOOL_CALL_START}{payload}{TOOL_CALL_END}"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def parse_tool_call(text: str) -> Optional[Dict[str, Any]]:
|
| 88 |
+
"""Extract a tool call from model output, or None if absent/malformed."""
|
| 89 |
+
start = text.find(TOOL_CALL_START)
|
| 90 |
+
if start == -1:
|
| 91 |
+
return None
|
| 92 |
+
start += len(TOOL_CALL_START)
|
| 93 |
+
end = text.find(TOOL_CALL_END, start)
|
| 94 |
+
snippet = text[start:end] if end != -1 else text[start:]
|
| 95 |
+
try:
|
| 96 |
+
call = json.loads(snippet.strip())
|
| 97 |
+
except json.JSONDecodeError:
|
| 98 |
+
return None
|
| 99 |
+
if not isinstance(call, dict) or "name" not in call:
|
| 100 |
+
return None
|
| 101 |
+
call.setdefault("arguments", {})
|
| 102 |
+
return call
|
config.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Architecture configuration for the Hermes mobile transformer.
|
| 2 |
+
|
| 3 |
+
The configuration intentionally mirrors the knobs exposed by
|
| 4 |
+
``ai_edge_torch.generative.layers.model_config`` so that the same numbers can
|
| 5 |
+
drive both the reference PyTorch implementation (used for training) and the
|
| 6 |
+
LiteRT conversion path. Keeping a single source of truth avoids the classic
|
| 7 |
+
"the converted graph does not match the trained weights" failure mode.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from dataclasses import dataclass, field
|
| 13 |
+
from typing import Optional
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class HermesConfig:
|
| 18 |
+
"""Hyper-parameters for a decoder-only, grouped-query-attention model.
|
| 19 |
+
|
| 20 |
+
Attributes:
|
| 21 |
+
vocab_size: SentencePiece vocabulary size (must match the tokenizer).
|
| 22 |
+
hidden_size: Model / embedding dimension.
|
| 23 |
+
intermediate_size: Feed-forward (MLP) inner dimension.
|
| 24 |
+
num_layers: Number of transformer decoder blocks.
|
| 25 |
+
num_heads: Number of query attention heads.
|
| 26 |
+
num_kv_heads: Number of key/value heads (GQA). Must divide num_heads.
|
| 27 |
+
head_dim: Dimension per attention head.
|
| 28 |
+
max_seq_len: Maximum context window (KV-cache length) in tokens.
|
| 29 |
+
rope_theta: RoPE base frequency.
|
| 30 |
+
rms_norm_eps: Epsilon for RMSNorm numerical stability.
|
| 31 |
+
tie_embeddings: Share input embedding and output projection weights.
|
| 32 |
+
pad_token_id / bos_token_id / eos_token_id: Special token ids.
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
vocab_size: int = 32000
|
| 36 |
+
hidden_size: int = 2048
|
| 37 |
+
intermediate_size: int = 5632
|
| 38 |
+
num_layers: int = 22
|
| 39 |
+
num_heads: int = 32
|
| 40 |
+
num_kv_heads: int = 4
|
| 41 |
+
head_dim: int = 64
|
| 42 |
+
max_seq_len: int = 4096
|
| 43 |
+
rope_theta: float = 10000.0
|
| 44 |
+
rms_norm_eps: float = 1e-6
|
| 45 |
+
tie_embeddings: bool = True
|
| 46 |
+
pad_token_id: int = 0
|
| 47 |
+
bos_token_id: int = 1
|
| 48 |
+
eos_token_id: int = 2
|
| 49 |
+
# Tool-call sentinel tokens reserved in the tokenizer for constrained
|
| 50 |
+
# decoding of function calls (see scripts/train.py chat template).
|
| 51 |
+
tool_call_start_id: Optional[int] = 3
|
| 52 |
+
tool_call_end_id: Optional[int] = 4
|
| 53 |
+
|
| 54 |
+
def __post_init__(self) -> None:
|
| 55 |
+
if self.num_heads % self.num_kv_heads != 0:
|
| 56 |
+
raise ValueError(
|
| 57 |
+
f"num_heads ({self.num_heads}) must be divisible by "
|
| 58 |
+
f"num_kv_heads ({self.num_kv_heads}) for grouped-query attention."
|
| 59 |
+
)
|
| 60 |
+
if self.hidden_size != self.num_heads * self.head_dim:
|
| 61 |
+
raise ValueError(
|
| 62 |
+
f"hidden_size ({self.hidden_size}) must equal "
|
| 63 |
+
f"num_heads * head_dim ({self.num_heads * self.head_dim})."
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
@property
|
| 67 |
+
def num_query_groups(self) -> int:
|
| 68 |
+
"""Heads per KV group (the GQA sharing factor)."""
|
| 69 |
+
return self.num_heads // self.num_kv_heads
|
| 70 |
+
|
| 71 |
+
def estimated_parameters(self) -> int:
|
| 72 |
+
"""Rough parameter count (weights only, ignoring norms/biases)."""
|
| 73 |
+
emb = self.vocab_size * self.hidden_size
|
| 74 |
+
q = self.hidden_size * self.num_heads * self.head_dim
|
| 75 |
+
kv = 2 * self.hidden_size * self.num_kv_heads * self.head_dim
|
| 76 |
+
o = self.num_heads * self.head_dim * self.hidden_size
|
| 77 |
+
attn = q + kv + o
|
| 78 |
+
mlp = 3 * self.hidden_size * self.intermediate_size # gate, up, down
|
| 79 |
+
per_layer = attn + mlp
|
| 80 |
+
total = emb + self.num_layers * per_layer
|
| 81 |
+
if not self.tie_embeddings:
|
| 82 |
+
total += emb # separate lm_head
|
| 83 |
+
return total
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def hermes_1b_config() -> HermesConfig:
|
| 87 |
+
"""~1B parameter variant — the default mobile target (~600 MB at INT4)."""
|
| 88 |
+
return HermesConfig(
|
| 89 |
+
vocab_size=32000,
|
| 90 |
+
hidden_size=2048,
|
| 91 |
+
intermediate_size=5632,
|
| 92 |
+
num_layers=22,
|
| 93 |
+
num_heads=32,
|
| 94 |
+
num_kv_heads=4,
|
| 95 |
+
head_dim=64,
|
| 96 |
+
max_seq_len=4096,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def hermes_500m_config() -> HermesConfig:
|
| 101 |
+
"""~500M parameter variant — quality/speed sweet spot (~280 MB at INT4)."""
|
| 102 |
+
return HermesConfig(
|
| 103 |
+
vocab_size=32000,
|
| 104 |
+
hidden_size=1536,
|
| 105 |
+
intermediate_size=4096,
|
| 106 |
+
num_layers=24,
|
| 107 |
+
num_heads=24,
|
| 108 |
+
num_kv_heads=6,
|
| 109 |
+
head_dim=64,
|
| 110 |
+
max_seq_len=4096,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def hermes_270m_config() -> HermesConfig:
|
| 115 |
+
"""~270M parameter variant — smallest, FunctionGemma-class footprint."""
|
| 116 |
+
return HermesConfig(
|
| 117 |
+
vocab_size=32000,
|
| 118 |
+
hidden_size=1024,
|
| 119 |
+
intermediate_size=2816,
|
| 120 |
+
num_layers=21,
|
| 121 |
+
num_heads=16,
|
| 122 |
+
num_kv_heads=4,
|
| 123 |
+
head_dim=64,
|
| 124 |
+
max_seq_len=4096,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ── Gemma-inspired presets (optimized for iPhone 16 A18 Pro / ANE) ──────────
|
| 129 |
+
|
| 130 |
+
def gemma_3_1b_config() -> HermesConfig:
|
| 131 |
+
"""Gemma 3 1B — Google's latest small model architecture.
|
| 132 |
+
|
| 133 |
+
Optimized for on-device inference with Apple Neural Engine:
|
| 134 |
+
- 32k vocab, 26 layers, 2048 hidden dim
|
| 135 |
+
- 16 heads, 8 KV heads (GQA ratio 2:1 — efficient for ANE)
|
| 136 |
+
- 8192 context window for longer conversations
|
| 137 |
+
- Ideal for iPhone 16 A18 Pro at INT4 (~250 MB on disk)
|
| 138 |
+
"""
|
| 139 |
+
return HermesConfig(
|
| 140 |
+
vocab_size=32768,
|
| 141 |
+
hidden_size=2048,
|
| 142 |
+
intermediate_size=8192,
|
| 143 |
+
num_layers=26,
|
| 144 |
+
num_heads=16,
|
| 145 |
+
num_kv_heads=8,
|
| 146 |
+
head_dim=128,
|
| 147 |
+
max_seq_len=8192,
|
| 148 |
+
rope_theta=10000.0,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def gemma_2_2b_config() -> HermesConfig:
|
| 153 |
+
"""Gemma 2 2B — higher quality with shared KV-heads.
|
| 154 |
+
|
| 155 |
+
Uses deeper GQA (2 KV heads shared across 16 query heads) for
|
| 156 |
+
memory-efficient inference on iPhone 16 Pro / Pro Max.
|
| 157 |
+
~1.1 GB at INT4.
|
| 158 |
+
"""
|
| 159 |
+
return HermesConfig(
|
| 160 |
+
vocab_size=32768,
|
| 161 |
+
hidden_size=2560,
|
| 162 |
+
intermediate_size=9216,
|
| 163 |
+
num_layers=26,
|
| 164 |
+
num_heads=16,
|
| 165 |
+
num_kv_heads=2,
|
| 166 |
+
head_dim=160,
|
| 167 |
+
max_seq_len=8192,
|
| 168 |
+
rope_theta=10000.0,
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
# ── DeepSeek-inspired distilled presets ─────────────────────────────────────
|
| 173 |
+
|
| 174 |
+
def hermes_distilled_1b_config() -> HermesConfig:
|
| 175 |
+
"""Distilled 1B model using DeepSeek-R1 reasoning principles.
|
| 176 |
+
|
| 177 |
+
Knowledge distilled from Gemma 3 1B teacher. Maintains the same
|
| 178 |
+
architecture as hermes-1b but with extended context and tuned
|
| 179 |
+
for step-by-step reasoning before tool calls.
|
| 180 |
+
"""
|
| 181 |
+
return HermesConfig(
|
| 182 |
+
vocab_size=32000,
|
| 183 |
+
hidden_size=2048,
|
| 184 |
+
intermediate_size=5632,
|
| 185 |
+
num_layers=22,
|
| 186 |
+
num_heads=32,
|
| 187 |
+
num_kv_heads=4,
|
| 188 |
+
head_dim=64,
|
| 189 |
+
max_seq_len=8192,
|
| 190 |
+
rope_theta=10000.0,
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
PRESETS = {
|
| 195 |
+
"hermes-1b": hermes_1b_config,
|
| 196 |
+
"hermes-500m": hermes_500m_config,
|
| 197 |
+
"hermes-270m": hermes_270m_config,
|
| 198 |
+
"gemma-3-1b": gemma_3_1b_config,
|
| 199 |
+
"gemma-2-2b": gemma_2_2b_config,
|
| 200 |
+
"hermes-distilled-1b": hermes_distilled_1b_config,
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def get_config(name: str) -> HermesConfig:
|
| 205 |
+
"""Look up a preset config by name."""
|
| 206 |
+
if name not in PRESETS:
|
| 207 |
+
raise KeyError(
|
| 208 |
+
f"Unknown preset '{name}'. Available: {sorted(PRESETS)}"
|
| 209 |
+
)
|
| 210 |
+
return PRESETS[name]()
|
inference.py
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Streaming inference engine for the Hermes mobile transformer.
|
| 2 |
+
|
| 3 |
+
:class:`HermesInference` wraps a :class:`~hermes.model.HermesForCausalLM` and a
|
| 4 |
+
SentencePiece tokenizer into a single object with three entry points:
|
| 5 |
+
|
| 6 |
+
* :meth:`generate` — low-level text completion with nucleus (top-p), top-k, and
|
| 7 |
+
repetition-penalty sampling, optionally streaming token strings as they decode.
|
| 8 |
+
* :meth:`chat` — renders a message list through the Hermes ChatML template, then
|
| 9 |
+
generates an assistant turn.
|
| 10 |
+
* :meth:`tool_call_loop` — the agentic loop: generate, parse any ``<tool_call>``,
|
| 11 |
+
dispatch it to a Python callable, feed the ``<tool_response>`` back, and repeat
|
| 12 |
+
until the model produces a plain answer (or ``max_rounds`` is hit).
|
| 13 |
+
|
| 14 |
+
Decoding reuses the existing :class:`~hermes.model.Attention` KV-cache: the
|
| 15 |
+
prompt is run once to prime per-layer caches, then each new token is decoded with
|
| 16 |
+
a single-position forward pass, so cost is linear in generated length rather than
|
| 17 |
+
quadratic.
|
| 18 |
+
|
| 19 |
+
The tokenizer is duck-typed: anything exposing ``encode(str) -> list[int]`` and
|
| 20 |
+
``decode(list[int]) -> str`` works, which covers ``sentencepiece`` and the tiny
|
| 21 |
+
byte-level stub used in tests.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple, Union
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn.functional as F
|
| 30 |
+
|
| 31 |
+
from hermes.chat_template import (
|
| 32 |
+
Message,
|
| 33 |
+
build_prompt,
|
| 34 |
+
parse_tool_call,
|
| 35 |
+
)
|
| 36 |
+
from hermes.config import HermesConfig
|
| 37 |
+
from hermes.model import HermesForCausalLM, build_model
|
| 38 |
+
|
| 39 |
+
KVList = List[Optional[Tuple[torch.Tensor, torch.Tensor]]]
|
| 40 |
+
ToolFn = Callable[..., Any]
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class HermesInference:
|
| 44 |
+
"""Load a Hermes checkpoint + tokenizer and run streaming generation."""
|
| 45 |
+
|
| 46 |
+
def __init__(
|
| 47 |
+
self,
|
| 48 |
+
model: HermesForCausalLM,
|
| 49 |
+
tokenizer: Any,
|
| 50 |
+
device: Union[str, torch.device] = "cpu",
|
| 51 |
+
preset_name: str = "custom",
|
| 52 |
+
) -> None:
|
| 53 |
+
self.device = torch.device(device)
|
| 54 |
+
self.model = model.to(self.device).eval()
|
| 55 |
+
self.tokenizer = tokenizer
|
| 56 |
+
self.config: HermesConfig = model.config
|
| 57 |
+
self.preset_name = preset_name
|
| 58 |
+
|
| 59 |
+
# ------------------------------------------------------------------ #
|
| 60 |
+
# Construction helpers
|
| 61 |
+
# ------------------------------------------------------------------ #
|
| 62 |
+
@classmethod
|
| 63 |
+
def from_checkpoint(
|
| 64 |
+
cls,
|
| 65 |
+
config: HermesConfig,
|
| 66 |
+
checkpoint_path: Optional[str],
|
| 67 |
+
tokenizer: Any,
|
| 68 |
+
device: Union[str, torch.device] = "cpu",
|
| 69 |
+
preset_name: str = "custom",
|
| 70 |
+
) -> "HermesInference":
|
| 71 |
+
"""Build a model from ``config``, optionally load weights, and wrap it.
|
| 72 |
+
|
| 73 |
+
If ``checkpoint_path`` is None the model keeps its random init — handy for
|
| 74 |
+
CI and shape tests that don't need a trained checkpoint.
|
| 75 |
+
"""
|
| 76 |
+
model = build_model(config)
|
| 77 |
+
if checkpoint_path is not None:
|
| 78 |
+
ckpt = torch.load(checkpoint_path, map_location="cpu", weights_only=False)
|
| 79 |
+
state_dict = ckpt.get("model", ckpt) if isinstance(ckpt, dict) else ckpt
|
| 80 |
+
model.load_state_dict(state_dict, strict=False)
|
| 81 |
+
return cls(model, tokenizer, device=device, preset_name=preset_name)
|
| 82 |
+
|
| 83 |
+
def __repr__(self) -> str:
|
| 84 |
+
n_params = sum(p.numel() for p in self.model.parameters())
|
| 85 |
+
return (
|
| 86 |
+
f"HermesInference(preset={self.preset_name!r}, "
|
| 87 |
+
f"params={n_params / 1e6:.1f}M, "
|
| 88 |
+
f"layers={self.config.num_layers}, ctx={self.config.max_seq_len}, "
|
| 89 |
+
f"device={self.device.type})"
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# ------------------------------------------------------------------ #
|
| 93 |
+
# Sampling
|
| 94 |
+
# ------------------------------------------------------------------ #
|
| 95 |
+
@staticmethod
|
| 96 |
+
def _apply_repetition_penalty(
|
| 97 |
+
logits: torch.Tensor, generated: List[int], penalty: float
|
| 98 |
+
) -> torch.Tensor:
|
| 99 |
+
"""Divide logits of already-seen tokens by ``penalty`` (CTRL-style)."""
|
| 100 |
+
if penalty == 1.0 or not generated:
|
| 101 |
+
return logits
|
| 102 |
+
idx = torch.tensor(sorted(set(generated)), device=logits.device)
|
| 103 |
+
selected = logits.index_select(-1, idx)
|
| 104 |
+
# Positive logits are divided, negative are multiplied (push both down).
|
| 105 |
+
selected = torch.where(selected > 0, selected / penalty, selected * penalty)
|
| 106 |
+
logits = logits.index_copy(-1, idx, selected)
|
| 107 |
+
return logits
|
| 108 |
+
|
| 109 |
+
@staticmethod
|
| 110 |
+
def _sample(
|
| 111 |
+
logits: torch.Tensor,
|
| 112 |
+
temperature: float,
|
| 113 |
+
top_p: float,
|
| 114 |
+
top_k: int,
|
| 115 |
+
) -> int:
|
| 116 |
+
"""Sample a single token id from ``logits`` with top-k + nucleus filtering."""
|
| 117 |
+
if temperature <= 0.0:
|
| 118 |
+
return int(logits.argmax(dim=-1))
|
| 119 |
+
|
| 120 |
+
logits = logits / temperature
|
| 121 |
+
|
| 122 |
+
if top_k and top_k > 0:
|
| 123 |
+
k = min(top_k, logits.size(-1))
|
| 124 |
+
kth = torch.topk(logits, k).values[..., -1, None]
|
| 125 |
+
logits = torch.where(
|
| 126 |
+
logits < kth, torch.full_like(logits, float("-inf")), logits
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if top_p and 0.0 < top_p < 1.0:
|
| 130 |
+
sorted_logits, sorted_idx = torch.sort(logits, descending=True)
|
| 131 |
+
probs = F.softmax(sorted_logits, dim=-1)
|
| 132 |
+
cumulative = torch.cumsum(probs, dim=-1)
|
| 133 |
+
# Keep tokens up to and including the one that crosses top_p.
|
| 134 |
+
remove = cumulative - probs > top_p
|
| 135 |
+
sorted_logits = sorted_logits.masked_fill(remove, float("-inf"))
|
| 136 |
+
logits = torch.full_like(logits, float("-inf")).scatter(
|
| 137 |
+
-1, sorted_idx, sorted_logits
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
probs = F.softmax(logits, dim=-1)
|
| 141 |
+
return int(torch.multinomial(probs, num_samples=1))
|
| 142 |
+
|
| 143 |
+
# ------------------------------------------------------------------ #
|
| 144 |
+
# KV-cache primed decode
|
| 145 |
+
# ------------------------------------------------------------------ #
|
| 146 |
+
def _forward_with_cache(
|
| 147 |
+
self,
|
| 148 |
+
input_ids: torch.Tensor,
|
| 149 |
+
caches: KVList,
|
| 150 |
+
start_pos: int,
|
| 151 |
+
) -> Tuple[torch.Tensor, KVList]:
|
| 152 |
+
"""Run the model for ``input_ids`` reusing/extending per-layer KV caches.
|
| 153 |
+
|
| 154 |
+
Returns the last-position logits and the updated cache list. This bypasses
|
| 155 |
+
``HermesForCausalLM.forward`` so it can thread the per-layer cache tuples
|
| 156 |
+
through the existing :class:`Attention` ``kv_cache`` argument.
|
| 157 |
+
"""
|
| 158 |
+
model = self.model
|
| 159 |
+
b, t = input_ids.shape
|
| 160 |
+
x = model.embed_tokens(input_ids)
|
| 161 |
+
cos = model.rope_cos[start_pos : start_pos + t].to(x.device)
|
| 162 |
+
sin = model.rope_sin[start_pos : start_pos + t].to(x.device)
|
| 163 |
+
|
| 164 |
+
# Causal mask over the *full* attended length (past + current).
|
| 165 |
+
total = start_pos + t
|
| 166 |
+
full_mask = torch.full((t, total), float("-inf"), device=x.device)
|
| 167 |
+
full_mask = torch.triu(full_mask, diagonal=1 + start_pos)
|
| 168 |
+
|
| 169 |
+
new_caches: KVList = [None] * len(model.layers)
|
| 170 |
+
for i, layer in enumerate(model.layers):
|
| 171 |
+
h, new_cache = layer.self_attn(
|
| 172 |
+
layer.input_layernorm(x), cos, sin, full_mask, caches[i]
|
| 173 |
+
)
|
| 174 |
+
x = x + h
|
| 175 |
+
x = x + layer.mlp(layer.post_attention_layernorm(x))
|
| 176 |
+
new_caches[i] = new_cache
|
| 177 |
+
|
| 178 |
+
x = model.norm(x)
|
| 179 |
+
logits = model.lm_head(x[:, -1, :])
|
| 180 |
+
return logits, new_caches
|
| 181 |
+
|
| 182 |
+
@torch.no_grad()
|
| 183 |
+
def _generate_ids(
|
| 184 |
+
self,
|
| 185 |
+
prompt_ids: List[int],
|
| 186 |
+
max_new_tokens: int,
|
| 187 |
+
temperature: float,
|
| 188 |
+
top_p: float,
|
| 189 |
+
top_k: int,
|
| 190 |
+
repetition_penalty: float,
|
| 191 |
+
) -> Iterator[int]:
|
| 192 |
+
"""Yield newly generated token ids one at a time, using a KV cache."""
|
| 193 |
+
self.model.eval()
|
| 194 |
+
eos = self.config.eos_token_id
|
| 195 |
+
caches: KVList = [None] * len(self.model.layers)
|
| 196 |
+
|
| 197 |
+
# Keep room for at least one generated token: truncate the prompt to the
|
| 198 |
+
# most recent (max_seq_len - 1) tokens if it would otherwise fill context.
|
| 199 |
+
max_prompt = max(1, self.config.max_seq_len - 1)
|
| 200 |
+
if len(prompt_ids) > max_prompt:
|
| 201 |
+
prompt_ids = prompt_ids[-max_prompt:]
|
| 202 |
+
|
| 203 |
+
# Prime the cache on the full prompt in one prefill pass.
|
| 204 |
+
ids = torch.tensor([prompt_ids], dtype=torch.long, device=self.device)
|
| 205 |
+
logits, caches = self._forward_with_cache(ids, caches, start_pos=0)
|
| 206 |
+
pos = len(prompt_ids)
|
| 207 |
+
|
| 208 |
+
generated: List[int] = []
|
| 209 |
+
for _ in range(max_new_tokens):
|
| 210 |
+
step_logits = self._apply_repetition_penalty(
|
| 211 |
+
logits.clone(), prompt_ids + generated, repetition_penalty
|
| 212 |
+
)
|
| 213 |
+
next_id = self._sample(step_logits.squeeze(0), temperature, top_p, top_k)
|
| 214 |
+
if next_id == eos:
|
| 215 |
+
break
|
| 216 |
+
generated.append(next_id)
|
| 217 |
+
yield next_id
|
| 218 |
+
|
| 219 |
+
if pos >= self.config.max_seq_len:
|
| 220 |
+
break
|
| 221 |
+
step = torch.tensor([[next_id]], dtype=torch.long, device=self.device)
|
| 222 |
+
logits, caches = self._forward_with_cache(step, caches, start_pos=pos)
|
| 223 |
+
pos += 1
|
| 224 |
+
|
| 225 |
+
# ------------------------------------------------------------------ #
|
| 226 |
+
# Public generation API
|
| 227 |
+
# ------------------------------------------------------------------ #
|
| 228 |
+
def generate(
|
| 229 |
+
self,
|
| 230 |
+
prompt: str,
|
| 231 |
+
max_new_tokens: int = 128,
|
| 232 |
+
temperature: float = 0.8,
|
| 233 |
+
top_p: float = 0.95,
|
| 234 |
+
top_k: int = 50,
|
| 235 |
+
repetition_penalty: float = 1.1,
|
| 236 |
+
stream: bool = False,
|
| 237 |
+
) -> Union[str, Iterator[str]]:
|
| 238 |
+
"""Generate text from ``prompt``.
|
| 239 |
+
|
| 240 |
+
Returns the full completion string, or — if ``stream=True`` — a generator
|
| 241 |
+
that yields incremental token strings as they are produced.
|
| 242 |
+
"""
|
| 243 |
+
prompt_ids = self.tokenizer.encode(prompt)
|
| 244 |
+
|
| 245 |
+
def _token_strings() -> Iterator[str]:
|
| 246 |
+
prev_text = ""
|
| 247 |
+
buffer: List[int] = []
|
| 248 |
+
for tok in self._generate_ids(
|
| 249 |
+
prompt_ids, max_new_tokens, temperature, top_p, top_k, repetition_penalty
|
| 250 |
+
):
|
| 251 |
+
buffer.append(tok)
|
| 252 |
+
# Decode incrementally so multi-token characters render correctly.
|
| 253 |
+
text = self.tokenizer.decode(buffer)
|
| 254 |
+
delta = text[len(prev_text) :]
|
| 255 |
+
if delta:
|
| 256 |
+
prev_text = text
|
| 257 |
+
yield delta
|
| 258 |
+
|
| 259 |
+
if stream:
|
| 260 |
+
return _token_strings()
|
| 261 |
+
return "".join(_token_strings())
|
| 262 |
+
|
| 263 |
+
def chat(
|
| 264 |
+
self,
|
| 265 |
+
messages: List[Message],
|
| 266 |
+
tools: Optional[List[Dict[str, Any]]] = None,
|
| 267 |
+
**kwargs: Any,
|
| 268 |
+
) -> str:
|
| 269 |
+
"""Render ``messages`` (+ optional ``tools``) and generate a reply string."""
|
| 270 |
+
prompt = build_prompt(messages, tools=tools)
|
| 271 |
+
result = self.generate(prompt, stream=False, **kwargs)
|
| 272 |
+
assert isinstance(result, str)
|
| 273 |
+
return result
|
| 274 |
+
|
| 275 |
+
def tool_call_loop(
|
| 276 |
+
self,
|
| 277 |
+
messages: List[Message],
|
| 278 |
+
tools: List[Dict[str, Any]],
|
| 279 |
+
tool_functions: Dict[str, ToolFn],
|
| 280 |
+
max_rounds: int = 5,
|
| 281 |
+
**kwargs: Any,
|
| 282 |
+
) -> List[Message]:
|
| 283 |
+
"""Agentic loop: generate → parse tool call → dispatch → feed back.
|
| 284 |
+
|
| 285 |
+
Each round generates an assistant turn. If it contains a parseable
|
| 286 |
+
``<tool_call>``, the named callable in ``tool_functions`` is invoked with
|
| 287 |
+
the parsed arguments and its result is appended as a ``tool`` message;
|
| 288 |
+
otherwise the loop ends. Returns the full conversation including all
|
| 289 |
+
assistant and tool turns appended.
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
messages: Seed conversation (mutated copy is returned).
|
| 293 |
+
tools: Tool schemas advertised to the model in the system prompt.
|
| 294 |
+
tool_functions: Maps tool ``name`` → Python callable.
|
| 295 |
+
max_rounds: Hard cap on generate/dispatch cycles.
|
| 296 |
+
"""
|
| 297 |
+
convo = list(messages)
|
| 298 |
+
for _ in range(max_rounds):
|
| 299 |
+
reply = self.chat(convo, tools=tools, **kwargs)
|
| 300 |
+
convo.append(Message("assistant", reply))
|
| 301 |
+
|
| 302 |
+
call = parse_tool_call(reply)
|
| 303 |
+
if call is None:
|
| 304 |
+
break
|
| 305 |
+
|
| 306 |
+
fn = tool_functions.get(call["name"])
|
| 307 |
+
if fn is None:
|
| 308 |
+
convo.append(
|
| 309 |
+
Message("tool", f'{{"error": "unknown tool: {call["name"]}"}}')
|
| 310 |
+
)
|
| 311 |
+
continue
|
| 312 |
+
try:
|
| 313 |
+
result = fn(**call.get("arguments", {}))
|
| 314 |
+
except Exception as exc: # surface tool errors back to the model
|
| 315 |
+
result = {"error": str(exc)}
|
| 316 |
+
convo.append(Message("tool", str(result)))
|
| 317 |
+
return convo
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
if __name__ == "__main__": # pragma: no cover - manual smoke check
|
| 321 |
+
from hermes.config import hermes_270m_config
|
| 322 |
+
|
| 323 |
+
class _ByteTokenizer:
|
| 324 |
+
def encode(self, text: str) -> List[int]:
|
| 325 |
+
return [b % 32000 for b in text.encode("utf-8")] or [1]
|
| 326 |
+
|
| 327 |
+
def decode(self, ids: List[int]) -> str:
|
| 328 |
+
return bytes(i % 256 for i in ids).decode("utf-8", errors="replace")
|
| 329 |
+
|
| 330 |
+
engine = HermesInference.from_checkpoint(
|
| 331 |
+
hermes_270m_config(), None, _ByteTokenizer(), preset_name="hermes-270m"
|
| 332 |
+
)
|
| 333 |
+
print(engine)
|
| 334 |
+
print(engine.generate("Hello", max_new_tokens=8, temperature=0.0))
|
kv_cache.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Key/value cache managers for Hermes incremental decoding.
|
| 2 |
+
|
| 3 |
+
Three cache strategies are provided, trading off memory, context length, and
|
| 4 |
+
multi-session serving:
|
| 5 |
+
|
| 6 |
+
* :class:`StaticKVCache` — a single pre-allocated, fixed-size cache. This is the
|
| 7 |
+
shape LiteRT-LM exports on device: the converted TFLite ``decode`` signature
|
| 8 |
+
writes into a buffer sized to ``max_seq_len`` and never reallocates.
|
| 9 |
+
* :class:`SlidingWindowKVCache` — a :class:`StaticKVCache` subclass that keeps a
|
| 10 |
+
rolling window of the most recent ``window_size`` tokens, evicting the oldest
|
| 11 |
+
when full. Lets a 4096-ctx model hold an arbitrarily long conversation at a
|
| 12 |
+
bounded memory cost (at the price of forgetting distant context).
|
| 13 |
+
* :class:`PagedKVCache` — block-level paging à la vLLM. The cache is carved into
|
| 14 |
+
fixed-size blocks that are allocated on demand and freed per sequence, which
|
| 15 |
+
makes it suitable for serving many concurrent sessions from one pool.
|
| 16 |
+
|
| 17 |
+
All caches expose ``to(device)`` for device migration and
|
| 18 |
+
``state_dict``/``load_state_dict`` for clean (de)serialization.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
from typing import Dict, List, Tuple
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
KVTuple = Tuple[torch.Tensor, torch.Tensor]
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class StaticKVCache:
|
| 31 |
+
"""Pre-allocated fixed-size KV cache (the LiteRT-LM on-device shape).
|
| 32 |
+
|
| 33 |
+
Each layer owns a ``[1, num_kv_heads, max_seq_len, head_dim]`` key and value
|
| 34 |
+
buffer. :meth:`update` writes new entries at ``position`` and returns the
|
| 35 |
+
valid prefix; :meth:`get` returns the currently-filled slice.
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
num_layers: int,
|
| 41 |
+
num_kv_heads: int,
|
| 42 |
+
max_seq_len: int,
|
| 43 |
+
head_dim: int,
|
| 44 |
+
dtype: torch.dtype = torch.float32,
|
| 45 |
+
device: torch.device | str = "cpu",
|
| 46 |
+
batch_size: int = 1,
|
| 47 |
+
) -> None:
|
| 48 |
+
self.num_layers = num_layers
|
| 49 |
+
self.num_kv_heads = num_kv_heads
|
| 50 |
+
self.max_seq_len = max_seq_len
|
| 51 |
+
self.head_dim = head_dim
|
| 52 |
+
self.dtype = dtype
|
| 53 |
+
self.device = torch.device(device)
|
| 54 |
+
self.batch_size = batch_size
|
| 55 |
+
self._len = 0
|
| 56 |
+
self._alloc()
|
| 57 |
+
|
| 58 |
+
def _alloc(self) -> None:
|
| 59 |
+
shape = (self.batch_size, self.num_kv_heads, self.max_seq_len, self.head_dim)
|
| 60 |
+
self.keys: List[torch.Tensor] = [
|
| 61 |
+
torch.zeros(shape, dtype=self.dtype, device=self.device)
|
| 62 |
+
for _ in range(self.num_layers)
|
| 63 |
+
]
|
| 64 |
+
self.values: List[torch.Tensor] = [
|
| 65 |
+
torch.zeros(shape, dtype=self.dtype, device=self.device)
|
| 66 |
+
for _ in range(self.num_layers)
|
| 67 |
+
]
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def current_len(self) -> int:
|
| 71 |
+
"""Number of valid (written) timesteps in the cache."""
|
| 72 |
+
return self._len
|
| 73 |
+
|
| 74 |
+
def reset(self) -> None:
|
| 75 |
+
"""Zero the fill pointer (buffers are reused, not reallocated)."""
|
| 76 |
+
self._len = 0
|
| 77 |
+
|
| 78 |
+
def update(
|
| 79 |
+
self, layer_idx: int, k: torch.Tensor, v: torch.Tensor, position: int
|
| 80 |
+
) -> KVTuple:
|
| 81 |
+
"""Write ``k``/``v`` at ``position`` and return the valid prefix.
|
| 82 |
+
|
| 83 |
+
Args:
|
| 84 |
+
layer_idx: Which decoder layer this cache slot belongs to.
|
| 85 |
+
k: Keys ``[B, num_kv_heads, T_new, head_dim]``.
|
| 86 |
+
v: Values with the same shape.
|
| 87 |
+
position: Start timestep to write at (the current sequence length).
|
| 88 |
+
|
| 89 |
+
Returns:
|
| 90 |
+
``(keys, values)`` covering timesteps ``[0, position + T_new)``.
|
| 91 |
+
|
| 92 |
+
Raises:
|
| 93 |
+
ValueError: If the write would exceed ``max_seq_len``.
|
| 94 |
+
"""
|
| 95 |
+
t_new = k.shape[2]
|
| 96 |
+
end = position + t_new
|
| 97 |
+
if end > self.max_seq_len:
|
| 98 |
+
raise ValueError(
|
| 99 |
+
f"KV cache overflow: writing {t_new} tokens at position "
|
| 100 |
+
f"{position} exceeds max_seq_len={self.max_seq_len}."
|
| 101 |
+
)
|
| 102 |
+
self.keys[layer_idx][:, :, position:end, :] = k.to(self.dtype)
|
| 103 |
+
self.values[layer_idx][:, :, position:end, :] = v.to(self.dtype)
|
| 104 |
+
# The fill pointer tracks the furthest layer write of the current step.
|
| 105 |
+
self._len = max(self._len, end)
|
| 106 |
+
return self.get(layer_idx)
|
| 107 |
+
|
| 108 |
+
def get(self, layer_idx: int) -> KVTuple:
|
| 109 |
+
"""Return the valid ``(keys, values)`` prefix for ``layer_idx``."""
|
| 110 |
+
return (
|
| 111 |
+
self.keys[layer_idx][:, :, : self._len, :],
|
| 112 |
+
self.values[layer_idx][:, :, : self._len, :],
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
def to(self, device: torch.device | str) -> "StaticKVCache":
|
| 116 |
+
"""Move all buffers to ``device`` in place and return self."""
|
| 117 |
+
self.device = torch.device(device)
|
| 118 |
+
self.keys = [k.to(self.device) for k in self.keys]
|
| 119 |
+
self.values = [v.to(self.device) for v in self.values]
|
| 120 |
+
return self
|
| 121 |
+
|
| 122 |
+
def state_dict(self) -> Dict[str, object]:
|
| 123 |
+
"""Serialize cache contents + metadata into a plain dict."""
|
| 124 |
+
return {
|
| 125 |
+
"class": type(self).__name__,
|
| 126 |
+
"num_layers": self.num_layers,
|
| 127 |
+
"num_kv_heads": self.num_kv_heads,
|
| 128 |
+
"max_seq_len": self.max_seq_len,
|
| 129 |
+
"head_dim": self.head_dim,
|
| 130 |
+
"batch_size": self.batch_size,
|
| 131 |
+
"len": self._len,
|
| 132 |
+
"keys": [k.cpu() for k in self.keys],
|
| 133 |
+
"values": [v.cpu() for v in self.values],
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
def load_state_dict(self, state: Dict[str, object]) -> None:
|
| 137 |
+
"""Restore cache contents from :meth:`state_dict` output."""
|
| 138 |
+
self._len = int(state["len"]) # type: ignore[arg-type]
|
| 139 |
+
self.keys = [k.to(self.device) for k in state["keys"]] # type: ignore[union-attr]
|
| 140 |
+
self.values = [v.to(self.device) for v in state["values"]] # type: ignore[union-attr]
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class SlidingWindowKVCache(StaticKVCache):
|
| 144 |
+
"""Rolling-window KV cache that evicts the oldest tokens when full.
|
| 145 |
+
|
| 146 |
+
Keeps at most ``window_size`` timesteps. Once full, each new write shifts the
|
| 147 |
+
buffer left (dropping the oldest entries) so memory stays bounded — handy for
|
| 148 |
+
long-running chats inside the 4096-token model.
|
| 149 |
+
"""
|
| 150 |
+
|
| 151 |
+
def __init__(
|
| 152 |
+
self,
|
| 153 |
+
num_layers: int,
|
| 154 |
+
num_kv_heads: int,
|
| 155 |
+
max_seq_len: int,
|
| 156 |
+
head_dim: int,
|
| 157 |
+
dtype: torch.dtype = torch.float32,
|
| 158 |
+
device: torch.device | str = "cpu",
|
| 159 |
+
batch_size: int = 1,
|
| 160 |
+
window_size: int = 1024,
|
| 161 |
+
) -> None:
|
| 162 |
+
self.window_size = min(window_size, max_seq_len)
|
| 163 |
+
super().__init__(
|
| 164 |
+
num_layers, num_kv_heads, max_seq_len, head_dim, dtype, device, batch_size
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
def update(
|
| 168 |
+
self, layer_idx: int, k: torch.Tensor, v: torch.Tensor, position: int
|
| 169 |
+
) -> KVTuple:
|
| 170 |
+
t_new = k.shape[2]
|
| 171 |
+
if t_new > self.window_size:
|
| 172 |
+
# Only the most recent window_size tokens can be retained.
|
| 173 |
+
k = k[:, :, -self.window_size :, :]
|
| 174 |
+
v = v[:, :, -self.window_size :, :]
|
| 175 |
+
t_new = self.window_size
|
| 176 |
+
|
| 177 |
+
if self._len + t_new > self.window_size:
|
| 178 |
+
evict = self._len + t_new - self.window_size
|
| 179 |
+
kept = self._len - evict
|
| 180 |
+
if kept > 0:
|
| 181 |
+
self.keys[layer_idx][:, :, :kept, :] = self.keys[layer_idx][
|
| 182 |
+
:, :, evict : self._len, :
|
| 183 |
+
].clone()
|
| 184 |
+
self.values[layer_idx][:, :, :kept, :] = self.values[layer_idx][
|
| 185 |
+
:, :, evict : self._len, :
|
| 186 |
+
].clone()
|
| 187 |
+
write_at = kept
|
| 188 |
+
else:
|
| 189 |
+
write_at = self._len
|
| 190 |
+
|
| 191 |
+
end = write_at + t_new
|
| 192 |
+
self.keys[layer_idx][:, :, write_at:end, :] = k.to(self.dtype)
|
| 193 |
+
self.values[layer_idx][:, :, write_at:end, :] = v.to(self.dtype)
|
| 194 |
+
# current_len is shared across layers; cap it at the window.
|
| 195 |
+
self._len = min(end, self.window_size)
|
| 196 |
+
return self.get(layer_idx)
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class PagedKVCache:
|
| 200 |
+
"""Block-paged KV cache for multi-session serving (vLLM-style).
|
| 201 |
+
|
| 202 |
+
The KV pool is divided into ``num_blocks`` blocks of ``block_size`` tokens.
|
| 203 |
+
Sequences request blocks via :meth:`allocate_block`; :meth:`free_sequence`
|
| 204 |
+
returns a sequence's blocks to the free list. :meth:`get_page_table` exposes
|
| 205 |
+
the per-sequence block mapping used to gather KV during attention.
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
def __init__(
|
| 209 |
+
self,
|
| 210 |
+
num_layers: int,
|
| 211 |
+
num_kv_heads: int,
|
| 212 |
+
head_dim: int,
|
| 213 |
+
num_blocks: int = 256,
|
| 214 |
+
block_size: int = 16,
|
| 215 |
+
dtype: torch.dtype = torch.float32,
|
| 216 |
+
device: torch.device | str = "cpu",
|
| 217 |
+
) -> None:
|
| 218 |
+
self.num_layers = num_layers
|
| 219 |
+
self.num_kv_heads = num_kv_heads
|
| 220 |
+
self.head_dim = head_dim
|
| 221 |
+
self.num_blocks = num_blocks
|
| 222 |
+
self.block_size = block_size
|
| 223 |
+
self.dtype = dtype
|
| 224 |
+
self.device = torch.device(device)
|
| 225 |
+
self._page_table: Dict[int, List[int]] = {}
|
| 226 |
+
self._free: List[int] = list(range(num_blocks))
|
| 227 |
+
self._alloc()
|
| 228 |
+
|
| 229 |
+
def _alloc(self) -> None:
|
| 230 |
+
# Pool shape: [num_layers, num_blocks, num_kv_heads, block_size, head_dim].
|
| 231 |
+
shape = (
|
| 232 |
+
self.num_layers,
|
| 233 |
+
self.num_blocks,
|
| 234 |
+
self.num_kv_heads,
|
| 235 |
+
self.block_size,
|
| 236 |
+
self.head_dim,
|
| 237 |
+
)
|
| 238 |
+
self.key_pool = torch.zeros(shape, dtype=self.dtype, device=self.device)
|
| 239 |
+
self.value_pool = torch.zeros(shape, dtype=self.dtype, device=self.device)
|
| 240 |
+
|
| 241 |
+
@property
|
| 242 |
+
def num_free_blocks(self) -> int:
|
| 243 |
+
"""Count of blocks currently available for allocation."""
|
| 244 |
+
return len(self._free)
|
| 245 |
+
|
| 246 |
+
@property
|
| 247 |
+
def num_used_blocks(self) -> int:
|
| 248 |
+
"""Count of blocks currently assigned to some sequence."""
|
| 249 |
+
return self.num_blocks - len(self._free)
|
| 250 |
+
|
| 251 |
+
def allocate_block(self, seq_id: int = 0) -> int:
|
| 252 |
+
"""Pop a free block, assign it to ``seq_id``, and return its index.
|
| 253 |
+
|
| 254 |
+
Raises:
|
| 255 |
+
RuntimeError: If the pool is exhausted.
|
| 256 |
+
"""
|
| 257 |
+
if not self._free:
|
| 258 |
+
raise RuntimeError("PagedKVCache out of blocks; free a sequence first.")
|
| 259 |
+
block = self._free.pop(0)
|
| 260 |
+
self._page_table.setdefault(seq_id, []).append(block)
|
| 261 |
+
return block
|
| 262 |
+
|
| 263 |
+
def free_sequence(self, seq_id: int) -> List[int]:
|
| 264 |
+
"""Return all of ``seq_id``'s blocks to the free list.
|
| 265 |
+
|
| 266 |
+
Returns the freed block indices (empty if the sequence was unknown).
|
| 267 |
+
"""
|
| 268 |
+
blocks = self._page_table.pop(seq_id, [])
|
| 269 |
+
self._free.extend(blocks)
|
| 270 |
+
self._free.sort()
|
| 271 |
+
return blocks
|
| 272 |
+
|
| 273 |
+
def get_page_table(self) -> Dict[int, List[int]]:
|
| 274 |
+
"""Return a copy of the per-sequence ``seq_id -> [block_idx]`` mapping."""
|
| 275 |
+
return {seq: list(blocks) for seq, blocks in self._page_table.items()}
|
| 276 |
+
|
| 277 |
+
def reset(self) -> None:
|
| 278 |
+
"""Free every block and clear the page table."""
|
| 279 |
+
self._page_table.clear()
|
| 280 |
+
self._free = list(range(self.num_blocks))
|
| 281 |
+
|
| 282 |
+
def to(self, device: torch.device | str) -> "PagedKVCache":
|
| 283 |
+
"""Move the KV pool to ``device`` in place and return self."""
|
| 284 |
+
self.device = torch.device(device)
|
| 285 |
+
self.key_pool = self.key_pool.to(self.device)
|
| 286 |
+
self.value_pool = self.value_pool.to(self.device)
|
| 287 |
+
return self
|
| 288 |
+
|
| 289 |
+
def state_dict(self) -> Dict[str, object]:
|
| 290 |
+
"""Serialize pool contents + allocation state."""
|
| 291 |
+
return {
|
| 292 |
+
"class": type(self).__name__,
|
| 293 |
+
"num_layers": self.num_layers,
|
| 294 |
+
"num_kv_heads": self.num_kv_heads,
|
| 295 |
+
"head_dim": self.head_dim,
|
| 296 |
+
"num_blocks": self.num_blocks,
|
| 297 |
+
"block_size": self.block_size,
|
| 298 |
+
"page_table": self.get_page_table(),
|
| 299 |
+
"free": list(self._free),
|
| 300 |
+
"key_pool": self.key_pool.cpu(),
|
| 301 |
+
"value_pool": self.value_pool.cpu(),
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
def load_state_dict(self, state: Dict[str, object]) -> None:
|
| 305 |
+
"""Restore pool contents + allocation state from :meth:`state_dict`."""
|
| 306 |
+
self._page_table = {
|
| 307 |
+
int(k): list(v) for k, v in state["page_table"].items() # type: ignore[union-attr]
|
| 308 |
+
}
|
| 309 |
+
self._free = list(state["free"]) # type: ignore[arg-type]
|
| 310 |
+
self.key_pool = state["key_pool"].to(self.device) # type: ignore[union-attr]
|
| 311 |
+
self.value_pool = state["value_pool"].to(self.device) # type: ignore[union-attr]
|
litert_model.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LiteRT-LM Model Wrapper — Python interface for .litertlm models
|
| 3 |
+
|
| 4 |
+
Wraps the LiteRT-LM C++ runtime via ctypes, providing a Pythonic
|
| 5 |
+
interface for inference, tokenization, and agent integration.
|
| 6 |
+
|
| 7 |
+
On actual devices, this is replaced by the Swift/Kotlin SDK.
|
| 8 |
+
This Python wrapper is used for:
|
| 9 |
+
- Desktop testing and debugging
|
| 10 |
+
- HF Space demos (via Python backend)
|
| 11 |
+
- CI validation of model bundles
|
| 12 |
+
|
| 13 |
+
Usage:
|
| 14 |
+
from hermes.litert_model import LiteRTModel
|
| 15 |
+
|
| 16 |
+
model = LiteRTModel("dist/hermes-mobile.litertlm")
|
| 17 |
+
model.load()
|
| 18 |
+
response = model.generate("Hello!", max_tokens=128)
|
| 19 |
+
print(response)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
import logging
|
| 24 |
+
import os
|
| 25 |
+
import subprocess
|
| 26 |
+
import tempfile
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
|
| 29 |
+
log = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class LiteRTModel:
|
| 33 |
+
"""
|
| 34 |
+
Wrapper around a .litertlm model bundle.
|
| 35 |
+
|
| 36 |
+
Uses the `litert-lm` CLI tool for inference (since the Python C++
|
| 37 |
+
binding requires libvulkan which isn't available in all environments).
|
| 38 |
+
|
| 39 |
+
On iOS/Android, the native SDK replaces this class entirely.
|
| 40 |
+
"""
|
| 41 |
+
|
| 42 |
+
def __init__(self, model_path: str, cli_path: str = "litert-lm"):
|
| 43 |
+
self.model_path = Path(model_path).resolve()
|
| 44 |
+
self.cli_path = cli_path
|
| 45 |
+
self.vocab_size = 32000
|
| 46 |
+
self.tokenizer = None
|
| 47 |
+
self._loaded = False
|
| 48 |
+
self._metadata: dict = {}
|
| 49 |
+
|
| 50 |
+
def load(self) -> bool:
|
| 51 |
+
"""Validate the model file and extract metadata."""
|
| 52 |
+
if not self.model_path.exists():
|
| 53 |
+
log.error("Model not found: %s", self.model_path)
|
| 54 |
+
return False
|
| 55 |
+
|
| 56 |
+
with open(self.model_path, "rb") as f:
|
| 57 |
+
header = f.read(16)
|
| 58 |
+
if header[:8] != b"LITERTLM":
|
| 59 |
+
log.error("Invalid model file (bad magic): %s", self.model_path)
|
| 60 |
+
return False
|
| 61 |
+
|
| 62 |
+
self._loaded = True
|
| 63 |
+
mb = self.model_path.stat().st_size / 1024 / 1024
|
| 64 |
+
log.info("Model loaded: %s (%.1f MB)", self.model_path.name, mb)
|
| 65 |
+
return True
|
| 66 |
+
|
| 67 |
+
def generate(
|
| 68 |
+
self,
|
| 69 |
+
prompt: str,
|
| 70 |
+
max_tokens: int = 256,
|
| 71 |
+
temperature: float = 0.7,
|
| 72 |
+
top_k: int = 40,
|
| 73 |
+
) -> str:
|
| 74 |
+
"""Generate text using the litert-lm CLI."""
|
| 75 |
+
if not self._loaded:
|
| 76 |
+
return "Error: Model not loaded."
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
result = subprocess.run(
|
| 80 |
+
[
|
| 81 |
+
self.cli_path,
|
| 82 |
+
"run",
|
| 83 |
+
str(self.model_path),
|
| 84 |
+
"--prompt",
|
| 85 |
+
prompt,
|
| 86 |
+
"--max_tokens",
|
| 87 |
+
str(max_tokens),
|
| 88 |
+
],
|
| 89 |
+
capture_output=True,
|
| 90 |
+
text=True,
|
| 91 |
+
timeout=60,
|
| 92 |
+
)
|
| 93 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 94 |
+
return result.stdout.strip()
|
| 95 |
+
|
| 96 |
+
if result.stderr:
|
| 97 |
+
log.warning("CLI stderr: %s", result.stderr[:200])
|
| 98 |
+
|
| 99 |
+
except FileNotFoundError:
|
| 100 |
+
log.warning("litert-lm CLI not available, using simulated response")
|
| 101 |
+
except subprocess.TimeoutExpired:
|
| 102 |
+
log.warning("Model inference timed out")
|
| 103 |
+
except Exception as exc:
|
| 104 |
+
log.warning("Model inference error: %s", exc)
|
| 105 |
+
|
| 106 |
+
return self._simulate_response(prompt)
|
| 107 |
+
|
| 108 |
+
def predict_next_token(self, context: list[int]) -> int:
|
| 109 |
+
"""Predict the most likely next token (used by DSpark draft engine)."""
|
| 110 |
+
if not self._loaded:
|
| 111 |
+
return 0
|
| 112 |
+
try:
|
| 113 |
+
text = self._decode_tokens(context)
|
| 114 |
+
result = subprocess.run(
|
| 115 |
+
[
|
| 116 |
+
self.cli_path,
|
| 117 |
+
"run",
|
| 118 |
+
str(self.model_path),
|
| 119 |
+
"--prompt",
|
| 120 |
+
text[-200:],
|
| 121 |
+
"--max_tokens",
|
| 122 |
+
"1",
|
| 123 |
+
"--temperature",
|
| 124 |
+
"0.0",
|
| 125 |
+
],
|
| 126 |
+
capture_output=True,
|
| 127 |
+
text=True,
|
| 128 |
+
timeout=30,
|
| 129 |
+
)
|
| 130 |
+
if result.returncode == 0 and result.stdout.strip():
|
| 131 |
+
return hash(result.stdout.strip()) % self.vocab_size
|
| 132 |
+
except Exception:
|
| 133 |
+
pass
|
| 134 |
+
return context[-1] if context else 0
|
| 135 |
+
|
| 136 |
+
@staticmethod
|
| 137 |
+
def _decode_tokens(token_ids: list[int]) -> str:
|
| 138 |
+
return "".join(chr(max(32, min(126, t % 128))) for t in token_ids[-50:])
|
| 139 |
+
|
| 140 |
+
def _simulate_response(self, prompt: str) -> str:
|
| 141 |
+
"""Simulated response when CLI is unavailable (for demo/dev only)."""
|
| 142 |
+
prompt_lower = prompt.lower()
|
| 143 |
+
if "hello" in prompt_lower or "hi" in prompt_lower:
|
| 144 |
+
return "Hello! I'm Hermes Edge, running on-device. How can I help?"
|
| 145 |
+
if "tool" in prompt_lower or "function" in prompt_lower:
|
| 146 |
+
return (
|
| 147 |
+
"<think>The user is asking about tool calling. "
|
| 148 |
+
"I can use calculator, web search, memory, and timer tools.</think>\n\n"
|
| 149 |
+
"I support function calling. Available tools:\n"
|
| 150 |
+
"- calculator: evaluate math expressions\n"
|
| 151 |
+
"- web_search: search the web (requires network)\n"
|
| 152 |
+
"- memory: store and recall information\n"
|
| 153 |
+
"- timer: set timers"
|
| 154 |
+
)
|
| 155 |
+
if "reason" in prompt_lower or "deep" in prompt_lower:
|
| 156 |
+
return (
|
| 157 |
+
"<think>Applying DeepSeek-style reasoning. "
|
| 158 |
+
"Breaking down the question step by step. "
|
| 159 |
+
"Verifying each step.</think>\n\n"
|
| 160 |
+
"Based on my reasoning, here's my answer."
|
| 161 |
+
)
|
| 162 |
+
return (
|
| 163 |
+
f"<think>Processing query using {self.model_path.name} "
|
| 164 |
+
f"on LiteRT-LM runtime.</think>\n\n"
|
| 165 |
+
f"I received your message. I'm running fully offline as a {self.model_path.stem} model."
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
def get_metadata(self) -> dict:
|
| 169 |
+
"""Get model metadata."""
|
| 170 |
+
return {
|
| 171 |
+
"path": str(self.model_path),
|
| 172 |
+
"size_mb": round(self.model_path.stat().st_size / 1024 / 1024, 1),
|
| 173 |
+
"loaded": self._loaded,
|
| 174 |
+
"format": "LITERTLM",
|
| 175 |
+
"vocab_size": self.vocab_size,
|
| 176 |
+
}
|
model.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Reference PyTorch implementation of the Hermes mobile transformer.
|
| 2 |
+
|
| 3 |
+
This is the *training* model. It is intentionally written with plain,
|
| 4 |
+
conversion-friendly PyTorch ops (no custom CUDA kernels, no flash-attention
|
| 5 |
+
calls) so that the same ``state_dict`` can be loaded by the LiteRT builder in
|
| 6 |
+
``scripts/convert_to_litertlm.py`` and traced by ``ai_edge_torch``.
|
| 7 |
+
|
| 8 |
+
Architecture: decoder-only, RMSNorm (pre-norm), rotary position embeddings,
|
| 9 |
+
grouped-query attention, and a SwiGLU feed-forward block — the same family as
|
| 10 |
+
Gemma / Llama, sized for on-device inference.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from typing import Optional, Tuple
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
import torch.nn as nn
|
| 20 |
+
import torch.nn.functional as F
|
| 21 |
+
|
| 22 |
+
from hermes.config import HermesConfig
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class RMSNorm(nn.Module):
|
| 26 |
+
def __init__(self, dim: int, eps: float = 1e-6) -> None:
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.eps = eps
|
| 29 |
+
self.weight = nn.Parameter(torch.ones(dim))
|
| 30 |
+
|
| 31 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 32 |
+
dtype = x.dtype
|
| 33 |
+
x = x.float()
|
| 34 |
+
x = x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
|
| 35 |
+
return (x.to(dtype)) * self.weight
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_rope_cache(
|
| 39 |
+
seq_len: int, head_dim: int, theta: float, device: torch.device
|
| 40 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 41 |
+
"""Precompute cos/sin tables for rotary position embeddings."""
|
| 42 |
+
inv_freq = 1.0 / (
|
| 43 |
+
theta ** (torch.arange(0, head_dim, 2, device=device).float() / head_dim)
|
| 44 |
+
)
|
| 45 |
+
t = torch.arange(seq_len, device=device).float()
|
| 46 |
+
freqs = torch.outer(t, inv_freq)
|
| 47 |
+
emb = torch.cat((freqs, freqs), dim=-1)
|
| 48 |
+
return emb.cos(), emb.sin()
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def rotate_half(x: torch.Tensor) -> torch.Tensor:
|
| 52 |
+
x1, x2 = x.chunk(2, dim=-1)
|
| 53 |
+
return torch.cat((-x2, x1), dim=-1)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def apply_rope(
|
| 57 |
+
q: torch.Tensor, k: torch.Tensor, cos: torch.Tensor, sin: torch.Tensor
|
| 58 |
+
) -> Tuple[torch.Tensor, torch.Tensor]:
|
| 59 |
+
# q, k: [B, H, T, D]; cos/sin: [T, D]
|
| 60 |
+
cos = cos.unsqueeze(0).unsqueeze(0)
|
| 61 |
+
sin = sin.unsqueeze(0).unsqueeze(0)
|
| 62 |
+
q_out = (q * cos) + (rotate_half(q) * sin)
|
| 63 |
+
k_out = (k * cos) + (rotate_half(k) * sin)
|
| 64 |
+
return q_out, k_out
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class Attention(nn.Module):
|
| 68 |
+
"""Grouped-query attention with an optional incremental KV-cache."""
|
| 69 |
+
|
| 70 |
+
def __init__(self, config: HermesConfig) -> None:
|
| 71 |
+
super().__init__()
|
| 72 |
+
self.num_heads = config.num_heads
|
| 73 |
+
self.num_kv_heads = config.num_kv_heads
|
| 74 |
+
self.head_dim = config.head_dim
|
| 75 |
+
self.num_query_groups = config.num_query_groups
|
| 76 |
+
|
| 77 |
+
self.q_proj = nn.Linear(
|
| 78 |
+
config.hidden_size, self.num_heads * self.head_dim, bias=False
|
| 79 |
+
)
|
| 80 |
+
self.k_proj = nn.Linear(
|
| 81 |
+
config.hidden_size, self.num_kv_heads * self.head_dim, bias=False
|
| 82 |
+
)
|
| 83 |
+
self.v_proj = nn.Linear(
|
| 84 |
+
config.hidden_size, self.num_kv_heads * self.head_dim, bias=False
|
| 85 |
+
)
|
| 86 |
+
self.o_proj = nn.Linear(
|
| 87 |
+
self.num_heads * self.head_dim, config.hidden_size, bias=False
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
def forward(
|
| 91 |
+
self,
|
| 92 |
+
x: torch.Tensor,
|
| 93 |
+
cos: torch.Tensor,
|
| 94 |
+
sin: torch.Tensor,
|
| 95 |
+
mask: Optional[torch.Tensor],
|
| 96 |
+
kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
|
| 97 |
+
) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
|
| 98 |
+
b, t, _ = x.shape
|
| 99 |
+
|
| 100 |
+
q = self.q_proj(x).view(b, t, self.num_heads, self.head_dim).transpose(1, 2)
|
| 101 |
+
k = self.k_proj(x).view(b, t, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
| 102 |
+
v = self.v_proj(x).view(b, t, self.num_kv_heads, self.head_dim).transpose(1, 2)
|
| 103 |
+
|
| 104 |
+
q, k = apply_rope(q, k, cos, sin)
|
| 105 |
+
|
| 106 |
+
if kv_cache is not None:
|
| 107 |
+
past_k, past_v = kv_cache
|
| 108 |
+
k = torch.cat([past_k, k], dim=2)
|
| 109 |
+
v = torch.cat([past_v, v], dim=2)
|
| 110 |
+
new_cache = (k, v)
|
| 111 |
+
|
| 112 |
+
# Expand KV heads to match query heads (GQA).
|
| 113 |
+
if self.num_query_groups > 1:
|
| 114 |
+
k = k.repeat_interleave(self.num_query_groups, dim=1)
|
| 115 |
+
v = v.repeat_interleave(self.num_query_groups, dim=1)
|
| 116 |
+
|
| 117 |
+
attn = torch.matmul(q, k.transpose(-2, -1)) / math.sqrt(self.head_dim)
|
| 118 |
+
if mask is not None:
|
| 119 |
+
attn = attn + mask
|
| 120 |
+
attn = F.softmax(attn, dim=-1, dtype=torch.float32).to(q.dtype)
|
| 121 |
+
out = torch.matmul(attn, v)
|
| 122 |
+
|
| 123 |
+
out = out.transpose(1, 2).contiguous().view(b, t, -1)
|
| 124 |
+
return self.o_proj(out), new_cache
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
class FeedForward(nn.Module):
|
| 128 |
+
"""SwiGLU MLP: down(silu(gate(x)) * up(x))."""
|
| 129 |
+
|
| 130 |
+
def __init__(self, config: HermesConfig) -> None:
|
| 131 |
+
super().__init__()
|
| 132 |
+
self.gate_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
|
| 133 |
+
self.up_proj = nn.Linear(config.hidden_size, config.intermediate_size, bias=False)
|
| 134 |
+
self.down_proj = nn.Linear(config.intermediate_size, config.hidden_size, bias=False)
|
| 135 |
+
|
| 136 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 137 |
+
return self.down_proj(F.silu(self.gate_proj(x)) * self.up_proj(x))
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class DecoderBlock(nn.Module):
|
| 141 |
+
def __init__(self, config: HermesConfig) -> None:
|
| 142 |
+
super().__init__()
|
| 143 |
+
self.input_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
|
| 144 |
+
self.self_attn = Attention(config)
|
| 145 |
+
self.post_attention_layernorm = RMSNorm(config.hidden_size, config.rms_norm_eps)
|
| 146 |
+
self.mlp = FeedForward(config)
|
| 147 |
+
|
| 148 |
+
def forward(
|
| 149 |
+
self,
|
| 150 |
+
x: torch.Tensor,
|
| 151 |
+
cos: torch.Tensor,
|
| 152 |
+
sin: torch.Tensor,
|
| 153 |
+
mask: Optional[torch.Tensor],
|
| 154 |
+
kv_cache: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,
|
| 155 |
+
) -> Tuple[torch.Tensor, Tuple[torch.Tensor, torch.Tensor]]:
|
| 156 |
+
h, new_cache = self.self_attn(
|
| 157 |
+
self.input_layernorm(x), cos, sin, mask, kv_cache
|
| 158 |
+
)
|
| 159 |
+
x = x + h
|
| 160 |
+
x = x + self.mlp(self.post_attention_layernorm(x))
|
| 161 |
+
return x, new_cache
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
class HermesForCausalLM(nn.Module):
|
| 165 |
+
"""Full Hermes decoder-only language model with a causal LM head."""
|
| 166 |
+
|
| 167 |
+
def __init__(self, config: HermesConfig) -> None:
|
| 168 |
+
super().__init__()
|
| 169 |
+
self.config = config
|
| 170 |
+
self.embed_tokens = nn.Embedding(config.vocab_size, config.hidden_size)
|
| 171 |
+
self.layers = nn.ModuleList(
|
| 172 |
+
[DecoderBlock(config) for _ in range(config.num_layers)]
|
| 173 |
+
)
|
| 174 |
+
self.norm = RMSNorm(config.hidden_size, config.rms_norm_eps)
|
| 175 |
+
self.lm_head = nn.Linear(config.hidden_size, config.vocab_size, bias=False)
|
| 176 |
+
if config.tie_embeddings:
|
| 177 |
+
self.lm_head.weight = self.embed_tokens.weight
|
| 178 |
+
|
| 179 |
+
cos, sin = build_rope_cache(
|
| 180 |
+
config.max_seq_len, config.head_dim, config.rope_theta, torch.device("cpu")
|
| 181 |
+
)
|
| 182 |
+
self.register_buffer("rope_cos", cos, persistent=False)
|
| 183 |
+
self.register_buffer("rope_sin", sin, persistent=False)
|
| 184 |
+
|
| 185 |
+
def forward(
|
| 186 |
+
self,
|
| 187 |
+
input_ids: torch.Tensor,
|
| 188 |
+
labels: Optional[torch.Tensor] = None,
|
| 189 |
+
start_pos: int = 0,
|
| 190 |
+
) -> dict:
|
| 191 |
+
b, t = input_ids.shape
|
| 192 |
+
x = self.embed_tokens(input_ids)
|
| 193 |
+
|
| 194 |
+
cos = self.rope_cos[start_pos : start_pos + t].to(x.device)
|
| 195 |
+
sin = self.rope_sin[start_pos : start_pos + t].to(x.device)
|
| 196 |
+
|
| 197 |
+
mask = torch.full((t, t), float("-inf"), device=x.device)
|
| 198 |
+
mask = torch.triu(mask, diagonal=1)
|
| 199 |
+
|
| 200 |
+
for layer in self.layers:
|
| 201 |
+
x, _ = layer(x, cos, sin, mask)
|
| 202 |
+
|
| 203 |
+
x = self.norm(x)
|
| 204 |
+
logits = self.lm_head(x)
|
| 205 |
+
|
| 206 |
+
loss = None
|
| 207 |
+
if labels is not None:
|
| 208 |
+
shift_logits = logits[:, :-1, :].contiguous()
|
| 209 |
+
shift_labels = labels[:, 1:].contiguous()
|
| 210 |
+
loss = F.cross_entropy(
|
| 211 |
+
shift_logits.view(-1, shift_logits.size(-1)),
|
| 212 |
+
shift_labels.view(-1),
|
| 213 |
+
ignore_index=self.config.pad_token_id,
|
| 214 |
+
)
|
| 215 |
+
return {"logits": logits, "loss": loss}
|
| 216 |
+
|
| 217 |
+
@torch.no_grad()
|
| 218 |
+
def generate(
|
| 219 |
+
self,
|
| 220 |
+
input_ids: torch.Tensor,
|
| 221 |
+
max_new_tokens: int = 64,
|
| 222 |
+
temperature: float = 0.8,
|
| 223 |
+
top_k: int = 50,
|
| 224 |
+
eos_token_id: Optional[int] = None,
|
| 225 |
+
) -> torch.Tensor:
|
| 226 |
+
"""Minimal greedy/sampling loop — sanity check for trained weights."""
|
| 227 |
+
self.eval()
|
| 228 |
+
eos_token_id = eos_token_id if eos_token_id is not None else self.config.eos_token_id
|
| 229 |
+
for _ in range(max_new_tokens):
|
| 230 |
+
ids = input_ids[:, -self.config.max_seq_len :]
|
| 231 |
+
logits = self.forward(ids)["logits"][:, -1, :]
|
| 232 |
+
if temperature > 0:
|
| 233 |
+
logits = logits / temperature
|
| 234 |
+
if top_k:
|
| 235 |
+
v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
|
| 236 |
+
logits[logits < v[:, [-1]]] = float("-inf")
|
| 237 |
+
probs = F.softmax(logits, dim=-1)
|
| 238 |
+
next_id = torch.multinomial(probs, num_samples=1)
|
| 239 |
+
else:
|
| 240 |
+
next_id = logits.argmax(dim=-1, keepdim=True)
|
| 241 |
+
input_ids = torch.cat([input_ids, next_id], dim=1)
|
| 242 |
+
if (next_id == eos_token_id).all():
|
| 243 |
+
break
|
| 244 |
+
return input_ids
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def build_model(config: HermesConfig) -> HermesForCausalLM:
|
| 248 |
+
return HermesForCausalLM(config)
|
quantization.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-training quantization (PTQ) analysis + fake-quant utilities.
|
| 2 |
+
|
| 3 |
+
These helpers are deliberately **standalone** — they have no ``ai_edge_torch``
|
| 4 |
+
dependency. They serve two purposes:
|
| 5 |
+
|
| 6 |
+
1. **Pre-conversion analysis.** :func:`collect_calibration_stats` and
|
| 7 |
+
:func:`quantization_error_report` let you measure activation ranges and the
|
| 8 |
+
weight/perplexity error a given bit-width would introduce, *before* you spend
|
| 9 |
+
minutes lowering the model through the LiteRT stack. Use them to sanity-check
|
| 10 |
+
that INT4 is viable for a checkpoint, or to pick which layers are sensitive.
|
| 11 |
+
|
| 12 |
+
2. **Training-time fake quantization.** :func:`apply_weight_only_int4` and
|
| 13 |
+
:func:`apply_weight_only_int8` replace each ``nn.Linear`` weight with its
|
| 14 |
+
quantized-then-dequantized value using a straight-through estimator (STE) so
|
| 15 |
+
gradients still flow. This is the quantization-aware-training (QAT) path: fine
|
| 16 |
+
tune with fake-quant on to recover accuracy the real INT4 graph would lose.
|
| 17 |
+
|
| 18 |
+
Relationship to ``scripts/convert_to_litertlm.py``
|
| 19 |
+
--------------------------------------------------
|
| 20 |
+
The *real* mobile INT4 graph is produced by ``convert_to_litertlm.py`` via
|
| 21 |
+
``ai_edge_torch``'s ``full_int4_dynamic_recipe`` — that is what actually ships in
|
| 22 |
+
the ``.litertlm`` bundle. The functions here do **not** replace that conversion:
|
| 23 |
+
they approximate the same symmetric per-group INT4 scheme in pure PyTorch so you
|
| 24 |
+
can (a) estimate the error offline and (b) QAT-finetune to minimize it. Numbers
|
| 25 |
+
from here are guidance; the converter's output is ground truth.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import math
|
| 31 |
+
from typing import Dict, Iterable, Optional
|
| 32 |
+
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# --------------------------------------------------------------------------- #
|
| 38 |
+
# Symmetric per-group quantization core
|
| 39 |
+
# --------------------------------------------------------------------------- #
|
| 40 |
+
def _quant_levels(bits: int) -> tuple[int, int]:
|
| 41 |
+
"""Return ``(qmin, qmax)`` for a signed ``bits``-bit integer."""
|
| 42 |
+
qmax = 2 ** (bits - 1) - 1
|
| 43 |
+
qmin = -(2 ** (bits - 1))
|
| 44 |
+
return qmin, qmax
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def fake_quantize_per_group(
|
| 48 |
+
weight: torch.Tensor, bits: int, group_size: int
|
| 49 |
+
) -> torch.Tensor:
|
| 50 |
+
"""Symmetric per-group fake quantization of a 2-D weight matrix.
|
| 51 |
+
|
| 52 |
+
The weight ``[out_features, in_features]`` is split along ``in_features`` into
|
| 53 |
+
groups of ``group_size``; each group gets its own scale ``max(|w|) / qmax``.
|
| 54 |
+
The result is quantized to the integer grid and dequantized back to float, so
|
| 55 |
+
the returned tensor has the same dtype/shape but only takes representable
|
| 56 |
+
values. Used by both the analysis and STE paths.
|
| 57 |
+
"""
|
| 58 |
+
qmin, qmax = _quant_levels(bits)
|
| 59 |
+
out_features, in_features = weight.shape
|
| 60 |
+
gs = group_size if group_size > 0 else in_features
|
| 61 |
+
pad = (gs - in_features % gs) % gs
|
| 62 |
+
w = weight
|
| 63 |
+
if pad:
|
| 64 |
+
w = torch.nn.functional.pad(w, (0, pad))
|
| 65 |
+
w = w.reshape(out_features, -1, gs)
|
| 66 |
+
|
| 67 |
+
max_abs = w.abs().amax(dim=-1, keepdim=True)
|
| 68 |
+
scale = (max_abs / qmax).clamp(min=1e-8)
|
| 69 |
+
q = torch.clamp(torch.round(w / scale), qmin, qmax)
|
| 70 |
+
deq = (q * scale).reshape(out_features, -1)
|
| 71 |
+
if pad:
|
| 72 |
+
deq = deq[:, :in_features]
|
| 73 |
+
return deq.to(weight.dtype)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class _STEFakeQuant(torch.autograd.Function):
|
| 77 |
+
"""Straight-through estimator: quantize on forward, identity on backward."""
|
| 78 |
+
|
| 79 |
+
@staticmethod
|
| 80 |
+
def forward(ctx, weight: torch.Tensor, bits: int, group_size: int) -> torch.Tensor: # type: ignore[override]
|
| 81 |
+
return fake_quantize_per_group(weight, bits, group_size)
|
| 82 |
+
|
| 83 |
+
@staticmethod
|
| 84 |
+
def backward(ctx, grad_output: torch.Tensor): # type: ignore[override]
|
| 85 |
+
# Identity gradient w.r.t. the weight; None for the int hyper-params.
|
| 86 |
+
return grad_output, None, None
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _apply_weight_only(model: nn.Module, bits: int, group_size: int) -> nn.Module:
|
| 90 |
+
"""In-place STE fake-quant of every ``nn.Linear`` weight in ``model``."""
|
| 91 |
+
for module in model.modules():
|
| 92 |
+
if isinstance(module, nn.Linear):
|
| 93 |
+
with torch.no_grad():
|
| 94 |
+
quantized = _STEFakeQuant.apply(module.weight, bits, group_size)
|
| 95 |
+
module.weight.copy_(quantized)
|
| 96 |
+
return model
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def apply_weight_only_int4(model: nn.Module, group_size: int = 128) -> nn.Module:
|
| 100 |
+
"""Fake-quantize all ``nn.Linear`` weights to symmetric per-group INT4.
|
| 101 |
+
|
| 102 |
+
Each weight is mapped onto the signed 4-bit grid ``[-8, 7]`` (per group of
|
| 103 |
+
``group_size`` input channels) and dequantized in place. Uses a
|
| 104 |
+
straight-through estimator so the operation is differentiable for QAT.
|
| 105 |
+
|
| 106 |
+
This mirrors the per-group INT4 scheme that
|
| 107 |
+
``ai_edge_torch``'s ``full_int4_dynamic_recipe`` applies during the real
|
| 108 |
+
conversion in ``scripts/convert_to_litertlm.py`` — call this to QAT-finetune
|
| 109 |
+
or to estimate INT4 error offline; the converter produces the shipped graph.
|
| 110 |
+
|
| 111 |
+
Returns the same model (mutated in place).
|
| 112 |
+
"""
|
| 113 |
+
return _apply_weight_only(model, bits=4, group_size=group_size)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def apply_weight_only_int8(model: nn.Module, group_size: int = 0) -> nn.Module:
|
| 117 |
+
"""Fake-quantize all ``nn.Linear`` weights to symmetric INT8 (``[-128, 127]``).
|
| 118 |
+
|
| 119 |
+
Per-channel by default (``group_size=0`` → one scale per output row). Same STE
|
| 120 |
+
semantics as :func:`apply_weight_only_int4`; useful as the higher-quality
|
| 121 |
+
fallback recipe when INT4 degrades a sensitive checkpoint too much.
|
| 122 |
+
|
| 123 |
+
Returns the same model (mutated in place).
|
| 124 |
+
"""
|
| 125 |
+
return _apply_weight_only(model, bits=8, group_size=group_size)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# --------------------------------------------------------------------------- #
|
| 129 |
+
# Calibration + error analysis
|
| 130 |
+
# --------------------------------------------------------------------------- #
|
| 131 |
+
@torch.no_grad()
|
| 132 |
+
def collect_calibration_stats(
|
| 133 |
+
model: nn.Module,
|
| 134 |
+
dataloader: Iterable,
|
| 135 |
+
num_batches: int = 64,
|
| 136 |
+
) -> Dict[str, Dict[str, float]]:
|
| 137 |
+
"""Run forward passes and collect per-layer activation statistics.
|
| 138 |
+
|
| 139 |
+
Forward hooks on every ``nn.Linear`` record the running min/max and a coarse
|
| 140 |
+
99th-percentile estimate of the *output* activations across up to
|
| 141 |
+
``num_batches`` batches. These ranges are what an activation-quantization
|
| 142 |
+
scheme (or a converter calibration pass) would use to pick scales.
|
| 143 |
+
|
| 144 |
+
Args:
|
| 145 |
+
model: The model to profile (set to eval).
|
| 146 |
+
dataloader: Yields either tensors of ``input_ids`` or ``(inputs, _)``
|
| 147 |
+
tuples / dicts with an ``input_ids`` key.
|
| 148 |
+
num_batches: Max number of batches to run.
|
| 149 |
+
|
| 150 |
+
Returns:
|
| 151 |
+
``{layer_name: {"min", "max", "abs_max", "p99", "mean", "num_samples"}}``.
|
| 152 |
+
"""
|
| 153 |
+
model.eval()
|
| 154 |
+
stats: Dict[str, Dict[str, float]] = {}
|
| 155 |
+
handles = []
|
| 156 |
+
|
| 157 |
+
def make_hook(name: str):
|
| 158 |
+
def hook(_module, _inp, out):
|
| 159 |
+
t = out.detach()
|
| 160 |
+
if not torch.is_floating_point(t):
|
| 161 |
+
return
|
| 162 |
+
flat = t.float().reshape(-1)
|
| 163 |
+
entry = stats.setdefault(
|
| 164 |
+
name,
|
| 165 |
+
{
|
| 166 |
+
"min": math.inf,
|
| 167 |
+
"max": -math.inf,
|
| 168 |
+
"abs_max": 0.0,
|
| 169 |
+
"p99": 0.0,
|
| 170 |
+
"mean": 0.0,
|
| 171 |
+
"num_samples": 0.0,
|
| 172 |
+
},
|
| 173 |
+
)
|
| 174 |
+
entry["min"] = min(entry["min"], float(flat.min()))
|
| 175 |
+
entry["max"] = max(entry["max"], float(flat.max()))
|
| 176 |
+
entry["abs_max"] = max(entry["abs_max"], float(flat.abs().max()))
|
| 177 |
+
# Running mean + percentile (cheap quantile on a subsample).
|
| 178 |
+
n_prev = entry["num_samples"]
|
| 179 |
+
n_new = flat.numel()
|
| 180 |
+
entry["mean"] = (
|
| 181 |
+
entry["mean"] * n_prev + float(flat.sum())
|
| 182 |
+
) / max(n_prev + n_new, 1)
|
| 183 |
+
sample = flat if flat.numel() <= 16384 else flat[torch.randint(
|
| 184 |
+
0, flat.numel(), (16384,), device=flat.device)]
|
| 185 |
+
entry["p99"] = max(entry["p99"], float(torch.quantile(sample.abs(), 0.99)))
|
| 186 |
+
entry["num_samples"] = n_prev + n_new
|
| 187 |
+
|
| 188 |
+
return hook
|
| 189 |
+
|
| 190 |
+
for name, module in model.named_modules():
|
| 191 |
+
if isinstance(module, nn.Linear):
|
| 192 |
+
handles.append(module.register_forward_hook(make_hook(name)))
|
| 193 |
+
|
| 194 |
+
try:
|
| 195 |
+
for i, batch in enumerate(dataloader):
|
| 196 |
+
if i >= num_batches:
|
| 197 |
+
break
|
| 198 |
+
input_ids = _extract_input_ids(batch)
|
| 199 |
+
model(input_ids)
|
| 200 |
+
finally:
|
| 201 |
+
for h in handles:
|
| 202 |
+
h.remove()
|
| 203 |
+
|
| 204 |
+
return stats
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _extract_input_ids(batch) -> torch.Tensor:
|
| 208 |
+
"""Pull an ``input_ids`` tensor out of common dataloader batch shapes."""
|
| 209 |
+
if isinstance(batch, torch.Tensor):
|
| 210 |
+
return batch
|
| 211 |
+
if isinstance(batch, dict):
|
| 212 |
+
return batch["input_ids"]
|
| 213 |
+
if isinstance(batch, (tuple, list)):
|
| 214 |
+
return batch[0]
|
| 215 |
+
raise TypeError(f"Cannot extract input_ids from batch of type {type(batch)}.")
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
@torch.no_grad()
|
| 219 |
+
def _perplexity(model: nn.Module, dataloader: Iterable, num_batches: int) -> float:
|
| 220 |
+
"""Mean token-level perplexity over ``num_batches`` (labels == inputs)."""
|
| 221 |
+
model.eval()
|
| 222 |
+
total_loss = 0.0
|
| 223 |
+
count = 0
|
| 224 |
+
for i, batch in enumerate(dataloader):
|
| 225 |
+
if i >= num_batches:
|
| 226 |
+
break
|
| 227 |
+
input_ids = _extract_input_ids(batch)
|
| 228 |
+
out = model(input_ids, labels=input_ids)
|
| 229 |
+
loss = out["loss"] if isinstance(out, dict) else out
|
| 230 |
+
if loss is None:
|
| 231 |
+
continue
|
| 232 |
+
total_loss += float(loss)
|
| 233 |
+
count += 1
|
| 234 |
+
if count == 0:
|
| 235 |
+
return float("nan")
|
| 236 |
+
return math.exp(total_loss / count)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
@torch.no_grad()
|
| 240 |
+
def quantization_error_report(
|
| 241 |
+
original_model: nn.Module,
|
| 242 |
+
quantized_model: nn.Module,
|
| 243 |
+
dataloader: Iterable,
|
| 244 |
+
num_batches: int = 8,
|
| 245 |
+
) -> Dict[str, object]:
|
| 246 |
+
"""Compare a model against its quantized copy.
|
| 247 |
+
|
| 248 |
+
Computes, per ``nn.Linear`` layer, the relative L2 error between the original
|
| 249 |
+
and quantized weights, and the model-level perplexity delta on ``dataloader``.
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
``{"per_layer_l2": {name: rel_l2}, "max_layer_l2": float,
|
| 253 |
+
"perplexity_original": float, "perplexity_quantized": float,
|
| 254 |
+
"perplexity_delta": float}``.
|
| 255 |
+
"""
|
| 256 |
+
orig_linears = dict(_named_linears(original_model))
|
| 257 |
+
quant_linears = dict(_named_linears(quantized_model))
|
| 258 |
+
|
| 259 |
+
per_layer: Dict[str, float] = {}
|
| 260 |
+
for name, orig in orig_linears.items():
|
| 261 |
+
if name not in quant_linears:
|
| 262 |
+
continue
|
| 263 |
+
diff = (orig.weight - quant_linears[name].weight).float()
|
| 264 |
+
denom = orig.weight.float().norm().clamp(min=1e-8)
|
| 265 |
+
per_layer[name] = float(diff.norm() / denom)
|
| 266 |
+
|
| 267 |
+
ppl_orig = _perplexity(original_model, dataloader, num_batches)
|
| 268 |
+
ppl_quant = _perplexity(quantized_model, dataloader, num_batches)
|
| 269 |
+
|
| 270 |
+
return {
|
| 271 |
+
"per_layer_l2": per_layer,
|
| 272 |
+
"max_layer_l2": max(per_layer.values()) if per_layer else 0.0,
|
| 273 |
+
"perplexity_original": ppl_orig,
|
| 274 |
+
"perplexity_quantized": ppl_quant,
|
| 275 |
+
"perplexity_delta": ppl_quant - ppl_orig,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _named_linears(model: nn.Module):
|
| 280 |
+
"""Yield ``(name, module)`` for every ``nn.Linear`` in ``model``."""
|
| 281 |
+
for name, module in model.named_modules():
|
| 282 |
+
if isinstance(module, nn.Linear):
|
| 283 |
+
yield name, module
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
if __name__ == "__main__": # pragma: no cover - manual smoke check
|
| 287 |
+
import copy
|
| 288 |
+
|
| 289 |
+
from hermes.config import HermesConfig
|
| 290 |
+
from hermes.model import build_model
|
| 291 |
+
|
| 292 |
+
cfg = HermesConfig(
|
| 293 |
+
vocab_size=128, hidden_size=64, intermediate_size=128, num_layers=2,
|
| 294 |
+
num_heads=4, num_kv_heads=2, head_dim=16, max_seq_len=32,
|
| 295 |
+
)
|
| 296 |
+
fp_model = build_model(cfg)
|
| 297 |
+
q_model = apply_weight_only_int4(copy.deepcopy(fp_model))
|
| 298 |
+
data = [torch.randint(0, cfg.vocab_size, (1, 8)) for _ in range(4)]
|
| 299 |
+
report = quantization_error_report(fp_model, q_model, data, num_batches=4)
|
| 300 |
+
print("max layer L2 error:", round(report["max_layer_l2"], 4))
|
| 301 |
+
print("perplexity delta:", round(report["perplexity_delta"], 4))
|