mini-infer 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.
mini_infer/__init__.py ADDED
@@ -0,0 +1,9 @@
1
+ """mini-infer: Minimal continuous-batching inference engine with paged KV-cache."""
2
+
3
+ from mini_infer.engine import InferenceEngine
4
+ from mini_infer.models.config import Qwen2Config
5
+ from mini_infer.models.qwen2 import Qwen2ForCausalLM
6
+
7
+ __version__ = "0.1.0"
8
+
9
+ __all__ = ["InferenceEngine", "Qwen2Config", "Qwen2ForCausalLM"]
@@ -0,0 +1,4 @@
1
+ from mini_infer.cache.block_pool import BlockPool, BlockTable
2
+ from mini_infer.cache.prefix_cache import PrefixCache
3
+
4
+ __all__ = ["BlockPool", "BlockTable", "PrefixCache"]
@@ -0,0 +1,120 @@
1
+ import torch
2
+
3
+
4
+ class BlockPool:
5
+ """Fixed-size GPU memory pool for Paged KV-Cache blocks.
6
+
7
+ Instead of allocating a large contiguous buffer per request, the engine pre-allocates
8
+ a single fixed pool of blocks on the GPU and maps requests dynamically via a block table.
9
+ """
10
+
11
+ def __init__(
12
+ self,
13
+ num_blocks: int,
14
+ block_size: int,
15
+ num_layers: int,
16
+ num_kv_heads: int,
17
+ head_dim: int,
18
+ device: torch.device | None = None,
19
+ dtype: torch.dtype | None = None,
20
+ ):
21
+ if device is None:
22
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
23
+ if dtype is None:
24
+ dtype = torch.float16 if torch.cuda.is_available() else torch.float32
25
+ self.num_blocks = num_blocks
26
+ self.block_size = block_size
27
+ self.num_layers = num_layers
28
+ self.num_kv_heads = num_kv_heads
29
+ self.head_dim = head_dim
30
+ self.device = device
31
+ self.dtype = dtype
32
+
33
+ # Free list of physical block indices
34
+ self.free_list: list[int] = list(range(num_blocks))
35
+
36
+ # Reference count per block for prefix caching / copy-on-write
37
+ self.ref_counts: dict[int, int] = {i: 0 for i in range(num_blocks)}
38
+
39
+ # Pre-allocate physical KV tensors per layer
40
+ # Shape per layer: [num_blocks, num_kv_heads, block_size, head_dim]
41
+ self.key_caches: list[torch.Tensor] = [
42
+ torch.zeros((num_blocks, num_kv_heads, block_size, head_dim), device=device, dtype=dtype)
43
+ for _ in range(num_layers)
44
+ ]
45
+ self.value_caches: list[torch.Tensor] = [
46
+ torch.zeros((num_blocks, num_kv_heads, block_size, head_dim), device=device, dtype=dtype)
47
+ for _ in range(num_layers)
48
+ ]
49
+
50
+ @property
51
+ def num_free(self) -> int:
52
+ """Number of currently available unallocated physical blocks."""
53
+ return len(self.free_list)
54
+
55
+ def allocate_block(self) -> int:
56
+ """Allocate a single physical block from the free list."""
57
+ if not self.free_list:
58
+ raise MemoryError("Paged KV-Cache pool exhausted! Out of GPU memory blocks.")
59
+ block_id = self.free_list.pop()
60
+ self.ref_counts[block_id] = 1
61
+ return block_id
62
+
63
+ def allocate_blocks(self, count: int) -> list[int]:
64
+ """Allocate multiple physical blocks."""
65
+ if len(self.free_list) < count:
66
+ raise MemoryError(
67
+ f"Cannot allocate {count} blocks; only {len(self.free_list)} available."
68
+ )
69
+ allocated = []
70
+ for _ in range(count):
71
+ allocated.append(self.allocate_block())
72
+ return allocated
73
+
74
+ def retain_block(self, block_id: int):
75
+ """Increment reference count for a block (used in prefix caching)."""
76
+ self.ref_counts[block_id] += 1
77
+
78
+ def free_block(self, block_id: int):
79
+ """Decrement reference count; return to free list if count reaches 0."""
80
+ self.ref_counts[block_id] -= 1
81
+ if self.ref_counts[block_id] <= 0:
82
+ self.ref_counts[block_id] = 0
83
+ self.free_list.append(block_id)
84
+
85
+ def free_blocks(self, block_ids: list[int]):
86
+ """Free a list of blocks."""
87
+ for b_id in block_ids:
88
+ self.free_block(b_id)
89
+
90
+
91
+ class BlockTable:
92
+ """Logical-to-physical block mapping for an active request sequence."""
93
+
94
+ def __init__(self, block_size: int = 16):
95
+ self.block_size = block_size
96
+ self.physical_block_ids: list[int] = []
97
+ self.num_tokens: int = 0
98
+
99
+ def append_slot(self, block_pool: BlockPool) -> tuple[int, int]:
100
+ """Reserve the next token slot.
101
+
102
+ Allocates a new physical block from the pool if the current block is full.
103
+ Returns:
104
+ Tuple of (physical_block_id, slot_offset_within_block).
105
+ """
106
+ slot_offset = self.num_tokens % self.block_size
107
+ if slot_offset == 0:
108
+ # Need a new physical block
109
+ new_block_id = block_pool.allocate_block()
110
+ self.physical_block_ids.append(new_block_id)
111
+
112
+ current_block_id = self.physical_block_ids[-1]
113
+ self.num_tokens += 1
114
+ return current_block_id, slot_offset
115
+
116
+ def free(self, block_pool: BlockPool):
117
+ """Free all physical blocks associated with this request."""
118
+ block_pool.free_blocks(self.physical_block_ids)
119
+ self.physical_block_ids.clear()
120
+ self.num_tokens = 0
@@ -0,0 +1,86 @@
1
+ import time
2
+
3
+ from mini_infer.cache.block_pool import BlockPool
4
+
5
+
6
+ class PrefixCache:
7
+ """Radix / Hash-based Prefix Cache for zero-recompute prompt KV reuse.
8
+
9
+ Stores and matches chains of fixed-size token blocks so that common prefixes
10
+ (system instructions, multi-turn chat history, few-shot examples) bypass prefill
11
+ and reuse pre-computed KV tensors directly from GPU VRAM.
12
+ """
13
+
14
+ def __init__(self, block_size: int = 16):
15
+ self.block_size = block_size
16
+ # Maps chained block tuple hash -> physical_block_id
17
+ self.cache: dict[tuple[int, ...], int] = {}
18
+ # Tracks last access time for LRU eviction: physical_block_id -> timestamp
19
+ self.last_accessed: dict[int, float] = {}
20
+
21
+ def match_prefix(
22
+ self, token_ids: list[int], block_pool: BlockPool
23
+ ) -> tuple[list[int], int]:
24
+ """Find the longest cached block sequence that matches the start of token_ids.
25
+
26
+ Args:
27
+ token_ids: Full prompt token sequence.
28
+ block_pool: Global block pool to retain matched blocks.
29
+
30
+ Returns:
31
+ Tuple of (matched_physical_block_ids, num_cached_tokens).
32
+ """
33
+ matched_blocks = []
34
+ num_full_blocks = len(token_ids) // self.block_size
35
+
36
+ prefix_tokens: list[int] = []
37
+ for i in range(num_full_blocks):
38
+ chunk = tuple(token_ids[i * self.block_size : (i + 1) * self.block_size])
39
+ prefix_tokens.extend(chunk)
40
+ key = tuple(prefix_tokens)
41
+
42
+ if key in self.cache:
43
+ physical_block_id = self.cache[key]
44
+ # Retain in memory pool
45
+ block_pool.retain_block(physical_block_id)
46
+ matched_blocks.append(physical_block_id)
47
+ self.last_accessed[physical_block_id] = time.time()
48
+ else:
49
+ break
50
+
51
+ num_cached_tokens = len(matched_blocks) * self.block_size
52
+ return matched_blocks, num_cached_tokens
53
+
54
+ def insert_prefix_block(
55
+ self,
56
+ full_prefix_tokens_up_to_block: tuple[int, ...],
57
+ physical_block_id: int,
58
+ block_pool: BlockPool,
59
+ ):
60
+ """Register a newly computed full block into the prefix cache."""
61
+ assert len(full_prefix_tokens_up_to_block) % self.block_size == 0
62
+ key = tuple(full_prefix_tokens_up_to_block)
63
+
64
+ if key not in self.cache:
65
+ self.cache[key] = physical_block_id
66
+ block_pool.retain_block(physical_block_id)
67
+ self.last_accessed[physical_block_id] = time.time()
68
+
69
+ def evict_lru(self, block_pool: BlockPool, count: int = 1) -> int:
70
+ """Evict the least recently used cached prefix blocks when VRAM is tight."""
71
+ evicted = 0
72
+ # Sort by access timestamp ascending
73
+ sorted_blocks = sorted(self.last_accessed.items(), key=lambda x: x[1])
74
+
75
+ for block_id, _ in sorted_blocks:
76
+ if evicted >= count:
77
+ break
78
+ # Find the cache key
79
+ keys_to_delete = [k for k, v in self.cache.items() if v == block_id]
80
+ for k in keys_to_delete:
81
+ del self.cache[k]
82
+ del self.last_accessed[block_id]
83
+ block_pool.free_block(block_id)
84
+ evicted += 1
85
+
86
+ return evicted
mini_infer/cli.py ADDED
@@ -0,0 +1,77 @@
1
+ import typer
2
+ from rich.console import Console
3
+
4
+ app = typer.Typer(
5
+ name="mini-infer",
6
+ help="Minimal continuous-batching LLM inference engine with paged KV-cache.",
7
+ no_args_is_help=True,
8
+ )
9
+ console = Console()
10
+
11
+
12
+ @app.command()
13
+ def serve(
14
+ model: str = typer.Option("Qwen/Qwen2.5-0.5B-Instruct", "--model", "-m", help="Model path or HuggingFace repo ID"),
15
+ host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host address to bind to"),
16
+ port: int = typer.Option(8000, "--port", "-p", help="Port to listen on"),
17
+ num_blocks: int = typer.Option(256, "--num-blocks", help="Number of physical Paged KV blocks"),
18
+ block_size: int = typer.Option(16, "--block-size", help="Tokens per physical block"),
19
+ device: str = typer.Option("auto", "--device", help="Device (cuda/cpu/auto)"),
20
+ ):
21
+ """Launch the OpenAI-compatible streaming HTTP server."""
22
+ import torch
23
+ import uvicorn
24
+ from transformers import AutoTokenizer
25
+
26
+ from mini_infer import server
27
+ from mini_infer.engine import InferenceEngine
28
+ from mini_infer.models.loader import load_model_from_safetensors
29
+
30
+ selected_device = "cuda" if (device == "auto" and torch.cuda.is_available()) else ("cpu" if device == "auto" else device)
31
+ console.print(f"[bold cyan]mini-infer[/bold cyan] serving [bold green]{model}[/bold green] on [yellow]{host}:{port}[/yellow] ({selected_device})")
32
+
33
+ # Load tokenizer
34
+ console.print("Loading tokenizer...")
35
+ try:
36
+ server.tokenizer = AutoTokenizer.from_pretrained(model)
37
+ except Exception as e:
38
+ console.print(f"[yellow]Warning: Could not load tokenizer ({e}). Using character fallback.[/yellow]")
39
+
40
+ # Load model
41
+ console.print("Loading model weights...")
42
+ model_instance, config = load_model_from_safetensors(model, device=selected_device)
43
+
44
+ # Initialize inference engine
45
+ console.print(f"Initializing Paged KV-Cache ({num_blocks} blocks of size {block_size})...")
46
+ server.engine = InferenceEngine(
47
+ model=model_instance,
48
+ config=config,
49
+ num_blocks=num_blocks,
50
+ block_size=block_size,
51
+ device=torch.device(selected_device),
52
+ )
53
+
54
+ console.print(f"[bold green]Server ready![/bold green] Connect any OpenAI-compatible client or run [bold]servebench quick[/bold] against http://{host}:{port}/v1")
55
+ uvicorn.run(server.app, host=host, port=port, log_level="info")
56
+
57
+
58
+ @app.command()
59
+ def info():
60
+ """Display system hardware and GPU VRAM budget for mini-infer."""
61
+ import torch
62
+
63
+ console.print("[bold cyan]mini-infer System Diagnostics[/bold cyan]")
64
+ console.print(f"PyTorch version: {torch.__version__}")
65
+ cuda_avail = torch.cuda.is_available()
66
+ console.print(f"CUDA available: [{'green' if cuda_avail else 'red'}]{cuda_avail}[/]")
67
+
68
+ if cuda_avail:
69
+ device_name = torch.cuda.get_device_name(0)
70
+ total_vram_gb = torch.cuda.get_device_properties(0).total_memory / (1024 ** 3)
71
+ console.print(f"GPU Device: [bold green]{device_name}[/bold green]")
72
+ console.print(f"Total VRAM: [bold yellow]{total_vram_gb:.2f} GB[/bold yellow]")
73
+ console.print("\n[bold]Memory Allocation Plan (RTX 3050 6 GB):[/bold]")
74
+ console.print(" • Qwen2.5-1.5B (Target Model): ~3.0 GB VRAM")
75
+ console.print(" • Qwen2.5-0.5B (Draft Model): ~1.0 GB VRAM")
76
+ console.print(" • Paged KV-Cache Memory Pool: ~1.5 GB VRAM (~2000 blocks)")
77
+ console.print(" • Safety Buffer: ~0.5 GB")
mini_infer/engine.py ADDED
@@ -0,0 +1,155 @@
1
+ import torch
2
+
3
+ from mini_infer.cache.block_pool import BlockPool
4
+ from mini_infer.cache.prefix_cache import PrefixCache
5
+ from mini_infer.models.config import Qwen2Config
6
+ from mini_infer.models.qwen2 import Qwen2ForCausalLM
7
+ from mini_infer.models.sampler import sample_next_token
8
+ from mini_infer.scheduler.continuous import (
9
+ ContinuousScheduler,
10
+ Sequence,
11
+ SequenceStatus,
12
+ )
13
+
14
+
15
+ class InferenceEngine:
16
+ """End-to-End LLM Serving Engine combining Model, Paged KV-Cache, Prefix Cache, and Continuous Batching."""
17
+
18
+ def __init__(
19
+ self,
20
+ model: Qwen2ForCausalLM,
21
+ config: Qwen2Config,
22
+ num_blocks: int = 256,
23
+ block_size: int = 16,
24
+ max_batch_size: int = 8,
25
+ enable_prefix_caching: bool = True,
26
+ device: torch.device | None = None,
27
+ ):
28
+ if device is None:
29
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
30
+ self.model = model
31
+ self.config = config
32
+ self.device = device
33
+ self.block_size = block_size
34
+ self.enable_prefix_caching = enable_prefix_caching
35
+
36
+ # 1. Paged KV memory pool
37
+ self.block_pool = BlockPool(
38
+ num_blocks=num_blocks,
39
+ block_size=block_size,
40
+ num_layers=config.num_hidden_layers,
41
+ num_kv_heads=config.num_key_value_heads,
42
+ head_dim=config.head_dim,
43
+ device=device,
44
+ dtype=next(model.parameters()).dtype,
45
+ )
46
+
47
+ # 2. Prefix Cache (Radix / Hash based)
48
+ self.prefix_cache = PrefixCache(block_size=block_size) if enable_prefix_caching else None
49
+
50
+ # 3. Continuous Batching Scheduler
51
+ self.scheduler = ContinuousScheduler(
52
+ block_pool=self.block_pool,
53
+ max_batch_size=max_batch_size,
54
+ )
55
+
56
+ def add_request(
57
+ self,
58
+ prompt_token_ids: list[int],
59
+ max_tokens: int = 128,
60
+ temperature: float = 0.0,
61
+ top_p: float = 1.0,
62
+ stop_token_ids: set[int] | None = None,
63
+ request_id: str | None = None,
64
+ ) -> Sequence:
65
+ """Submit a new request to the waiting queue."""
66
+ seq = Sequence(
67
+ prompt_token_ids=prompt_token_ids,
68
+ max_tokens=max_tokens,
69
+ temperature=temperature,
70
+ top_p=top_p,
71
+ stop_token_ids=stop_token_ids,
72
+ request_id=request_id,
73
+ )
74
+ self.scheduler.add_sequence(seq)
75
+ return seq
76
+
77
+ def step(self) -> list[tuple[Sequence, int]]:
78
+ """Execute a single continuous batching iteration step.
79
+
80
+ Returns:
81
+ List of (sequence, newly_generated_token_id) for each active request in this step.
82
+ """
83
+ prefill_seqs, decode_seqs = self.scheduler.schedule()
84
+ step_outputs: list[tuple[Sequence, int]] = []
85
+
86
+ # 1. Handle Prefill Sequences (newly admitted requests)
87
+ for seq in prefill_seqs:
88
+ input_tokens = seq.prompt_token_ids
89
+
90
+ # Prefix caching check
91
+ cached_blocks = []
92
+ num_cached = 0
93
+ if self.prefix_cache:
94
+ cached_blocks, num_cached = self.prefix_cache.match_prefix(input_tokens, self.block_pool)
95
+ seq.block_table.physical_block_ids.extend(cached_blocks)
96
+ seq.block_table.num_tokens = num_cached
97
+
98
+ # Uncached prompt tokens that must be computed
99
+ uncached_tokens = input_tokens[num_cached:]
100
+ if not uncached_tokens:
101
+ # Prompt was 100% cached!
102
+ uncached_tokens = [input_tokens[-1]]
103
+ seq.block_table.num_tokens -= 1
104
+
105
+ # Allocate slots for uncached tokens
106
+ for _ in uncached_tokens:
107
+ seq.block_table.append_slot(self.block_pool)
108
+
109
+ # Model forward pass for prefill
110
+ input_tensor = torch.tensor([uncached_tokens], dtype=torch.long, device=self.device)
111
+ with torch.no_grad():
112
+ logits, past_kv = self.model(input_tensor)
113
+ seq.past_key_values = past_kv
114
+
115
+ # Sample first output token
116
+ next_token = sample_next_token(
117
+ logits[:, -1, :],
118
+ temperature=seq.temperature,
119
+ top_p=seq.top_p,
120
+ ).item()
121
+
122
+ seq.append_token(next_token)
123
+ step_outputs.append((seq, next_token))
124
+
125
+ # Register completed prompt blocks into prefix cache
126
+ if self.prefix_cache:
127
+ full_blocks_count = len(input_tokens) // self.block_size
128
+ for b_idx in range(len(cached_blocks), full_blocks_count):
129
+ block_id = seq.block_table.physical_block_ids[b_idx]
130
+ prefix_chunk = tuple(input_tokens[: (b_idx + 1) * self.block_size])
131
+ self.prefix_cache.insert_prefix_block(prefix_chunk, block_id, self.block_pool)
132
+
133
+ # 2. Handle Decode Sequences (all running streams generate 1 token concurrently)
134
+ for seq in decode_seqs:
135
+ if seq.status == SequenceStatus.FINISHED:
136
+ continue
137
+
138
+ last_token = seq.output_token_ids[-1]
139
+ seq.block_table.append_slot(self.block_pool)
140
+
141
+ input_tensor = torch.tensor([[last_token]], dtype=torch.long, device=self.device)
142
+ with torch.no_grad():
143
+ logits, past_kv = self.model(input_tensor, past_key_values=seq.past_key_values)
144
+ seq.past_key_values = past_kv
145
+
146
+ next_token = sample_next_token(
147
+ logits[:, -1, :],
148
+ temperature=seq.temperature,
149
+ top_p=seq.top_p,
150
+ ).item()
151
+
152
+ seq.append_token(next_token)
153
+ step_outputs.append((seq, next_token))
154
+
155
+ return step_outputs
@@ -0,0 +1,5 @@
1
+ from mini_infer.models.config import Qwen2Config
2
+ from mini_infer.models.loader import load_model_from_safetensors
3
+ from mini_infer.models.qwen2 import Qwen2ForCausalLM
4
+
5
+ __all__ = ["Qwen2Config", "Qwen2ForCausalLM", "load_model_from_safetensors"]
@@ -0,0 +1,68 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass
5
+ class Qwen2Config:
6
+ """Configuration class for Qwen2.5 model architectures."""
7
+ vocab_size: int = 151936
8
+ hidden_size: int = 896
9
+ intermediate_size: int = 4864
10
+ num_hidden_layers: int = 24
11
+ num_attention_heads: int = 14
12
+ num_key_value_heads: int = 2
13
+ head_dim: int = 64
14
+ rms_norm_eps: float = 1e-6
15
+ rope_theta: float = 1000000.0
16
+ max_position_embeddings: int = 32768
17
+ tie_word_embeddings: bool = True
18
+
19
+ @classmethod
20
+ def qwen2_5_0_5b(cls) -> "Qwen2Config":
21
+ """Default preset for Qwen2.5-0.5B-Instruct."""
22
+ return cls(
23
+ vocab_size=151936,
24
+ hidden_size=896,
25
+ intermediate_size=4864,
26
+ num_hidden_layers=24,
27
+ num_attention_heads=14,
28
+ num_key_value_heads=2,
29
+ head_dim=64,
30
+ rms_norm_eps=1e-6,
31
+ rope_theta=1000000.0,
32
+ max_position_embeddings=32768,
33
+ tie_word_embeddings=True,
34
+ )
35
+
36
+ @classmethod
37
+ def qwen2_5_1_5b(cls) -> "Qwen2Config":
38
+ """Default preset for Qwen2.5-1.5B-Instruct."""
39
+ return cls(
40
+ vocab_size=151936,
41
+ hidden_size=1536,
42
+ intermediate_size=8960,
43
+ num_hidden_layers=28,
44
+ num_attention_heads=12,
45
+ num_key_value_heads=2,
46
+ head_dim=128,
47
+ rms_norm_eps=1e-6,
48
+ rope_theta=1000000.0,
49
+ max_position_embeddings=32768,
50
+ tie_word_embeddings=False,
51
+ )
52
+
53
+ @classmethod
54
+ def from_dict(cls, data: dict) -> "Qwen2Config":
55
+ """Construct config from HuggingFace config.json dictionary."""
56
+ return cls(
57
+ vocab_size=data.get("vocab_size", 151936),
58
+ hidden_size=data.get("hidden_size", 896),
59
+ intermediate_size=data.get("intermediate_size", 4864),
60
+ num_hidden_layers=data.get("num_hidden_layers", 24),
61
+ num_attention_heads=data.get("num_attention_heads", 14),
62
+ num_key_value_heads=data.get("num_key_value_heads", 2),
63
+ head_dim=data.get("head_dim", data.get("hidden_size", 896) // data.get("num_attention_heads", 14)),
64
+ rms_norm_eps=data.get("rms_norm_eps", 1e-6),
65
+ rope_theta=data.get("rope_theta", 1000000.0),
66
+ max_position_embeddings=data.get("max_position_embeddings", 32768),
67
+ tie_word_embeddings=data.get("tie_word_embeddings", True),
68
+ )
@@ -0,0 +1,122 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import torch
5
+
6
+ from mini_infer.models.config import Qwen2Config
7
+ from mini_infer.models.qwen2 import Qwen2ForCausalLM
8
+
9
+ DT_MAP = {
10
+ "BF16": torch.bfloat16,
11
+ "F16": torch.float16,
12
+ "F32": torch.float32,
13
+ "I32": torch.int32,
14
+ "I64": torch.int64,
15
+ }
16
+
17
+
18
+ def _load_safetensors_into_model(
19
+ model: torch.nn.Module,
20
+ sf_path: Path,
21
+ target_dtype: torch.dtype,
22
+ ) -> None:
23
+ """Stream weights directly into model parameters in-place to avoid duplicate VRAM allocations."""
24
+ import struct
25
+
26
+ param_dict = dict(model.named_parameters())
27
+ buffer_dict = dict(model.named_buffers())
28
+
29
+ with open(sf_path, "rb") as f:
30
+ header_size = struct.unpack("<Q", f.read(8))[0]
31
+ header = json.loads(f.read(header_size).decode("utf-8"))
32
+ data_base_offset = 8 + header_size
33
+
34
+ for name, info in header.items():
35
+ if name == "__metadata__":
36
+ continue
37
+ start, end = info["data_offsets"]
38
+ length = end - start
39
+ f.seek(data_base_offset + start)
40
+ raw = f.read(length)
41
+
42
+ src_dt = DT_MAP.get(info["dtype"], torch.float16)
43
+ t = torch.frombuffer(bytearray(raw), dtype=src_dt).reshape(info["shape"])
44
+
45
+ if name in param_dict:
46
+ param_dict[name].data.copy_(t.to(dtype=target_dtype))
47
+ elif name in buffer_dict:
48
+ buffer_dict[name].data.copy_(t.to(dtype=target_dtype))
49
+ del raw, t
50
+
51
+
52
+ def load_model_from_safetensors(
53
+ model_dir_or_repo: str | Path,
54
+ device: str | None = None,
55
+ dtype: torch.dtype | None = None,
56
+ ) -> tuple[Qwen2ForCausalLM, Qwen2Config]:
57
+ """Load Qwen2.5 weights directly from safetensors with zero host memory duplication.
58
+
59
+ Args:
60
+ model_dir_or_repo: Local folder path or HuggingFace repo ID (e.g. 'Qwen/Qwen2.5-0.5B-Instruct').
61
+ device: Target torch device ('cuda', 'cpu').
62
+ dtype: Target torch dtype (torch.bfloat16, torch.float16, torch.float32).
63
+
64
+ Returns:
65
+ Tuple of (model, config).
66
+ """
67
+ if device is None:
68
+ device = "cuda" if torch.cuda.is_available() else "cpu"
69
+ if dtype is None:
70
+ if torch.cuda.is_available() and torch.cuda.is_bf16_supported():
71
+ dtype = torch.bfloat16
72
+ elif torch.cuda.is_available():
73
+ dtype = torch.float16
74
+ else:
75
+ dtype = torch.float32
76
+
77
+ path = Path(model_dir_or_repo)
78
+
79
+ # If it's a HuggingFace Hub repo id and not an existing local directory, download via huggingface_hub
80
+ if not path.exists():
81
+ from huggingface_hub import snapshot_download
82
+
83
+ print(f"Downloading model {model_dir_or_repo} from Hugging Face Hub...")
84
+ path = Path(snapshot_download(repo_id=str(model_dir_or_repo), allow_patterns=["*.safetensors", "config.json", "tokenizer*"]))
85
+
86
+ # 1. Load config.json
87
+ config_path = path / "config.json"
88
+ if not config_path.exists():
89
+ raise FileNotFoundError(f"config.json not found in {path}")
90
+
91
+ with open(config_path, encoding="utf-8") as f:
92
+ config_dict = json.load(f)
93
+
94
+ config = Qwen2Config.from_dict(config_dict)
95
+
96
+ # 2. Instantiate model directly with target dtype on target device
97
+ print(f"Initializing Qwen2ForCausalLM ({config.num_hidden_layers} layers, hidden_size={config.hidden_size})...")
98
+ orig_dtype = torch.get_default_dtype()
99
+ torch.set_default_dtype(dtype)
100
+ try:
101
+ model = Qwen2ForCausalLM(config).to(device=device, dtype=dtype)
102
+ finally:
103
+ torch.set_default_dtype(orig_dtype)
104
+
105
+ # 3. Locate safetensors files
106
+ safetensor_files = sorted(list(path.glob("*.safetensors")))
107
+ if not safetensor_files:
108
+ raise FileNotFoundError(f"No .safetensors files found in {path}")
109
+
110
+ print(f"Streaming weights from {len(safetensor_files)} safetensors file(s) into {device} ({dtype})...")
111
+ for sf in safetensor_files:
112
+ _load_safetensors_into_model(model, sf, target_dtype=dtype)
113
+
114
+ # 4. Handle tied word embeddings
115
+ if config.tie_word_embeddings:
116
+ model.lm_head.weight = model.model.embed_tokens.weight
117
+
118
+ if torch.cuda.is_available():
119
+ torch.cuda.empty_cache()
120
+
121
+ model.eval()
122
+ return model, config