26S30n

QK·2일 전
#!/usr/bin/env bash
# ==========================================================================
# STAGE 3a-8: Prefix Caching True A/B Test
#
# 목적:
#   동일한 32K prompt를 반복 요청하여 Prefix Caching의 순수 효과 측정
#
# Workload:
#   Total input      = 32,768 tokens
#   Fixed prefix     = 32,000 tokens
#   Random suffix    =    768 tokens
#   Output           =    256 tokens
#   Requests         =    100 / run
#   Concurrency      =      8
#
# 핵심:
#   random-prefix-len은 "전체 input 중 prefix 길이"가 아니라
#   random context 앞에 추가되는 fixed prefix이므로,
#
#       random-input-len = 768
#       random-prefix-len = 32000
#
#   으로 설정하여 총 input = 32000 + 768 = 32768로 맞춘다.
#
#   vLLM metrics:
#     vllm:prefix_cache_queries
#     vllm:prefix_cache_hits
#     vllm:prompt_tokens_cached
#
#   를 함께 수집하여 실제 cache hit 여부를 검증한다.
# ==========================================================================

set -euo pipefail

# --------------------------------------------------------------------------
# Offline environment
# --------------------------------------------------------------------------
export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export HF_DATASETS_OFFLINE=1

# --------------------------------------------------------------------------
# Model
# --------------------------------------------------------------------------
LOCAL_MODEL_ROOT="${LOCAL_MODEL_ROOT:-/mnt/local-nvme-cache/models}"
MODEL_70B="${MODEL_70B:-${LOCAL_MODEL_ROOT}/Meta-Llama-3.1-70B-Instruct}"

[ -d "${MODEL_70B}" ] || {
    echo "모델 없음: ${MODEL_70B}"
    exit 1
}

# --------------------------------------------------------------------------
# vLLM
# --------------------------------------------------------------------------
TP_SIZE="${TP_SIZE:-8}"
VLLM_PORT="${VLLM_PORT:-8000}"
SERVER_HOST="${SERVER_HOST:-127.0.0.1}"

# --------------------------------------------------------------------------
# Benchmark workload
# --------------------------------------------------------------------------
TOTAL_INPUT_LEN=32768
PREFIX_LEN=32000
RANDOM_SUFFIX_LEN=$((TOTAL_INPUT_LEN - PREFIX_LEN))
OUTPUT_LEN=256

NUM_PROMPTS="${NUM_PROMPTS:-100}"
MAX_CONCURRENCY="${MAX_CONCURRENCY:-8}"

# 동일한 synthetic dataset 생성
BENCH_SEED="${BENCH_SEED:-12345}"

# 각 조건 독립 반복 횟수
REPEATS="${REPEATS:-2}"

# --------------------------------------------------------------------------
# Logging
# --------------------------------------------------------------------------
LOG_DIR="/var/log/b300_validation/stage3a8_prefixab_$(date +%Y%m%d_%H%M%S)"
mkdir -p "${LOG_DIR}"

log() {
    echo -e "\n\033[1;32m[$(date '+%Y-%m-%d %H:%M:%S')] $1\033[0m"
}

warn() {
    echo -e "\033[1;33mWARNING: $1\033[0m"
}

source ~/vllm-bench-env/bin/activate

# --------------------------------------------------------------------------
# Cleanup
# --------------------------------------------------------------------------
SERVER_PID=""

cleanup_server() {
    if [[ -n "${SERVER_PID}" ]]; then
        kill -TERM "${SERVER_PID}" 2>/dev/null || true
        wait "${SERVER_PID}" 2>/dev/null || true
        SERVER_PID=""
    fi
}

trap cleanup_server EXIT INT TERM

# --------------------------------------------------------------------------
# Wait for server
# --------------------------------------------------------------------------
wait_server() {
    local timeout="${1:-600}"
    local elapsed=0

    log "vLLM server 준비 대기..."

    until curl -sf \
        "http://${SERVER_HOST}:${VLLM_PORT}/v1/models" \
        >/dev/null 2>&1
    do
        if [[ -n "${SERVER_PID}" ]]; then
            kill -0 "${SERVER_PID}" 2>/dev/null || {
                echo "vLLM 서버 기동 실패"
                return 1
            }
        fi

        sleep 5
        elapsed=$((elapsed + 5))

        if (( elapsed >= timeout )); then
            echo "vLLM server readiness timeout (${timeout}s)"
            return 1
        fi
    done

    log "vLLM server READY (${elapsed}s)"
}

