#!/usr/bin/env python3 """Export any HuggingFace causal LM as ONNX for epimem. Exports two models: 1. backbone.onnx: token_ids → hidden_state at target layer (for trigger key computation) 2. lm_head.onnx: full_hidden → logits (for generation) Model-agnostic: works with Qwen, Gemma, Llama, Mistral, Phi, etc. For multimodal models (Gemma 4), exports only the text decoder. Usage: python export_onnx.py # default: Qwen 2.5 0.5B python export_onnx.py --model google/gemma-4-E4B-it # Gemma 4 python export_onnx.py --model google/gemma-4-E2B-it # Gemma 4 small python export_onnx.py --model meta-llama/Llama-3.2-1B # Llama """ import argparse import torch import torch.nn as nn import os DEFAULT_MODEL = "Qwen/Qwen2.5-0.5B" def resolve_text_model(model): """Extract the text decoder from multimodal models, or return as-is. Gemma 4: ConditionalGeneration → .model (Gemma4Model) → .language_model LLaVA: ConditionalGeneration → .language_model Pure CausalLM: model itself """ inner = getattr(model, 'model', model) if hasattr(inner, 'language_model'): return inner.language_model if hasattr(model, 'language_model'): return model.language_model return model def get_lm_head_and_norm(model): """Find lm_head and final layer norm regardless of architecture. Returns (norm, lm_head) where norm may be None if not found separately. """ text_model = resolve_text_model(model) # Find lm_head lm_head = None if hasattr(text_model, 'lm_head'): lm_head = text_model.lm_head elif hasattr(model, 'lm_head'): lm_head = model.lm_head # Find final norm — varies by architecture norm = None inner = getattr(text_model, 'model', text_model) for attr in ['norm', 'final_layernorm', 'ln_f', 'final_norm']: if hasattr(inner, attr): norm = getattr(inner, attr) break return norm, lm_head class GenericBackboneWrapper(nn.Module): """Architecture-agnostic wrapper that extracts hidden state at target layer. Uses output_hidden_states=True to get intermediate representations without manually walking architecture-specific layer structures. Returns: hidden: hidden state at target_layer (for trigger key) full_hidden: final layer hidden state (for lm_head / logit computation) """ def __init__(self, text_model, target_layer): super().__init__() self.text_model = text_model self.target_layer = target_layer def forward(self, input_ids): outputs = self.text_model(input_ids, output_hidden_states=True) hidden_states = outputs.hidden_states # tuple of (n_layers+1) tensors target_hidden = hidden_states[self.target_layer] # [B, T, D] final_hidden = hidden_states[-1] # [B, T, D] return target_hidden, final_hidden class GenericLMHead(nn.Module): """Architecture-agnostic lm_head wrapper: hidden → logits.""" def __init__(self, norm, lm_head): super().__init__() self.norm = norm self.lm_head = lm_head def forward(self, full_hidden): x = full_hidden if self.norm is not None: x = self.norm(x) return self.lm_head(x) def export(model_name: str, output_dir: str): from transformers import AutoModelForCausalLM, AutoTokenizer print(f"Loading {model_name}...") # Try CausalLM first, fall back to auto for multimodal is_multimodal = False try: model = AutoModelForCausalLM.from_pretrained( model_name, torch_dtype=torch.float32, trust_remote_code=True) except (ValueError, KeyError): from transformers import AutoModel model = AutoModel.from_pretrained( model_name, torch_dtype=torch.float32, trust_remote_code=True) is_multimodal = True tokenizer = AutoTokenizer.from_pretrained(model_name, trust_remote_code=True) model.eval() text_model = resolve_text_model(model) config = text_model.config D = config.hidden_size n_layers = config.num_hidden_layers V = config.vocab_size target_layer = n_layers - 1 print(f" arch={type(model).__name__}") print(f" D={D}, layers={n_layers}, vocab={V}, target_layer={target_layer}") os.makedirs(output_dir, exist_ok=True) # ─── Export backbone: tokens → hidden at target layer ───────── print("Exporting backbone (tokens → hidden)...") backbone = GenericBackboneWrapper(text_model, target_layer) dummy_ids = torch.randint(0, min(V, 10000), (1, 32)) with torch.no_grad(): hidden, full = backbone(dummy_ids) print(f" hidden: {hidden.shape}, full: {full.shape}") backbone_path = os.path.join(output_dir, "backbone.onnx") torch.onnx.export( backbone, dummy_ids, backbone_path, input_names=["input_ids"], output_names=["hidden", "full_hidden"], dynamic_axes={ "input_ids": {0: "batch", 1: "seq_len"}, "hidden": {0: "batch", 1: "seq_len"}, "full_hidden": {0: "batch", 1: "seq_len"}, }, opset_version=17, ) backbone_size = os.path.getsize(backbone_path) print(f" Saved {backbone_path} ({backbone_size / 1e6:.1f} MB)") # ─── Export lm_head: full_hidden → logits ───────────────── print("Exporting lm_head (hidden → logits)...") norm, lm_head = get_lm_head_and_norm(model) if lm_head is None: print(" WARNING: Could not find lm_head, skipping lm_head export") return head = GenericLMHead(norm, lm_head) dummy_hidden = torch.randn(1, 32, D) head_path = os.path.join(output_dir, "lm_head.onnx") torch.onnx.export( head, dummy_hidden, head_path, input_names=["full_hidden"], output_names=["logits"], dynamic_axes={ "full_hidden": {0: "batch", 1: "seq_len"}, "logits": {0: "batch", 1: "seq_len"}, }, opset_version=17, ) head_size = os.path.getsize(head_path) print(f" Saved {head_path} ({head_size / 1e6:.1f} MB)") # Save tokenizer tokenizer_path = os.path.join(output_dir, "tokenizer") tokenizer.save_pretrained(tokenizer_path) print(f" Saved {tokenizer_path}/") # Verify print("\nVerifying ONNX export...") import onnxruntime as ort sess_backbone = ort.InferenceSession(backbone_path) sess_head = ort.InferenceSession(head_path) test_text = "The capital of France is" ids = tokenizer.encode(test_text, add_special_tokens=False) input_ids = torch.tensor([ids]).numpy() hidden_out, full_out = sess_backbone.run(None, {"input_ids": input_ids}) logits_out = sess_head.run(None, {"full_hidden": full_out}) next_token = logits_out[0][0, -1].argmax() predicted = tokenizer.decode([next_token]) print(f" '{test_text}' → '{predicted}'") print(f" Hidden shape: {hidden_out.shape}") print(f" Logits shape: {logits_out[0].shape}") print(f"\nDone. Total model size: {(backbone_size + head_size) / 1e6:.1f} MB") print(f"Model: {model_name}") if __name__ == "__main__": parser = argparse.ArgumentParser( description="Export a HuggingFace model as ONNX for epimem") parser.add_argument("--model", type=str, default=DEFAULT_MODEL, help=f"HuggingFace model name (default: {DEFAULT_MODEL})") parser.add_argument("--output", type=str, default="models", help="Output directory (default: models)") args = parser.parse_args() export(args.model, args.output)