26O14m

QK·3일 전
#!/usr/bin/env bash
# ==========================================================================
# STAGE 3a-3 (신규): 혼합(Mixed) 워크로드 테스트
# 목적: 짧은 챗(1k in/512 out)과 긴 RAG형(32k in/512 out) 요청이 "동시에" 같은
#       서버로 들어올 때, 긴 프리필이 짧은 요청의 디코딩을 막는 Head-of-line
#       blocking이 얼마나 발생하는지 확인. stage3a의 "격리 테스트" 결과와
#       비교해서 짧은 요청의 TTFT/ITL이 얼마나 나빠지는지가 핵심 지표.
# 사전조건: stage3a2 결과로 정한 최적 서버 설정값을 아래 SERVER_EXTRA_ARGS에 반영 권장
# ==========================================================================
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "${VALIDATION_ENV_FILE:-${SCRIPT_DIR}/validation_env.sh}"

export HF_HUB_OFFLINE=1
export TRANSFORMERS_OFFLINE=1
export HF_DATASETS_OFFLINE=1

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; }

# stage3a2 결과 반영 지점 (기본값은 3a와 동일 보수적 설정)
SERVER_EXTRA_ARGS="${SERVER_EXTRA_ARGS:---gpu-memory-utilization 0.90 --enable-chunked-prefill}"

TP_SIZE=8
VLLM_PORT=8000
SERVER_HOST="127.0.0.1"
# 콜드스타트(CUDA 그래프 캡처/커널 컴파일 등, prefill=TTFT에 집중적으로 영향)가 "가장 먼저
# 실행되는 베이스라인(isolated_short)"에 전부 몰려서 기준선을 왜곡하는 문제를 막기 위한 워밍업.
NUM_WARMUPS=0  # legacy compatibility; explicit workload warm-up 사용

# v13.1 Stage3a3 optional stability controls.
# Enhanced crossover defaults: 5 total rounds = 1 discard + 4 balanced retained rounds.
# Set STAGE3A3_CROSSOVER_ENABLED=0 and/or STAGE3A3_CONVERGENCE_WARMUP_ENABLED=0 to disable.
STAGE3A3_CROSSOVER_ENABLED="${STAGE3A3_CROSSOVER_ENABLED:-1}"
STAGE3A3_CROSSOVER_ROUNDS="${STAGE3A3_CROSSOVER_ROUNDS:-5}"
STAGE3A3_CROSSOVER_DISCARD_ROUNDS="${STAGE3A3_CROSSOVER_DISCARD_ROUNDS:-1}"
STAGE3A3_ORDER_EFFECT_WARN_PCT="${STAGE3A3_ORDER_EFFECT_WARN_PCT:-10}"
STAGE3A3_MIN_RETAINED_ROUNDS="${STAGE3A3_MIN_RETAINED_ROUNDS:-4}"
STAGE3A3_MIN_DIRECTION_CONSISTENCY_PCT="${STAGE3A3_MIN_DIRECTION_CONSISTENCY_PCT:-75}"
STAGE3A3_CONVERGENCE_SCOPE="${STAGE3A3_CONVERGENCE_SCOPE:-initial}"
STAGE3A3_CONVERGENCE_WARMUP_ENABLED="${STAGE3A3_CONVERGENCE_WARMUP_ENABLED:-1}"
STAGE3A3_WARMUP_WINDOW_REQUESTS="${STAGE3A3_WARMUP_WINDOW_REQUESTS:-20}"
STAGE3A3_WARMUP_MIN_WINDOWS="${STAGE3A3_WARMUP_MIN_WINDOWS:-3}"
STAGE3A3_WARMUP_MAX_WINDOWS="${STAGE3A3_WARMUP_MAX_WINDOWS:-10}"
STAGE3A3_WARMUP_CONVERGENCE_PCT="${STAGE3A3_WARMUP_CONVERGENCE_PCT:-3}"
STAGE3A3_WARMUP_TTFT_CONVERGENCE_PCT="${STAGE3A3_WARMUP_TTFT_CONVERGENCE_PCT:-5}"
STAGE3A3_WARMUP_CONSECUTIVE_WINDOWS="${STAGE3A3_WARMUP_CONSECUTIVE_WINDOWS:-2}"
STAGE3A3_DIAG_METRICS_INTERVAL="${STAGE3A3_DIAG_METRICS_INTERVAL:-1}"
STAGE3A3_DIAG_KV_HIGH_PCT="${STAGE3A3_DIAG_KV_HIGH_PCT:-85}"
STAGE3A3_DIAG_WAITING_THRESHOLD="${STAGE3A3_DIAG_WAITING_THRESHOLD:-1}"
if [[ "${STAGE3A3_CROSSOVER_ENABLED}" != "1" ]]; then
  echo "ERROR: crossover must be enabled for an order-balanced qualification (STAGE3A3_CROSSOVER_ENABLED=1)" >&2
  exit 2
