File size: 14,844 Bytes
4d5ce47
 
 
 
40367a2
 
e67e206
9d36b97
 
40367a2
9d36b97
40367a2
9d36b97
40367a2
 
4d5ce47
a68dd7c
422c40f
9d36b97
422c40f
 
4d5ce47
 
 
 
40367a2
4d5ce47
422c40f
 
4d5ce47
 
422c40f
4d5ce47
40367a2
 
 
 
 
422c40f
 
 
 
 
40367a2
 
 
 
 
 
 
422c40f
4d5ce47
422c40f
40367a2
 
 
 
4d5ce47
40367a2
 
 
 
4d5ce47
40367a2
 
 
 
 
 
 
 
 
 
fee8b80
 
40367a2
 
dbbd6a9
4d5ce47
dbbd6a9
 
 
 
 
 
 
 
 
 
 
 
 
4d5ce47
 
40367a2
 
dbbd6a9
40367a2
dbbd6a9
 
 
 
40367a2
dbbd6a9
 
 
 
40367a2
dbbd6a9
 
 
 
 
 
40367a2
dbbd6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
40367a2
dbbd6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40367a2
dbbd6a9
 
 
 
 
 
 
 
40367a2
dbbd6a9
40367a2
 
 
 
4d5ce47
 
 
 
 
40367a2
 
4d5ce47
 
 
 
 
40367a2
 
4d5ce47
40367a2
4d5ce47
422c40f
 
4d5ce47
 
 
 
 
40367a2
 
a68dd7c
40367a2
 
4d5ce47
40367a2
 
 
 
 
 
 
 
 
 
 
 
dbbd6a9
4d5ce47
dbbd6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40367a2
dbbd6a9
 
4d5ce47
dbbd6a9
4d5ce47
 
 
 
 
dbbd6a9
4d5ce47
40367a2
dbbd6a9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40367a2
 
 
86bc9d7
e67e206
86bc9d7
7ba12eb
 
fee8b80
86bc9d7
 
7ba12eb
e67e206
86bc9d7
5eb4e13
 
e67e206
86bc9d7
e67e206
 
 
 
86bc9d7
 
40367a2
 
 
4d5ce47
40367a2
4d5ce47
40367a2
 
4d5ce47
40367a2
422c40f
7ba12eb
40367a2
422c40f
4d5ce47
40367a2
 
 
 
 
 
7ba12eb
40367a2
422c40f
40367a2
422c40f
40367a2
422c40f
4d5ce47
40367a2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4d5ce47
40367a2
 
 
 
 
 
 
4d5ce47
40367a2
 
a68dd7c
40367a2
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
"""
Model manager module with strict typing using newtype pattern.
"""

import torch
import asyncio
from typing import Dict, List, Tuple, Any, Set, cast
from transformers import AutoModelForCausalLM, AutoTokenizer
from transformers.generation.configuration_utils import GenerationConfig
import gc
from loguru import logger
from dataclasses import dataclass
from huggingface_hub import login

from config import ModelConfig, MODELS_CONFIG, HF_TOKEN
from custom_types import (
    ModelId, ModelMemoryUsage, Timestamp, GPUMemoryUsageWrapper,
    create_model_id, create_gpu_memory_usage, ModelStatus,
    HuggingFaceModel, HuggingFaceTokenizer, HuggingFaceGenerationConfig, TorchDevice,
    create_huggingface_model, create_huggingface_tokenizer, create_huggingface_generation_config,
    create_torch_tensor, create_torch_device
)


@dataclass(frozen=True)
class ModelInfo:
    """Information about a loaded model with strict typing."""
    model: HuggingFaceModel
    tokenizer: HuggingFaceTokenizer
    memory_used: ModelMemoryUsage
    last_used: Timestamp
    generation_config: HuggingFaceGenerationConfig
    status: ModelStatus


