ClaireLee2429 Claude Opus 4.6 commited on
Commit
9ca1dd6
·
1 Parent(s): aa607c1

Switch to GGUF Q4_K_M inference with llama-cpp-python

Browse files

Replace PyTorch FP32 (~10GB) + PEFT LoRA loading with quantized
GGUF model (~1.5GB) served via llama-cpp-python. This drops
first-token latency from >25s to ~1-2s on CPU and boosts
throughput to ~10-20 tok/s.

- inference.py: swap torch/peft/transformers for llama-cpp-python
- server.py: remove threading, use sync stream_recipe() iterator
- Dockerfile: python:3.11-slim + llama-cpp-python (no CUDA image)
- Add convert_to_gguf.py for reproducible model conversion
- GGUF model hosted at ClaireLee2429/gemma-2b-recipes-gguf

Co-Authored-By: Claude Opus 4.6 <[email protected]>

Files changed (5) hide show
  1. Dockerfile +6 -9
  2. README.md +1 -0
  3. convert_to_gguf.py +41 -0
  4. inference.py +58 -78
  5. server.py +30 -55
Dockerfile CHANGED
@@ -1,9 +1,10 @@
1
- FROM pytorch/pytorch:2.4.0-cuda12.1-cudnn9-runtime
2
 
3
  ENV DEBIAN_FRONTEND=noninteractive
4
 
 
5
  RUN apt-get update && \