fi
if (( STAGE3A3_CROSSOVER_ROUNDS - STAGE3A3_CROSSOVER_DISCARD_ROUNDS < 4 )); then
  echo "ERROR: at least four retained crossover rounds are required" >&2; exit 2
fi
LOG_DIR="${VALIDATION_ROOT}/stage3a3_mixed_$(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"; }
validation_activate_venv
source "${SCRIPT_DIR}/stage3a_warmup_lib.sh"
log "vLLM 버전: $(python3 -c 'import vllm; print(vllm.__version__)' 2>/dev/null || echo '확인 불가')"

log "vLLM 서버 기동 (혼합 워크로드용, extra=[${SERVER_EXTRA_ARGS}])"
python3 -m vllm.entrypoints.openai.api_server \
    --model "${MODEL_70B}" --served-model-name "llama-3.1-70b" \
    --tensor-parallel-size "${TP_SIZE}" --max-model-len 65536 \
    --port "${VLLM_PORT}" ${SERVER_EXTRA_ARGS} \
    > "${LOG_DIR}/server.log" 2>&1 &
SERVER_PID=$!
until curl -s "http://${SERVER_HOST}:${VLLM_PORT}/v1/models" > /dev/null 2>&1; do
    kill -0 "${SERVER_PID}" 2>/dev/null || { echo "서버 기동 실패: ${LOG_DIR}/server.log"; exit 1; }
    sleep 5
done
log "서버 준비 완료"
stage3a_engine_warmup "${SERVER_HOST}" "${VLLM_PORT}" "llama-3.1-70b" "${MODEL_70B}" "mixed"

# --- 서버 /metrics의 TTFT/ITL 히스토그램에서 P50/P90/P99 직접 추출 (신규) ---
# 클라이언트가 계산한 median 하나만으로는 콜드스타트/이상치 때문인지 구분이 안 되므로,
# 서버가 직접 집계한 값을 별도 저장해서 교차검증. vLLM 0.30.0 metric family 기준.
capture_metrics_snapshot() {
    local tag=$1
    local raw="${LOG_DIR}/metrics_raw_${tag}.txt"
    curl -s "http://${SERVER_HOST}:${VLLM_PORT}/metrics" > "${raw}" || { log "  /metrics 조회 실패"; return; }
    python3 - "${raw}" "${LOG_DIR}/metrics_percentiles_${tag}.json" << 'PYEOF'
import re, sys, json
raw_path, out_path = sys.argv[1], sys.argv[2]
text = open(raw_path).read()
def percentiles_from_histogram(metric_name):
    pattern = re.compile(rf'{re.escape(metric_name)}_bucket\{{[^}}]*le="([^"]+)"[^}}]*\}}\s+([0-9.eE+-]+)')
    buckets = []
    for le, count in pattern.findall(text):
        le_val = float("inf") if le == "+Inf" else float(le)
        buckets.append((le_val, float(count)))
    if not buckets:
        return None
    buckets.sort(key=lambda x: x[0])
    total = buckets[-1][1]
    if total <= 0:
        return None
    result = {}
    for p in (0.5, 0.9, 0.99):
        target = total * p
        for le_val, cum in buckets:
            if cum >= target:
                result[f"p{int(p*100)}"] = None if le_val == float("inf") else round(le_val * 1000, 1)
                break
    result["sample_count"] = int(total)
    return result
out = {"ttft_ms": percentiles_from_histogram("vllm:time_to_first_token_seconds"),
       "itl_ms": percentiles_from_histogram("vllm:inter_token_latency_seconds")}
json.dump(out, open(out_path, "w"), indent=2)
if out["ttft_ms"]:
    print(f"  [서버 /metrics] TTFT P50={out['ttft_ms'].get('p50')}ms P90={out['ttft_ms'].get('p90')}ms "
          f"P99={out['ttft_ms'].get('p99')}ms (n={out['ttft_ms']['sample_count']})")
else:
    print("  [서버 /metrics] time_to_first_token_seconds 히스토그램 없음 — raw 파일 직접 확인 필요")
PYEOF
}