# --------------------------------------------------------------------------
# Collect Prometheus metrics
# --------------------------------------------------------------------------
collect_metrics() {
    local output_file="$1"

    curl -sf \
        "http://${SERVER_HOST}:${VLLM_PORT}/metrics" \
        > "${output_file}" || {
            warn "Prometheus metrics 수집 실패: ${output_file}"
            touch "${output_file}"
        }
}

# --------------------------------------------------------------------------
# Extract a counter from Prometheus text
# Handles optional labels.
# --------------------------------------------------------------------------
metric_value() {
    local file="$1"
    local metric="$2"

    awk -v metric="${metric}" '
        $1 ~ ("^" metric "($|\\{)") {
            print $NF
            exit
        }
    ' "${file}" 2>/dev/null || true
}

# --------------------------------------------------------------------------
# Start server
# --------------------------------------------------------------------------
start_server() {
    local tag="$1"
    local cache_flag="$2"
    local server_log="${LOG_DIR}/server_${tag}.log"

    log "============================================================"
    log "서버 기동: ${tag}"
    log "Prefix caching: ${cache_flag}"
    log "============================================================"

    python3 -m vllm.entrypoints.openai.api_server \
        --model "${MODEL_70B}" \
        --served-model-name "llama-3.1-70b" \
        --tensor-parallel-size "${TP_SIZE}" \
        --gpu-memory-utilization 0.90 \
        --max-model-len 65536 \
        --enable-metrics \
        ${cache_flag} \
        --port "${VLLM_PORT}" \
        > "${server_log}" 2>&1 &

    SERVER_PID=$!

    wait_server 900
}

# --------------------------------------------------------------------------
# Warm-up
#
# IMPORTANT:
# random dataset is deterministic for a fixed seed.
#
# Warm-up uses exactly the same seed as the measured run so that the first
# generated prompt is the same fixed-prefix workload.
# --------------------------------------------------------------------------
warmup() {
    local tag="$1"

    log "[${tag}] Prefix cache warm-up"

    vllm bench serve \
        --backend openai-chat \
        --endpoint /v1/chat/completions \
        --host "${SERVER_HOST}" \
        --port "${VLLM_PORT}" \
        --model "llama-3.1-70b" \
        --tokenizer "${MODEL_70B}" \
        --dataset-name random \
        --random-input-len "${RANDOM_SUFFIX_LEN}" \
        --random-output-len "${OUTPUT_LEN}" \
        --random-prefix-len "${PREFIX_LEN}" \
        --random-range-ratio 0 \
        --num-prompts 1 \
        --max-concurrency 1 \
        --request-rate inf \
        --seed "${BENCH_SEED}" \
        --ignore-eos \
        > "${LOG_DIR}/warmup_${tag}.log" 2>&1 || true

    sleep 2
}

# --------------------------------------------------------------------------
# Run benchmark
# --------------------------------------------------------------------------
run_benchmark() {
    local tag="$1"

    local result_file="${LOG_DIR}/${tag}.json"
    local bench_log="${LOG_DIR}/${tag}.log"

    log "[${tag}] Benchmark 시작"
    log "  total input      : ${TOTAL_INPUT_LEN}"
    log "  fixed prefix     : ${PREFIX_LEN}"
    log "  random suffix    : ${RANDOM_SUFFIX_LEN}"
    log "  output           : ${OUTPUT_LEN}"
    log "  requests         : ${NUM_PROMPTS}"
    log "  concurrency      : ${MAX_CONCURRENCY}"

    vllm bench serve \
        --backend openai-chat \
        --endpoint /v1/chat/completions \
        --host "${SERVER_HOST}" \
        --port "${VLLM_PORT}" \
        --model "llama-3.1-70b" \
        --tokenizer "${MODEL_70B}" \
        --dataset-name random \
        --random-input-len "${RANDOM_SUFFIX_LEN}" \
        --random-output-len "${OUTPUT_LEN}" \
        --random-prefix-len "${PREFIX_LEN}" \
        --random-range-ratio 0 \
        --num-prompts "${NUM_PROMPTS}" \
        --max-concurrency "${MAX_CONCURRENCY}" \
        --request-rate inf \
        --seed "${BENCH_SEED}" \
        --ignore-eos \
        --save-result \
        --result-dir "${LOG_DIR}" \
        --result-filename "$(basename "${result_file}")" \
        2>&1 | tee "${bench_log}"

    # vLLM bench may generate a different result path/name depending on
    # installed version. Verify existence.
    if [[ ! -f "${result_file}" ]]; then
        warn "Expected result file not found: ${result_file}"
    fi
}