class ModelManager:
    """Manages multiple models with memory optimization and multiplexing."""

    models: Dict[ModelId, ModelInfo]
    max_memory_usage: GPUMemoryUsageWrapper
    device: TorchDevice
    lock: asyncio.Lock

    def __init__(self, max_memory_usage: float = 0.9):
        """
        Initialize the model manager.

        Args:
            max_memory_usage: Maximum fraction of GPU memory to use (0.9 = 90%)
        """
        self.models = {}
        self.max_memory_usage = create_gpu_memory_usage(max_memory_usage)
        self.device = create_torch_device(torch.device("cuda" if torch.cuda.is_available() else "cpu"))
        self.lock = asyncio.Lock()

        # Login to HuggingFace
        if HF_TOKEN:
            login(token=str(HF_TOKEN))
            logger.info("Logged into HuggingFace Hub")
        else:
            logger.warning("No HuggingFace token provided - private models may not be accessible")

    async def initialize(self) -> None:
        """Initialize the model manager and load default models."""
        logger.info("Initializing Model Manager...")

        # Check GPU availability
        if not torch.cuda.is_available():
            raise RuntimeError("CUDA is not available. This service requires GPU.")

        logger.info(f"Using device: {self.device}")
        logger.info(f"Available GPU memory: {self.get_total_gpu_memory():.2f} GB")

        # Load Mistral 7B as the default model
        await self.load_model("mistralai/Mistral-7B-Instruct-v0.3")

    async def load_model(self, model_id: str) -> None:
        """Load a model into memory.

        Avoids holding the manager lock during heavy I/O or GPU work to prevent
        deadlocks and to allow concurrent requests to progress.
        """
        # Validate and normalize model id
        try:
            model_id_wrapper = create_model_id(model_id)
            model_id_typed = model_id_wrapper.unwrap()
        except ValueError as e:
            logger.error(f"Invalid model ID: {model_id}, error: {e}")
            raise

        # Fast-path: if already loaded, return early
        async with self.lock:
            if model_id_typed in self.models:
                logger.info(f"Model {model_id_typed} already loaded")
                return

        logger.info(f"Loading model: {model_id_typed}")

        # If memory is tight, free up space (this will acquire the lock internally)
        current_usage = self.get_gpu_memory_usage()
        if current_usage.unwrap() > self.max_memory_usage.unwrap():
            await self._free_memory()

        # Heavy work happens outside the lock
        try:
            # Get model config
            config = MODELS_CONFIG.get(model_id_typed, ModelConfig())

            # Load tokenizer
            tokenizer_raw: Any = AutoTokenizer.from_pretrained(
                model_id_typed,
                trust_remote_code=True,
                token=str(HF_TOKEN) if HF_TOKEN else None
            )

            # Set padding token if not set
            if tokenizer_raw.pad_token is None:
                tokenizer_raw.pad_token = tokenizer_raw.eos_token

            # Load model
            model_raw: Any = AutoModelForCausalLM.from_pretrained(
                model_id_typed,
                device_map="auto",
                torch_dtype=torch.float16,  # Use fp16 for memory efficiency
                trust_remote_code=True,
                token=str(HF_TOKEN) if HF_TOKEN else None,
                low_cpu_mem_usage=True,
                **config.model_kwargs
            )

            # Create generation config
            generation_config_raw: Any = GenerationConfig(
                eos_token_id=tokenizer_raw.eos_token_id,
                pad_token_id=tokenizer_raw.pad_token_id,
                **config.generation_defaults
            )

            # Wrap external library objects
            model = create_huggingface_model(model_raw)
            tokenizer = create_huggingface_tokenizer(tokenizer_raw)
            generation_config = create_huggingface_generation_config(generation_config_raw)

            # Calculate memory usage
            memory_used = self._calculate_model_memory(model)

        except Exception as e:
            logger.error(f"Failed to load model {model_id_typed}: {str(e)}")
            raise

        # Store the model info under the lock; handle races where another task
        # finished loading the same model while we were working
        async with self.lock:
            if model_id_typed in self.models:
                # Another coroutine loaded it; dispose of the duplicate we created
                if 'model_raw' in locals():
                    del model_raw
                if 'tokenizer_raw' in locals():
                    del tokenizer_raw
                _ = gc.collect()
                if torch.cuda.is_available():
                    torch.cuda.empty_cache()
                logger.info(f"Model {model_id_typed} was loaded concurrently; discarded duplicate")
                return

            self.models[model_id_typed] = ModelInfo(
                model=model,
                tokenizer=tokenizer,
                memory_used=memory_used,
                last_used=Timestamp(asyncio.get_event_loop().time()),
                generation_config=generation_config,
                status="loaded"
            )

        logger.info(f"Successfully loaded {model_id_typed} ({memory_used:.2f} GB)")

    async def unload_model(self, model_id: str) -> None:
        """Unload a model from memory."""
        async with self.lock:
            try:
                model_id_wrapper = create_model_id(model_id)
                model_id_typed = model_id_wrapper.unwrap()
            except ValueError as e:
                logger.error(f"Invalid model ID: {model_id}, error: {e}")
                return

            if model_id_typed not in self.models:
                logger.warning(f"Model {model_id_typed} not loaded")
                return

            logger.info(f"Unloading model: {model_id_typed}")

            # Remove from models dict
            model_info = self.models.pop(model_id_typed)

            # Clear references to allow garbage collection
            model = model_info.model.unwrap()
            tokenizer = model_info.tokenizer.unwrap()

            # Delete references
            del model
            del tokenizer
            del model_info

            # Force garbage collection
            _ = gc.collect()
            torch.cuda.empty_cache()

            logger.info(f"Successfully unloaded {model_id_typed}")

    async def generate_response(
        self,
        model_id: str,
        prompt: str,
        max_new_tokens: int = 80,
        temperature: float = 0.6,
        top_p: float = 0.85,
        repetition_penalty: float = 1.25,
        no_repeat_ngram_size: int = 3,
        do_sample: bool = True
    ) -> Tuple[str, int]:
        """Generate a response using the specified model.

        Ensures the model is loaded without holding the manager lock, and
        only locks around brief metadata updates. This avoids re-entrant lock
        deadlocks and improves concurrency.
        """
        # Validate and wrap inputs
        try:
            model_id_wrapper = create_model_id(model_id)
            model_id_typed = model_id_wrapper.unwrap()
            from custom_types import (
                create_max_new_tokens,
                create_temperature,
                create_top_p,
                create_repetition_penalty,
                create_no_repeat_ngram_size,
            )
            max_tokens_wrapper = create_max_new_tokens(max_new_tokens)
            temp_wrapper = create_temperature(temperature)
            top_p_wrapper = create_top_p(top_p)
            rep_penalty_wrapper = create_repetition_penalty(repetition_penalty)
            ngram_size_wrapper = create_no_repeat_ngram_size(no_repeat_ngram_size)
        except ValueError as e:
            logger.error(f"Invalid generation parameters: {e}")
            raise

        # Ensure the model is loaded (this acquires the internal lock itself)
        async with self.lock:
            is_loaded = model_id_typed in self.models
        if not is_loaded:
            await self.load_model(model_id)

        # Snapshot the model info and update last_used under the lock
        async with self.lock:
            model_info = self.models[model_id_typed]
            self.models[model_id_typed] = ModelInfo(
                model=model_info.model,
                tokenizer=model_info.tokenizer,
                memory_used=model_info.memory_used,
                last_used=Timestamp(asyncio.get_event_loop().time()),
                generation_config=model_info.generation_config,
                status=model_info.status,
            )

        # Heavy work outside the lock
        try:
            # Get model config for context length
            config = MODELS_CONFIG.get(model_id_typed, ModelConfig())
            context_length = int(config.context_length)

            inputs_raw = model_info.tokenizer.unwrap()(
                prompt,
                return_tensors="pt",
                truncation=True,
                max_length=context_length,
            ).to(self.device.unwrap())
            inputs = create_torch_tensor(inputs_raw)

            # Generate response
            with torch.no_grad():
                outputs_raw = model_info.model.unwrap().generate(
                    **inputs.unwrap(),
                    max_new_tokens=max_tokens_wrapper.unwrap(),
                    temperature=temp_wrapper.unwrap(),
                    top_p=top_p_wrapper.unwrap(),
                    repetition_penalty=rep_penalty_wrapper.unwrap(),
                    no_repeat_ngram_size=ngram_size_wrapper.unwrap(),
                    do_sample=do_sample,
                    eos_token_id=model_info.tokenizer.unwrap().eos_token_id,
                    pad_token_id=model_info.tokenizer.unwrap().pad_token_id,
                    use_cache=True,
                )
                outputs = create_torch_tensor(outputs_raw)

            # Decode response (raw, no post-processing)
            input_length: int = inputs.unwrap().input_ids.shape[1]
            generated_tokens = create_torch_tensor(outputs.unwrap()[0][input_length:])
            response: str = model_info.tokenizer.unwrap().decode(
                generated_tokens.unwrap(), skip_special_tokens=True
            )

            return response, len(generated_tokens.unwrap())

        except Exception as e:
            logger.error(f"Error generating response with {model_id_typed}: {str(e)}")
            raise

    def get_available_models(self) -> List[str]:
        """Get list of available models."""
        # Include explicitly configured models
        configured_models: List[str] = cast(List[str], list(MODELS_CONFIG.keys()))

        # Add additional whitelisted models
        whitelisted_models: List[str] = [
            "mistralai/Mistral-7B-Instruct-v0.3"
        ]

        available_models: List[str] = configured_models + whitelisted_models

        # Remove duplicates while preserving order
        seen: Set[str] = set()
        unique_models: List[str] = []

        for model in available_models:
            typed_model = cast(str, model)
            if typed_model not in seen:
                seen.add(typed_model)
                unique_models.append(typed_model)

        return unique_models

    def get_loaded_models(self) -> List[str]:
        """Get list of currently loaded models."""
        return [str(model_id) for model_id in self.models.keys()]

    def get_gpu_memory_usage(self) -> GPUMemoryUsageWrapper:
        """Get current GPU memory usage as a fraction."""
        if not torch.cuda.is_available():
            return create_gpu_memory_usage(0.0)

        allocated: float = torch.cuda.memory_allocated() / 1024**3  # Convert to GB
        total: float = float(torch.cuda.get_device_properties(0).total_memory / 1024**3)  # Convert to GB

        usage: float = allocated / total if total > 0 else 0.0
        return create_gpu_memory_usage(usage)

    def get_total_gpu_memory(self) -> float:
        """Get total GPU memory in GB."""
        if not torch.cuda.is_available():
            return 0.0

        return float(torch.cuda.get_device_properties(0).total_memory / 1024**3)

    def _calculate_model_memory(self, model: HuggingFaceModel) -> ModelMemoryUsage:
        """Calculate approximate memory usage of a model in GB."""
        num_params: int = sum(p.numel() for p in model.unwrap().parameters())
        # Approximate: 2 bytes per parameter for fp16
        memory_gb: float = (num_params * 2) / 1024**3
        return ModelMemoryUsage(memory_gb)

    async def _free_memory(self) -> None:
        """Free memory by unloading least recently used models."""
        if not self.models:
            return

        # Sort models by last used time
        sorted_models = sorted(
            self.models.items(),
            key=lambda x: x[1].last_used
        )

        # Unload least recently used model
        oldest_model_id = sorted_models[0][0]
        logger.info(f"Freeing memory by unloading {oldest_model_id}")
        await self.unload_model(str(oldest_model_id))

    async def cleanup(self) -> None:
        """Clean up all models and resources."""
        logger.info("Cleaning up model manager...")

        # Unload all models
        for model_id in list(self.models.keys()):
            await self.unload_model(str(model_id))

        # Final cleanup
        _ = gc.collect()
        torch.cuda.empty_cache()

        logger.info("Model manager cleanup complete")