# Return 0 when the latest warm-up windows have converged. Writes per-window JSON files.
convergence_warmup() {
    local tag=$1 in_len=$2 out_len=$3 concurrency=$4
    local stable=0 w tmp prev="" cur="" decision
    log "[convergence warm-up] ${tag}: min=${STAGE3A3_WARMUP_MIN_WINDOWS}, max=${STAGE3A3_WARMUP_MAX_WINDOWS}, n/window=${STAGE3A3_WARMUP_WINDOW_REQUESTS}"
    for ((w=1; w<=STAGE3A3_WARMUP_MAX_WINDOWS; w++)); do
        tmp="${LOG_DIR}/warmup_convergence_${tag}_w${w}.json"
        "${VLLM_BIN}" 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 "${in_len}" --random-output-len "${out_len}" \
            --num-prompts "${STAGE3A3_WARMUP_WINDOW_REQUESTS}" --max-concurrency "${concurrency}" --request-rate inf \
            --save-result --result-dir "${LOG_DIR}" --result-filename "$(basename "${tmp}")" >/dev/null || true
        cur=$(python3 - "${tmp}" <<'PYEOF'
import json,sys
try:d=json.load(open(sys.argv[1]))
except Exception:d={}
print(f"{float(d.get('request_throughput') or 0):.9f},{float(d.get('median_ttft_ms') or 0):.9f}")
PYEOF
)
        decision=$(python3 - "${prev}" "${cur}" "${STAGE3A3_WARMUP_CONVERGENCE_PCT}" "${STAGE3A3_WARMUP_TTFT_CONVERGENCE_PCT}" <<'PYEOF'
import sys
prev,cur,tpct,lpct=sys.argv[1:]
if not prev:
 print('0,NA,NA'); raise SystemExit
pr,pt=map(float,prev.split(',')); cr,ct=map(float,cur.split(','))
def delta(a,b): return abs(b-a)/abs(a)*100 if a else 999.0
dt,dl=delta(pr,cr),delta(pt,ct)
print(f"{int(dt<=float(tpct) and dl<=float(lpct))},{dt:.3f},{dl:.3f}")
PYEOF
)
        IFS=, read -r ok throughput_delta ttft_delta <<< "${decision}"
        log "  window=${w} req/s,ttft=${cur} delta(req/s)=${throughput_delta}% delta(TTFT)=${ttft_delta}% stable=${ok}"
        if (( w >= STAGE3A3_WARMUP_MIN_WINDOWS )) && [[ "${ok}" == "1" ]]; then stable=$((stable+1)); else stable=0; fi
        if (( stable >= STAGE3A3_WARMUP_CONSECUTIVE_WINDOWS )); then
            echo "CONVERGED,${w}" > "${LOG_DIR}/warmup_status_${tag}.txt"
            return 0
        fi
        prev="${cur}"
    done
    echo "NOT_CONVERGED,${STAGE3A3_WARMUP_MAX_WINDOWS}" > "${LOG_DIR}/warmup_status_${tag}.txt"
    return 1
}

declare -A STAGE3A3_CONVERGED_SHAPES=()
workload_warmup() {
    local tag=$1 in_len=$2 out_len=$3 concurrency=$4
    local shape="${in_len}_${out_len}_${concurrency}"
    if [[ "${STAGE3A3_CONVERGENCE_WARMUP_ENABLED}" != "1" ]]; then
        stage3a_load_warmup "${SERVER_HOST}" "${VLLM_PORT}" "llama-3.1-70b" "${MODEL_70B}" "${in_len}" "${out_len}" "${concurrency}" 0 0 "${tag}"
        return
    fi
    if [[ "${STAGE3A3_CONVERGENCE_SCOPE}" == "per_mode" ]]; then
        convergence_warmup "${tag}" "${in_len}" "${out_len}" "${concurrency}" || true
    elif [[ "${STAGE3A3_CONVERGENCE_SCOPE}" == "initial" ]]; then
        if [[ -z "${STAGE3A3_CONVERGED_SHAPES[${shape}]:-}" ]]; then
            convergence_warmup "initial_${shape}" "${in_len}" "${out_len}" "${concurrency}" || true
            STAGE3A3_CONVERGED_SHAPES["${shape}"]=1
        else
            stage3a_load_warmup "${SERVER_HOST}" "${VLLM_PORT}" "llama-3.1-70b" "${MODEL_70B}" "${in_len}" "${out_len}" "${concurrency}" 0 0 "${tag}"
        fi
    else
        echo "ERROR: STAGE3A3_CONVERGENCE_SCOPE must be initial or per_mode" >&2
        exit 2
    fi
}