# --------------------------------------------------------------------------
# Run one complete case
# --------------------------------------------------------------------------
run_case() {
    local condition="$1"
    local cache_flag="$2"
    local repeat="$3"

    local tag="${condition}_r${repeat}"

    start_server "${tag}" "${cache_flag}"

    # Initial metrics
    collect_metrics "${LOG_DIR}/metrics_${tag}_before.txt"

    # Warm-up
    warmup "${tag}"

    # Metrics after warm-up / before measured workload
    collect_metrics "${LOG_DIR}/metrics_${tag}_warmup.txt"

    # Main benchmark
    run_benchmark "${tag}"

    # Final metrics
    collect_metrics "${LOG_DIR}/metrics_${tag}_after.txt"

    # Extract cache metrics
    local q0 q1 h0 h1 c0 c1

    q0=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_warmup.txt" \
        "vllm:prefix_cache_queries")
    q1=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_after.txt" \
        "vllm:prefix_cache_queries")

    h0=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_warmup.txt" \
        "vllm:prefix_cache_hits")
    h1=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_after.txt" \
        "vllm:prefix_cache_hits")

    c0=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_warmup.txt" \
        "vllm:prompt_tokens_cached")
    c1=$(metric_value \
        "${LOG_DIR}/metrics_${tag}_after.txt" \
        "vllm:prompt_tokens_cached")

    python3 - "${tag}" \
        "${q0:-0}" "${q1:-0}" \
        "${h0:-0}" "${h1:-0}" \
        "${c0:-0}" "${c1:-0}" \
        > "${LOG_DIR}/metrics_${tag}.json" <<'PYEOF'
import json
import sys

tag = sys.argv[1]

q0, q1 = float(sys.argv[2]), float(sys.argv[3])
h0, h1 = float(sys.argv[4]), float(sys.argv[5])
c0, c1 = float(sys.argv[6]), float(sys.argv[7])

queries = max(0, q1 - q0)
hits = max(0, h1 - h0)
cached_tokens = max(0, c1 - c0)

hit_rate = hits / queries if queries > 0 else 0.0

print(json.dumps({
    "tag": tag,
    "prefix_cache_queries": queries,
    "prefix_cache_hits": hits,
    "prefix_cache_hit_rate": hit_rate,
    "prompt_tokens_cached": cached_tokens,
}, indent=2))
PYEOF

    log "[${tag}] Metrics:"
    cat "${LOG_DIR}/metrics_${tag}.json"

    cleanup_server
    sleep 5
}

# ==========================================================================
# Main
# ==========================================================================

log "============================================================"
log "STAGE 3a-8 Prefix Caching A/B Test"
log "============================================================"

log "Workload:"
log "  Total input tokens : ${TOTAL_INPUT_LEN}"
log "  Fixed prefix       : ${PREFIX_LEN}"
log "  Random suffix      : ${RANDOM_SUFFIX_LEN}"
log "  Output tokens      : ${OUTPUT_LEN}"
log "  Requests           : ${NUM_PROMPTS}"
log "  Concurrency        : ${MAX_CONCURRENCY}"
log "  Seed               : ${BENCH_SEED}"
log "  Repeats            : ${REPEATS}"

# Sanity check
if (( PREFIX_LEN + RANDOM_SUFFIX_LEN != TOTAL_INPUT_LEN )); then
    echo "ERROR: input length mismatch"
    exit 1
fi

# --------------------------------------------------------------------------
# Run A/B
# --------------------------------------------------------------------------

for repeat in $(seq 1 "${REPEATS}"); do
    run_case "prefix_cache_on" "--enable-prefix-caching" "${repeat}"
    run_case "prefix_cache_off" "--no-enable-prefix-caching" "${repeat}"
