513 lines
21 KiB
Python
513 lines
21 KiB
Python
"""Lazy local model adapter for observable forward and backward traces.
|
||
|
||
The adapter is deliberately conservative: it summarizes tensors in the
|
||
runtime process instead of sending activations or weights to the browser.
|
||
Large checkpoints are loaded only after the first stream request and failures
|
||
fall back to the mock event contract in ``main.py``.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import importlib.util
|
||
import base64
|
||
import json
|
||
import queue
|
||
import re
|
||
import threading
|
||
from io import BytesIO
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
DEFAULT_ARCHITECTURE = {
|
||
"model_type": "qwen3_5",
|
||
"num_layers": 32,
|
||
"pattern": "8 × (3L + 1A)",
|
||
"hidden_size": 2560,
|
||
"vocab_size": 248320,
|
||
"context_length": 262144,
|
||
"intermediate_size": 9216,
|
||
"delta_heads_v": 32,
|
||
"delta_heads_qk": 16,
|
||
"attention_heads": 16,
|
||
"attention_kv_heads": 4,
|
||
"attention_head_dim": 256,
|
||
"rope_dim": 64,
|
||
"vision_encoder": True,
|
||
"sparse_moe": True,
|
||
"mtp": True,
|
||
"precision": "BF16 / INT4",
|
||
}
|
||
|
||
|
||
class ModelRuntime:
|
||
def __init__(self, model_dir: Path, model_name: str) -> None:
|
||
self.model_dir = model_dir
|
||
self.model_name = model_name
|
||
self.status = "idle"
|
||
self.error: str | None = None
|
||
self.model: Any = None
|
||
self.processor: Any = None
|
||
self._torch: Any = None
|
||
self._hooks: list[Any] = []
|
||
self._load_lock = threading.Lock()
|
||
self._trace_context = threading.local()
|
||
|
||
@property
|
||
def ready(self) -> bool:
|
||
return self.status == "ready" and self.model is not None
|
||
|
||
@property
|
||
def checkpoint_path(self) -> Path | None:
|
||
if not self.model_dir.exists():
|
||
return None
|
||
candidates = [self.model_dir]
|
||
candidates.extend(path for path in sorted(self.model_dir.iterdir()) if path.is_dir())
|
||
for path in candidates:
|
||
has_config = (path / "config.json").exists()
|
||
has_weights = any(path.glob("*.safetensors")) or any(path.glob("*.safetensors.index.json"))
|
||
if has_config and has_weights:
|
||
return path
|
||
return None
|
||
|
||
@property
|
||
def checkpoint_ready(self) -> bool:
|
||
return self.checkpoint_path is not None
|
||
|
||
@property
|
||
def architecture(self) -> dict[str, Any]:
|
||
architecture = dict(DEFAULT_ARCHITECTURE)
|
||
checkpoint_path = self.checkpoint_path
|
||
if checkpoint_path is None:
|
||
return architecture
|
||
try:
|
||
config = json.loads((checkpoint_path / "config.json").read_text(encoding="utf-8"))
|
||
except (OSError, ValueError, UnicodeDecodeError):
|
||
return architecture
|
||
text_config = config.get("text_config") if isinstance(config.get("text_config"), dict) else config
|
||
field_map = {
|
||
"num_hidden_layers": "num_layers",
|
||
"hidden_size": "hidden_size",
|
||
"vocab_size": "vocab_size",
|
||
"max_position_embeddings": "context_length",
|
||
"intermediate_size": "intermediate_size",
|
||
"num_attention_heads": "attention_heads",
|
||
"num_key_value_heads": "attention_kv_heads",
|
||
"head_dim": "attention_head_dim",
|
||
"rope_theta_dim": "rope_dim",
|
||
"model_type": "model_type",
|
||
}
|
||
for source_key, target_key in field_map.items():
|
||
value = text_config.get(source_key)
|
||
if isinstance(value, (int, float, str)):
|
||
architecture[target_key] = value
|
||
if not architecture.get("attention_head_dim") and architecture.get("attention_heads"):
|
||
architecture["attention_head_dim"] = architecture["hidden_size"] // architecture["attention_heads"]
|
||
return architecture
|
||
|
||
def ensure_loaded(self) -> bool:
|
||
if self.ready:
|
||
return True
|
||
checkpoint_path = self.checkpoint_path
|
||
if checkpoint_path is None:
|
||
self.status = "downloading"
|
||
return False
|
||
|
||
with self._load_lock:
|
||
if self.ready:
|
||
return True
|
||
self.status = "loading"
|
||
self.error = None
|
||
try:
|
||
import torch
|
||
from transformers import AutoModelForCausalLM, AutoProcessor
|
||
|
||
self._torch = torch
|
||
try:
|
||
self.processor = AutoProcessor.from_pretrained(checkpoint_path, local_files_only=True, trust_remote_code=True)
|
||
except Exception:
|
||
from transformers import AutoTokenizer
|
||
|
||
self.processor = AutoTokenizer.from_pretrained(checkpoint_path, local_files_only=True, trust_remote_code=True)
|
||
|
||
model_class = AutoModelForCausalLM
|
||
image_text_model = getattr(__import__("transformers", fromlist=["AutoModelForImageTextToText"]), "AutoModelForImageTextToText", None)
|
||
if image_text_model is not None:
|
||
model_class = image_text_model
|
||
|
||
load_kwargs = {
|
||
"local_files_only": True,
|
||
"trust_remote_code": True,
|
||
"torch_dtype": "auto",
|
||
}
|
||
if importlib.util.find_spec("accelerate") is not None:
|
||
load_kwargs.update(device_map="auto", low_cpu_mem_usage=True)
|
||
|
||
try:
|
||
self.model = model_class.from_pretrained(
|
||
checkpoint_path,
|
||
**load_kwargs,
|
||
)
|
||
except Exception:
|
||
self.model = AutoModelForCausalLM.from_pretrained(
|
||
checkpoint_path,
|
||
**load_kwargs,
|
||
)
|
||
|
||
self.model.eval()
|
||
self._install_hooks()
|
||
self.status = "ready"
|
||
return True
|
||
except Exception as exc: # pragma: no cover - depends on local GPU/runtime
|
||
self.status = "error"
|
||
self.error = f"{type(exc).__name__}: {exc}"
|
||
self.model = None
|
||
return False
|
||
|
||
def start_trace(self, prompt: str, mode: str, image_data: str | None = None) -> queue.Queue[dict[str, Any]] | None:
|
||
if not self.ensure_loaded():
|
||
return None
|
||
events: queue.Queue[dict[str, Any]] = queue.Queue()
|
||
worker = threading.Thread(target=self._trace_worker, args=(prompt, mode, image_data, events), daemon=True)
|
||
worker.start()
|
||
return events
|
||
|
||
def _install_hooks(self) -> None:
|
||
if self._hooks or self.model is None:
|
||
return
|
||
|
||
seen_layers: set[int] = set()
|
||
for name, module in self.model.named_modules():
|
||
match = re.search(r"(?:^|\.)(?:language_model\.)?(?:layers|(?:model|transformer|decoder)\.(?:layers|h))\.(\d+)$", name)
|
||
if match is not None:
|
||
layer_index = int(match.group(1))
|
||
if layer_index not in seen_layers:
|
||
seen_layers.add(layer_index)
|
||
self._hooks.append(module.register_forward_hook(self._make_forward_hook(layer_index)))
|
||
self._hooks.append(module.register_full_backward_hook(self._make_backward_hook(layer_index)))
|
||
elif re.search(r"(?:^|\.)\b(?:visual|vision_model|vision_tower)\b$", name):
|
||
self._hooks.append(module.register_forward_hook(self._make_vision_hook()))
|
||
elif re.search(r"(?:^|\.)\b(?:router|routing|gate|experts|expert_gate|mtp)\b$", name, re.IGNORECASE):
|
||
kind = "router_weights" if re.search(r"router|routing|gate", name, re.IGNORECASE) else "expert_mixture"
|
||
if re.search(r"mtp", name, re.IGNORECASE):
|
||
kind = "mtp_logits"
|
||
self._hooks.append(module.register_forward_hook(self._make_aux_hook(name, kind)))
|
||
elif re.search(r"(?:^|\.)lm_head$", name):
|
||
self._hooks.append(module.register_forward_hook(self._make_logits_hook()))
|
||
|
||
def _make_forward_hook(self, layer_index: int):
|
||
def forward_hook(_module: Any, _inputs: Any, output: Any) -> None:
|
||
context = getattr(self._trace_context, "queue", None)
|
||
if context is None:
|
||
return
|
||
tensor = self._first_tensor(output)
|
||
summary = self._summarize(tensor)
|
||
full_attention = (layer_index + 1) % 4 == 0
|
||
mode = getattr(self._trace_context, "mode", "forward")
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
if mode == "attention":
|
||
kind = "attention_output" if full_attention else "delta_context"
|
||
elif mode == "state":
|
||
kind = "delta_state"
|
||
else:
|
||
kind = "full_attention" if full_attention else "delta_state"
|
||
context.put({
|
||
"step": min(step, 511),
|
||
"layer": f"L{layer_index + 1:02d}",
|
||
"kind": kind,
|
||
"mode": mode,
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"source": "forward_hook",
|
||
})
|
||
|
||
return forward_hook
|
||
|
||
def _make_vision_hook(self):
|
||
def vision_hook(_module: Any, _inputs: Any, output: Any) -> None:
|
||
context = getattr(self._trace_context, "queue", None)
|
||
if context is None:
|
||
return
|
||
tensor = self._first_tensor(output)
|
||
summary = self._summarize(tensor)
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
context.put({
|
||
"step": min(step, 511),
|
||
"kind": "vision_encoder",
|
||
"mode": getattr(self._trace_context, "mode", "forward"),
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"source": "vision_hook",
|
||
})
|
||
|
||
return vision_hook
|
||
|
||
def _make_aux_hook(self, module_name: str, kind: str):
|
||
def aux_hook(_module: Any, _inputs: Any, output: Any) -> None:
|
||
context = getattr(self._trace_context, "queue", None)
|
||
if context is None:
|
||
return
|
||
tensor = self._first_tensor(output)
|
||
summary = self._summarize(tensor)
|
||
layer_match = re.search(r"layers\.(\d+)", module_name)
|
||
layer = f"L{int(layer_match.group(1)) + 1:02d}" if layer_match else "AUX"
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
context.put({
|
||
"step": min(step, 511),
|
||
"layer": layer,
|
||
"kind": kind,
|
||
"mode": getattr(self._trace_context, "mode", "forward"),
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"module": module_name,
|
||
"source": "aux_hook",
|
||
})
|
||
|
||
return aux_hook
|
||
|
||
def _make_backward_hook(self, layer_index: int):
|
||
def backward_hook(_module: Any, _grad_input: Any, grad_output: Any) -> None:
|
||
context = getattr(self._trace_context, "queue", None)
|
||
if context is None or getattr(self._trace_context, "mode", "forward") != "backward":
|
||
return
|
||
tensor = self._first_tensor(grad_output)
|
||
summary = self._summarize(tensor)
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
context.put({
|
||
"step": min(step, 511),
|
||
"layer": f"L{layer_index + 1:02d}",
|
||
"kind": "gradient",
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"source": "backward_hook",
|
||
})
|
||
|
||
return backward_hook
|
||
|
||
def _make_logits_hook(self):
|
||
def logits_hook(_module: Any, _inputs: Any, output: Any) -> None:
|
||
context = getattr(self._trace_context, "queue", None)
|
||
if context is None:
|
||
return
|
||
tensor = self._first_tensor(output)
|
||
if tensor is not None and tensor.ndim >= 2:
|
||
tensor = tensor[:, -1, :]
|
||
summary = self._summarize(tensor)
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
context.put({
|
||
"step": min(step, 511),
|
||
"layer": "OUT",
|
||
"kind": "logits",
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"source": "logits_hook",
|
||
})
|
||
|
||
return logits_hook
|
||
|
||
def _trace_worker(self, prompt: str, mode: str, image_data: str | None, events: queue.Queue[dict[str, Any]]) -> None:
|
||
self._trace_context.queue = events
|
||
self._trace_context.mode = mode
|
||
self._trace_context.step = 0
|
||
try:
|
||
inputs = self._encode(prompt, image_data)
|
||
if image_data and "pixel_values" in inputs:
|
||
summary = self._summarize(inputs["pixel_values"])
|
||
events.put({
|
||
"step": 0,
|
||
"kind": "vision_input",
|
||
"mode": mode,
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": summary["shape"],
|
||
"source": "processor",
|
||
})
|
||
if mode == "backward":
|
||
self._run_backward(inputs, events)
|
||
elif mode == "attention":
|
||
self._run_attention(inputs, events)
|
||
else:
|
||
self._run_generation(inputs, events)
|
||
except Exception as exc: # pragma: no cover - depends on checkpoint/runtime
|
||
events.put({"kind": "runtime_error", "error": f"{type(exc).__name__}: {exc}", "source": "model_runtime"})
|
||
finally:
|
||
events.put({"kind": "done", "source": "model_runtime"})
|
||
self._trace_context.queue = None
|
||
|
||
def _run_attention(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
|
||
torch = self._torch
|
||
try:
|
||
with torch.inference_mode():
|
||
outputs = self.model(
|
||
**inputs,
|
||
use_cache=False,
|
||
output_attentions=True,
|
||
return_dict=True,
|
||
)
|
||
attentions = getattr(outputs, "attentions", None)
|
||
emitted = False
|
||
if attentions:
|
||
for layer_index, attention in enumerate(attentions):
|
||
if attention is None or getattr(attention, "ndim", 0) < 4:
|
||
continue
|
||
size = min(7, int(attention.shape[-1]))
|
||
matrix = attention[0, :, -size:, -size:].float().mean(dim=0)
|
||
values = matrix.detach().cpu().reshape(-1).tolist()
|
||
summary = self._summarize(matrix)
|
||
step = getattr(self._trace_context, "step", 0)
|
||
self._trace_context.step = step + 1
|
||
events.put({
|
||
"step": min(step, 511),
|
||
"layer": f"L{layer_index + 1:02d}",
|
||
"kind": "attention_weight",
|
||
"mode": "attention",
|
||
"value": summary["rms"],
|
||
"mean": summary["mean"],
|
||
"shape": list(attention.shape),
|
||
"attention": [round(float(value), 6) for value in values],
|
||
"attention_shape": [size, size],
|
||
"source": "attention_output",
|
||
})
|
||
emitted = True
|
||
if not emitted:
|
||
events.put({
|
||
"step": min(getattr(self._trace_context, "step", 0), 511),
|
||
"kind": "attention_unavailable",
|
||
"mode": "attention",
|
||
"source": "model_runtime",
|
||
})
|
||
except Exception:
|
||
events.put({
|
||
"step": min(getattr(self._trace_context, "step", 0), 511),
|
||
"kind": "attention_unavailable",
|
||
"mode": "attention",
|
||
"source": "model_runtime",
|
||
})
|
||
self._run_generation(inputs, events)
|
||
|
||
def _encode(self, prompt: str, image_data: str | None = None) -> dict[str, Any]:
|
||
processor = self.processor
|
||
rendered_prompt = prompt
|
||
image = None
|
||
if image_data:
|
||
try:
|
||
from PIL import Image
|
||
|
||
encoded_image = image_data.split(",", 1)[-1]
|
||
image = Image.open(BytesIO(base64.b64decode(encoded_image))).convert("RGB")
|
||
except Exception as exc:
|
||
raise RuntimeError(f"invalid image input: {type(exc).__name__}") from exc
|
||
apply_chat_template = getattr(processor, "apply_chat_template", None)
|
||
if callable(apply_chat_template):
|
||
try:
|
||
content = [{"type": "image", "image": image}, {"type": "text", "text": prompt}] if image is not None else prompt
|
||
rendered_prompt = apply_chat_template(
|
||
[{"role": "user", "content": content}],
|
||
tokenize=False,
|
||
add_generation_prompt=True,
|
||
)
|
||
except Exception:
|
||
rendered_prompt = prompt
|
||
processor_kwargs: dict[str, Any] = {"text": rendered_prompt, "return_tensors": "pt", "truncation": True, "max_length": 256}
|
||
if image is not None:
|
||
processor_kwargs["images"] = image
|
||
try:
|
||
encoded = processor(**processor_kwargs)
|
||
except Exception:
|
||
processor_kwargs.pop("truncation", None)
|
||
processor_kwargs.pop("max_length", None)
|
||
encoded = processor(**processor_kwargs)
|
||
device = self._input_device()
|
||
return {key: value.to(device) if hasattr(value, "to") else value for key, value in encoded.items()}
|
||
|
||
def _run_generation(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
|
||
torch = self._torch
|
||
with torch.inference_mode():
|
||
generated = self.model.generate(**inputs, max_new_tokens=32, do_sample=False, use_cache=True)
|
||
if hasattr(generated, "sequences"):
|
||
generated = generated.sequences
|
||
input_length = 0
|
||
if "input_ids" in inputs and getattr(inputs["input_ids"], "ndim", 0) >= 2:
|
||
input_length = int(inputs["input_ids"].shape[-1])
|
||
if getattr(generated, "ndim", 0) >= 2 and input_length:
|
||
generated = generated[:, input_length:]
|
||
try:
|
||
text = self.processor.batch_decode(generated, skip_special_tokens=True)[0]
|
||
except Exception:
|
||
tokenizer = getattr(self.processor, "tokenizer", None)
|
||
try:
|
||
text = tokenizer.batch_decode(generated, skip_special_tokens=True)[0] if tokenizer is not None else ""
|
||
except Exception:
|
||
text = ""
|
||
events.put({
|
||
"step": min(getattr(self._trace_context, "step", 0), 511),
|
||
"kind": "generation",
|
||
"token": text[-1:] if text else "",
|
||
"text": text,
|
||
"value": 1.0,
|
||
"source": "model_runtime",
|
||
})
|
||
|
||
def _run_backward(self, inputs: dict[str, Any], events: queue.Queue[dict[str, Any]]) -> None:
|
||
torch = self._torch
|
||
if "input_ids" not in inputs:
|
||
raise RuntimeError("processor did not return input_ids; backward trace requires text tokens")
|
||
self.model.zero_grad(set_to_none=True)
|
||
labels = inputs["input_ids"].clone()
|
||
with torch.enable_grad():
|
||
outputs = self.model(**inputs, labels=labels, use_cache=False)
|
||
loss = outputs.loss
|
||
loss.backward()
|
||
events.put({
|
||
"step": min(getattr(self._trace_context, "step", 0), 511),
|
||
"kind": "loss",
|
||
"loss": float(loss.detach().float().item()),
|
||
"value": float(loss.detach().float().item()),
|
||
"source": "autograd",
|
||
})
|
||
|
||
def _input_device(self):
|
||
try:
|
||
return self.model.device
|
||
except Exception:
|
||
for parameter in self.model.parameters():
|
||
if parameter.device.type != "meta":
|
||
return parameter.device
|
||
return self._torch.device("cpu")
|
||
|
||
def _first_tensor(self, value: Any):
|
||
if self._torch is not None and self._torch.is_tensor(value):
|
||
return value
|
||
if isinstance(value, (tuple, list)):
|
||
for item in value:
|
||
tensor = self._first_tensor(item)
|
||
if tensor is not None:
|
||
return tensor
|
||
if isinstance(value, dict):
|
||
for item in value.values():
|
||
tensor = self._first_tensor(item)
|
||
if tensor is not None:
|
||
return tensor
|
||
return None
|
||
|
||
def _summarize(self, tensor: Any) -> dict[str, Any]:
|
||
if tensor is None:
|
||
return {"mean": 0.0, "rms": 0.0, "shape": []}
|
||
torch = self._torch
|
||
sampled = tensor.detach().float().reshape(-1)
|
||
if sampled.numel() > 4096:
|
||
stride = max(1, sampled.numel() // 4096)
|
||
sampled = sampled[::stride]
|
||
mean = float(sampled.mean().item())
|
||
rms = float(torch.sqrt(torch.mean(sampled * sampled)).item())
|
||
return {"mean": round(mean, 6), "rms": round(rms, 6), "shape": list(tensor.shape)}
|