bench_measure() {
    local tag=$1 in_len=$2 out_len=$3 concurrency=$4 num_prompts=$5
    "${VLLM_BIN}" 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 "${in_len}" --random-output-len "${out_len}" \
        --num-prompts "${num_prompts}" --max-concurrency "${concurrency}" --request-rate inf \
        --save-result --result-dir "${LOG_DIR}" --result-filename "${tag}.json" || true
    capture_metrics_snapshot "${tag}"
}

# Collect per-second vLLM scheduler/KV telemetry DURING each mode, not only cumulative histograms.
# Samples include running/waiting, KV usage and prefill/decode counters when exported.
diagnostic_sampler() {
    local tag="$1"
    local file="${LOG_DIR}/diagnostics_${tag}.jsonl"
    while :; do
        curl -fsS --max-time 3 "http://${SERVER_HOST}:${VLLM_PORT}/metrics" 2>/dev/null |
          python3 -c '
import sys,re,json,time
names=("vllm:num_requests_running","vllm:num_requests_waiting",
       "vllm:kv_cache_usage_perc","vllm:gpu_cache_usage_perc",
       "vllm:num_requests_swapped","vllm:request_prefill_time_seconds_sum",
       "vllm:request_decode_time_seconds_sum")
d={"timestamp":time.time()}
for line in sys.stdin:
 if line.startswith("#"): continue
 m=re.match(r"([^ {]+)(?:\\{[^}]*\\})?\\s+([-+0-9.eE]+)",line)
 if m and m.group(1) in names:
  try: d[m.group(1)]=max(float(m.group(2)),d.get(m.group(1),float("-inf")))
  except ValueError: pass
print(json.dumps(d))
' >> "$file" || true
        sleep "${STAGE3A3_DIAG_METRICS_INTERVAL}"
    done
}
run_with_diagnostics() {
    local tag="$1"; shift
    diagnostic_sampler "$tag" &
    local sampler_pid=$!
    "$@"
    local rc=$?
    kill "$sampler_pid" 2>/dev/null || true
    wait "$sampler_pid" 2>/dev/null || true
    return "$rc"
}
run_isolated() {
    local suffix=$1
    workload_warmup "isolated_short_${suffix}" 1024 512 32
    run_with_diagnostics "isolated_${suffix}" bench_measure "isolated_short_c32_${suffix}" 1024 512 32 160
    workload_warmup "isolated_long_${suffix}" 32768 512 8
    bench_measure "isolated_long_c8_${suffix}" 32768 512 8 16
}

run_mixed() {
    local suffix=$1
    # Warm both workload shapes before simultaneous measurement; warm-up itself is not concurrent.
    workload_warmup "mixed_short_${suffix}" 1024 512 32
    workload_warmup "mixed_long_${suffix}" 32768 512 8
    diagnostic_sampler "mixed_${suffix}" &
    local sampler_pid=$!
    bench_measure "mixed_short_c32_${suffix}" 1024 512 32 160 & local spid=$!
    bench_measure "mixed_long_c8_${suffix}" 32768 512 8 16 & local lpid=$!
    wait "${spid}" "${lpid}"
    kill "${sampler_pid}" 2>/dev/null || true
    wait "${sampler_pid}" 2>/dev/null || true
}

if [[ "${STAGE3A3_CROSSOVER_ENABLED}" == "1" ]]; then
    log "=== Stage3a3 crossover mode: rounds=${STAGE3A3_CROSSOVER_ROUNDS}, discard=${STAGE3A3_CROSSOVER_DISCARD_ROUNDS} ==="
    for ((r=0; r<STAGE3A3_CROSSOVER_ROUNDS; r++)); do
        if (( r % 2 == 0 )); then
            log "=== Round ${r}: isolated -> mixed ==="
            run_isolated "r${r}_first"
            run_mixed "r${r}_second"
        else
            log "=== Round ${r}: mixed -> isolated ==="
            run_mixed "r${r}_first"
            run_isolated "r${r}_second"
        fi
    done