done

# ==========================================================================
# Result analysis
# ==========================================================================

log "=== 결과 분석 ==="

python3 - "${LOG_DIR}" <<'PYEOF'

import glob
import json
import os
import statistics
import re
import sys

log_dir = sys.argv[1]

def load_json(path):
    try:
        with open(path) as f:
            return json.load(f)
    except Exception:
        return {}

def find_result(tag):
    candidates = [
        os.path.join(log_dir, f"{tag}.json"),
    ]

    for path in candidates:
        if os.path.exists(path):
            return load_json(path)

    return {}

def find_metric(tag):
    path = os.path.join(log_dir, f"metrics_{tag}.json")
    return load_json(path)

def get_num(data, *keys):
    for key in keys:
        value = data.get(key)
        if isinstance(value, (int, float)):
            return float(value)
    return None

# ----------------------------------------------------------------------
# Extract benchmark results
# ----------------------------------------------------------------------

on_results = []
off_results = []

for path in sorted(glob.glob(os.path.join(log_dir, "prefix_cache_on_r*.json"))):
    data = load_json(path)
    if data:
        on_results.append(data)

for path in sorted(glob.glob(os.path.join(log_dir, "prefix_cache_off_r*.json"))):
    data = load_json(path)
    if data:
        off_results.append(data)

def summarize(results):
    fields = [
        "median_ttft_ms",
        "p95_ttft_ms",
        "p99_ttft_ms",
        "mean_ttft_ms",
        "median_tpot_ms",
        "p95_tpot_ms",
        "request_throughput",
        "output_throughput",
        "total_token_throughput",
    ]

    out = {}

    for field in fields:
        values = [
            get_num(x, field)
            for x in results
            if get_num(x, field) is not None
        ]

        if values:
            out[field] = statistics.mean(values)

    return out

on = summarize(on_results)
off = summarize(off_results)

# ----------------------------------------------------------------------
# Prefix cache metrics
# ----------------------------------------------------------------------

on_cache = []

for path in sorted(glob.glob(
    os.path.join(log_dir, "metrics_prefix_cache_on_r*.json")
)):
    data = load_json(path)
    if data:
        on_cache.append(data)

off_cache = []

for path in sorted(glob.glob(
    os.path.join(log_dir, "metrics_prefix_cache_off_r*.json")
)):
    data = load_json(path)
    if data:
        off_cache.append(data)

def avg_metric(items, key):
    values = [
        float(x[key])
        for x in items
        if key in x
    ]
    return statistics.mean(values) if values else 0.0

on_hit_rate = avg_metric(on_cache, "prefix_cache_hit_rate")
on_cached_tokens = avg_metric(on_cache, "prompt_tokens_cached")

off_hit_rate = avg_metric(off_cache, "prefix_cache_hit_rate")
off_cached_tokens = avg_metric(off_cache, "prompt_tokens_cached")

# ----------------------------------------------------------------------
# Speedup
# ----------------------------------------------------------------------

off_ttft = off.get("median_ttft_ms", 0)
on_ttft = on.get("median_ttft_ms", 0)

speedup = (
    off_ttft / on_ttft
    if on_ttft and on_ttft > 0
    else 0
)

improvement_pct = (
    (1 - on_ttft / off_ttft) * 100
    if off_ttft and off_ttft > 0
    else 0
)

# ----------------------------------------------------------------------
# Interpretation
# ----------------------------------------------------------------------

if speedup >= 8:
    interpretation = "매우 큰 Prefix Caching 효과"
elif speedup >= 4:
    interpretation = "큰 Prefix Caching 효과"
elif speedup >= 2:
    interpretation = "명확한 Prefix Caching 효과"
elif speedup >= 1.5:
    interpretation = "효과가 있으나 상대적으로 제한적"
else:
    interpretation = "효과가 작음 — cache hit 및 workload 조건 재확인 필요"

# ----------------------------------------------------------------------
# Markdown report
# ----------------------------------------------------------------------

