Bhuvandesai commited on
Commit
564ad28
·
1 Parent(s): a7bb6b3

Migrate CPU serving to llama.cpp + Q5_K_M GGUF (was bf16 transformers)

Browse files
.gitignore CHANGED
@@ -14,6 +14,16 @@ __pycache__/
14
  models/phi3-text-to-sql/
15
  models/phi3-text-to-sql-adapter/
16
 
 
 
 
 
 
 
 
 
 
 
17
  # Local database (regenerated by database.py on cold start)
18
  data/company_sales.db
19
 
 
14
  models/phi3-text-to-sql/
15
  models/phi3-text-to-sql-adapter/
16
 
17
+ # GGUF conversion intermediates (large; GGUF is hosted on HF Hub)
18
+ models/phi3-text-to-sql-merged/
19
+ *.gguf
20
+ llama.cpp/
21
+
22
+ # llama.cpp prebuilt release binaries (downloaded locally; never commit)
23
+ llama-b*/
24
+ *.dll
25
+ *.exe
26
+
27
  # Local database (regenerated by database.py on cold start)
28
  data/company_sales.db
29
 
Dockerfile CHANGED
@@ -1,23 +1,28 @@
1
- # Use Python 3.11 slim as base
2
- # HF Spaces injects NVIDIA runtime for GPU; PyTorch ships its own CUDA libs
 
 
3
  FROM python:3.11-slim
4
 
5
- # System deps
 
6
  RUN apt-get update && apt-get install -y --no-install-recommends \
7
  git \
8
  curl \
 
 
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
  WORKDIR /app
12
 
13
- # Install PyTorch with CUDA 12.1 first (large layer, cache separately)
14
  RUN pip install --no-cache-dir \
15
- torch==2.4.1 \
16
- --index-url https://download.pytorch.org/whl/cu121
17
 
18
- # Install remaining dependencies
19
- COPY requirements.txt .
20
- RUN pip install --no-cache-dir -r requirements.txt
21
 
22
  # Copy project source
23
  COPY . .
@@ -32,7 +37,7 @@ RUN mkdir -p data models templates static
32
  RUN useradd -m -u 1000 appuser && chown -R appuser /app
33
  USER appuser
34
 
35
- # Point HF cache to a writable location inside the container
36
  ENV HF_HOME=/app/.cache/huggingface
37
 
38
  CMD ["python", "src/server.py"]
 
1
+ # CPU-only Hugging Face Space image.
2
+ # Serving uses llama.cpp + a pre-merged GGUF (Q4_K_M), so there is no torch /
3
+ # transformers / bitsandbytes in the production image. This keeps the image small,
4
+ # cold starts fast, and RAM well under the 16 GB free-tier ceiling.
5
  FROM python:3.11-slim
6
 
7
+ # System deps. build-essential + cmake are a safety net in case pip falls back to
8
+ # building llama-cpp-python from source; normally the prebuilt CPU wheel is used.
9
  RUN apt-get update && apt-get install -y --no-install-recommends \
10
  git \
11
  curl \