else
    log "=== 1. 베이스라인(격리) — 짧은/긴 요청 ==="
    run_isolated "legacy"
    log "=== 2. 혼합 워크로드 ==="
    run_mixed "legacy"
    # Preserve legacy filenames for downstream consumers.
    cp -f "${LOG_DIR}/isolated_short_c32_legacy.json" "${LOG_DIR}/isolated_short_c32.json"
    cp -f "${LOG_DIR}/isolated_long_c8_legacy.json" "${LOG_DIR}/isolated_long_c8.json"
    cp -f "${LOG_DIR}/mixed_short_c32_legacy.json" "${LOG_DIR}/mixed_short_c32.json"
    cp -f "${LOG_DIR}/mixed_long_c8_legacy.json" "${LOG_DIR}/mixed_long_c8.json"
fi

kill -TERM "${SERVER_PID}" 2>/dev/null || true
wait "${SERVER_PID}" 2>/dev/null || true

export STAGE3A3_DIAG_KV_HIGH_PCT STAGE3A3_DIAG_WAITING_THRESHOLD
log "=== 결과 비교 ==="
python3 - "${LOG_DIR}" "${STAGE3A3_CROSSOVER_ENABLED}" "${STAGE3A3_CROSSOVER_ROUNDS}" "${STAGE3A3_CROSSOVER_DISCARD_ROUNDS}" "${STAGE3A3_ORDER_EFFECT_WARN_PCT}" "${STAGE3A3_CONVERGENCE_WARMUP_ENABLED}" "${STAGE3A3_MIN_RETAINED_ROUNDS}" "${STAGE3A3_MIN_DIRECTION_CONSISTENCY_PCT}" "${STAGE3A3_CONVERGENCE_SCOPE}" <<'PYEOF'
import glob,json,os,statistics,sys
(log_dir,crossover,rounds,discard,order_warn,conv,min_retained,min_direction,conv_scope)=sys.argv[1:]
rounds,discard=int(rounds),int(discard)
order_warn=float(order_warn); min_retained=int(min_retained); min_direction=float(min_direction)

def load(path):
    try:return json.load(open(path))
    except Exception:return {}
def metric(path,k):
    v=load(path).get(k)
    try:return float(v)
    except Exception:return 0.0
def med(xs): return statistics.median(xs) if xs else 0.0
def pct(base,value): return (value-base)/base*100 if base else 0.0
def fmt(v): return f"{v:+.2f}%"

rows=[]
if crossover=='1':
    for r in range(rounds):
        if r < discard: continue
        iso_pos='first' if r%2==0 else 'second'
        mix_pos='second' if r%2==0 else 'first'
        order='AB' if iso_pos=='first' else 'BA'   # A=isolated, B=mixed
        ip=os.path.join(log_dir,f'isolated_short_c32_r{r}_{iso_pos}.json')
        mp=os.path.join(log_dir,f'mixed_short_c32_r{r}_{mix_pos}.json')
        row={'round':r,'order':order,'iso_pos':iso_pos,'mix_pos':mix_pos,
             'iso_ttft':metric(ip,'median_ttft_ms'),'mix_ttft':metric(mp,'median_ttft_ms'),
             'iso_itl':metric(ip,'median_itl_ms'),'mix_itl':metric(mp,'median_itl_ms'),
             'iso_rps':metric(ip,'request_throughput'),'mix_rps':metric(mp,'request_throughput')}
        row['paired_ttft_degradation_pct']=pct(row['iso_ttft'],row['mix_ttft'])
        row['paired_itl_degradation_pct']=pct(row['iso_itl'],row['mix_itl'])
        rows.append(row)
else:
    ip=os.path.join(log_dir,'isolated_short_c32.json'); mp=os.path.join(log_dir,'mixed_short_c32.json')
    row={'round':0,'order':'AB','iso_pos':'first','mix_pos':'second',
         'iso_ttft':metric(ip,'median_ttft_ms'),'mix_ttft':metric(mp,'median_ttft_ms'),
         'iso_itl':metric(ip,'median_itl_ms'),'mix_itl':metric(mp,'median_itl_ms'),
         'iso_rps':metric(ip,'request_throughput'),'mix_rps':metric(mp,'request_throughput')}
    row['paired_ttft_degradation_pct']=pct(row['iso_ttft'],row['mix_ttft'])
    row['paired_itl_degradation_pct']=pct(row['iso_itl'],row['mix_itl'])
    rows=[row]