report = f"""# Stage 3a-8 Prefix Caching A/B 결과

## Workload

| 항목 | 값 |
|---|---:|
| Total input | 32,768 tokens |
| Fixed prefix | 32,000 tokens |
| Random suffix | 768 tokens |
| Output | 256 tokens |
| Requests / run | {os.environ.get("NUM_PROMPTS", "100")} |
| Concurrency | {os.environ.get("MAX_CONCURRENCY", "8")} |
| Repeats | {os.environ.get("REPEATS", "2")} |
| Seed | {os.environ.get("BENCH_SEED", "12345")} |

> Prefix = 32,000 tokens + random suffix = 768 tokens,
> therefore total input = 32,768 tokens.

## TTFT

| Metric | Cache OFF | Cache ON | 결과 |
|---|---:|---:|---:|
| Median TTFT | {off.get("median_ttft_ms", 0):.1f} ms | {on.get("median_ttft_ms", 0):.1f} ms | {speedup:.2f}x |
| P95 TTFT | {off.get("p95_ttft_ms", 0):.1f} ms | {on.get("p95_ttft_ms", 0):.1f} ms | - |
| P99 TTFT | {off.get("p99_ttft_ms", 0):.1f} ms | {on.get("p99_ttft_ms", 0):.1f} ms | - |

### Median TTFT improvement

**{improvement_pct:.1f}%**

**Speedup: {speedup:.2f}x**

## Prefix Cache Metrics

| Metric | Cache OFF | Cache ON |
|---|---:|---:|
| Prefix cache hit rate | {off_hit_rate:.2%} | {on_hit_rate:.2%} |
| Prompt tokens cached | {off_cached_tokens:,.0f} | {on_cached_tokens:,.0f} |

## Other Performance Metrics

| Metric | Cache OFF | Cache ON |
|---|---:|---:|
| Median TPOT | {off.get("median_tpot_ms", 0):.2f} ms | {on.get("median_tpot_ms", 0):.2f} ms |
| Request throughput | {off.get("request_throughput", 0):.2f} req/s | {on.get("request_throughput", 0):.2f} req/s |
| Output throughput | {off.get("output_throughput", 0):.2f} tok/s | {on.get("output_throughput", 0):.2f} tok/s |
| Total token throughput | {off.get("total_token_throughput", 0):.2f} tok/s | {on.get("total_token_throughput", 0):.2f} tok/s |

## Interpretation

**{interpretation}**

Reference interpretation for this POC:

- `< 1.5x` : 효과가 작음
- `1.5x ~ 2x` : 제한적인 효과
- `2x ~ 4x` : 명확한 효과
- `4x ~ 8x` : 큰 효과
- `>= 8x` : 매우 큰 효과

These are **POC interpretation thresholds**, not official vLLM pass/fail criteria.

## Validation Criteria

### Functional

- [ ] 동일한 32K fixed prefix가 모든 request에서 사용됨
- [ ] Cache ON에서 prefix cache hit가 관찰됨
- [ ] Cache OFF에서는 prefix cache hit가 발생하지 않음
- [ ] Cache ON/OFF의 output length가 동일함
- [ ] Request failure가 없어야 함

### Performance

- [ ] Median TTFT 감소
- [ ] P95/P99 TTFT도 악화되지 않는지 확인
- [ ] Prefix cache hit와 TTFT 개선이 함께 관찰되는지 확인
- [ ] TPOT/ITL이 불필요하게 악화되지 않는지 확인

## Important Note

이 테스트는 **32K fixed-prefix 반복이라는 Prefix Caching에 매우 유리한 synthetic workload**이다.

따라서 이 결과를 실제 서비스의 예상 성능 향상률로 직접 해석해서는 안 된다.

실제 workload 효과는 다음 단계에서:

- Prefix length: 8K / 16K / 32K
- Prefix reuse ratio: 25% / 50% / 75% / 100%
- Concurrency: 1 / 2 / 4 / 8 / 16

등으로 추가 검증하는 것이 적절하다.
"""

report_path = os.path.join(
    log_dir,
    "prefix_cache_ab_report.md"
)

with open(report_path, "w") as f:
    f.write(report)

print(report)
print()
print(f"Report: {report_path}")

PYEOF

log "============================================================"
log "Prefix Caching A/B 완료"
log "Report: ${LOG_DIR}/prefix_cache_ab_report.md"
log "============================================================"
profile
engineer

0개의 댓글