12
+ build-essential \
13
+ cmake \
14
  && rm -rf /var/lib/apt/lists/*
15
 
16
  WORKDIR /app
17
 
18
+ # Install llama-cpp-python from the prebuilt CPU wheel index (avoids a slow source build).
19
  RUN pip install --no-cache-dir \
20
+ "llama-cpp-python>=0.3.0,<0.4.0" \
21
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
22
 
23
+ # Install the slim CPU-serving dependencies.
24
+ COPY requirements-space.txt .
25
+ RUN pip install --no-cache-dir -r requirements-space.txt
26
 
27
  # Copy project source
28
  COPY . .
 
37
  RUN useradd -m -u 1000 appuser && chown -R appuser /app
38
  USER appuser
39
 
40
+ # Point HF cache to a writable location inside the container (GGUF is cached here)
41
  ENV HF_HOME=/app/.cache/huggingface
42
 
43
  CMD ["python", "src/server.py"]
requirements-space.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Slim dependency set for the Hugging Face CPU Space (used by the Dockerfile).
2
+ # CPU serving uses llama.cpp + a pre-merged GGUF, so torch/transformers/peft/
3
+ # bitsandbytes are NOT needed in the production image.
4
+ #
5
+ # NOTE: llama-cpp-python is installed separately in the Dockerfile from the
6
+ # prebuilt CPU wheel index to avoid a slow source build.
7
+
8
+ flask==3.0.3
9
+ pandas==2.2.3
10
+ huggingface_hub>=0.24.0
requirements.txt CHANGED
@@ -15,3 +15,7 @@ pandas==2.2.3
15
 
16
  # HF Hub (model + adapter download)
17
  huggingface_hub>=0.24.0
 
 
 
 
 
15
 
16
  # HF Hub (model + adapter download)
17
  huggingface_hub>=0.24.0
18
+
19
+ # CPU/GGUF inference + local GGUF validation harness.
20
+ # Lazy-imported (CPU path only); the CUDA/laptop training path does not require it at runtime.
21
+ llama-cpp-python>=0.3.0,<0.4.0
scripts/CONVERT_GGUF.md ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Convert the fine-tuned model to GGUF (Q4_K_M) — run on your laptop
2
+
3
+ One-time process. Produces `phi3-text-to-sql-Q4_K_M.gguf` (~2.3 GB), which you
4
+ then push to a Hugging Face model repo. The CPU Space downloads it at startup.
5
+
6
+ Prerequisites: your trained adapter at `models/phi3-text-to-sql-adapter`, Python
7
+ env with `transformers`, `peft`, `torch` (your existing `.venv`), and `git`.
8
+
9
+ ---
10
+
11
+ ## Step 1 — Merge the adapter into the base model
12
+
13
+ ```bash
14
+ python scripts/merge_adapter.py
15
+ ```
16
+
17
+ Output: `models/phi3-text-to-sql-merged/` (fp16, ~7.6 GB).
18
+
19
+ ## Step 2 — Get llama.cpp (for the converter + quantizer)
20
+
21
+ ```bash
22
+ git clone https://github.com/ggerganov/llama.cpp
23
+ pip install -r llama.cpp/requirements.txt
24
+ ```
25
+
26
+ ## Step 3 — Convert merged HF model → GGUF (F16)
27
+
28
+ ```bash
29
+ python llama.cpp/convert_hf_to_gguf.py models/phi3-text-to-sql-merged \
30
+ --outfile models/phi3-text-to-sql-f16.gguf \
31
+ --outtype f16
32
+ ```
33
+
34
+ ## Step 4 — Quantize F16 → Q4_K_M
35
+
36
+ You need the `llama-quantize` binary. Two options:
37
+
38
+ **Option A — prebuilt binary (easiest on Windows):**
39
+ Download the latest llama.cpp release for your OS from
40
+ https://github.com/ggerganov/llama.cpp/releases (it includes `llama-quantize`
41
+ / `llama-quantize.exe`), then:
42
+
43
+ ```bash
44
+ # Linux/macOS
45
+ ./llama-quantize models/phi3-text-to-sql-f16.gguf \
46
+ models/phi3-text-to-sql-Q4_K_M.gguf Q4_K_M
47
+ ```
48
+ ```powershell
49
+ # Windows
50
+ .\llama-quantize.exe models\phi3-text-to-sql-merged\..\phi3-text-to-sql-f16.gguf `
51
+ models\phi3-text-to-sql-Q4_K_M.gguf Q4_K_M
52
+ ```
53
+
54
+ **Option B — build from source:**
55
+ ```bash
56
+ cd llama.cpp && cmake -B build && cmake --build build --config Release -j
57
+ # binary at: build/bin/llama-quantize
58
+ ./build/bin/llama-quantize ../models/phi3-text-to-sql-f16.gguf \
59
+ ../models/phi3-text-to-sql-Q4_K_M.gguf Q4_K_M
60
+ ```
61
+
62
+ Result: `models/phi3-text-to-sql-Q4_K_M.gguf` (~2.3 GB).
63
+
64
+ ## Step 5 — Validate quality BEFORE deploying
65
+
66
+ ```bash
67
+ python scripts/validate_gguf.py --gguf models/phi3-text-to-sql-Q4_K_M.gguf
68
+ ```
69
+
70
+ Acceptance bar: ≥ 95% execution-equivalent on `data/test_dataset.jsonl`.
71
+ If it fails, redo Step 4 with `Q5_K_M` (larger/slower, higher fidelity) and
72
+ re-validate.
73
+
74
+ ## Step 6 — Push the GGUF to Hugging Face
75
+
76
+ The Space pulls from repo `Bhuvandesai/phi3-text-to-sql-gguf`, file
77
+ `phi3-text-to-sql-Q4_K_M.gguf` (override via `GGUF_REPO_ID` / `GGUF_FILENAME`
78
+ env vars on the Space). Create the repo and upload:
79
+
80
+ ```bash
81
+ huggingface-cli repo create phi3-text-to-sql-gguf --type model
82
+ huggingface-cli upload Bhuvandesai/phi3-text-to-sql-gguf \
83
+ models/phi3-text-to-sql-Q4_K_M.gguf phi3-text-to-sql-Q4_K_M.gguf
84
+ ```
85
+
86
+ (If you used Q5_K_M, set `GGUF_FILENAME=phi3-text-to-sql-Q5_K_M.gguf` in the
87
+ Space settings and upload that file instead.)
88
+
89
+ ## Step 7 — Deploy
90
+
91
+ Commit and push the repo. The Dockerfile installs llama-cpp-python and the slim
92
+ deps; on boot `src/inference.py` downloads the GGUF and serves on CPU.
93
+
94
+ ---
95
+
96
+ ### Notes
97
+ - Do **not** commit the multi-GB `.gguf` into the Space git repo. Host it on the
98
+ Hub (Step 6) so it is pulled at runtime and cached. If you ever want it bundled,
99
+ the loader also accepts a local copy at `models/<GGUF_FILENAME>`.
100
+ - `models/phi3-text-to-sql-merged/`, `*.gguf`, and `llama.cpp/` are intermediate
101
+ artifacts — keep them out of git (see `.gitignore`).
scripts/merge_adapter.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Merge the trained LoRA adapter into the Phi-3 base model and save a standalone
3
+ fp16 model. This merged model is the input to GGUF conversion.
4
+
5
+ Run on your laptop (the base model is already cached there):
6
+
7
+ python scripts/merge_adapter.py
8
+
9
+ Env overrides (optional):
10
+ BASE_MODEL_ID default: microsoft/Phi-3-mini-4k-instruct
11
+ ADAPTER_PATH default: models/phi3-text-to-sql-adapter
12
+ MERGED_OUT default: models/phi3-text-to-sql-merged
13
+
14
+ Notes:
15
+ - Merging is weight arithmetic (W' = W + (alpha/r) * B@A); it is mathematically
16
+ equivalent to applying the adapter. Under greedy decoding the SQL output is the
17
+ same as the current fine-tuned model.
18
+ - fp16 keeps the merged checkpoint ~7.6 GB. GGUF conversion + Q4_K_M quantization
19
+ shrinks it to ~2.3 GB.
20
+ """
21
+ import os
22
+ import torch
23
+ from transformers import AutoModelForCausalLM, AutoTokenizer
24
+ from peft import PeftModel
25
+
26
+ BASE_MODEL_ID = os.environ.get("BASE_MODEL_ID", "microsoft/Phi-3-mini-4k-instruct")
27
+ ADAPTER_PATH = os.environ.get("ADAPTER_PATH", "models/phi3-text-to-sql-adapter")
28
+ MERGED_OUT = os.environ.get("MERGED_OUT", "models/phi3-text-to-sql-merged")
29
+
30
+
31
+ def main():
32
+ print(f"Base model : {BASE_MODEL_ID}")
33
+ print(f"Adapter : {ADAPTER_PATH}")
34
+ print(f"Output : {MERGED_OUT}")
35
+
36
+ print("\nLoading tokenizer...")
37
+ tokenizer = AutoTokenizer.from_pretrained(BASE_MODEL_ID, trust_remote_code=False)
38
+
39
+ print("Loading base model in fp16 (CPU)...")
40
+ model = AutoModelForCausalLM.from_pretrained(
41
+ BASE_MODEL_ID,
42
+ torch_dtype=torch.float16,
43
+ device_map=None,
44
+ low_cpu_mem_usage=True,
45
+ trust_remote_code=False,
46
+ )
47
+
48
+ print("Attaching adapter...")
49
+ model = PeftModel.from_pretrained(model, ADAPTER_PATH)
50
+
51
+ print("Merging adapter into base weights (merge_and_unload)...")
52
+ model = model.merge_and_unload()
53
+
54
+ os.makedirs(MERGED_OUT, exist_ok=True)
55
+ print(f"Saving merged model to {MERGED_OUT} ...")
56
+ model.save_pretrained(MERGED_OUT, safe_serialization=True)
57
+ tokenizer.save_pretrained(MERGED_OUT)
58
+
59
+ # Phi-3's GGUF converter needs the original SentencePiece tokenizer.model,
60
+ # which save_pretrained() does not emit for the fast tokenizer. Copy it over.
61
+ tok_model_dst = os.path.join(MERGED_OUT, "tokenizer.model")
62
+ if not os.path.exists(tok_model_dst):
63
+ try:
64
+ import shutil
65
+ from huggingface_hub import hf_hub_download
66
+ src = hf_hub_download(BASE_MODEL_ID, "tokenizer.model")
67
+ shutil.copy(src, tok_model_dst)
68
+ print("Copied tokenizer.model (required by the GGUF converter).")
69
+ except Exception as e:
70
+ print(f"WARNING: could not fetch tokenizer.model ({e}). "
71
+ "Copy it manually from the base model before converting.")
72
+
73
+ print("\nDone. Next: convert to GGUF (see scripts/CONVERT_GGUF.md).")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ main()
scripts/validate_gguf.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Validate the GGUF model's SQL quality against the held-out test set BEFORE deploying.
3
+
4
+ For each example in data/test_dataset.jsonl:
5
+ 1. Generate SQL from the GGUF model (greedy / temperature 0).
6
+ 2. Execute both predicted and expected SQL on data/company_sales.db.
7
+ 3. Count as a match if results are EXECUTION-EQUIVALENT (same rows, order-independent).
8
+
9
+ Acceptance bar: >= 95% match. If below, re-quantize at Q5_K_M and re-run.
10
+
11
+ Default backend: llama-server (recommended on Windows/local).
12
+ The harness launches llama-server.exe (from the llama.cpp release you already
13
+ downloaded), waits for it to load the model ONCE, then hits its OpenAI-compatible
14
+ /v1/chat/completions endpoint over HTTP -- the same call shape production uses.
15
+ This avoids:
16
+ - the prebuilt llama-cpp-python wheel (illegal-instruction 0xc000001d on CPUs
17
+ without the AVX-512 it was built for), and
18
+ - llama-cli interactive/conversation-mode hangs.
19
+
20
+ Usage:
21
+ python scripts/validate_gguf.py --gguf models/phi3-text-to-sql-Q4_K_M.gguf
22
+ python scripts/validate_gguf.py --gguf models/...gguf --server .\\llama-b9637-bin-win-cpu-x64\\llama-server.exe
23
+ python scripts/validate_gguf.py --gguf models/...gguf --use-python # only if the wheel runs on your CPU
24
+
25
+ Requires: data/company_sales.db (run `python src/database.py` first if missing).
26
+ """
27
+ import argparse
28
+ import glob
29
+ import json
30
+ import os
31
+ import re
32
+ import sqlite3
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ import urllib.error
37
+ import urllib.request
38
+
39
+ DEFAULT_GGUF = "models/phi3-text-to-sql-Q4_K_M.gguf"
40
+ TEST_PATH = "data/test_dataset.jsonl"
41
+ DB_PATH = "data/company_sales.db"
42
+
43
+
44
+ def clean_sql(raw_output):
45
+ """Same extraction logic as src/inference.py so validation matches serving."""
46
+ if "<|assistant|>" in raw_output:
47
+ raw_output = raw_output.split("<|assistant|>")[-1]
48
+ cleaned = re.sub(r"```sql\s*", "", raw_output, flags=re.IGNORECASE)
49
+ cleaned = re.sub(r"```", "", cleaned)
50
+ cleaned = re.sub(r"<\|.*?\|>", "", cleaned).strip()
51
+ m = re.search(r"(SELECT\s+.*?;)", cleaned, re.DOTALL | re.IGNORECASE)
52
+ if m:
53
+ return m.group(1).strip()
54
+ if "select" in cleaned.lower():
55
+ return cleaned.strip()
56
+ return cleaned.strip()
57
+
58
+
59
+ def run_sql(conn, sql):
60
+ """Returns (rows_as_sorted_list, error_or_None)."""
61
+ if not sql or "select" not in sql.lower():
62
+ return None, "invalid/blank SQL"
63
+ try:
64
+ cur = conn.cursor()
65
+ cur.execute(sql)
66
+ rows = cur.fetchall()
67
+ return sorted([tuple(r) for r in rows], key=lambda r: str(r)), None
68
+ except Exception as e:
69
+ return None, str(e)
70
+
71
+
72
+ def load_test_set(path):
73
+ items = []
74
+ with open(path, "r", encoding="utf-8") as f:
75
+ for line in f:
76
+ line = line.strip()
77
+ if not line:
78
+ continue
79
+ obj = json.loads(line)
80
+ msgs = obj["messages"]
81
+ user = next(m["content"] for m in msgs if m["role"] == "user")
82
+ expected = next(m["content"] for m in msgs if m["role"] == "assistant")
83
+ question = user.split("Question:", 1)[-1].strip() if "Question:" in user else user
84
+ items.append({"user_prompt": user, "question": question, "expected_sql": expected})
85
+ return items
86
+
87
+
88
+ # ---------- llama-server backend ----------
89
+
90
+ def find_llama_server():
91
+ candidates = []
92
+ candidates += glob.glob(os.path.join("llama-b*", "llama-server.exe"))
93
+ candidates += glob.glob(os.path.join("llama-b*-bin-win-*", "llama-server.exe"))
94
+ candidates += glob.glob(os.path.join("llama-bin", "llama-server.exe"))
95
+ for name in ("llama-server.exe", "llama-server"):
96
+ if os.path.exists(name):
97
+ candidates.append(name)
98
+ return candidates[0] if candidates else None
99
+
100
+
101
+ def start_server(server_path, gguf, n_ctx, port, log_path="llama-server.log"):
102
+ cmd = [server_path, "-m", gguf, "-c", str(n_ctx),
103
+ "--host", "127.0.0.1", "--port", str(port)]
104
+ print(f"Starting llama-server (logs -> {log_path}) ...")
105
+ log = open(log_path, "w", encoding="utf-8")
106
+ proc = subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)
107
+
108
+ health = f"http://127.0.0.1:{port}/health"
109
+ for _ in range(180): # up to ~3 min for first model load
110
+ if proc.poll() is not None:
111
+ raise RuntimeError(f"llama-server exited early; see {log_path}")
112
+ try:
113
+ with urllib.request.urlopen(health, timeout=2) as r:
114
+ if r.status == 200 and json.loads(r.read()).get("status") == "ok":
115
+ print("Server ready.")
116
+ return proc
117
+ except Exception:
118
+ pass
119
+ time.sleep(1)
120
+ proc.terminate()
121
+ raise RuntimeError(f"llama-server did not become ready in time; see {log_path}")
122
+
123
+
124
+ def make_http_generator(port):
125
+ url = f"http://127.0.0.1:{port}/v1/chat/completions"
126
+
127
+ def gen(user_content):
128
+ body = json.dumps({
129
+ "messages": [{"role": "user", "content": user_content}],
130
+ "temperature": 0.0,
131
+ "max_tokens": 150,
132
+ }).encode("utf-8")
133
+ req = urllib.request.Request(url, data=body, headers={"Content-Type": "application/json"})
134
+ with urllib.request.urlopen(req, timeout=180) as r:
135
+ out = json.loads(r.read())
136
+ return out["choices"][0]["message"]["content"]
137
+ return gen
138
+
139
+
140
+ def make_python_generator(gguf, n_ctx):
141
+ from llama_cpp import Llama
142
+ llm = Llama(model_path=gguf, n_ctx=n_ctx, n_threads=os.cpu_count() or 2, verbose=False)
143
+
144
+ def gen(user_content):
145
+ out = llm.create_chat_completion(
146
+ messages=[{"role": "user", "content": user_content}],
147
+ max_tokens=150, temperature=0.0,
148
+ )
149
+ return out["choices"][0]["message"]["content"]
150
+ return gen
151
+
152
+
153
+ def main():
154
+ ap = argparse.ArgumentParser()
155
+ ap.add_argument("--gguf", default=DEFAULT_GGUF, help="Path to the GGUF file")
156
+ ap.add_argument("--threshold", type=float, default=0.95, help="Acceptance match rate")
157
+ ap.add_argument("--n_ctx", type=int, default=2048)
158
+ ap.add_argument("--server", default=None, help="Path to llama-server(.exe). Auto-detected if omitted.")
159
+ ap.add_argument("--port", type=int, default=8080)
160
+ ap.add_argument("--use-python", action="store_true", help="Use llama-cpp-python bindings instead of llama-server")
161
+ args = ap.parse_args()
162
+
163
+ if not os.path.exists(args.gguf):
164
+ sys.exit(f"GGUF not found: {args.gguf}")
165
+ if not os.path.exists(TEST_PATH):
166
+ sys.exit(f"Test set not found: {TEST_PATH}")
167
+ if not os.path.exists(DB_PATH):
168
+ sys.exit(f"Database not found: {DB_PATH}. Run `python src/database.py` first.")
169
+
170
+ server_proc = None
171
+ try:
172
+ if args.use_python:
173
+ print("Backend: llama-cpp-python bindings")
174
+ generate = make_python_generator(args.gguf, args.n_ctx)
175
+ else:
176
+ server = args.server or find_llama_server()
177
+ if not server:
178
+ sys.exit("Could not find llama-server(.exe). Pass --server <path>, "
179
+ "or use --use-python if the wheel runs on your CPU.")
180
+ print(f"Backend: llama-server -> {server}")
181
+ server_proc = start_server(server, args.gguf, args.n_ctx, args.port)
182
+ generate = make_http_generator(args.port)
183
+
184
+ tests = load_test_set(TEST_PATH)
185
+ conn = sqlite3.connect(DB_PATH)
186
+
187
+ passed = 0
188
+ failures = []
189
+ for i, t in enumerate(tests, 1):
190
+ raw = generate(t["user_prompt"])
191
+ pred_sql = clean_sql(raw)
192
+
193
+ exp_rows, exp_err = run_sql(conn, t["expected_sql"])
194
+ pred_rows, pred_err = run_sql(conn, pred_sql)
195
+
196
+ if exp_err is not None:
197
+ ok = pred_sql.strip().lower().rstrip(";") == t["expected_sql"].strip().lower().rstrip(";")
198
+ else:
199
+ ok = (pred_err is None) and (pred_rows == exp_rows)
200
+
201
+ status = "PASS" if ok else "FAIL"
202
+ if ok:
203
+ passed += 1
204
+ else:
205
+ failures.append({
206
+ "n": i, "question": t["question"],
207
+ "expected": t["expected_sql"], "predicted": pred_sql, "pred_err": pred_err,
208
+ })
209
+ print(f"[{i:02d}/{len(tests)}] {status} {t['question'][:70]}")
210
+
211
+ conn.close()
212
+
213
+ rate = passed / len(tests) if tests else 0.0
214
+ print("\n" + "=" * 60)
215
+ print(f"Execution-equivalent: {passed}/{len(tests)} = {rate:.1%}")
216
+ print(f"Acceptance bar : {args.threshold:.0%}")
217
+ print("Result :", "ACCEPTED" if rate >= args.threshold else "BELOW BAR (try Q5_K_M)")
218
+ print("=" * 60)
219
+
220
+ if failures:
221
+ print("\nFailures:")
222
+ for f in failures:
223
+ print(f"\n Q{f['n']}: {f['question']}")
224
+ print(f" expected : {f['expected']}")
225
+ print(f" predicted: {f['predicted']}")
226
+ if f["pred_err"]:
227
+ print(f" sql error: {f['pred_err']}")
228
+
229
+ exit_code = 0 if rate >= args.threshold else 1
230
+ finally:
231
+ if server_proc is not None:
232
+ server_proc.terminate()
233
+ try:
234
+ server_proc.wait(timeout=10)
235
+ except Exception:
236
+ server_proc.kill()
237
+
238
+ sys.exit(exit_code)
239
+
240
+
241
+ if __name__ == "__main__":
242
+ main()
src/inference.py CHANGED
@@ -15,6 +15,13 @@ class TextToSQLInference:
15
  self.db_path = "data/company_sales.db"
16
  self.tokenizer = None
17
  self.model = None
 
 
 
 
 
 
 
18
  # Support both local paths and HF Hub repo IDs
19
  self.has_adapter = self._adapter_available(adapter_path)
20
 
@@ -33,36 +40,39 @@ class TextToSQLInference:
33
  return False
34
 
35
  def _load_model_and_tokenizer(self):
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  print(f"Loading tokenizer: {self.base_model_id}...")
37
  self.tokenizer = AutoTokenizer.from_pretrained(self.base_model_id, trust_remote_code=False)
38
  self.tokenizer.pad_token = self.tokenizer.eos_token
39
-
40
- if self.device == "cuda":
41
- print("CUDA detected — configuring 4-bit quantization...")
42
- bnb_config = BitsAndBytesConfig(
43
- load_in_4bit=True,
44
- bnb_4bit_quant_type="nf4",
45
- bnb_4bit_use_double_quant=True,
46
- bnb_4bit_compute_dtype=torch.float16
47
- )
48
- print("Loading base model with 4-bit quantization...")
49
- self.model = AutoModelForCausalLM.from_pretrained(
50
- self.base_model_id,
51
- quantization_config=bnb_config,
52
- device_map="auto",
53
- trust_remote_code=False,
54
- attn_implementation="eager"
55
- )
56
- else:
57
- print("CPU detected — loading model in bfloat16 (no quantization)...")
58
- self.model = AutoModelForCausalLM.from_pretrained(
59
- self.base_model_id,
60
- torch_dtype=torch.bfloat16,
61
- device_map="cpu",
62
- trust_remote_code=False,
63
- attn_implementation="eager"
64
- )
65
-
66
  if self.has_adapter:
67
  print(f"Loading fine-tuned adapter from: {self.adapter_path}...")
68
  self.model = PeftModel.from_pretrained(
@@ -74,6 +84,36 @@ class TextToSQLInference:
74
  else:
75
  print("WARNING: No adapter found. Will run base model only.")
76
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  def clean_sql(self, raw_output):
78
  """Cleans and extracts SQL query from model output, stripping backticks and markdown formatting."""
79
  # Print for raw debugging
@@ -119,9 +159,50 @@ class TextToSQLInference:
119
  return None, str(e)
120
 
121
  def generate_sql(self, question, use_adapter=True):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  """Generates SQL query from a natural language question."""
123
  prompt = f"{SCHEMA_PROMPT}\n\nQuestion: {question}"
124
-
125
  # Standard Phi-3 ChatML template formatting
126
  messages = [
127
  {"role": "user", "content": prompt}
 
15
  self.db_path = "data/company_sales.db"
16
  self.tokenizer = None
17
  self.model = None
18
+ self.llm = None # llama_cpp.Llama instance (CPU/GGUF backend only)
19
+ self.backend = None # "transformers" (CUDA) or "llama_cpp" (CPU)
20
+
21
+ # GGUF source for CPU serving (override via env on the Space)
22
+ self.gguf_repo_id = os.environ.get("GGUF_REPO_ID", "Bhuvandesai/phi3-text-to-sql-gguf")
23
+ self.gguf_filename = os.environ.get("GGUF_FILENAME", "phi3-text-to-sql-Q5_K_M.gguf")
24
+
25
  # Support both local paths and HF Hub repo IDs
26
  self.has_adapter = self._adapter_available(adapter_path)
27
 
 
40
  return False
41
 
42
  def _load_model_and_tokenizer(self):
43
+ # Two backends, chosen by hardware:
44
+ # - CUDA (laptop/dev): transformers + 4-bit bitsandbytes + PEFT adapter
45
+ # - CPU (HF Space): llama.cpp + pre-merged GGUF (Q4_K_M)
46
+ # The CPU path never imports torch-side quantization or PEFT, and llama_cpp
47
+ # is imported lazily so the CUDA path doesn't need it installed.
48
+ if self.device == "cuda":
49
+ self.backend = "transformers"
50
+ self._load_transformers_cuda()
51
+ else:
52
+ self.backend = "llama_cpp"
53
+ self._load_llama_cpp_cpu()
54
+
55
+ def _load_transformers_cuda(self):
56
  print(f"Loading tokenizer: {self.base_model_id}...")
57
  self.tokenizer = AutoTokenizer.from_pretrained(self.base_model_id, trust_remote_code=False)
58
  self.tokenizer.pad_token = self.tokenizer.eos_token
59
+
60
+ print("CUDA detected — configuring 4-bit quantization...")
61
+ bnb_config = BitsAndBytesConfig(
62
+ load_in_4bit=True,
63
+ bnb_4bit_quant_type="nf4",
64
+ bnb_4bit_use_double_quant=True,
65
+ bnb_4bit_compute_dtype=torch.float16
66
+ )
67
+ print("Loading base model with 4-bit quantization...")
68
+ self.model = AutoModelForCausalLM.from_pretrained(
69
+ self.base_model_id,
70
+ quantization_config=bnb_config,
71
+ device_map="auto",
72
+ trust_remote_code=False,
73
+ attn_implementation="eager"
74
+ )
75
+
 
 
 
 
 
 
 
 
 
 
76
  if self.has_adapter:
77
  print(f"Loading fine-tuned adapter from: {self.adapter_path}...")
78
  self.model = PeftModel.from_pretrained(
 
84
  else:
85
  print("WARNING: No adapter found. Will run base model only.")
86
 
87
+ def _load_llama_cpp_cpu(self):
88
+ # GGUF already contains the merged fine-tuned weights, so there is no
89
+ # separate adapter to load at runtime.
90
+ self.has_adapter = True
91
+ print("CPU detected — using llama.cpp + GGUF (Q4_K_M) backend.")
92
+
93
+ from llama_cpp import Llama
94
+ from huggingface_hub import hf_hub_download
95
+
96
+ # Allow a local GGUF (committed under models/) to take precedence; otherwise pull from Hub.
97
+ local_candidate = os.path.join("models", self.gguf_filename)
98
+ if os.path.exists(local_candidate):
99
+ model_path = local_candidate
100
+ print(f"Loading local GGUF: {model_path}")
101
+ else:
102
+ print(f"Downloading GGUF {self.gguf_filename} from {self.gguf_repo_id}...")
103
+ model_path = hf_hub_download(repo_id=self.gguf_repo_id, filename=self.gguf_filename)
104
+ print(f"GGUF cached at: {model_path}")
105
+
106
+ n_threads = os.cpu_count() or 2
107
+ self.llm = Llama(
108
+ model_path=model_path,
109
+ n_ctx=2048, # schema prompt (~300 tok) + question + 150 generated
110
+ n_threads=n_threads,
111
+ n_batch=512,
112
+ logits_all=False,
113
+ verbose=False,
114
+ )
115
+ print(f"llama.cpp model loaded (n_threads={n_threads}).")
116
+
117
  def clean_sql(self, raw_output):
118
  """Cleans and extracts SQL query from model output, stripping backticks and markdown formatting."""
119
  # Print for raw debugging
 
159
  return None, str(e)
160
 
161
  def generate_sql(self, question, use_adapter=True):
162
+ """Dispatches to the active backend. Output contract is identical for both."""
163
+ if self.backend == "llama_cpp":
164
+ return self._generate_llama(question, use_adapter=use_adapter)
165
+ return self._generate_transformers(question, use_adapter=use_adapter)
166
+
167
+ def _generate_llama(self, question, use_adapter=True):
168
+ """CPU/GGUF generation via llama.cpp. The GGUF is the merged fine-tuned model."""
169
+ if not use_adapter:
170
+ # GGUF has the fine-tuned weights baked in; there is no base toggle on CPU.
171
+ # The base comparison is served by generate_sql_base_via_api (HF API) instead.
172
+ # query_pipeline catches this and returns a clean error rather than crashing.
173
+ raise RuntimeError(
174
+ "Local base-model comparison is unavailable in CPU/GGUF mode; "
175
+ "use the HF Inference API path for base comparison."
176
+ )
177
+
178
+ prompt = f"{SCHEMA_PROMPT}\n\nQuestion: {question}"
179
+ print("--- Generating with Fine-tuned GGUF (llama.cpp) ---")
180
+
181
+ start_time = time.perf_counter()
182
+ result = self.llm.create_chat_completion(
183
+ messages=[{"role": "user", "content": prompt}],
184
+ max_tokens=150,
185
+ temperature=0.0, # greedy / deterministic, matches do_sample=False
186
+ )
187
+ latency_sec = time.perf_counter() - start_time
188
+
189
+ raw_output = result["choices"][0]["message"]["content"]
190
+ usage = result.get("usage", {}) or {}
191
+ tokens_generated = usage.get("completion_tokens", 0) or 0
192
+ tokens_per_second = tokens_generated / latency_sec if latency_sec > 0 else 0.0
193
+
194
+ sql_query = self.clean_sql(raw_output)
195
+ metrics = {
196
+ "latency_sec": latency_sec,
197
+ "tokens_generated": tokens_generated,
198
+ "tokens_per_second": tokens_per_second
199
+ }
200
+ return sql_query, metrics
201
+
202
+ def _generate_transformers(self, question, use_adapter=True):
203
  """Generates SQL query from a natural language question."""
204
  prompt = f"{SCHEMA_PROMPT}\n\nQuestion: {question}"
205
+
206
  # Standard Phi-3 ChatML template formatting
207
  messages = [
208
  {"role": "user", "content": prompt}