iso_ttft=med([x['iso_ttft'] for x in rows]); mix_ttft=med([x['mix_ttft'] for x in rows])
iso_itl=med([x['iso_itl'] for x in rows]); mix_itl=med([x['mix_itl'] for x in rows])
aggregate_ttft=pct(iso_ttft,mix_ttft)
aggregate_itl=pct(iso_itl,mix_itl)
paired_ttft=med([x['paired_ttft_degradation_pct'] for x in rows])
paired_itl=med([x['paired_itl_degradation_pct'] for x in rows])

ab=[x['paired_ttft_degradation_pct'] for x in rows if x['order']=='AB']
ba=[x['paired_ttft_degradation_pct'] for x in rows if x['order']=='BA']
ab_med=med(ab) if ab else None
ba_med=med(ba) if ba else None
order_delta=abs(ab_med-ba_med) if ab_med is not None and ba_med is not None else 0.0

# Direction consistency is relative to the paired median's sign. Zero median => require near-zero rounds only.
eps=1e-12
if abs(paired_ttft)<=eps:
    agreeing=sum(1 for x in rows if abs(x['paired_ttft_degradation_pct'])<=eps)
else:
    sign=1 if paired_ttft>0 else -1
    agreeing=sum(1 for x in rows if (x['paired_ttft_degradation_pct']>0)==(sign>0))
direction_consistency=100.0*agreeing/len(rows) if rows else 0.0

warm_files=glob.glob(os.path.join(log_dir,'warmup_status_*.txt'))
warm_states=[open(x).read().strip() for x in warm_files]
not_conv=[x for x in warm_states if x.startswith('NOT_CONVERGED')]
reasons=[]
if crossover=='1' and len(rows)<min_retained:
    reasons.append(f'retained_rounds<{min_retained}')
if crossover=='1' and direction_consistency<min_direction:
    reasons.append(f'direction_consistency<{min_direction:.1f}%')
if crossover=='1' and order_delta>order_warn:
    reasons.append(f'AB_BA_order_delta>{order_warn:.1f}%')
if conv=='1' and not_conv:
    reasons.append('warmup_not_converged')
stability='UNSTABLE' if reasons else 'STABLE'
verdict='REVIEW_REQUIRED' if stability=='UNSTABLE' else 'READY_FOR_PASS_FAIL_EVALUATION'

# Scheduler pressure vs KV pressure is a diagnostic hypothesis, never an automatic causal proof.
def diag_summary(pattern):
    samples=[]
    for f in glob.glob(os.path.join(log_dir,pattern)):
        for line in open(f):
            try: samples.append(json.loads(line))
            except Exception: pass
    def maximum(*keys):
        vals=[float(d[k]) for d in samples for k in keys if k in d]
        return max(vals) if vals else None
    return {'samples':len(samples),
            'max_waiting':maximum('vllm:num_requests_waiting'),
            'max_running':maximum('vllm:num_requests_running'),
            'max_kv_usage':maximum('vllm:kv_cache_usage_perc','vllm:gpu_cache_usage_perc')}
diag_iso=diag_summary('diagnostics_isolated_r*.jsonl')
diag_mix=diag_summary('diagnostics_mixed_r*.jsonl')
kv=diag_mix['max_kv_usage']
waiting=diag_mix['max_waiting']
if kv is None or waiting is None:
    cause='INSUFFICIENT_TELEMETRY'
elif kv>=float(os.environ.get('STAGE3A3_DIAG_KV_HIGH_PCT','85'))/100:
    cause='KV_PRESSURE_POSSIBLE'
elif waiting>=float(os.environ.get('STAGE3A3_DIAG_WAITING_THRESHOLD','1')):
    cause='SCHEDULER_QUEUE_OR_PREFILL_CONTENTION_POSSIBLE'
else:
    cause='PREFILL_COMPUTE_CONTENTION_POSSIBLE'
