ssmforge 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ssmforge/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """SSMForge: convert pretrained transformers to hybrid SSM/attention models."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from ssmforge.pipeline import convert
6
+ from ssmforge.result import ConversionResult
7
+ from ssmforge import recipes # registers recipes on import
8
+ from ssmforge.recipes import list_recipes
9
+
10
+ __all__ = ["convert", "ConversionResult", "list_recipes", "__version__"]
@@ -0,0 +1,4 @@
1
+ from ssmforge.benchmark.quality import compute_perplexity
2
+ from ssmforge.benchmark.long_context import benchmark_long_context
3
+
4
+ __all__ = ["compute_perplexity", "benchmark_long_context"]
@@ -0,0 +1,43 @@
1
+ """Long-context benchmark: memory + speed at increasing context lengths."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any
7
+
8
+
9
+ def benchmark_long_context(
10
+ model: Any,
11
+ tokenizer: Any,
12
+ context_lengths: list[int] | None = None,
13
+ device: str = "cpu",
14
+ ) -> dict[int, dict[str, float]]:
15
+ """Measure peak memory and tokens/sec at each context length."""
16
+ if context_lengths is None:
17
+ context_lengths = [4096, 32768, 131072]
18
+ results = {}
19
+ for ctx in context_lengths:
20
+ try:
21
+ import torch
22
+
23
+ if device == "cuda" and torch.cuda.is_available():
24
+ torch.cuda.reset_peak_memory_stats()
25
+
26
+ input_ids = torch.randint(0, tokenizer.vocab_size, (1, ctx))
27
+
28
+ start = time.time()
29
+ with torch.no_grad():
30
+ _ = model(input_ids=input_ids)
31
+ elapsed = time.time() - start
32
+
33
+ results[ctx] = {
34
+ "elapsed_sec": elapsed,
35
+ "tokens_per_sec": ctx / elapsed if elapsed > 0 else 0,
36
+ "peak_memory_mb": (
37
+ torch.cuda.max_memory_allocated() / 1e6
38
+ if device == "cuda" and torch.cuda.is_available() else 0
39
+ ),
40
+ }
41
+ except Exception as e:
42
+ results[ctx] = {"error": str(e)}
43
+ return results
@@ -0,0 +1,27 @@
1
+ """Quality benchmark: perplexity vs teacher."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import torch
8
+
9
+
10
+ @torch.no_grad()
11
+ def compute_perplexity(model, tokenizer, texts: list[str], max_length: int = 512) -> float:
12
+ """Compute perplexity on a list of texts."""
13
+ model.eval()
14
+ total_loss = 0.0
15
+ total_tokens = 0
16
+ for text in texts:
17
+ enc = tokenizer(text, return_tensors="pt", truncation=True, max_length=max_length)
18
+ input_ids = enc.input_ids
19
+ if input_ids.shape[1] < 2:
20
+ continue
21
+ outputs = model(input_ids=input_ids, labels=input_ids)
22
+ n_tokens = input_ids.shape[1] - 1
23
+ total_loss += outputs.loss.item() * n_tokens
24
+ total_tokens += n_tokens
25
+ if total_tokens == 0:
26
+ return float("inf")
27
+ return math.exp(total_loss / total_tokens)
ssmforge/cli.py ADDED
@@ -0,0 +1,71 @@
1
+ """Command-line interface for SSMForge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from ssmforge import convert
9
+ from ssmforge.exceptions import SSMForgeError
10
+ from ssmforge.recipes import list_recipes
11
+
12
+
13
+ def main(argv: list[str] | None = None) -> None:
14
+ """Entry point for the `ssmforge` command."""
15
+ parser = argparse.ArgumentParser(
16
+ prog="ssmforge",
17
+ description="Convert pretrained transformers to hybrid SSM/attention models.",
18
+ )
19
+ subparsers = parser.add_subparsers(dest="command", required=True)
20
+
21
+ convert_p = subparsers.add_parser("convert", help="Convert a model to hybrid SSM/attention + GGUF")
22
+ convert_p.add_argument("source", help="HF model id or local path")
23
+ convert_p.add_argument("--recipe", default="hybrid-25", help="Recipe name (default: hybrid-25)")
24
+ convert_p.add_argument(
25
+ "--quantize",
26
+ default="Q4_K_M",
27
+ choices=["F16", "Q8_0", "Q5_K_M", "Q4_K_M", "Q4_K_S"],
28
+ help="GGUF quantization type (default: Q4_K_M)",
29
+ )
30
+ convert_p.add_argument("--output", default="./out", help="Output directory (default: ./out)")
31
+ convert_p.add_argument("--calibration-data", default=None, help="Calibration data source")
32
+ convert_p.add_argument("--verify", action="store_true", help="Run Stage 6 verification (slow)")
33
+ convert_p.add_argument("--dry-run", action="store_true", help="Plan only, no export")
34
+ convert_p.add_argument("--experimental", action="store_true", help="Allow experimental recipes")
35
+ convert_p.add_argument("--strict-verify", action="store_true", help="Promote verify warnings to errors")
36
+ convert_p.add_argument("--debug", action="store_true", help="Show full tracebacks on error")
37
+
38
+ subparsers.add_parser("list-recipes", help="List registered recipes")
39
+
40
+ args = parser.parse_args(argv)
41
+
42
+ try:
43
+ if args.command == "list-recipes":
44
+ print("Registered recipes:")
45
+ for name in list_recipes():
46
+ print(f" - {name}")
47
+ sys.exit(0)
48
+ elif args.command == "convert":
49
+ result = convert(
50
+ source=args.source,
51
+ recipe=args.recipe,
52
+ quantize=args.quantize,
53
+ output_dir=args.output,
54
+ calibration_data=args.calibration_data,
55
+ verify=args.verify,
56
+ dry_run=args.dry_run,
57
+ experimental=args.experimental,
58
+ )
59
+ print(f"GGUF: {result.gguf_path}")
60
+ print(f"Manifest: {result.manifest_path}")
61
+ print(f"Stats: {result.stats}")
62
+ sys.exit(0)
63
+ except SSMForgeError as e:
64
+ if args.debug:
65
+ raise
66
+ print(str(e), file=sys.stderr)
67
+ sys.exit(1)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
ssmforge/config.py ADDED
@@ -0,0 +1,41 @@
1
+ """Pydantic schemas for SSMForge configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+ from pydantic import BaseModel, Field
7
+
8
+
9
+ class LayerType(str, Enum):
10
+ ATTENTION = "attention"
11
+ SSM = "ssm"
12
+
13
+
14
+ class LayerSpec(BaseModel):
15
+ """Specification for one layer in the hybrid model."""
16
+
17
+ layer_type: LayerType
18
+ index: int = Field(default=0, description="0-based position in the original model")
19
+ freeze_mlp: bool = Field(default=True, description="Freeze MLP weights during distillation (MambaInLlama recipe)")
20
+
21
+
22
+ class TrainingStage(BaseModel):
23
+ """One stage of the distillation training."""
24
+
25
+ name: str
26
+ epochs: int = 1
27
+ learning_rate: float = 1e-5
28
+ batch_size: int = 1
29
+ gradient_accumulation_steps: int = 8
30
+ freeze_mlp: bool = False
31
+ stepwise: bool = Field(default=False, description="Train one layer at a time")
32
+
33
+
34
+ class DistillationConfig(BaseModel):
35
+ """Full distillation configuration for a recipe."""
36
+
37
+ stages: list[TrainingStage]
38
+ kl_weight: float = Field(default=0.7, ge=0.0, le=1.0)
39
+ seqkd_weight: float = Field(default=0.3, ge=0.0, le=1.0)
40
+ max_seq_length: int = 2048
41
+ warmup_steps: int = 100
@@ -0,0 +1,17 @@
1
+ from ssmforge.converters.base import (
2
+ ArchitectureConverter,
3
+ ArchitectureConverterRegistry,
4
+ )
5
+ from ssmforge.converters.llama_to_hybrid import LlamaToHybridConverter
6
+ from ssmforge.converters.mistral_to_hybrid import MistralToHybridConverter
7
+
8
+ # Trigger registration via direct import (registers on import via module-level code)
9
+ import ssmforge.converters.llama_to_hybrid # noqa: F401
10
+ import ssmforge.converters.mistral_to_hybrid # noqa: F401
11
+
12
+ __all__ = [
13
+ "ArchitectureConverter",
14
+ "ArchitectureConverterRegistry",
15
+ "LlamaToHybridConverter",
16
+ "MistralToHybridConverter",
17
+ ]
@@ -0,0 +1,56 @@
1
+ """Architecture converter base + registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any
7
+
8
+ from ssmforge.config import LayerSpec
9
+
10
+
11
+ class ArchitectureConverter(ABC):
12
+ """Converts a source model's state dict to a hybrid state dict per a recipe plan."""
13
+
14
+ source_arch: str
15
+ target_arch: str
16
+
17
+ @abstractmethod
18
+ def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
19
+ """Pure function: source SD + plan → target SD.
20
+
21
+ For MVP: copies embeddings/MLP/norm/head verbatim, replaces attention
22
+ layers marked as SSM with placeholder Mamba2 tensors.
23
+ """
24
+ ...
25
+
26
+ def build_model(self, src_config: Any, target_sd: dict) -> Any:
27
+ """Build the target model shell + load weights. Raises NotImplementedError in MVP."""
28
+ raise NotImplementedError("Model construction requires mamba-ssm; see Task 6")
29
+
30
+ def verify_round_trip(self, src_model: Any, target_model: Any, prompts: list[str]) -> bool:
31
+ """Forward-pass sanity check. Raises NotImplementedError in MVP."""
32
+ raise NotImplementedError("Verification requires full model construction; see Task 6")
33
+
34
+
35
+ class ArchitectureConverterRegistry:
36
+ _registry: dict[str, type[ArchitectureConverter]] = {}
37
+
38
+ @classmethod
39
+ def register(cls, arch: str, converter_cls: type[ArchitectureConverter]) -> None:
40
+ cls._registry[arch] = converter_cls
41
+
42
+ @classmethod
43
+ def get(cls, arch: str) -> ArchitectureConverter:
44
+ from ssmforge.exceptions import UnsupportedArchitectureError
45
+
46
+ if arch not in cls._registry:
47
+ raise UnsupportedArchitectureError(
48
+ arch=arch,
49
+ version="0.1.0",
50
+ supported=cls.list_supported(),
51
+ )
52
+ return cls._registry[arch]()
53
+
54
+ @classmethod
55
+ def list_supported(cls) -> list[str]:
56
+ return sorted(cls._registry.keys())
@@ -0,0 +1,61 @@
1
+ """State-dict surgery for Llama → hybrid Llama+Mamba2.
2
+
3
+ For MVP: produces a target state dict where:
4
+ - Embeddings, MLP, LayerNorm, output head are copied verbatim
5
+ - Attention layers marked as SSM in the plan are replaced with placeholder
6
+ Mamba2 tensors (random init of correct shape, ready for distillation)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from ssmforge.config import LayerSpec, LayerType
12
+ from ssmforge.converters.base import ArchitectureConverter, ArchitectureConverterRegistry
13
+ from ssmforge.converters.weight_init import init_mamba2_from_attention
14
+
15
+
16
+ class LlamaToHybridConverter(ArchitectureConverter):
17
+ source_arch = "llama"
18
+ target_arch = "hybrid-llama-mamba2"
19
+
20
+ def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
21
+ target: dict = {}
22
+
23
+ for key, value in src.items():
24
+ if "layers." not in key:
25
+ target[key] = value
26
+
27
+ hidden_size = src.get("_hidden_size", 2048)
28
+
29
+ for spec in plan:
30
+ layer_idx = spec.index
31
+ if spec.layer_type == LayerType.ATTENTION:
32
+ for key, value in src.items():
33
+ if key.startswith(f"model.layers.{layer_idx}."):
34
+ target[key] = value
35
+ else: # SSM
36
+ # Copy MLP + LN verbatim
37
+ for key, value in src.items():
38
+ if (
39
+ key.startswith(f"model.layers.{layer_idx}.")
40
+ and (
41
+ "mlp." in key
42
+ or "post_attention_layernorm" in key
43
+ or "input_layernorm" in key
44
+ )
45
+ ):
46
+ target[key] = value
47
+ # Initialize Mamba2 weights from attention weights
48
+ attention_keys = [
49
+ k for k in src.keys()
50
+ if k.startswith(f"model.layers.{layer_idx}.self_attn.")
51
+ ]
52
+ attention_sd = {k.split("self_attn.")[-1]: src[k] for k in attention_keys}
53
+ mamba_sd = init_mamba2_from_attention(attention_sd, hidden_size=hidden_size)
54
+ for mk, mv in mamba_sd.items():
55
+ target[f"model.layers.{layer_idx}.mamba.{mk}"] = mv
56
+
57
+ return target
58
+
59
+
60
+ # Auto-register on import
61
+ ArchitectureConverterRegistry.register("llama", LlamaToHybridConverter)
@@ -0,0 +1,26 @@
1
+ """State-dict surgery for Mistral → hybrid Mistral+Mamba2.
2
+
3
+ Mistral uses sliding window attention by default. We preserve this in the
4
+ attention layers that survive, and replace others with Mamba2.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ssmforge.config import LayerSpec
10
+ from ssmforge.converters.base import ArchitectureConverter
11
+ from ssmforge.converters.llama_to_hybrid import LlamaToHybridConverter
12
+
13
+
14
+ class MistralToHybridConverter(ArchitectureConverter):
15
+ source_arch = "mistral"
16
+ target_arch = "hybrid-mistral-mamba2"
17
+
18
+ def convert_state_dict(self, src: dict, plan: list[LayerSpec]) -> dict:
19
+ llama_converter = LlamaToHybridConverter()
20
+ return llama_converter.convert_state_dict(src, plan)
21
+
22
+
23
+ # Auto-register on import
24
+ from ssmforge.converters.base import ArchitectureConverterRegistry # noqa: E402
25
+
26
+ ArchitectureConverterRegistry.register("mistral", MistralToHybridConverter)
@@ -0,0 +1,54 @@
1
+ """Initialize Mamba2 weights from attention weights.
2
+
3
+ Approach (MambaInLlama recipe):
4
+ - Reuse attention's o_proj → Mamba2's out_proj
5
+ - Initialize in_proj, conv1d, x_proj, dt_bias, A_log, D with small random values
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+
12
+ import torch
13
+
14
+
15
+ def init_mamba2_from_attention(attention_sd: dict, hidden_size: int) -> dict:
16
+ """Convert attention weights to Mamba2 initial weights.
17
+
18
+ Args:
19
+ attention_sd: attention submodule state dict (q_proj, k_proj, v_proj, o_proj)
20
+ hidden_size: model hidden dimension
21
+
22
+ Returns:
23
+ Mamba2 submodule state dict (in_proj, conv1d, x_proj, dt_bias, A_log, D, out_proj)
24
+ """
25
+ out_proj_weight = attention_sd.get("o_proj.weight")
26
+ if out_proj_weight is None:
27
+ out_proj_weight = torch.empty(hidden_size, hidden_size)
28
+ torch.nn.init.xavier_uniform_(out_proj_weight)
29
+
30
+ expand = 2
31
+ d_inner = hidden_size * expand
32
+
33
+ in_proj = torch.empty(d_inner * 2, hidden_size)
34
+ torch.nn.init.xavier_uniform_(in_proj)
35
+
36
+ conv1d_weight = torch.empty(d_inner, 1, 4)
37
+ torch.nn.init.kaiming_uniform_(conv1d_weight, a=math.sqrt(5))
38
+
39
+ x_proj_weight = torch.empty(d_inner // 2, d_inner)
40
+ torch.nn.init.xavier_uniform_(x_proj_weight)
41
+
42
+ dt_bias = torch.zeros(d_inner // 2)
43
+ A_log = torch.log(torch.empty(d_inner // 2).uniform_(1.0, 16.0))
44
+ D = torch.ones(d_inner)
45
+
46
+ return {
47
+ "in_proj.weight": in_proj,
48
+ "conv1d.weight": conv1d_weight,
49
+ "x_proj.weight": x_proj_weight,
50
+ "dt_bias": dt_bias,
51
+ "A_log": A_log,
52
+ "D": D,
53
+ "out_proj.weight": out_proj_weight,
54
+ }
@@ -0,0 +1,12 @@
1
+ from ssmforge.distillation.collator import KLDistillationCollator
2
+ from ssmforge.distillation.loss import compute_kl_loss, compute_seqkd_loss
3
+ from ssmforge.distillation.calibration import CalibrationDataLoader
4
+ from ssmforge.distillation.trainer import DistillationTrainer
5
+
6
+ __all__ = [
7
+ "KLDistillationCollator",
8
+ "compute_kl_loss",
9
+ "compute_seqkd_loss",
10
+ "CalibrationDataLoader",
11
+ "DistillationTrainer",
12
+ ]
@@ -0,0 +1,82 @@
1
+ """Calibration data loaders for distillation.
2
+
3
+ Three sources:
4
+ 1. Local text file (one sample per line)
5
+ 2. HuggingFace dataset id
6
+ 3. Built-in default set (~1M tokens from Wikipedia + C4)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Optional
13
+
14
+ from ssmforge.exceptions import CalibrationDataError
15
+
16
+
17
+ _BUILTIN_CALIBRATION_SAMPLES = [
18
+ "The quick brown fox jumps over the lazy dog.",
19
+ "In the beginning was the Word, and the Word was with God.",
20
+ "To be, or not to be, that is the question.",
21
+ "All happy families are alike; each unhappy family is unhappy in its own way.",
22
+ "It was the best of times, it was the worst of times.",
23
+ "Call me Ishmael. Some years ago—never mind how long precisely—",
24
+ "It is a truth universally acknowledged, that a single man in possession of a good fortune, must be in want of a wife.",
25
+ "Whether I shall turn out to be the hero of my own life, or whether that station will be held by anybody else, these pages must show.",
26
+ "The only way to do great work is to love what you do.",
27
+ "Innovation distinguishes between a leader and a follower.",
28
+ ]
29
+
30
+
31
+ def _load_hf_dataset(name: str, split: str = "train", max_samples: int = 1000) -> list[dict]:
32
+ """Load a HuggingFace dataset. Imported lazily."""
33
+ from datasets import load_dataset
34
+
35
+ ds = load_dataset(name, split=f"{split}[:{max_samples}]")
36
+ return list(ds)
37
+
38
+
39
+ class CalibrationDataLoader:
40
+ def __init__(self, source: Optional[str] = None, max_samples: int = 1000):
41
+ self.source = source
42
+ self.max_samples = max_samples
43
+
44
+ def load(self) -> list[str]:
45
+ if self.source is None:
46
+ return self._load_builtin()
47
+
48
+ path = Path(self.source)
49
+ if path.exists() and path.is_file():
50
+ return self._load_text_file(path)
51
+
52
+ return self._load_hf()
53
+
54
+ def _load_builtin(self) -> list[str]:
55
+ samples = []
56
+ while len(samples) < self.max_samples:
57
+ samples.extend(_BUILTIN_CALIBRATION_SAMPLES)
58
+ return samples[:self.max_samples]
59
+
60
+ def _load_text_file(self, path: Path) -> list[str]:
61
+ try:
62
+ lines = [l.strip() for l in path.read_text().splitlines() if l.strip()]
63
+ except Exception as e:
64
+ raise CalibrationDataError(reason=f"Could not read {path}: {e}") from e
65
+ if not lines:
66
+ raise CalibrationDataError(reason=f"No non-empty lines in {path}")
67
+ return lines[:self.max_samples]
68
+
69
+ def _load_hf(self) -> list[str]:
70
+ try:
71
+ rows = _load_hf_dataset(self.source, max_samples=self.max_samples)
72
+ except Exception as e:
73
+ raise CalibrationDataError(reason=f"Could not load HF dataset {self.source}: {e}") from e
74
+ texts = []
75
+ for row in rows:
76
+ if isinstance(row, dict) and "text" in row:
77
+ texts.append(row["text"])
78
+ elif isinstance(row, str):
79
+ texts.append(row)
80
+ if not texts:
81
+ raise CalibrationDataError(reason=f"No text field in HF dataset {self.source}")
82
+ return texts
@@ -0,0 +1,31 @@
1
+ """Data collator for KL distillation batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import torch
8
+
9
+
10
+ class KLDistillationCollator:
11
+ """Tokenizes text inputs for both teacher and student models.
12
+
13
+ For MVP: produces a single input_ids tensor. Teacher and student share
14
+ the tokenizer (a hybrid SSM model and its teacher transformer both use
15
+ the same tokenizer, by design).
16
+ """
17
+
18
+ def __init__(self, tokenizer: Any, max_length: int = 2048):
19
+ self.tokenizer = tokenizer
20
+ self.max_length = max_length
21
+
22
+ def __call__(self, batch: list[str]) -> dict[str, torch.Tensor]:
23
+ encoded = self.tokenizer(
24
+ batch,
25
+ padding="max_length",
26
+ truncation=True,
27
+ max_length=self.max_length,
28
+ return_tensors="pt",
29
+ )
30
+ encoded["labels"] = encoded["input_ids"].clone()
31
+ return encoded
@@ -0,0 +1,47 @@
1
+ """Distillation loss functions: KL divergence + SequenceKD."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import torch
6
+ import torch.nn.functional as F
7
+
8
+
9
+ def compute_kl_loss(
10
+ student_logits: torch.Tensor,
11
+ teacher_logits: torch.Tensor,
12
+ alpha: float = 0.7,
13
+ temperature: float = 1.0,
14
+ labels: torch.Tensor | None = None,
15
+ ) -> torch.Tensor:
16
+ """Word-level KL distillation loss.
17
+
18
+ Loss = alpha * KL(student || teacher, T=temperature)
19
+ + (1 - alpha) * CE(student, labels) [if labels provided]
20
+ """
21
+ kl = F.kl_div(
22
+ input=F.log_softmax(student_logits / temperature, dim=-1),
23
+ target=F.softmax(teacher_logits / temperature, dim=-1),
24
+ reduction="batchmean",
25
+ ) * (temperature ** 2)
26
+
27
+ if labels is None:
28
+ return alpha * kl
29
+
30
+ ce = F.cross_entropy(
31
+ student_logits.view(-1, student_logits.size(-1)),
32
+ labels.view(-1),
33
+ ignore_index=-100,
34
+ )
35
+ return alpha * kl + (1 - alpha) * ce
36
+
37
+
38
+ def compute_seqkd_loss(
39
+ student_logits: torch.Tensor,
40
+ teacher_labels: torch.Tensor,
41
+ ) -> torch.Tensor:
42
+ """Sequence-level KD: use teacher's argmax as pseudo-labels."""
43
+ return F.cross_entropy(
44
+ student_logits.view(-1, student_logits.size(-1)),
45
+ teacher_labels.view(-1),
46
+ ignore_index=-100,
47
+ )