6
- apt-get install -y --no-install-recommends git && \
7
  rm -rf /var/lib/apt/lists/*
8
 
9
  # Non-root user required by HuggingFace Spaces
@@ -17,11 +18,9 @@ ENV MKL_NUM_THREADS=8
17
 
18
  WORKDIR /home/user/app
19
 
20
- # Install serving dependencies (torch already in base image)
21
  RUN pip install --no-cache-dir \
22
- transformers \
23
- peft \
24
- accelerate \
25
  fastapi \
26
  "uvicorn[standard]" \
27
  sse-starlette \
@@ -33,6 +32,4 @@ COPY --chown=user:user inference.py .
33
 
34
  EXPOSE 7860
35
 
36
- CMD ["python", "server.py", \
37
- "--adapter", "ClaireLee2429/gemma-2b-recipes-lora", \
38
- "--port", "7860"]
 
1
+ FROM python:3.11-slim
2
 
3
  ENV DEBIAN_FRONTEND=noninteractive
4
 
5
+ # Build tools for llama-cpp-python compilation + git for HF Spaces
6
  RUN apt-get update && \
7
+ apt-get install -y --no-install-recommends build-essential cmake git && \
8
  rm -rf /var/lib/apt/lists/*
9
 
10
  # Non-root user required by HuggingFace Spaces
 
18
 
19
  WORKDIR /home/user/app
20
 
21
+ # Install Python dependencies (no torch needed!)
22
  RUN pip install --no-cache-dir \
23
+ llama-cpp-python \
 
 
24
  fastapi \
25
  "uvicorn[standard]" \
26
  sse-starlette \
 
32
 
33
  EXPOSE 7860
34
 
35
+ CMD ["python", "server.py", "--port", "7860"]
 
 
README.md CHANGED
@@ -5,6 +5,7 @@ colorFrom: yellow
5
  colorTo: red
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
  # recipe-lm
 
5
  colorTo: red
6
  sdk: docker
7
  pinned: false
8
+ startup_duration_timeout: 300
9
  ---
10
 
11
  # recipe-lm
convert_to_gguf.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ One-time script: merge LoRA adapter into base Gemma-2B and save the
3
+ merged model so it can be converted to GGUF with llama.cpp tooling.
4
+
5
+ Usage:
6
+ pip install torch transformers peft
7
+ python convert_to_gguf.py
8
+
9
+ Then, with llama.cpp built locally:
10
+ python llama.cpp/convert_hf_to_gguf.py ./merged_model --outtype f16 --outfile model.f16.gguf
11
+ ./llama.cpp/build/bin/llama-quantize model.f16.gguf model.q4_k_m.gguf Q4_K_M
12
+
13
+ Finally, upload:
14
+ huggingface-cli upload ClaireLee2429/gemma-2b-recipes-gguf model.q4_k_m.gguf
15
+ """
16
+
17
+ import torch
18
+ from peft import PeftModel
19
+ from transformers import AutoModelForCausalLM, AutoTokenizer
20
+
21
+ BASE_MODEL = "google/gemma-2b"
22
+ ADAPTER = "ClaireLee2429/gemma-2b-recipes-lora"
23
+ OUTPUT_DIR = "./merged_model"
24
+
25
+ print("Loading base model...")
26
+ base = AutoModelForCausalLM.from_pretrained(BASE_MODEL, torch_dtype=torch.float16)
27
+
28
+ print(f"Loading LoRA adapter from {ADAPTER}...")
29
+ model = PeftModel.from_pretrained(base, ADAPTER)
30
+
31
+ print("Merging adapter weights into base model...")
32
+ model = model.merge_and_unload()
33
+
34
+ print(f"Saving merged model to {OUTPUT_DIR}...")
35
+ model.save_pretrained(OUTPUT_DIR)
36
+
37
+ tokenizer = AutoTokenizer.from_pretrained(ADAPTER)
38
+ tokenizer.save_pretrained(OUTPUT_DIR)
39
+
40
+ print(f"Done. Merged model saved to {OUTPUT_DIR}/")
41
+ print("Next steps: see docstring for llama.cpp conversion commands.")
inference.py CHANGED
@@ -1,21 +1,21 @@
1
  """
2
- Standalone inference script for the fine-tuned recipe generation model.
3
 
4
  Usage:
5
  python inference.py --prompt "Recipe for chocolate chip cookies:"
6
  python inference.py --prompt "Recipe for pasta carbonara:" --save output.txt
7
  python inference.py --prompt "Recipe for banana bread:" --raw
8
- python inference.py --adapter ClaireLee2429/gemma-2b-recipes-lora --prompt "Recipe for soup:"
9
- python inference.py --no-adapter --prompt "Recipe for Thai green curry:"
10
  """
11
 
12
  import argparse
13
  import os
14
  import re
15
 
16
- import torch
17
- from peft import PeftModel
18
- from transformers import AutoModelForCausalLM, AutoTokenizer
 
 
19
 
20
 
21
  def clean_recipe(text: str) -> str:
@@ -157,69 +157,62 @@ def parse_ingredients(text: str) -> list[dict]:
157
  return ingredients
158
 
159
 
160
- def load_model(model_name: str, adapter_path: str, no_adapter: bool = False):
161
- """Load the base model, optionally with a LoRA adapter."""
162
- use_cuda = torch.cuda.is_available()
163
- use_mps = torch.backends.mps.is_available()
164
-
165
- dtype = torch.bfloat16 if use_cuda else (torch.float16 if use_mps else torch.float32)
166
- if use_cuda:
167
- device_map = "auto"
168
- elif use_mps:
169
- device_map = {"": "mps"}
170
- else:
171
- device_map = {"": "cpu"}
172
- # Use all available CPU cores for inference
173
- num_threads = int(os.environ.get("OMP_NUM_THREADS", 8))
174
- torch.set_num_threads(num_threads)
175
- torch.set_num_interop_threads(num_threads)
176
-
177
- device_name = "CUDA" if use_cuda else ("MPS" if use_mps else "CPU")
178
- print(f"Loading base model ({device_name})...")
179
-
180
- base_model = AutoModelForCausalLM.from_pretrained(
181
- model_name, torch_dtype=dtype, device_map=device_map
182
  )
 
 
183
 
184
- if no_adapter:
185
- print("Running base model without adapter")
186
- model = base_model
187
- tokenizer = AutoTokenizer.from_pretrained(model_name)
188
- else:
189
- print(f"Loading LoRA adapter from {adapter_path}...")
190
- model = PeftModel.from_pretrained(base_model, adapter_path)
191
- tokenizer = AutoTokenizer.from_pretrained(adapter_path)
192
-
193
- model.eval()
194
 
195
- device = "cuda" if use_cuda else ("mps" if use_mps else "cpu")
196
- return model, tokenizer, device
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
 
199
  def generate_recipe(
200
- model,
201
- tokenizer,
202
- device: str,
203
  prompt: str,
204
- max_new_tokens: int = 256,
205
  temperature: float = 0.7,
206
  raw: bool = False,
207
  ) -> str:
208
- """Generate a recipe from a prompt and optionally post-process."""
209
- inputs = tokenizer(prompt, return_tensors="pt").to(device)
210
-
211
- with torch.inference_mode():
212
- outputs = model.generate(
213
- **inputs,
214
- max_new_tokens=max_new_tokens,
215
- temperature=temperature,
216
- top_p=0.9,
217
- do_sample=True,
218
- repetition_penalty=1.2,
219
- )
220
-
221
- text = tokenizer.decode(outputs[0], skip_special_tokens=True)
222
-
223
  if raw:
224
  return text
225
  return clean_recipe(text)
@@ -234,16 +227,10 @@ def main():
234
  help="Prompt for recipe generation",
235
  )
236
  parser.add_argument(
237
- "--adapter",
238
- type=str,
239
- default="./processed_data/lora_adapter",
240
- help="Path to LoRA adapter (local or HuggingFace Hub ID)",
241
- )
242
- parser.add_argument(
243
- "--model",
244
  type=str,
245
- default="google/gemma-2b",
246
- help="Base model name",
247
  )
248
  parser.add_argument(
249
  "--max-tokens",
@@ -257,11 +244,6 @@ def main():
257
  default=0.7,
258
  help="Sampling temperature",
259
  )
260
- parser.add_argument(
261
- "--no-adapter",
262
- action="store_true",
263
- help="Run the base model without a LoRA adapter",
264
- )
265
  parser.add_argument(
266
  "--raw",
267
  action="store_true",
@@ -279,17 +261,15 @@ def main():
279
  # Ensure prompt ends with newline
280
  prompt = args.prompt if args.prompt.endswith("\n") else args.prompt + "\n"
281
 
282
- model, tokenizer, device = load_model(args.model, args.adapter, args.no_adapter)
283
 
284
  print(f"\nPrompt: {prompt.strip()}")
285
  print("-" * 40)
286
 
287
  result = generate_recipe(
288
- model,
289
- tokenizer,
290
- device,
291
  prompt,
292
- max_new_tokens=args.max_tokens,
293
  temperature=args.temperature,
294
  raw=args.raw,
295
  )
 
1
  """
2
+ Standalone inference script for the fine-tuned recipe generation model (GGUF).
3
 
4
  Usage:
5
  python inference.py --prompt "Recipe for chocolate chip cookies:"
6
  python inference.py --prompt "Recipe for pasta carbonara:" --save output.txt
7
  python inference.py --prompt "Recipe for banana bread:" --raw
 
 
8
  """
9
 
10
  import argparse
11
  import os
12
  import re
13
 
14
+ from huggingface_hub import hf_hub_download
15
+ from llama_cpp import Llama
16
+
17
+ GGUF_REPO = os.environ.get("GGUF_REPO", "ClaireLee2429/gemma-2b-recipes-gguf")
18
+ GGUF_FILE = os.environ.get("GGUF_FILE", "model.q4_k_m.gguf")
19
 
20
 
21
  def clean_recipe(text: str) -> str:
 
157
  return ingredients
158
 
159
 
160
+ def load_model(
161
+ n_threads: int = 8,
162
+ n_ctx: int = 2048,
163
+ model_path: str | None = None,
164
+ ) -> Llama:
165
+ """Download GGUF model from HuggingFace Hub and load with llama-cpp-python."""
166
+ if model_path is None:
167
+ print(f"Downloading {GGUF_REPO}/{GGUF_FILE}...")
168
+ model_path = hf_hub_download(repo_id=GGUF_REPO, filename=GGUF_FILE)
169
+ print(f"Loading GGUF model from {model_path}...")
170
+ llm = Llama(
171
+ model_path=model_path,
172
+ n_threads=n_threads,
173
+ n_ctx=n_ctx,
174
+ verbose=False,
 
 
 
 
 
 
 
175
  )
176
+ print(f"Model loaded ({n_threads} threads, {n_ctx} ctx).")
177
+ return llm
178
 
 
 
 
 
 
 
 
 
 
 
179
 
180
+ def stream_recipe(
181
+ llm: Llama,
182
+ prompt: str,
183
+ max_tokens: int = 256,
184
+ temperature: float = 0.7,
185
+ ):
186
+ """Yield token strings as they are generated."""
187
+ for chunk in llm.create_completion(
188
+ prompt,
189
+ max_tokens=max_tokens,
190
+ temperature=temperature,
191
+ top_p=0.9,
192
+ repeat_penalty=1.2,
193
+ stream=True,
194
+ ):
195
+ token_text = chunk["choices"][0]["text"]
196
+ if token_text:
197
+ yield token_text
198
 
199
 
200
  def generate_recipe(
201
+ llm: Llama,
 
 
202
  prompt: str,
203
+ max_tokens: int = 256,
204
  temperature: float = 0.7,
205
  raw: bool = False,
206
  ) -> str:
207
+ """Generate a complete recipe (non-streaming, for CLI use)."""
208
+ output = llm.create_completion(
209
+ prompt,
210
+ max_tokens=max_tokens,
211
+ temperature=temperature,
212
+ top_p=0.9,
213
+ repeat_penalty=1.2,
214
+ )
215
+ text = prompt + output["choices"][0]["text"]
 
 
 
 
 
 
216
  if raw:
217
  return text
218
  return clean_recipe(text)
 
227
  help="Prompt for recipe generation",
228
  )
229
  parser.add_argument(
230
+ "--model-path",
 
 
 
 
 
 
231
  type=str,
232
+ default=None,
233
+ help="Path to a local GGUF file (skips HF Hub download)",
234
  )
235
  parser.add_argument(
236
  "--max-tokens",
 
244
  default=0.7,
245
  help="Sampling temperature",
246
  )
 
 
 
 
 
247
  parser.add_argument(
248
  "--raw",
249
  action="store_true",
 
261
  # Ensure prompt ends with newline
262
  prompt = args.prompt if args.prompt.endswith("\n") else args.prompt + "\n"
263
 
264
+ llm = load_model(model_path=args.model_path)
265
 
266
  print(f"\nPrompt: {prompt.strip()}")
267
  print("-" * 40)
268
 
269
  result = generate_recipe(
270
+ llm,
 
 
271
  prompt,
272
+ max_tokens=args.max_tokens,
273
  temperature=args.temperature,
274
  raw=args.raw,
275
  )
server.py CHANGED
@@ -1,37 +1,37 @@
1
  """
2
- FastAPI server for recipe generation with streaming output.
3
 
4
  Usage:
5
- pip install -e ".[serve]"
6
  python server.py
7
- python server.py --adapter ClaireLee2429/gemma-2b-recipes-lora
8
  python server.py --port 8080
9
  """
10
 
11
  import argparse
 
12
  import base64
13
  import json
14
  import os
15
- import threading
16
  import time
17
  from contextlib import asynccontextmanager
18
 
19
  import httpx
20
- import torch
21
  import uvicorn
22
  from fastapi import FastAPI
23
  from fastapi.middleware.cors import CORSMiddleware
24
  from pydantic import BaseModel, Field
25
  from sse_starlette.sse import EventSourceResponse
26
- from transformers import TextIteratorStreamer
27
 
28
- from inference import clean_recipe, load_model, parse_ingredients
 
 
 
 
 
 
 
29
 
30
- # Global state for the loaded model
31
- _model = None
32
- _tokenizer = None
33
- _device = None
34
- _model_name = None
35
 
36
 
37
  class GenerateRequest(BaseModel):
@@ -56,25 +56,14 @@ _kroger_token_expiry: float = 0
56
 
57
  def _parse_args():
58
  parser = argparse.ArgumentParser(description="Recipe generation API server")
 
 
59
  parser.add_argument(
60
- "--adapter",
61
- type=str,
62
- default="./processed_data/lora_adapter",
63
- help="Path to LoRA adapter (local or HuggingFace Hub ID)",
64
- )
65
- parser.add_argument(
66
- "--model",
67
  type=str,
68
- default="google/gemma-2b",
69
- help="Base model name",
70
- )
71
- parser.add_argument(
72
- "--no-adapter",
73
- action="store_true",
74
- help="Run the base model without a LoRA adapter",
75
  )
76
- parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
77
- parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
78
  return parser.parse_args()
79
 
80
 
@@ -83,10 +72,10 @@ args = _parse_args()
83
 
84
  @asynccontextmanager
85
  async def lifespan(app: FastAPI):
86
- global _model, _tokenizer, _device, _model_name
87
- _model_name = args.model
88
- _model, _tokenizer, _device = load_model(args.model, args.adapter, args.no_adapter)
89
- print(f"Server ready — model: {args.model}, device: {_device}")
90
  yield
91
 
92
 
@@ -105,40 +94,26 @@ app.add_middleware(
105
  def health():
106
  return {
107
  "status": "ok",
108
- "model": _model_name,
109
- "device": _device,
110
  }
111
 
112
 
113
  @app.post("/generate")
114
  async def generate(req: GenerateRequest):
115
  prompt = req.prompt if req.prompt.endswith("\n") else req.prompt + "\n"
116
- inputs = _tokenizer(prompt, return_tensors="pt").to(_device)
117
-
118
- streamer = TextIteratorStreamer(
119
- _tokenizer, skip_prompt=True, skip_special_tokens=True
120
- )
121
-
122
- generate_kwargs = dict(
123
- **inputs,
124
- max_new_tokens=req.max_tokens,
125
- temperature=req.temperature,
126
- top_p=0.9,
127
- do_sample=True,
128
- repetition_penalty=1.2,
129
- streamer=streamer,
130
- )
131
-
132
- thread = threading.Thread(target=_model.generate, kwargs=generate_kwargs)
133
- thread.start()
134
 
135
  async def event_stream():
136
  full_text = prompt
137
- for token in streamer:
138
- if not token:
139
- continue
 
 
 
140
  full_text += token
141
  yield {"data": json.dumps({"token": token})}
 
142
  cleaned = clean_recipe(full_text)
143
  yield {"data": json.dumps({"done": True, "full_text": cleaned})}
144
 
 
1
  """
2
+ FastAPI server for recipe generation with streaming output (GGUF).
3
 
4
  Usage:
5
+ pip install llama-cpp-python fastapi "uvicorn[standard]" sse-starlette httpx
6
  python server.py
 
7
  python server.py --port 8080
8
  """
9
 
10
  import argparse
11
+ import asyncio
12
  import base64
13
  import json
14
  import os
 
15
  import time
16
  from contextlib import asynccontextmanager
17
 
18
  import httpx
 
19
  import uvicorn
20
  from fastapi import FastAPI
21
  from fastapi.middleware.cors import CORSMiddleware
22
  from pydantic import BaseModel, Field
23
  from sse_starlette.sse import EventSourceResponse
 
24
 
25
+ from inference import (
26
+ GGUF_FILE,
27
+ GGUF_REPO,
28
+ clean_recipe,
29
+ load_model,
30
+ parse_ingredients,
31
+ stream_recipe,
32
+ )
33
 
34
+ _llm = None
 
 
 
 
35
 
36
 
37
  class GenerateRequest(BaseModel):
 
56
 
57
  def _parse_args():
58
  parser = argparse.ArgumentParser(description="Recipe generation API server")
59
+ parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to")
60
+ parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
61
  parser.add_argument(
62
+ "--model-path",
 
 
 
 
 
 
63
  type=str,
64
+ default=None,
65
+ help="Path to a local GGUF file (skips HF Hub download)",
 
 
 
 
 
66
  )
 
 
67
  return parser.parse_args()
68
 
69
 
 
72
 
73
  @asynccontextmanager
74
  async def lifespan(app: FastAPI):
75
+ global _llm
76
+ n_threads = int(os.environ.get("OMP_NUM_THREADS", "8"))
77
+ _llm = load_model(n_threads=n_threads, n_ctx=2048, model_path=args.model_path)
78
+ print(f"Server ready — GGUF model loaded, {n_threads} threads")
79
  yield
80
 
81
 
 
94
  def health():
95
  return {
96
  "status": "ok",
97
+ "model": f"{GGUF_REPO}/{GGUF_FILE}",
98
+ "device": "cpu (GGUF Q4_K_M)",
99
  }
100
 
101
 
102
  @app.post("/generate")
103
  async def generate(req: GenerateRequest):
104
  prompt = req.prompt if req.prompt.endswith("\n") else req.prompt + "\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
 
106
  async def event_stream():
107
  full_text = prompt
108
+ for token in stream_recipe(
109
+ _llm,
110
+ prompt,
111
+ max_tokens=req.max_tokens,
112
+ temperature=req.temperature,
113
+ ):
114
  full_text += token
115
  yield {"data": json.dumps({"token": token})}
116
+ await asyncio.sleep(0) # yield control to event loop
117
  cleaned = clean_recipe(full_text)
118
  yield {"data": json.dumps({"done": True, "full_text": cleaned})}
119