summary={
 'schema_version':4,'diagnostics':{'isolated':diag_iso,'mixed':diag_mix,'cause_hypothesis':cause},'crossover_enabled':crossover=='1','rounds':rounds,'discard_rounds':discard,
 'retained_rounds':len(rows),'minimum_retained_rounds':min_retained,
 'convergence_warmup_enabled':conv=='1','convergence_scope':conv_scope,
 'warmup_not_converged_count':len(not_conv),
 'rows':rows,
 'isolated_median_ttft_ms':iso_ttft,'mixed_median_ttft_ms':mix_ttft,
 'aggregate_median_ttft_degradation_pct':aggregate_ttft,
 'paired_ttft_degradation_median_pct':paired_ttft,
 'isolated_median_itl_ms':iso_itl,'mixed_median_itl_ms':mix_itl,
 'aggregate_median_itl_degradation_pct':aggregate_itl,
 'paired_itl_degradation_median_pct':paired_itl,
 'ab_degradation_median_pct':ab_med,'ba_degradation_median_pct':ba_med,
 'ab_ba_order_delta_pct':order_delta,'order_effect_warn_pct':order_warn,
 'direction_consistency_pct':direction_consistency,
 'minimum_direction_consistency_pct':min_direction,
 'result_stability':stability,'stability_reasons':reasons,'verdict':verdict
}
json.dump(summary,open(os.path.join(log_dir,'stage3a3_stability_summary.json'),'w'),indent=2)

lines=[
 '# Stage3a-3 Mixed Workload Result','',
 f'- Crossover: {summary["crossover_enabled"]}',
 f'- Rounds: {rounds}; discarded={discard}; retained={len(rows)} (minimum={min_retained})',
 f'- Convergence warm-up: {summary["convergence_warmup_enabled"]} (scope={conv_scope})',
 f'- Warm-up not converged: {len(not_conv)}',
 f'- Paired TTFT degradation median: {fmt(paired_ttft)} **(primary)**',
 f'- Aggregate median TTFT degradation: {fmt(aggregate_ttft)} (reference)',
 f'- Direction consistency: {direction_consistency:.1f}% (minimum={min_direction:.1f}%)',
 f'- AB degradation median: {"N/A" if ab_med is None else fmt(ab_med)}',
 f'- BA degradation median: {"N/A" if ba_med is None else fmt(ba_med)}',
 f'- AB/BA order delta: {order_delta:.1f}% (warn>{order_warn:.1f}%)',
 f'- Stability: **{stability}**',
 f'- Interference hypothesis: **{cause}** (not causal proof)',
 f'- Mixed max waiting: {waiting}; max KV fraction: {kv}',
 f'- Verdict: **{verdict}**'
]
if reasons:
    lines += ['', '## Stability reasons'] + [f'- {x}' for x in reasons]
lines += [
 '', '## Short request: isolated vs mixed',
 '| Metric | Isolated median | Mixed median | Aggregate degradation | Paired degradation median |',
 '|---|---:|---:|---:|---:|',
 f'| TTFT | {iso_ttft:.1f} ms | {mix_ttft:.1f} ms | {aggregate_ttft:+.1f}% | {paired_ttft:+.1f}% |',
 f'| ITL | {iso_itl:.1f} ms | {mix_itl:.1f} ms | {aggregate_itl:+.1f}% | {paired_itl:+.1f}% |',
 '', '## Retained crossover rounds',
 '| Round | Order | ISO TTFT | MIX TTFT | Paired degradation |',
 '|---:|---|---:|---:|---:|'
]
for x in rows:
    lines.append(f'| {x["round"]} | {x["order"]} | {x["iso_ttft"]:.1f} ms | {x["mix_ttft"]:.1f} ms | {x["paired_ttft_degradation_pct"]:+.1f}% |')
lines += [
 '', '## Interpretation',
 '- Primary interference estimate is the median of matched-pair per-round degradations.',
 '- AB/BA order delta compares the degradation conclusion between isolated→mixed and mixed→isolated orders.',
 '- Direction consistency measures how many retained rounds agree with the sign of the paired median.',
 '- Scheduler-vs-KV attribution requires a controlled A/B run with varied max-num-batched-tokens or KV budget; telemetry alone is not proof.',
 '- UNSTABLE results are REVIEW_REQUIRED; the observed degradation is informational and must not be declared PASS/FAIL.',
]
open(os.path.join(log_dir,'mixed_workload_report.md'),'w').write('\n'.join(lines)+'\n')
print('\n'.join(lines))
PYEOF
log "혼합 워크로드 테스트 완료! 리포트: ${LOG_DIR}/mixed_workload_report.md"
profile
engineer

0개의 댓글