diff --git a/ai/scripts/llm_eval/prefill_bench.py b/ai/scripts/llm_eval/prefill_bench.py new file mode 100644 index 00000000..32386323 --- /dev/null +++ b/ai/scripts/llm_eval/prefill_bench.py @@ -0,0 +1,162 @@ +"""입력 길이에 따른 첫 토큰 지연(TTFT) 스케일링 측정 (RQ3·RQ5). + +운영 꼬리질문 프롬프트의 참고 자료(context) 자리를 한국어 기술 문서로 채워 입력 길이만 바꾼다. +프리픽스 캐시를 끄고 한 번에 한 요청만 보내 큐 대기를 배제하므로, TTFT 기울기가 곧 프리필 처리율이다. +같은 한국어 원문이 모델마다 몇 토큰이 되는지도 함께 기록해 토크나이저 효율이 지연으로 번역되는 양을 잰다. + +python prefill_bench.py --label qwen3-4b-q4 --base-url http://stackup-llmtest:8080/v1 \ + --corpus /q2/ko_eval.txt --chars 400,1200,3000,6000,12000,24000 --reps 5 --out /tmp/pf/x.jsonl +""" + +from __future__ import annotations + +import argparse +import asyncio +import importlib +import json +import os +import random +import statistics +import sys +import time + +import httpx + +sys.path.insert(0, os.path.dirname(__file__)) +_C = importlib.import_module(os.environ.get("LLM_EVAL_CASES", "cases")) # noqa: E402 + +from langchain_core.prompts import ChatPromptTemplate # noqa: E402 + +from ai_server.chain.prompts.followup_generation import ( # noqa: E402 + HUMAN_PROMPT, + SYSTEM_PROMPT, +) + +TEMPLATE = ChatPromptTemplate.from_messages( + [("system", SYSTEM_PROMPT), ("human", HUMAN_PROMPT)] +) +VARS = ( + "job_category", + "mode", + "previous_question", + "answer_text", + "context", + "parent_category", + "expected_signal", + "history", +) + + +def load_corpus(path: str) -> str: + with open(path, encoding="utf-8", errors="ignore") as f: + text = " ".join(line.strip() for line in f if line.strip()) + return text + + +def build(case: dict, corpus: str, chars: int, seed: int) -> list[dict]: + """참고 자료 자리를 corpus 로 채워 목표 글자 수를 맞춘다 (요청마다 다른 구간).""" + rng = random.Random(seed) + start = rng.randrange(0, max(1, len(corpus) - chars - 1)) + filler = (corpus * (chars // max(1, len(corpus)) + 2))[start : start + chars] + c = dict(case) + c["context"] = filler + msgs = TEMPLATE.format_messages(**{k: c[k] for k in VARS}) + return [ + {"role": "system" if m.type == "system" else "user", "content": m.content} + for m in msgs + ] + + +async def one(client, url, model, messages, extra: dict) -> dict: + body = { + "model": model, + "messages": messages, + "stream": True, + "stream_options": {"include_usage": True}, + "temperature": 0.0, + "max_tokens": 8, + "cache_prompt": False, + **extra, + } + t0 = time.perf_counter() + first = None + usage = None + try: + async with client.stream("POST", url, json=body, timeout=900) as resp: + resp.raise_for_status() + async for line in resp.aiter_lines(): + if not line.startswith("data: "): + continue + data = line[6:] + if data.strip() == "[DONE]": + break + obj = json.loads(data) + if obj.get("usage"): + usage = obj["usage"] + ch = obj.get("choices") or [] + if ch and (ch[0].get("delta") or {}).get("content") and first is None: + first = time.perf_counter() + return { + "ok": True, + "ttft": round((first or time.perf_counter()) - t0, 4), + "total": round(time.perf_counter() - t0, 4), + "in_tokens": (usage or {}).get("prompt_tokens"), + } + except Exception as exc: # noqa: BLE001 + return {"ok": False, "error": f"{type(exc).__name__}: {str(exc)[:200]}"} + + +async def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--label", required=True) + ap.add_argument("--base-url", required=True) + ap.add_argument("--model", default="testmodel") + ap.add_argument("--corpus", required=True, help="한국어 텍스트 파일") + ap.add_argument("--chars", default="400,1200,3000,6000,12000,24000") + ap.add_argument("--reps", type=int, default=5) + ap.add_argument("--extra-body", default="") + ap.add_argument("--out", required=True) + args = ap.parse_args() + extra = json.loads(args.extra_body) if args.extra_body else {} + corpus = load_corpus(args.corpus) + case = _C.FOLLOWUP_CASES[0] + url = args.base_url.rstrip("/") + "/chat/completions" + lengths = [int(x) for x in args.chars.split(",")] + plan = [(c, r) for c in lengths for r in range(args.reps)] + random.Random(f"{args.label}|plan").shuffle(plan) # 길이 순서 효과 제거 + os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True) + async with httpx.AsyncClient(timeout=900) as client: + await one(client, url, args.model, build(case, corpus, 400, 0), extra) # 워밍업 + with open(args.out, "a", encoding="utf-8") as fh: + for chars, rep in plan: + msgs = build(case, corpus, chars, seed=chars * 100 + rep) + rec = await one(client, url, args.model, msgs, extra) + rec.update(label=args.label, chars=chars, rep=rep) + fh.write(json.dumps(rec, ensure_ascii=False) + "\n") + fh.flush() + print( + f" {args.label} chars={chars:6d} rep={rep} " + f"tok={rec.get('in_tokens')} ttft={rec.get('ttft')}" + f"{' ERR ' + rec.get('error', '') if not rec.get('ok') else ''}", + file=sys.stderr, + ) + rows = [ + json.loads(x) + for x in open(args.out, encoding="utf-8") + if json.loads(x).get("label") == args.label and json.loads(x).get("ok") + ] + by: dict[int, list[float]] = {} + for r in rows: + by.setdefault(r["chars"], []).append(r["ttft"]) + print(f"== {args.label}", file=sys.stderr) + for c in sorted(by): + tok = [r["in_tokens"] for r in rows if r["chars"] == c and r["in_tokens"]] + print( + f" chars={c:6d} tokens={statistics.median(tok) if tok else '?':>7} " + f"ttft_median={statistics.median(by[c]):.3f} n={len(by[c])}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/ai/scripts/llm_eval/stats.py b/ai/scripts/llm_eval/stats.py index 0bce32c1..32aa0311 100644 --- a/ai/scripts/llm_eval/stats.py +++ b/ai/scripts/llm_eval/stats.py @@ -14,6 +14,7 @@ 4. 판정자 계열 편향: (Gemini 판정 − Claude 판정) 을 Google 계열 모델 vs 그 외로 비교, 차이의 부트스트랩 CI + Mann-Whitney U 5. 검정력: 관측된 쌍대 차이 표준편차로 0.3 / 0.5 점 차이를 검출하는 데 필요한 케이스 수 + + 동등성 검정(TOST): '차이가 없다'를 주장하려면 비유의(p>.05)가 아니라 등가 구간 안에 있음을 보여야 한다 6. 자동 지표 비율의 Wilson 95% CI, 지연 중간값의 부트스트랩 95% CI """ @@ -101,6 +102,44 @@ def n_for_paired( return int(math.ceil(((za + zb) * sd / delta) ** 2)) if delta > 0 and sd > 0 else 0 +def tost_paired(diffs: list[float], margin: float) -> dict: + """쌍대 차이의 동등성 검정 (TOST). |차이| < margin 이면 '실질적 차이 없음'. + + 두 단측 t 검정 중 큰 p 값이 결과다. p < .05 면 등가 구간 안에 있다고 본다. + margin 은 연구자가 정하는 실질적 유의미 경계이며, 근거를 함께 보고해야 한다. + """ + d = np.asarray(diffs, dtype=float) + n = len(d) + mean = float(d.mean()) + sd = float(d.std(ddof=1)) if n > 1 else 0.0 + se = sd / math.sqrt(n) if n > 1 and sd > 0 else 0.0 + if se == 0: + p = 0.0 if abs(mean) < margin else 1.0 + return { + "n": n, + "mean": mean, + "margin": margin, + "p_tost": p, + "ci90": (mean, mean), + } + df = n - 1 + t_lo = (mean + margin) / se + t_hi = (mean - margin) / se + p_lo = 1 - stats.t.cdf(t_lo, df) # H0: 차이 <= -margin + p_hi = stats.t.cdf(t_hi, df) # H0: 차이 >= +margin + tcrit = stats.t.ppf(0.95, df) + return { + "n": n, + "mean": mean, + "sd": sd, + "margin": margin, + "p_lower": float(p_lo), + "p_upper": float(p_hi), + "p_tost": float(max(p_lo, p_hi)), + "ci90": (mean - tcrit * se, mean + tcrit * se), + } + + def load_judge(path: str): """→ scores[suite][model][case][judge] = overall""" scores = defaultdict(lambda: defaultdict(lambda: defaultdict(dict))) diff --git a/docs/research/thesis/README.md b/docs/research/thesis/README.md index 7c5a8c03..7cc0859b 100644 --- a/docs/research/thesis/README.md +++ b/docs/research/thesis/README.md @@ -11,6 +11,7 @@ | [`research-design.md`](./research-design.md) | 연구 질문 RQ1~RQ7, 가설, 분석 계획, 대체 가능성 판정 기준 | | [`related-work.md`](./related-work.md) | 선행연구 정리와 연구 공백 | | [`quantization-study.md`](./quantization-study.md) | RQ2 — 비트 폭·한국어 보정 행렬·재양자화 | +| [`prefill-scaling.md`](./prefill-scaling.md) | RQ3·RQ5 — 입력 길이와 첫 응답 시간, 토크나이저 효율의 지연 환산 | | [`latency-experiment.md`](./latency-experiment.md) | RQ3 — 개방형 Poisson 부하, 프리픽스 캐시, 처리 용량 경계, 에너지 | | [`tokenizer-efficiency.md`](./tokenizer-efficiency.md) | RQ5 — 한국어 토크나이저 효율 | | [`judge-panel-analysis.md`](./judge-panel-analysis.md) | RQ6 — 3계열 판정자 패널, 일치도, 같은 계열 편향 | @@ -27,6 +28,7 @@ | `quant/raw/`, `quant/analysis/` | 양자화 조건별 모델 출력과 자동 지표 | | `judges/` | 판정자 원 응답과 패널 점수 | | `latency/` | 개방형 부하 요청별 기록, GPU 전력 원격 측정 | +| `prefill/` | 입력 길이별 첫 토큰 지연 원자료 (모델 3종 × 6단계 × 5회) | | `human-eval/` | 라벨 시트와 2차 주석자 응답 | ## 재현 diff --git a/docs/research/thesis/outline.md b/docs/research/thesis/outline.md index 4f9f5dcb..99d445e4 100644 --- a/docs/research/thesis/outline.md +++ b/docs/research/thesis/outline.md @@ -57,7 +57,7 @@ | 5.2 규칙 준수·의도 분류 | RQ4 | v2 70케이스로 16조건 측정 완료. 태그 준수·의도·점수 라벨·사실대조 전부 집계 | 게이트웨이 모델 v2 재측정(운영 키 외 필요) | 🔄 | | 5.3 양자화 | RQ2 | **완료** — 비트 곡선 10종 KL, 한국어 보정 효과(0.739→0.337), 재양자화 손실, 크기 의존성 | — | ✅ `quantization-study.md` | | 5.4 서빙 병목·동시성 | RQ3 | **완료** — 개방형 Poisson 부하, 프리픽스 캐시 필수성, 처리 용량 경계(0.6 req/s), J/token | — | ✅ `latency-experiment.md` | -| 5.5 토크나이저 | RQ5 | 구어체 최대 1.66배 차이, 부하 실험의 입력 토큰 실측치 확보 | 토큰 수 → TTFT 회귀 분석 | 🔄 | +| 5.5 토크나이저 | RQ5 | **완료** — 토큰 수 → TTFT 회귀(R² ≥ .996), 토크나이저가 지연 격차의 49% | — | ✅ `prefill-scaling.md` | | 5.6 판정자 편향 | RQ6 | 3계열 패널 α 0.715, 같은 계열 편향 +0.43 [0.27, 0.58], 양자화 판정에서도 재현 | 인간 평가(평가자 필요) | 🔄 | | 5.7 비용 | RQ7 | 손익분기 월 2,400~5,500 세션 | 민감도 분석 | ✅ (모형) | @@ -77,10 +77,10 @@ | 순위 | 작업 | 막힌 이유 | 없으면 | |---|---|---|---| -| 1 | 양자화 판정 나머지 28케이스 (163회 호출) | 게이트웨이 채점 키 크레딧 소진 | 5.3절은 규칙 지표만으로 서술 가능, 5.1 은 약해짐 | +| 1 | 양자화 판정 나머지 28케이스 (163회 호출) | 게이트웨이 채점 키 크레딧 소진 | 엄격 경계 동등성(48케이스 필요) 미확인, Gemma 8비트 우위 판정 불가 | | 2 | 게이트웨이 모델(Gemini·Gemma 31B 등) v2 케이스 재측정 | 실험 전용 키 필요 | 1·2라운드 v1 결과로 대체 서술 | | 3 | 인간 전문가 평가 3인 | 평가자 섭외 | 5.6절이 LLM 판정자 간 비교에 머무름 | -| 4 | 토큰 수 → TTFT 회귀 | 없음 (분석만 하면 됨) | 5.5절이 토크나이저 계산에 머무름 | +| 4 | 비트 폭별 처리 용량 (개방형 부하) | 없음 (GPU 야간 실행) | 양자화와 서빙 장을 잇는 결론이 빠짐 | | 5 | 24GB GPU 실측 | 하드웨어 없음 | 6.1절 경계 주장이 추정으로 남음 | ## 부록 diff --git a/docs/research/thesis/prefill-scaling.md b/docs/research/thesis/prefill-scaling.md new file mode 100644 index 00000000..57acceb0 --- /dev/null +++ b/docs/research/thesis/prefill-scaling.md @@ -0,0 +1,72 @@ +# 입력 길이 스케일링 (RQ3·RQ5) — 프리필이 첫 응답 시간을 지배한다 + +> 2026-09-18. 프리픽스 캐시를 끄고 한 번에 한 요청만 보내 큐 대기를 배제한 상태에서, 입력 길이만 바꿔 첫 토큰까지 걸리는 시간(TTFT)을 측정했다. +> 도구: `ai/scripts/llm_eval/prefill_bench.py` · 원자료: `prefill/*.jsonl` (모델 3종 × 길이 6단계 × 5회 = 90건) + +--- + +## 1. 왜 이 실험이 필요했나 + +앞선 부하 실험의 케이스들은 입력이 1,343~1,987 토큰 범위에만 흩어져 있었다. 이 자료로 입력 길이와 지연의 관계를 회귀하면 결정계수가 0.02~0.14 에 그치고 기울기 부호조차 뒤집힌다. **범위가 좁아 기울기를 추정할 수 없는 것이지, 관계가 없는 것이 아니다.** 그래서 참고 자료 자리를 한국어 기술 문서로 채워 입력을 1,688 토큰에서 6,090 토큰까지 3.6배로 벌렸다. + +- 길이 순서는 무작위로 섞어 워밍업·발열 효과가 특정 길이에 몰리지 않게 했다. +- `cache_prompt=false` 로 매 요청 전체를 다시 계산하게 했다. 캐시가 켜지면 재계산이 사라져 프리필 비용을 잴 수 없다. +- 출력은 8토큰으로 제한했다. 측정 대상은 첫 토큰까지이므로 디코딩 시간이 섞이지 않는다. +- 서빙은 llama.cpp, 문맥 8192, 슬롯 1개, 모두 Q4_K_M 로 고정했다. + +## 2. 결과 — 거의 완벽한 직선 + +| 모델 | 기울기 (토큰당) | 95% 신뢰구간 | 프리필 처리율 | 결정계수 | +|---|---|---|---|---| +| Gemma 4 E2B | 2.408 ms | [2.385, 2.431] | **415 tok/s** | 0.9994 | +| Gemma 4 E4B | 4.416 ms | [4.393, 4.438] | **226 tok/s** | 0.9998 | +| Qwen3 4B | 5.476 ms | [5.343, 5.609] | **183 tok/s** | 0.9961 | + +결정계수가 0.996 이상이라는 것은 **첫 응답 시간의 거의 전부가 입력 길이로 설명된다**는 뜻이다. 모델 크기나 양자화 수준이 아니라 입력 토큰 수가 지배 변수다. + +| 입력 | Gemma E2B | Gemma E4B | Qwen3 4B | +|---|---|---|---| +| 1,688 토큰 | 3.52초 | 6.95초 | 7.45초 (1,901 토큰) | +| 3,226 토큰 | 7.04초 | 13.59초 | 15.41초 (3,558 토큰) | +| 5,493 토큰 | 12.70초 | 23.77초 | 30.43초 (6,090 토큰) | + +## 3. 토크나이저 효율이 지연으로 번역되는 양 + +같은 한국어 원문을 넣었을 때 모델마다 토큰 수가 다르다. 두 Gemma 는 같은 토크나이저를 쓰고, Qwen3 는 같은 글자에 **10.9% 더 많은 토큰**을 쓴다. + +| 원문 글자 수 | Gemma (E2B·E4B) | Qwen3 4B | 차이 | +|---|---|---|---| +| 400 | 1,688 | 1,901 | +12.6% | +| 2,400 | 2,611 | 2,923 | +11.9% | +| 9,000 | 5,493 | 6,090 | +10.9% | + +9,000자 입력에서 Qwen3 는 Gemma E4B 보다 6.66초 느리다. 이를 나누면 이렇다. + +- **토큰 수가 많아서 생긴 손해 3.27초 (49%)** — 597개 더 많은 토큰 × 토큰당 5.476 ms +- 토큰당 처리 속도가 느려서 생긴 손해 3.39초 (51%) + +즉 **한국어 토크나이저 비효율이 체감 지연의 절반을 만든다.** 토크나이저 효율은 그동안 어휘 통계로만 이야기돼 왔지만, 이 실험은 그것이 초 단위 지연으로 얼마나 번역되는지 실측으로 보여준다. + +## 4. 운영 기준으로 환산하면 + +운영 로그에서 질문 풀 생성의 입력은 p90 기준 약 4,700 토큰이다. 프리필만으로 이만큼 걸린다. + +| 모델 | 4,700 토큰 | 문맥 8,192 토큰을 채웠을 때 | +|---|---|---| +| Gemma E2B | 11.3초 | 19.7초 | +| Gemma E4B | 20.8초 | 36.2초 | +| Qwen3 4B | 25.7초 | 44.9초 | + +앞선 양자화 스윕에서 질문 풀 생성 지연 중앙값이 26~32초로 나온 것과 맞아떨어진다. **서로 다른 방법으로 잰 두 수치가 일치하므로, 프리필 병목이라는 진단은 측정 방식의 산물이 아니다.** + +이 결과는 세 가지를 함의한다. + +1. **이력서·GitHub 분석처럼 긴 입력을 넣는 기능은 이 하드웨어에서 로컬로 감당할 수 없다.** 문맥을 채우면 첫 글자가 20~45초 뒤에 나온다. +2. **프리픽스 캐시가 선택이 아닌 이유가 설명된다.** 대화가 이어질 때 공통 접두부를 재계산하지 않는 것이 유일한 탈출구다. +3. **한국어 서비스는 토크나이저를 모델 선택 기준에 넣어야 한다.** 품질이 비슷하면 한국어를 적은 토큰으로 쪼개는 모델이 체감 속도에서 앞선다. + +## 5. 한계 + +- 절편이 음수로 나온다(−0.56 ~ −3.52초). 측정 구간이 1,688 토큰부터라 그 아래로 외삽한 값이며, 고정 비용이 음수라는 뜻은 아니다. Qwen3 의 절편이 특히 큰 음수인 것은 입력이 길수록 배치 효율이 좋아져 기울기가 완만해진다는 뜻으로 읽힌다. 짧은 입력 구간을 따로 재야 확인된다. +- 양자화 수준을 Q4_K_M 하나로 고정했다. 비트 폭이 프리필 속도에 주는 영향은 별도 실험이 필요하다. +- 단일 GPU·단일 서빙 엔진 결과다. diff --git a/docs/research/thesis/prefill/pf-gemma4-e2b-q4.jsonl b/docs/research/thesis/prefill/pf-gemma4-e2b-q4.jsonl new file mode 100644 index 00000000..554788b0 --- /dev/null +++ b/docs/research/thesis/prefill/pf-gemma4-e2b-q4.jsonl @@ -0,0 +1,30 @@ +{"ok": true, "ttft": 3.5601, "total": 3.6263, "in_tokens": 1711, "label": "pf-gemma4-e2b-q4", "chars": 400, "rep": 3} +{"ok": true, "ttft": 6.939, "total": 7.0068, "in_tokens": 3205, "label": "pf-gemma4-e2b-q4", "chars": 4000, "rep": 2} +{"ok": true, "ttft": 5.7089, "total": 5.7766, "in_tokens": 2634, "label": "pf-gemma4-e2b-q4", "chars": 2400, "rep": 4} +{"ok": true, "ttft": 12.6991, "total": 12.7713, "in_tokens": 5498, "label": "pf-gemma4-e2b-q4", "chars": 9000, "rep": 3} +{"ok": true, "ttft": 4.4006, "total": 4.4673, "in_tokens": 2070, "label": "pf-gemma4-e2b-q4", "chars": 1200, "rep": 3} +{"ok": true, "ttft": 4.2301, "total": 4.297, "in_tokens": 2014, "label": "pf-gemma4-e2b-q4", "chars": 1200, "rep": 4} +{"ok": true, "ttft": 4.402, "total": 4.4684, "in_tokens": 2073, "label": "pf-gemma4-e2b-q4", "chars": 1200, "rep": 0} +{"ok": true, "ttft": 5.6687, "total": 5.735, "in_tokens": 2611, "label": "pf-gemma4-e2b-q4", "chars": 2400, "rep": 0} +{"ok": true, "ttft": 5.7122, "total": 5.7801, "in_tokens": 2647, "label": "pf-gemma4-e2b-q4", "chars": 2400, "rep": 2} +{"ok": true, "ttft": 4.4007, "total": 4.4687, "in_tokens": 2065, "label": "pf-gemma4-e2b-q4", "chars": 1200, "rep": 1} +{"ok": true, "ttft": 3.501, "total": 3.568, "in_tokens": 1679, "label": "pf-gemma4-e2b-q4", "chars": 400, "rep": 4} +{"ok": true, "ttft": 3.5166, "total": 3.5837, "in_tokens": 1687, "label": "pf-gemma4-e2b-q4", "chars": 400, "rep": 2} +{"ok": true, "ttft": 7.0445, "total": 7.1136, "in_tokens": 3226, "label": "pf-gemma4-e2b-q4", "chars": 4000, "rep": 0} +{"ok": true, "ttft": 5.3853, "total": 5.4529, "in_tokens": 2524, "label": "pf-gemma4-e2b-q4", "chars": 2400, "rep": 1} +{"ok": true, "ttft": 3.5165, "total": 3.5825, "in_tokens": 1688, "label": "pf-gemma4-e2b-q4", "chars": 400, "rep": 0} +{"ok": true, "ttft": 12.6973, "total": 12.7701, "in_tokens": 5493, "label": "pf-gemma4-e2b-q4", "chars": 9000, "rep": 1} +{"ok": true, "ttft": 7.2818, "total": 7.3501, "in_tokens": 3331, "label": "pf-gemma4-e2b-q4", "chars": 4000, "rep": 3} +{"ok": true, "ttft": 12.6964, "total": 12.7693, "in_tokens": 5487, "label": "pf-gemma4-e2b-q4", "chars": 9000, "rep": 0} +{"ok": true, "ttft": 5.5595, "total": 5.6293, "in_tokens": 2594, "label": "pf-gemma4-e2b-q4", "chars": 2400, "rep": 3} +{"ok": true, "ttft": 12.698, "total": 12.7678, "in_tokens": 5501, "label": "pf-gemma4-e2b-q4", "chars": 9000, "rep": 2} +{"ok": true, "ttft": 12.6376, "total": 12.7099, "in_tokens": 5477, "label": "pf-gemma4-e2b-q4", "chars": 9000, "rep": 4} +{"ok": true, "ttft": 9.2176, "total": 9.2867, "in_tokens": 4113, "label": "pf-gemma4-e2b-q4", "chars": 6000, "rep": 2} +{"ok": true, "ttft": 9.3194, "total": 9.3869, "in_tokens": 4164, "label": "pf-gemma4-e2b-q4", "chars": 6000, "rep": 1} +{"ok": true, "ttft": 9.1795, "total": 9.2471, "in_tokens": 4097, "label": "pf-gemma4-e2b-q4", "chars": 6000, "rep": 3} +{"ok": true, "ttft": 6.9984, "total": 7.0668, "in_tokens": 3217, "label": "pf-gemma4-e2b-q4", "chars": 4000, "rep": 1} +{"ok": true, "ttft": 9.2084, "total": 9.277, "in_tokens": 4101, "label": "pf-gemma4-e2b-q4", "chars": 6000, "rep": 4} +{"ok": true, "ttft": 3.589, "total": 3.655, "in_tokens": 1719, "label": "pf-gemma4-e2b-q4", "chars": 400, "rep": 1} +{"ok": true, "ttft": 7.3496, "total": 7.4183, "in_tokens": 3339, "label": "pf-gemma4-e2b-q4", "chars": 4000, "rep": 4} +{"ok": true, "ttft": 9.4761, "total": 9.5446, "in_tokens": 4240, "label": "pf-gemma4-e2b-q4", "chars": 6000, "rep": 0} +{"ok": true, "ttft": 4.3986, "total": 4.4663, "in_tokens": 2076, "label": "pf-gemma4-e2b-q4", "chars": 1200, "rep": 2} diff --git a/docs/research/thesis/prefill/pf-gemma4-e4b-q4.jsonl b/docs/research/thesis/prefill/pf-gemma4-e4b-q4.jsonl new file mode 100644 index 00000000..d04aeaed --- /dev/null +++ b/docs/research/thesis/prefill/pf-gemma4-e4b-q4.jsonl @@ -0,0 +1,30 @@ +{"ok": true, "ttft": 14.1573, "total": 14.2746, "in_tokens": 3339, "label": "pf-gemma4-e4b-q4", "chars": 4000, "rep": 4} +{"ok": true, "ttft": 6.9135, "total": 7.0275, "in_tokens": 1679, "label": "pf-gemma4-e4b-q4", "chars": 400, "rep": 4} +{"ok": true, "ttft": 17.5485, "total": 17.6665, "in_tokens": 4101, "label": "pf-gemma4-e4b-q4", "chars": 6000, "rep": 4} +{"ok": true, "ttft": 6.9472, "total": 7.0597, "in_tokens": 1687, "label": "pf-gemma4-e4b-q4", "chars": 400, "rep": 2} +{"ok": true, "ttft": 17.5563, "total": 17.6759, "in_tokens": 4113, "label": "pf-gemma4-e4b-q4", "chars": 6000, "rep": 2} +{"ok": true, "ttft": 23.7753, "total": 23.8945, "in_tokens": 5493, "label": "pf-gemma4-e4b-q4", "chars": 9000, "rep": 1} +{"ok": true, "ttft": 10.4659, "total": 10.5824, "in_tokens": 2524, "label": "pf-gemma4-e4b-q4", "chars": 2400, "rep": 1} +{"ok": true, "ttft": 7.0855, "total": 7.1987, "in_tokens": 1719, "label": "pf-gemma4-e4b-q4", "chars": 400, "rep": 1} +{"ok": true, "ttft": 23.7735, "total": 23.8915, "in_tokens": 5501, "label": "pf-gemma4-e4b-q4", "chars": 9000, "rep": 2} +{"ok": true, "ttft": 8.6308, "total": 8.7435, "in_tokens": 2070, "label": "pf-gemma4-e4b-q4", "chars": 1200, "rep": 3} +{"ok": true, "ttft": 17.5178, "total": 17.6357, "in_tokens": 4097, "label": "pf-gemma4-e4b-q4", "chars": 6000, "rep": 3} +{"ok": true, "ttft": 23.7813, "total": 23.8995, "in_tokens": 5487, "label": "pf-gemma4-e4b-q4", "chars": 9000, "rep": 0} +{"ok": true, "ttft": 18.0381, "total": 18.1571, "in_tokens": 4240, "label": "pf-gemma4-e4b-q4", "chars": 6000, "rep": 0} +{"ok": true, "ttft": 8.2826, "total": 8.3988, "in_tokens": 2014, "label": "pf-gemma4-e4b-q4", "chars": 1200, "rep": 4} +{"ok": true, "ttft": 11.0921, "total": 11.2084, "in_tokens": 2634, "label": "pf-gemma4-e4b-q4", "chars": 2400, "rep": 4} +{"ok": true, "ttft": 6.9476, "total": 7.0606, "in_tokens": 1688, "label": "pf-gemma4-e4b-q4", "chars": 400, "rep": 0} +{"ok": true, "ttft": 11.0414, "total": 11.1581, "in_tokens": 2611, "label": "pf-gemma4-e4b-q4", "chars": 2400, "rep": 0} +{"ok": true, "ttft": 8.6315, "total": 8.7457, "in_tokens": 2076, "label": "pf-gemma4-e4b-q4", "chars": 1200, "rep": 2} +{"ok": true, "ttft": 17.7727, "total": 17.8908, "in_tokens": 4164, "label": "pf-gemma4-e4b-q4", "chars": 6000, "rep": 1} +{"ok": true, "ttft": 13.5009, "total": 13.6183, "in_tokens": 3217, "label": "pf-gemma4-e4b-q4", "chars": 4000, "rep": 1} +{"ok": true, "ttft": 8.6342, "total": 8.7516, "in_tokens": 2073, "label": "pf-gemma4-e4b-q4", "chars": 1200, "rep": 0} +{"ok": true, "ttft": 14.0304, "total": 14.1466, "in_tokens": 3331, "label": "pf-gemma4-e4b-q4", "chars": 4000, "rep": 3} +{"ok": true, "ttft": 23.7743, "total": 23.8941, "in_tokens": 5498, "label": "pf-gemma4-e4b-q4", "chars": 9000, "rep": 3} +{"ok": true, "ttft": 8.6319, "total": 8.7475, "in_tokens": 2065, "label": "pf-gemma4-e4b-q4", "chars": 1200, "rep": 1} +{"ok": true, "ttft": 7.0821, "total": 7.1957, "in_tokens": 1711, "label": "pf-gemma4-e4b-q4", "chars": 400, "rep": 3} +{"ok": true, "ttft": 23.6829, "total": 23.8026, "in_tokens": 5477, "label": "pf-gemma4-e4b-q4", "chars": 9000, "rep": 4} +{"ok": true, "ttft": 13.4631, "total": 13.5806, "in_tokens": 3205, "label": "pf-gemma4-e4b-q4", "chars": 4000, "rep": 2} +{"ok": true, "ttft": 13.5898, "total": 13.7063, "in_tokens": 3226, "label": "pf-gemma4-e4b-q4", "chars": 4000, "rep": 0} +{"ok": true, "ttft": 10.8224, "total": 10.9399, "in_tokens": 2594, "label": "pf-gemma4-e4b-q4", "chars": 2400, "rep": 3} +{"ok": true, "ttft": 11.0941, "total": 11.2101, "in_tokens": 2647, "label": "pf-gemma4-e4b-q4", "chars": 2400, "rep": 2} diff --git a/docs/research/thesis/prefill/pf-qwen3-4b-q4.jsonl b/docs/research/thesis/prefill/pf-qwen3-4b-q4.jsonl new file mode 100644 index 00000000..2bd5f355 --- /dev/null +++ b/docs/research/thesis/prefill/pf-qwen3-4b-q4.jsonl @@ -0,0 +1,30 @@ +{"ok": true, "ttft": 15.1086, "total": 15.2334, "in_tokens": 3558, "label": "pf-qwen3-4b-q4", "chars": 4000, "rep": 0} +{"ok": true, "ttft": 7.3614, "total": 7.4745, "in_tokens": 1901, "label": "pf-qwen3-4b-q4", "chars": 400, "rep": 2} +{"ok": true, "ttft": 7.3347, "total": 7.4481, "in_tokens": 1883, "label": "pf-qwen3-4b-q4", "chars": 400, "rep": 4} +{"ok": true, "ttft": 16.0861, "total": 16.2137, "in_tokens": 3720, "label": "pf-qwen3-4b-q4", "chars": 4000, "rep": 3} +{"ok": true, "ttft": 11.4554, "total": 11.5751, "in_tokens": 2802, "label": "pf-qwen3-4b-q4", "chars": 2400, "rep": 1} +{"ok": true, "ttft": 20.5917, "total": 20.7263, "in_tokens": 4528, "label": "pf-qwen3-4b-q4", "chars": 6000, "rep": 4} +{"ok": true, "ttft": 30.4297, "total": 30.5777, "in_tokens": 6118, "label": "pf-qwen3-4b-q4", "chars": 9000, "rep": 2} +{"ok": true, "ttft": 9.0054, "total": 9.1215, "in_tokens": 2270, "label": "pf-qwen3-4b-q4", "chars": 1200, "rep": 4} +{"ok": true, "ttft": 9.2884, "total": 9.4071, "in_tokens": 2323, "label": "pf-qwen3-4b-q4", "chars": 1200, "rep": 0} +{"ok": true, "ttft": 30.2805, "total": 30.4312, "in_tokens": 6083, "label": "pf-qwen3-4b-q4", "chars": 9000, "rep": 3} +{"ok": true, "ttft": 21.2464, "total": 21.3876, "in_tokens": 4612, "label": "pf-qwen3-4b-q4", "chars": 6000, "rep": 1} +{"ok": true, "ttft": 30.6716, "total": 30.8202, "in_tokens": 6115, "label": "pf-qwen3-4b-q4", "chars": 9000, "rep": 0} +{"ok": true, "ttft": 30.5887, "total": 30.7373, "in_tokens": 6090, "label": "pf-qwen3-4b-q4", "chars": 9000, "rep": 4} +{"ok": true, "ttft": 15.4142, "total": 15.5414, "in_tokens": 3552, "label": "pf-qwen3-4b-q4", "chars": 4000, "rep": 1} +{"ok": true, "ttft": 21.0759, "total": 21.2127, "in_tokens": 4566, "label": "pf-qwen3-4b-q4", "chars": 6000, "rep": 2} +{"ok": true, "ttft": 7.4485, "total": 7.564, "in_tokens": 1894, "label": "pf-qwen3-4b-q4", "chars": 400, "rep": 0} +{"ok": true, "ttft": 12.4763, "total": 12.5988, "in_tokens": 2957, "label": "pf-qwen3-4b-q4", "chars": 2400, "rep": 2} +{"ok": true, "ttft": 12.4764, "total": 12.5992, "in_tokens": 2960, "label": "pf-qwen3-4b-q4", "chars": 2400, "rep": 4} +{"ok": true, "ttft": 30.3141, "total": 30.4622, "in_tokens": 6077, "label": "pf-qwen3-4b-q4", "chars": 9000, "rep": 1} +{"ok": true, "ttft": 12.1986, "total": 12.3212, "in_tokens": 2904, "label": "pf-qwen3-4b-q4", "chars": 2400, "rep": 0} +{"ok": true, "ttft": 16.2151, "total": 16.3446, "in_tokens": 3712, "label": "pf-qwen3-4b-q4", "chars": 4000, "rep": 4} +{"ok": true, "ttft": 9.3631, "total": 9.4838, "in_tokens": 2329, "label": "pf-qwen3-4b-q4", "chars": 1200, "rep": 2} +{"ok": true, "ttft": 21.0676, "total": 21.2033, "in_tokens": 4561, "label": "pf-qwen3-4b-q4", "chars": 6000, "rep": 3} +{"ok": true, "ttft": 7.6985, "total": 7.8131, "in_tokens": 1929, "label": "pf-qwen3-4b-q4", "chars": 400, "rep": 3} +{"ok": true, "ttft": 12.2397, "total": 12.362, "in_tokens": 2923, "label": "pf-qwen3-4b-q4", "chars": 2400, "rep": 3} +{"ok": true, "ttft": 9.361, "total": 9.4815, "in_tokens": 2317, "label": "pf-qwen3-4b-q4", "chars": 1200, "rep": 3} +{"ok": true, "ttft": 9.328, "total": 9.447, "in_tokens": 2307, "label": "pf-qwen3-4b-q4", "chars": 1200, "rep": 1} +{"ok": true, "ttft": 15.4124, "total": 15.5396, "in_tokens": 3530, "label": "pf-qwen3-4b-q4", "chars": 4000, "rep": 2} +{"ok": true, "ttft": 22.0893, "total": 22.2284, "in_tokens": 4740, "label": "pf-qwen3-4b-q4", "chars": 6000, "rep": 0} +{"ok": true, "ttft": 7.6982, "total": 7.8138, "in_tokens": 1929, "label": "pf-qwen3-4b-q4", "chars": 400, "rep": 1} diff --git a/docs/research/thesis/quantization-study.md b/docs/research/thesis/quantization-study.md index 80924266..6df18f03 100644 --- a/docs/research/thesis/quantization-study.md +++ b/docs/research/thesis/quantization-study.md @@ -94,6 +94,24 @@ F16 원본이 각 토큰에 매긴 확률 분포를 기준으로 삼고, 양자 E4B 도 8비트에서 3비트까지 평평하다. **26B-A4B 는 3비트와 2비트 사이에서도 무너지지 않는다.** 점수 라벨이 96.3% 로 같고 의도 분류만 95.0% 대 92.5% 로 한 건 차이다. 4B 가 2비트에서 완전히 붕괴한 것과 대비된다. 파라미터가 많을수록 낮은 비트를 견딘다는 기존 관찰이 한국어 면접 과제에서도 확인된 것이며, 실무적으로는 **같은 메모리 예산에서 큰 모델을 더 강하게 줄이는 선택이 두 배로 유리하다**는 뜻이다. 다만 26B 는 이 GPU 에서 전문가 계층을 CPU 로 내보내야 하므로 지연이 8초대로 고정된다. 26B 조건은 이틀에 걸쳐 두 번 독립적으로 돌렸는데 점수 라벨 96.3%, 지연 중앙값 7.98초와 8.00초로 거의 같은 값이 나왔다(사실대조만 96.2% 대 94.2%). 케이스 70건·반복 1회 설계의 측정 잡음이 이 정도 범위임을 보여준다. 주목할 점은 **26B 를 2비트로 줄인 쪽이 4B 를 8비트로 쓰는 쪽보다 모든 품질 지표에서 낫다**는 것이다(점수 라벨 96.3% 대 88.9%, 카테고리 적합 100% 대 86.7%). 파일 크기는 10.0 GB 대 8.2 GB 로 비슷하다. 다만 지연은 2.3배로 늘어난다. +### 3.3 신뢰구간 — 표가 구분할 수 있는 것과 없는 것 + +케이스가 과제당 40건(점수 라벨은 채점 대상 27건)이므로 비율 지표의 신뢰구간은 넓다. Wilson 95% 구간으로 보면 이렇다. + +| 조건 | 답변 의도 (n=40) | 점수 라벨 (n=27) | +|---|---|---| +| Qwen3 4B Q8_0 | 92.5% [80.1, 97.4] | 70.4% [51.5, 84.1] | +| Qwen3 4B Q4_K_M | 97.5% [87.1, 99.6] | 70.4% [51.5, 84.1] | +| Qwen3 4B Q3_K_M | 92.5% [80.1, 97.4] | 70.4% [51.5, 84.1] | +| Qwen3 4B 보정 Q2 | 87.5% [73.9, 94.5] | 66.7% [47.8, 81.4] | +| Qwen3 4B 무보정 Q2 | **67.5% [52.0, 79.9]** | 51.9% [34.0, 69.3] | +| Gemma E4B Q8_0 | 97.5% [87.1, 99.6] | 88.9% [71.9, 96.1] | +| Gemma E4B Q3_K_M | 92.5% [80.1, 97.4] | 88.9% [71.9, 96.1] | +| Gemma 26B IQ3_S | 95.0% [83.5, 98.6] | 96.3% [81.7, 99.3] | +| Gemma 26B IQ2_M | 92.5% [80.1, 97.4] | 96.3% [81.7, 99.3] | + +**이 표가 확실히 말하는 것**은 무보정 2비트가 8비트보다 나쁘다는 것뿐이다(구간이 겹치지 않는다). 8비트와 3비트 사이의 1~2건 차이는 구간이 거의 완전히 겹쳐 구분되지 않는다. 즉 **"3비트까지 지표가 같다"는 주장은 이 표만으로는 "같다"가 아니라 "다름을 보이지 못했다"에 머문다.** 7-1절의 동등성 검정이 이 간극을 메운다. + --- ## 4. 결과 C — 원본과의 차이로 과제 품질을 예측할 수 있는가 @@ -182,13 +200,39 @@ Qwen3 4B 10종에 대해 평균 KL 과 각 지표의 순위 상관(스피어만 - **Gemma E4B 는 8비트가 3비트보다 0.50점 높게 나왔다**(p = 0.155). 케이스 12건으로는 유의하지 않지만, 규칙 지표가 완전히 같았던 두 조건 사이에 판정자만 감지한 차이라는 점에서 주목할 만하다. 40케이스 전체로 확인이 필요하다. - 같은 계열 편향도 재현됐다. Google 판정자는 Gemma 후보에 다른 계열 판정자보다 높은 점수를 줬다(Q8_0 에서 전체 평균 3.64 대 계열 제외 3.56). -**남은 작업**: 채점 크레딧을 다시 확보하면 같은 설계로 나머지 28케이스(163회 호출)를 이어 돌린다. 원자료는 `quant/judge-pilot.jsonl`. +### 동등성 검정 — "차이가 없다"를 제대로 주장하기 + +비유의(p > .05)는 "차이가 없다"의 근거가 되지 못한다. 차이가 실질적 경계 안에 있음을 보이는 동등성 검정(TOST)을 했다. 경계는 두 가지로 잡았다. + +- **0.43점** — 이 데이터에서 측정한 판정자 같은 계열 편향의 크기. "판정자 자체의 잡음보다 작은 차이"라는 뜻이다. +- **1.0점** — 5점 척도에서 한 등급. "사람이 등급을 다르게 매길 만한 차이"라는 뜻이다. + +| 비교 | 평균차 | 90% 신뢰구간 | 경계 1.0 | 경계 0.43 | +|---|---|---|---|---| +| Qwen3 4B 8비트 대 5비트 | −0.25 | [−0.76, +0.26] | **등가** (p = .012) | 판정 불가 | +| Qwen3 4B 8비트 대 4비트 | −0.06 | [−0.38, +0.27] | **등가** (p < .001) | **등가** (p = .032) | +| Qwen3 4B 8비트 대 3비트 | −0.01 | [−0.61, +0.58] | **등가** (p = .007) | 판정 불가 | +| Gemma E4B 8비트 대 3비트 | +0.50 | [−0.03, +1.03] | 판정 불가 (p = .060) | 판정 불가 | + +**읽는 법**: Qwen3 4B 는 8비트에서 3비트까지 내려도 품질 차이가 **한 등급 미만임이 통계적으로 확인됐다.** 다만 "판정자 잡음보다도 작다"까지는 12케이스로 말할 수 없다. Gemma E4B 는 반대로 8비트 우위 가능성이 남아 있다. + +필요 표본을 계산하면 이렇다. + +| 비교 | 관측 표준편차 | 경계 0.43 확인에 필요 | 경계 1.0 확인에 필요 | +|---|---|---|---| +| Qwen3 4B 8 대 3비트 | 1.15 | 48케이스 | 9케이스 (충족) | +| Qwen3 4B 8 대 5비트 | 0.99 | 187케이스 | 11케이스 (충족) | +| Gemma E4B 8 대 3비트 | 1.03 | 불가 (관측차가 경계 초과) | 27케이스 | + +계획했던 40케이스를 모두 채우면 Qwen3 의 3비트 동등성은 엄격한 경계(48케이스 필요)에 근접하고, Gemma 의 8비트 우위는 판정 가능(27케이스)해진다. **나머지 28케이스를 채우는 것이 이 절의 결론을 바꾼다.** + +**남은 작업**: 채점 크레딧을 다시 확보하면 같은 설계로 나머지 28케이스(163회 호출)를 이어 돌린다. 원자료는 `quant/judge-pilot.jsonl`, 도구는 `stats.py` 의 `tost_paired`. --- ## 8. 한계 -- 판정자 평가가 12케이스에서 멈췄다(7-1절). 본 분석의 주 결론은 자동 규칙 지표에 근거하며, 같은 형식을 지키면서 질문의 깊이만 떨어지는 변화는 규칙으로 잡히지 않는다. +- 판정자 평가가 12케이스에서 멈췄다(7-1절). 동등성 검정 기준 표본이 부족하다. 본 분석의 주 결론은 자동 규칙 지표에 근거하며, 같은 형식을 지키면서 질문의 깊이만 떨어지는 변화는 규칙으로 잡히지 않는다. - 케이스는 과제당 15~40건, 반복 1회다. 1~2건 차이는 해석하지 않았다. - 분포 차이 측정은 Qwen3 4B 계열만 했다. Gemma 계열은 과제 지표로만 확인했다. - 보정 텍스트가 이 프로젝트 문서에서 왔으므로, 평가 케이스와 주제가 겹칠 여지가 있다. 평가 텍스트는 분리했지만 도메인은 같다.