diff --git a/docs/design/gimin-#145-final-performance-report.md b/docs/design/gimin-#145-final-performance-report.md new file mode 100644 index 0000000..df31753 --- /dev/null +++ b/docs/design/gimin-#145-final-performance-report.md @@ -0,0 +1,126 @@ +# 인덱싱·Vector 검색 최종 통합 성능 리포트 설계 + +- 관련 이슈: [#145](https://github.com/DocGrid/backend/issues/145) +- 작성일: 2026-08-11 +- 상태: 구현 및 검증 완료 + +## 1. 배경 + +Job Claim, 실제 BGE-M3 배치, Exact·HNSW 검색, Worker 수평 확장, Queue Backpressure, +PDF·DOCX 전체 인덱싱 부하 결과는 각각 재현 가능한 문서로 남아 있다. 그러나 실험 목적과 환경이 +서로 달라 전체 병목의 이동, 권장 기본값과 운영 전 남은 검증을 한 문서에서 설명하기 어렵다. + +이번 작업은 기존 Benchmark를 다시 실행하거나 서로 다른 Workload를 하나의 순위로 합치지 않는다. +검증된 결과에서 핵심 수치와 출처를 정규화하고, 같은 실험 안에서만 비교하는 그래프와 최종 결론을 +재현 가능한 형태로 제공한다. + +## 2. 목표와 성공 기준 + +- 개별 결과 문서의 대표 수치를 하나의 JSON 데이터 계약으로 관리한다. +- 모든 데이터 행은 저장소 안의 원본 결과 문서로 추적할 수 있다. +- Python 표준 라이브러리만으로 GitHub에서 렌더링되는 SVG를 결정적으로 생성한다. +- `--check` 실행이 데이터 계약 오류, 사라진 출처와 생성 결과 불일치를 탐지한다. +- 최종 리포트가 비교표, 그래프, 병목 분석, 권장값, 한계와 후속 검증을 포함한다. + +## 3. 비교 경계 + +### 3.1 같은 그래프에서 비교하는 범위 + +| 영역 | 고정 조건 | 비교 변수 | +|---|---|---| +| Job Claim | OpenSQL 17.8, 5,000 Job, 5회 | Worker 1·5·10·20·40 | +| BGE-M3 | 실제 `BAAI/bge-m3`, CPU, 256 Text, 3회 | Batch 1·4·8·16·32·64 | +| Vector 검색 | PostgreSQL 17.8, pgvector 0.8.1, `vector(1024)`, topK 10 | 2천·1만·5만 Row, Exact·HNSW | +| Worker 확장 | TXT 16문서, 문서당 8 Chunk, Batch 32 | Worker 수와 실행 Slot | +| Backpressure | TXT 800자, Pool 4, Worker Slot 8 | 16·32·64·128문서와 업로더 수 | +| 실제 문서 E2E | PDF·DOCX 혼합, 문서당 4 Chunk, Slot 2, Batch 32 | 50·100문서 | + +### 3.2 직접 비교하지 않는 범위 + +- TXT 800자 Backpressure 처리량과 TXT 6,400자 Worker 처리량 +- PDF·DOCX 문서/분과 TXT 문서/분 +- Apple Silicon의 `linux/amd64` OpenSQL Container와 공급사 지원 Rocky Linux 원격 서버의 절대 성능 +- 합성 Random Vector Recall과 실제 문서 Corpus의 검색 품질 +- 서로 다른 실행 일자·장비의 수치를 하나의 통합 점수로 환산한 값 + +리포트는 각 영역 안의 상대 변화만 결론의 근거로 사용한다. 영역 간 수치는 병목이 어느 단계로 +이동했는지 설명하는 참고값이며 직접적인 우열 비교가 아니다. + +## 4. 데이터 계약 + +정규화 JSON은 다음 공통 정보를 가진다. + +- Schema Version과 생성 목적 +- Benchmark별 원본 Markdown 경로와 환경·Workload 설명 +- X축 Label과 각 Series의 이름·단위·값 +- 결과 문서에 표시할 대표 수치와 해석 경계 + +생성기는 다음 불변식을 검증한다. + +1. Schema Version과 필수 Benchmark 6종이 존재한다. +2. 모든 원본 경로가 저장소 내부의 일반 파일을 가리킨다. +3. Series 길이가 X축 길이와 같고 모든 수치가 유한한 0 이상의 값이다. +4. 그래프 정의가 등록된 Series와 단위만 참조한다. +5. 출력 파일 이름이 중복되지 않고 `docs/test-results/assets/` 아래에만 생성된다. + +## 5. 그래프 설계 + +| 파일 | 목적 | 표현 | +|---|---|---| +| `claim-throughput-tail-latency.svg` | Claim 동시성의 처리량과 Tail Latency 균형 | TPS와 p99 이중 축 Line | +| `bge-batch-throughput-latency.svg` | Batch Size의 처리량 포화와 요청 지연 | Text/s와 p95 이중 축 Line | +| `vector-exact-hnsw-latency-recall.svg` | HNSW 속도 향상과 Recall 비용 | 검색 p95 Log 축 Bar + Recall Line | +| `worker-horizontal-scaling.svg` | 실행 Slot 증가의 효율과 지연 이동 | 문서/분 Line + Queue·처리 p95 Bar | +| `queue-backpressure.svg` | 부하 증가 시 처리량 Plateau와 Queue 증가 | 문서/분과 Queue p95 이중 축 Line | +| `pdf-docx-e2e-load.svg` | 실제 문서 수 증가 시 처리량과 E2E 구성 | 문서/분 Line + Queue·처리 p95 Bar | + +SVG에는 `title`, `desc`, 범례, 축 이름과 단위를 포함한다. 색상만으로 Series를 구분하지 않도록 +Line Marker와 범례를 함께 제공하고, 정확한 수치는 Markdown 표에서 확인할 수 있게 한다. + +## 6. 생성과 검증 흐름 + +1. 생성기가 정규화 JSON과 저장소 Root를 읽는다. +2. Schema, 수치, Series 길이와 원본 문서 경로를 검증한다. +3. 고정된 크기·색상·정렬 규칙으로 SVG 6개를 Memory에서 생성한다. +4. 기본 실행은 대상 Directory에 결과를 기록한다. +5. `--check` 실행은 Memory 결과와 Commit된 SVG를 Byte 단위로 비교한다. +6. 불일치 시 재생성 명령과 대상 파일을 포함한 오류로 실패한다. + +```bash +python3 scripts/performance/generate_final_performance_report.py +python3 scripts/performance/generate_final_performance_report.py --check +``` + +## 7. 최종 리포트 구조 + +- Executive Summary와 단계별 핵심 결론 +- 실험별 환경·Workload 비교 경계 +- 6개 비교표와 6개 그래프 +- Claim, Embedding, Vector, Worker, Queue, 실제 문서 E2E 병목 분석 +- 측정으로 확정할 수 있는 권장값과 확정할 수 없는 운영값 +- 공급사 지원 Rocky Linux 원격 OpenSQL 검증 등 남은 작업 +- 원본 결과 문서와 재생성 명령 + +## 8. 제외 범위 + +- 기존 Benchmark 재실행과 원시 JSON 복구 +- 제품 코드, Database Schema 또는 운영 설정 변경 +- 현재 측정값을 운영 SLO로 확정 +- 새로운 성능 임계값으로 CI를 차단 +- 공급사 지원 Rocky Linux 환경의 최종 호환성 승인 +- Grafana Dashboard 또는 외부 Chart Service 추가 + +## 9. 커밋 분할 + +1. `docs: #145 최종 통합 성능 리포트 설계` +2. `docs: #145 통합 성능 원본 데이터 추가` +3. `build: #145 성능 그래프 생성 및 검증 도구 추가` +4. `docs: #145 최종 성능 비교표와 그래프 기록` + +## 10. 완료 조건 + +- 원본 결과 8개에서 선택한 핵심 수치가 정규화 JSON과 일치한다. +- SVG 6개가 GitHub에서 바로 표시되고 생성 결과가 결정적이다. +- `--check`가 정상 결과에서 성공하고 고의 불일치에서 실패한다. +- 최종 리포트가 비교 가능한 범위와 비교 금지 범위를 명시한다. +- 병목과 권장 기본값의 근거가 표·그래프·원본 문서로 추적된다. diff --git a/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg b/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg new file mode 100644 index 0000000..38f6ca8 --- /dev/null +++ b/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg @@ -0,0 +1,57 @@ + + +실제 BGE-M3 Batch 처리량과 요청 지연 +BAAI/bge-m3 · CPU · 256 texts · profile별 3회. X축은 Batch Size이며 좌우 축 Series를 함께 표시한다. + + +실제 BGE-M3 Batch 처리량과 요청 지연 +BAAI/bge-m3 · CPU · 256 texts · profile별 3회 + + +처리량 (texts/s) + + +요청 p95 (ms) + + +0 + +4 + +8 + +12 + +16 + +20 +0 +1,000 +2,000 +3,000 +4,000 +5,000 + + + + + + + + + + + + + + +1 +4 +8 +16 +32 +64 +Batch Size +texts/s +ms + diff --git a/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg b/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg new file mode 100644 index 0000000..5617d7e --- /dev/null +++ b/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg @@ -0,0 +1,54 @@ + + +OpenSQL Job Claim 처리량과 Tail Latency +OpenSQL 17.8 · 5,000 jobs · profile별 5회 중앙값. X축은 Worker 수이며 좌우 축 Series를 함께 표시한다. + + +OpenSQL Job Claim 처리량과 Tail Latency +OpenSQL 17.8 · 5,000 jobs · profile별 5회 중앙값 + + +중앙 TPS (TPS) + + +Claim p99 (ms) + + +0 + +200 + +400 + +600 + +800 + +1,000 +0 +100 +200 +300 +400 +500 + + + + + + + + + + + + +1 +5 +10 +20 +40 +Worker 수 +TPS +ms + diff --git a/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg b/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg new file mode 100644 index 0000000..0c1ab3c --- /dev/null +++ b/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg @@ -0,0 +1,49 @@ + + +PDF·DOCX 전체 인덱싱 E2E 부하 +PDF 50% + DOCX 50% · 4 chunks/doc · worker slots 2 · BGE batch 32. X축은 문서 수이며 좌우 축 Series를 함께 표시한다. + + +PDF·DOCX 전체 인덱싱 E2E 부하 +PDF 50% + DOCX 50% · 4 chunks/doc · worker slots 2 · BGE batch 32 + + +처리량 (docs/min) + + +Queue p95 (s) + + +처리 p95 (s) + + +0 + +10 + +20 + +30 + +40 + +50 +0 +40 +80 +120 +160 +200 + + + + + + + +50 +100 +문서 수 +docs/min +s + diff --git a/docs/test-results/assets/final-performance-report/queue-backpressure.svg b/docs/test-results/assets/final-performance-report/queue-backpressure.svg new file mode 100644 index 0000000..dd50c1d --- /dev/null +++ b/docs/test-results/assets/final-performance-report/queue-backpressure.svg @@ -0,0 +1,51 @@ + + +Queue 적체와 DB Pool Backpressure +TXT 800 chars/doc · 1 chunk/doc · Hikari pool 4 · worker slots 8. X축은 문서 수이며 좌우 축 Series를 함께 표시한다. + + +Queue 적체와 DB Pool Backpressure +TXT 800 chars/doc · 1 chunk/doc · Hikari pool 4 · worker slots 8 + + +처리량 (docs/min) + + +Queue p95 (s) + + +0 + +40 + +80 + +120 + +160 + +200 +0 +20 +40 +60 +80 +100 + + + + + + + + + + +16 +32 +64 +128 +문서 수 +docs/min +s + diff --git a/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg b/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg new file mode 100644 index 0000000..d25a83a --- /dev/null +++ b/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg @@ -0,0 +1,49 @@ + + +Exact Search 대비 HNSW 지연과 Recall@10 +PostgreSQL 17.8 · pgvector 0.8.1 · vector(1024) · topK 10 · ef_search 40. X축은 Vector 수이며 좌우 축 Series를 함께 표시한다. + + +Exact Search 대비 HNSW 지연과 Recall@10 +PostgreSQL 17.8 · pgvector 0.8.1 · vector(1024) · topK 10 · ef_search 40 + + +Exact p95 (ms) + + +HNSW p95 (ms) + + +평균 Recall@10 (ratio) + + +1 + +10 + +100 + +1,000 +0 +0.4 +0.8 +1.2 +1.6 +2 + + + + + + + + + + +2천 +1만 +5만 +Vector 수 +ms +ratio + diff --git a/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg b/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg new file mode 100644 index 0000000..3d17192 --- /dev/null +++ b/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg @@ -0,0 +1,61 @@ + + +Worker 수·실행 Slot별 수평 확장 +TXT 16 docs · 6,400 chars/doc · 8 chunks/doc · BGE batch 32. X축은 Worker-Slot Profile이며 좌우 축 Series를 함께 표시한다. + + +Worker 수·실행 Slot별 수평 확장 +TXT 16 docs · 6,400 chars/doc · 8 chunks/doc · BGE batch 32 + + +처리량 (docs/min) + + +Queue p95 (s) + + +처리 p95 (s) + + +0 + +10 + +20 + +30 + +40 + +50 +0 +10 +20 +30 +40 +50 + + + + + + + + + + + + + + + + +w1-s1 +w1-s2 +w2-s1 +w2-s2 +w4-s2 +Worker-Slot Profile +docs/min +s + diff --git a/docs/test-results/gimin-#145-final-performance-report-data.json b/docs/test-results/gimin-#145-final-performance-report-data.json new file mode 100644 index 0000000..1c96040 --- /dev/null +++ b/docs/test-results/gimin-#145-final-performance-report-data.json @@ -0,0 +1,423 @@ +{ + "schemaVersion": 1, + "issue": 145, + "title": "DocGrid 인덱싱·Vector 검색 최종 통합 성능 데이터", + "recordedAt": "2026-08-11", + "sources": [ + { + "id": "claim-local", + "path": "docs/test-results/gimin-#61-embedding-job-claim-performance.md", + "purpose": "PostgreSQL 14.6 기반 초기 Claim 처리량·경합 기준선" + }, + { + "id": "opensql-compatibility", + "path": "docs/test-results/gimin-#124-opensql-compatibility-performance.md", + "purpose": "OpenSQL 17.8 호환성·Vector·Claim 실측" + }, + { + "id": "bge-batch", + "path": "docs/test-results/gimin-#128-bge-m3-batch-size-performance.md", + "purpose": "실제 BAAI/bge-m3 Batch Size 최적화" + }, + { + "id": "vector-search", + "path": "docs/test-results/gimin-#131-hnsw-exact-scale-benchmark.md", + "purpose": "Exact Seq Scan 대비 HNSW 지연·Recall@10 비교" + }, + { + "id": "worker-throughput", + "path": "docs/test-results/gimin-#133-worker-indexing-throughput-benchmark.md", + "purpose": "TXT 16·32문서 자동 Worker 전체 처리량 기준선" + }, + { + "id": "worker-scaling", + "path": "docs/test-results/gimin-#138-worker-horizontal-scaling-benchmark.md", + "purpose": "Worker 수·실행 Slot별 수평 확장" + }, + { + "id": "queue-backpressure", + "path": "docs/test-results/gimin-#141-worker-queue-backpressure-benchmark.md", + "purpose": "Queue 적체·Hikari Pool Backpressure 경계" + }, + { + "id": "real-document-e2e", + "path": "docs/test-results/gimin-#143-pdf-docx-indexing-e2e-load-benchmark.md", + "purpose": "PDF·DOCX 50·100문서 실제 전체 E2E 부하" + } + ], + "benchmarks": { + "claim": { + "title": "OpenSQL Job Claim 처리량과 Tail Latency", + "subtitle": "OpenSQL 17.8 · 5,000 jobs · profile별 5회 중앙값", + "sourceIds": [ + "opensql-compatibility" + ], + "categories": [ + "1", + "5", + "10", + "20", + "40" + ], + "categoryLabel": "Worker 수", + "series": { + "throughput": { + "label": "중앙 TPS", + "unit": "TPS", + "values": [ + 273.61, + 748.45, + 563.07, + 591.32, + 591.89 + ] + }, + "p99": { + "label": "Claim p99", + "unit": "ms", + "values": [ + 6.077, + 13.187, + 67.78, + 89.913, + 212.795 + ] + } + }, + "chart": { + "file": "claim-throughput-tail-latency.svg", + "leftSeries": [ + "throughput" + ], + "rightSeries": [ + "p99" + ], + "leftScale": "linear", + "leftStyle": "line", + "rightStyle": "line" + } + }, + "bgeBatch": { + "title": "실제 BGE-M3 Batch 처리량과 요청 지연", + "subtitle": "BAAI/bge-m3 · CPU · 256 texts · profile별 3회", + "sourceIds": [ + "bge-batch" + ], + "categories": [ + "1", + "4", + "8", + "16", + "32", + "64" + ], + "categoryLabel": "Batch Size", + "series": { + "throughput": { + "label": "처리량", + "unit": "texts/s", + "values": [ + 5.85, + 11.12, + 13.57, + 15.15, + 17.08, + 17.38 + ] + }, + "p95": { + "label": "요청 p95", + "unit": "ms", + "values": [ + 222.97, + 409.23, + 637.61, + 1224.44, + 1975.7, + 3719.82 + ] + } + }, + "chart": { + "file": "bge-batch-throughput-latency.svg", + "leftSeries": [ + "throughput" + ], + "rightSeries": [ + "p95" + ], + "leftScale": "linear", + "leftStyle": "line", + "rightStyle": "line" + } + }, + "vectorSearch": { + "title": "Exact Search 대비 HNSW 지연과 Recall@10", + "subtitle": "PostgreSQL 17.8 · pgvector 0.8.1 · vector(1024) · topK 10 · ef_search 40", + "sourceIds": [ + "vector-search" + ], + "categories": [ + "2천", + "1만", + "5만" + ], + "categoryLabel": "Vector 수", + "series": { + "exactP95": { + "label": "Exact p95", + "unit": "ms", + "values": [ + 5.103, + 25.874, + 197.611 + ] + }, + "hnswP95": { + "label": "HNSW p95", + "unit": "ms", + "values": [ + 1.139, + 2.015, + 1.994 + ] + }, + "recall": { + "label": "평균 Recall@10", + "unit": "ratio", + "values": [ + 0.96, + 0.52, + 0.22 + ] + } + }, + "chart": { + "file": "vector-exact-hnsw-latency-recall.svg", + "leftSeries": [ + "exactP95", + "hnswP95" + ], + "rightSeries": [ + "recall" + ], + "leftScale": "log10", + "leftStyle": "bar", + "rightStyle": "line" + } + }, + "workerScaling": { + "title": "Worker 수·실행 Slot별 수평 확장", + "subtitle": "TXT 16 docs · 6,400 chars/doc · 8 chunks/doc · BGE batch 32", + "sourceIds": [ + "worker-scaling" + ], + "categories": [ + "w1-s1", + "w1-s2", + "w2-s1", + "w2-s2", + "w4-s2" + ], + "categoryLabel": "Worker-Slot Profile", + "series": { + "throughput": { + "label": "처리량", + "unit": "docs/min", + "values": [ + 19.475, + 21.862, + 21.576, + 21.577, + 21.677 + ] + }, + "queueP95": { + "label": "Queue p95", + "unit": "s", + "values": [ + 43.963, + 38.302, + 38.912, + 33.627, + 22.81 + ] + }, + "processingP95": { + "label": "처리 p95", + "unit": "s", + "values": [ + 3.391, + 5.55, + 5.647, + 11.693, + 22.741 + ] + } + }, + "chart": { + "file": "worker-horizontal-scaling.svg", + "leftSeries": [ + "throughput" + ], + "rightSeries": [ + "queueP95", + "processingP95" + ], + "leftScale": "linear", + "leftStyle": "line", + "rightStyle": "bar" + } + }, + "queueBackpressure": { + "title": "Queue 적체와 DB Pool Backpressure", + "subtitle": "TXT 800 chars/doc · 1 chunk/doc · Hikari pool 4 · worker slots 8", + "sourceIds": [ + "queue-backpressure" + ], + "categories": [ + "16", + "32", + "64", + "128" + ], + "categoryLabel": "문서 수", + "series": { + "throughput": { + "label": "처리량", + "unit": "docs/min", + "values": [ + 163.0, + 149.266, + 142.791, + 142.054 + ] + }, + "queueP95": { + "label": "Queue p95", + "unit": "s", + "values": [ + 3.07301, + 9.964056, + 23.777526, + 49.201731 + ] + } + }, + "chart": { + "file": "queue-backpressure.svg", + "leftSeries": [ + "throughput" + ], + "rightSeries": [ + "queueP95" + ], + "leftScale": "linear", + "leftStyle": "line", + "rightStyle": "line" + } + }, + "realDocumentE2E": { + "title": "PDF·DOCX 전체 인덱싱 E2E 부하", + "subtitle": "PDF 50% + DOCX 50% · 4 chunks/doc · worker slots 2 · BGE batch 32", + "sourceIds": [ + "real-document-e2e" + ], + "categories": [ + "50", + "100" + ], + "categoryLabel": "문서 수", + "series": { + "throughput": { + "label": "처리량", + "unit": "docs/min", + "values": [ + 31.046, + 32.063 + ] + }, + "queueP95": { + "label": "Queue p95", + "unit": "s", + "values": [ + 88.355, + 174.684 + ] + }, + "processingP95": { + "label": "처리 p95", + "unit": "s", + "values": [ + 4.093, + 4.026 + ] + } + }, + "chart": { + "file": "pdf-docx-e2e-load.svg", + "leftSeries": [ + "throughput" + ], + "rightSeries": [ + "queueP95", + "processingP95" + ], + "leftScale": "linear", + "leftStyle": "line", + "rightStyle": "bar" + } + } + }, + "supportingBaselines": { + "claimLocalPostgreSql14": { + "sourceIds": [ + "claim-local" + ], + "workers": [ + 1, + 5, + 10, + 20, + 40 + ], + "medianTps": [ + 364.29, + 1300.58, + 1301.34, + 1265.59, + 1559.8 + ], + "p99Ms": [ + 4.61, + 6.19, + 18.58, + 39.14, + 97.63 + ], + "comparisonBoundary": "OpenSQL 17.8 결과와 절대값을 직접 비교하지 않는 초기 로컬 기준선" + }, + "workerIndexingThroughput": { + "sourceIds": [ + "worker-throughput" + ], + "documentCounts": [ + 16, + 32 + ], + "documentsPerMinute": [ + 19.102, + 18.402 + ], + "queueP95Seconds": [ + 43.794, + 93.554 + ], + "processingP95Seconds": [ + 6.768, + 7.285 + ], + "comparisonBoundary": "TXT 6,400자·문서당 8 Chunk인 자동 Worker 기준선" + } + } +} diff --git a/docs/test-results/gimin-#145-final-performance-report.md b/docs/test-results/gimin-#145-final-performance-report.md new file mode 100644 index 0000000..961a51c --- /dev/null +++ b/docs/test-results/gimin-#145-final-performance-report.md @@ -0,0 +1,248 @@ +# DocGrid 인덱싱·Vector 검색 최종 통합 성능 리포트 + +- 관련 이슈: [#145](https://github.com/DocGrid/backend/issues/145) +- 기준 일자: 2026-08-11 +- 데이터: [정규화 성능 원본](gimin-%23145-final-performance-report-data.json) +- 재현성 검사: `python3 scripts/performance/generate_final_performance_report.py --check` + +## 1. Executive Summary + +DocGrid의 핵심 인덱싱 경로를 Job Claim부터 실제 PDF·DOCX의 `vector(1024)` 저장까지 단계별로 +측정했다. 한 장비에서 모든 수치를 하나의 처리량으로 합친 결과가 아니라, 각 단계의 고정 조건 안에서 +병목과 선택 근거를 찾은 결과다. + +| 단계 | 관측 결과 | 현재 판단 | +|---|---|---| +| Job Claim | OpenSQL 17.8에서 Worker 5가 748.45 TPS, p99 13.187ms로 처리량·Tail Latency 균형이 가장 좋음 | 검증 환경의 Claim 동시성 기준점은 5 | +| 실제 BGE-M3 | Batch 32가 최대 처리량의 98.27%이며 Batch 64보다 p95가 46.89% 낮음 | 기본 Batch 32 유지 | +| Vector 검색 | 5만 Vector에서 HNSW p95는 Exact보다 99.09배 빠르지만 평균 Recall@10은 0.22 | 실제 Corpus에서 `ef_search` 튜닝 전 품질 확정 금지 | +| Worker 확장 | 2 Slot부터 약 21.6문서/분으로 포화, 8 Slot 효율 0.139 | 단일 CPU BGE 환경에서는 2 Slot이 균형점 | +| Queue·DB Pool | 16문서부터 Pool Waiting, 64문서부터 약 142문서/분 Plateau, 128문서까지 붕괴 없음 | Queue depth·Oldest Pending Age 기반 운영 관측 필요 | +| PDF·DOCX E2E | 50·100문서에서 31.046·32.063문서/분, 본 측정 300문서·1,200 Vector 오류 0건 | 실제 문서 Pipeline 정합성과 처리량 기준선 확보 | + +가장 중요한 결론은 Worker를 늘리면 무조건 빨라지지 않는다는 점이다. Claim 자체는 수백 TPS를 +처리하지만 실제 전체 Pipeline에서는 CPU BGE-M3가 공유 병목이 된다. 실행 Slot을 늘리면 Job이 빨리 +Claim되어 Queue 대기는 줄지만, Embedding 처리 대기가 늘어 E2E 시간과 처리량은 거의 개선되지 않았다. + +## 2. 해석 경계 + +### 2.1 같은 표·그래프 안에서 비교 가능한 조건 + +| 영역 | 고정 조건 | 비교 변수 | +|---|---|---| +| Claim | OpenSQL 17.8, 5,000 Job, Profile별 5회 | Worker 1·5·10·20·40 | +| Embedding | 실제 `BAAI/bge-m3`, CPU, 256 Text, 3회 | Batch 1·4·8·16·32·64 | +| Vector | PostgreSQL 17.8, pgvector 0.8.1, `vector(1024)`, topK 10 | 2천·1만·5만 Row, Exact·HNSW | +| Worker | TXT 16문서, 6,400자/문서, 8 Chunk/문서, Batch 32 | Worker 수와 전체 실행 Slot | +| Backpressure | TXT 800자, 1 Chunk/문서, Hikari Pool 4, Slot 8 | 16·32·64·128문서와 업로더 수 | +| 실제 문서 | PDF 50% + DOCX 50%, 4 Chunk/문서, Slot 2, Batch 32 | 50·100문서 | + +### 2.2 직접 비교하면 안 되는 수치 + +- 800자 Backpressure Workload와 6,400자 Worker Workload의 문서/분 +- TXT 처리량과 PDF·DOCX 처리량 +- Apple Silicon의 `linux/amd64` OpenSQL Container와 공급사 지원 Rocky Linux 원격 서버의 절대 성능 +- 합성 Random Vector의 Recall과 실제 문서 Corpus 검색 품질 +- 실행 일자와 장비가 다른 Benchmark의 절대 지연을 하나의 순위나 통합 점수로 환산한 값 + +따라서 아래 그래프는 영역 안의 변화 추세를 설명한다. 서로 다른 그래프 높이는 시스템 단계 간 우열이나 +운영 SLO를 의미하지 않는다. + +## 3. OpenSQL Job Claim + +![Worker별 OpenSQL Claim 처리량과 p99 지연](assets/final-performance-report/claim-throughput-tail-latency.svg) + +| Worker | 중앙 TPS | p95 | p99 | Hikari 대기 최대 | +|---:|---:|---:|---:|---:| +| 1 | 273.61 | 5.525ms | 6.077ms | 0 | +| 5 | **748.45** | **9.872ms** | **13.187ms** | **0** | +| 10 | 563.07 | 61.265ms | 67.780ms | 0 | +| 20 | 591.32 | 79.235ms | 89.913ms | 0 | +| 40 | 591.89 | 166.329ms | 212.795ms | 20 | + +Worker 1에서 5로 늘리면 처리량은 2.74배가 됐다. 10 이상에서는 처리량이 오히려 563~592 TPS로 +낮아지고 Tail Latency가 급증했다. Worker 40은 Hikari 대기 Thread가 최대 20개까지 발생했다. +따라서 이 OpenSQL Container 환경에서는 Worker 5가 가장 좋은 균형점이다. + +초기 PostgreSQL 14.6 기준선에서는 Worker 5와 10이 약 1,301 TPS로 비슷하고 Worker 40이 가장 높은 +1,559.80 TPS였지만 p99 97.63ms와 Pool 대기가 발생했다. 이 값은 다른 DB·실행 환경의 과거 기준선이므로 +OpenSQL 17.8 절대 성능과 직접 비교하지 않고, 동시성 증가가 Tail Latency를 악화시킨다는 방향만 +교차 확인한다. + +## 4. 실제 BGE-M3 Batch Size + +![BGE-M3 Batch Size별 처리량과 p95 지연](assets/final-performance-report/bge-batch-throughput-latency.svg) + +| Batch | 처리량 | 요청 p95 | 평균 ms/Text | 최고 RSS | +|---:|---:|---:|---:|---:| +| 1 | 5.85 texts/s | 222.97ms | 170.81 | 2,176.80MiB | +| 4 | 11.12 texts/s | 409.23ms | 89.93 | 2,176.80MiB | +| 8 | 13.57 texts/s | 637.61ms | 73.69 | 2,176.68MiB | +| 16 | 15.15 texts/s | 1,224.44ms | 66.02 | 2,185.02MiB | +| 32 | **17.08 texts/s** | **1,975.70ms** | **58.56** | **2,176.68MiB** | +| 64 | 17.38 texts/s | 3,719.82ms | 57.55 | 2,259.72MiB | + +Batch 32는 최대 처리량인 Batch 64의 98.27%를 확보한다. 반면 p95는 1.98초로 Batch 64보다 +46.89% 낮고 최고 RSS도 약 83.05MiB 낮다. Batch 16에서 32로 올릴 때는 처리량이 12.74% +증가하지만, 32에서 64의 추가 이득은 1.76%뿐이다. 현재 CPU 실행 환경의 기본값은 32가 합리적이다. + +## 5. Exact Search 대비 HNSW + +![Vector 규모별 Exact와 HNSW p95 및 Recall](assets/final-performance-report/vector-exact-hnsw-latency-recall.svg) + +| Vector 수 | Exact p95 | HNSW p95 | 속도 배율 | 평균 Recall@10 | 최소 Recall@10 | +|---:|---:|---:|---:|---:|---:| +| 2,000 | 5.103ms | 1.139ms | 4.48배 | 0.96 | 0.90 | +| 10,000 | 25.874ms | 2.015ms | 12.84배 | 0.52 | 0.10 | +| 50,000 | 197.611ms | 1.994ms | **99.09배** | **0.22** | **0.00** | + +HNSW는 데이터가 25배 증가해도 p95가 약 1.75배만 증가했지만, 기본 `ef_search=40`의 Recall은 +합성 1,024차원 Random Vector에서 크게 낮아졌다. 이 결과는 HNSW를 제거해야 한다는 뜻이 아니라, +속도만 보고 운영값을 확정하면 안 된다는 뜻이다. 실제 문서 Corpus와 Query Set에서 `ef_search`별 +p95·Recall Pareto Curve를 추가 측정해야 한다. + +OpenSQL 17.8 호환성 Probe에서는 2천 Vector HNSW p95가 4.706ms였고 같은 Test의 로컬 PostgreSQL +17.8 기준선은 2.344ms였다. OpenSQL 실행에 x86-64 Emulation과 Container Network가 포함됐으므로 +이 차이를 Database Engine만의 차이로 해석하지 않는다. + +## 6. Worker 수평 확장 + +![Worker 수와 실행 Slot별 처리량 및 지연](assets/final-performance-report/worker-horizontal-scaling.svg) + +| Profile | 전체 Slot | 문서/분 | Speedup | Slot 효율 | Queue p95 | 처리 p95 | E2E p95 | +|---|---:|---:|---:|---:|---:|---:|---:| +| w1-s1 | 1 | 19.475 | 1.000x | 1.000 | 43.963초 | 3.391초 | 46.939초 | +| w1-s2 | 2 | **21.862** | **1.123x** | **0.561** | 38.302초 | 5.550초 | 43.671초 | +| w2-s1 | 2 | 21.576 | 1.108x | 0.554 | 38.912초 | 5.647초 | 44.225초 | +| w2-s2 | 4 | 21.577 | 1.108x | 0.277 | 33.627초 | 11.693초 | 44.294초 | +| w4-s2 | 8 | 21.677 | 1.113x | 0.139 | 22.810초 | 22.741초 | 44.206초 | + +1 Slot에서 2 Slot로 늘릴 때 처리량은 12.3% 증가했다. 이후 4·8 Slot로 늘려도 처리량은 약 +21.6문서/분에 머물렀다. 8 Slot에서는 Queue p95가 22.810초로 줄지만 처리 p95가 22.741초로 +증가한다. 공유 CPU BGE-M3 경합 때문에 대기 위치만 Queue에서 처리 단계로 이동한 것이다. + +별도 16·32문서 기준선에서도 처리량은 19.102에서 18.402문서/분으로 3.7% 감소하고 Queue p95는 +43.794에서 93.554초로 증가했다. 고정된 처리 용량에서 문서 수가 늘면 처리량보다 Queue 대기가 먼저 +증가한다는 결론과 일치한다. + +## 7. Queue 적체·DB Pool Backpressure + +![부하별 처리량과 Queue p95](assets/final-performance-report/queue-backpressure.svg) + +| 문서·업로더 | 상태 | 문서/분 | Upload p95 | Queue p95 | Queue AUC | max waiting | +|---|---|---:|---:|---:|---:|---:| +| 16·4 | `POOL_BACKPRESSURED` | 163.000 | 24.038ms | 3.073초 | 66.087 document·s | 6 | +| 32·8 | `POOL_BACKPRESSURED` | 149.266 | 37.815ms | 9.964초 | 250.325 document·s | 10 | +| 64·16 | `POOL_BACKPRESSURED` | 142.791 | 100.052ms | 23.778초 | 946.828 document·s | 18 | +| 128·32 | `POOL_BACKPRESSURED` | 142.054 | 186.350ms | 49.202초 | 3,673.122 document·s | 33 | + +Hikari Pool 크기 4는 가장 작은 16문서 Profile부터 최대 Active에 도달했고 Connection Waiting도 +관측됐다. 하지만 Waiting Sample 비율은 모든 Profile에서 2% 미만이었고, 128문서까지 업로드 실패, +Job 실패와 제한 시간 미완료는 없었다. 즉 측정 범위에서는 붕괴가 아니라 대기 기반 Backpressure였다. + +64문서부터 처리량은 약 142문서/분으로 Plateau를 형성하지만 Queue p95와 AUC는 계속 증가한다. +운영에서는 단순 TPS뿐 아니라 Queue Depth, Oldest Pending Age, Pool Waiting과 Upload Latency를 함께 +관측해야 한다. Admission Control 임계값은 실제 배포 자원과 SLO를 정한 뒤 별도로 결정해야 한다. + +## 8. 실제 PDF·DOCX 전체 E2E + +![PDF와 DOCX 50·100문서 전체 인덱싱 부하](assets/final-performance-report/pdf-docx-e2e-load.svg) + +| 문서 수 | 구성 | 중앙 총 시간 | 문서/분 | Chunk·Embedding/초 | Queue p95 | 처리 p95 | E2E p95 | +|---:|---|---:|---:|---:|---:|---:|---:| +| 50 | PDF 25 + DOCX 25 | 96.633초 | 31.046 | 2.070 | 88.355초 | 4.093초 | 92.178초 | +| 100 | PDF 50 + DOCX 50 | 187.138초 | 32.063 | 2.138 | 174.684초 | 4.026초 | 178.533초 | + +문서 수를 두 배로 늘려도 처리량과 개별 처리 p95는 유지됐고, Queue p95와 E2E p95가 약 두 배로 +증가했다. PDF·DOCX Parser보다 고정된 Worker 처리 용량 앞의 Queue가 전체 지연을 지배했다. + +50문서와 100문서 Profile을 각각 2회 실행한 본 측정은 합계 300문서다. 네 실행에서 Chunk와 +Embedding은 각각 1,200개였고 실패, Retry, 미완료, 중복 Vector는 0건이었다. 모든 Vector는 +1,024차원이었으며 PDF 페이지와 DOCX Section Metadata도 보존됐다. 스캔 PDF와 OCR은 이 +Workload 범위가 아니다. + +## 9. 병목 이동과 운영 판단 + +```text +Upload·DB Pool + └─ 고동시성에서 짧은 Connection Waiting 발생 + ↓ +Job Queue + └─ 처리 용량을 넘으면 실패보다 Queue 대기와 AUC가 먼저 증가 + ↓ +Worker 실행 Slot + └─ 2 Slot 이후 Claim은 빨라지지만 전체 처리량은 포화 + ↓ +실제 BGE-M3 + └─ 공유 CPU 병목, Batch 32가 처리량·지연·메모리 균형점 + ↓ +pgvector HNSW + └─ 검색 지연은 억제하지만 Recall을 별도로 튜닝해야 함 +``` + +| 결정 | 근거 | 적용 범위 | +|---|---|---| +| Embedding Batch 기본값 32 유지 | 최대 처리량의 98.27%, Batch 64 대비 p95 46.89% 절감 | 현재 CPU BGE-M3 환경 | +| Worker 실행 Slot 2를 초기 기준점으로 사용 | 2 Slot 이후 처리량 포화와 Slot 효율 급락 | 현재 단일 CPU BGE-M3 환경 | +| Claim Worker 5를 검증 기준점으로 사용 | OpenSQL 17.8에서 최고 균형 TPS·p99 | 측정 Container 환경 | +| Queue 지표를 TPS와 함께 관측 | 처리량 Plateau 이후 Queue p95·AUC 지속 증가 | 배포 환경별 임계값은 별도 결정 | +| HNSW `ef_search=40`을 품질 기본값으로 확정하지 않음 | 5만 합성 Vector 평균 Recall@10 0.22 | 실제 Corpus Pareto 측정 필요 | + +## 10. 남은 검증과 한계 + +1. **공급사 지원 환경 최종 검증**: Rocky Linux 9.7 x86-64 원격 서버의 OpenSQL 17.8과 발급 + 라이선스로 Flyway, Claim, Lease, HNSW, BGE-M3 E2E를 다시 검증해야 한다. +2. **실제 Corpus ANN 품질**: 실제 문서·Query 정답 Set에서 `ef_search` 40·80·120·200의 p95와 + Recall@10 Pareto Curve를 측정해야 한다. +3. **운영 SLO 기반 Admission Control**: Queue Depth와 Oldest Pending Age 임계값은 CPU·Memory, + BGE 배포 방식과 목표 완료 시간을 확정한 뒤 정해야 한다. +4. **환경 분리**: 현재 결과는 로컬 개발·호환성·용량 기준선이며 고정 CI Runner나 운영 부하의 + 절대 성능 보장이 아니다. +5. **OCR 제외**: 실제 문서 E2E는 Text Layer PDF와 OOXML DOCX만 포함한다. + +## 11. 재현성과 원본 출처 + +그래프는 외부 Chart Service나 Python Package 없이 정규화 JSON에서 생성된다. + +```bash +python3 scripts/performance/generate_final_performance_report.py +python3 scripts/performance/generate_final_performance_report.py --check +python3 -m unittest scripts/performance/test_generate_final_performance_report.py +``` + +| 검증 | 범위 | 결과 | +|---|---|---| +| 실제 PDF·DOCX 처리량 | 50·100문서 Profile, 각 2회 | PASS | +| 실제 PDF·DOCX 완전성 | 네 Profile 합계 300문서·1,200 Vector | PASS | +| 원본 부하 측정 회귀 | PDF·DOCX 결과 문서 작성 시 전체 일반 Java 회귀 | PASS, 746 tests | +| 통합 리포트 회귀 | 현재 Branch 전체 일반 Java 회귀 | PASS, 728 tests | +| 그래프 생성기 단위 테스트 | 데이터 계약·접근성·Drift 검출 | PASS, 4 tests | +| Commit된 그래프 재현 | 정규화 JSON과 SVG 6개 바이트 비교 | PASS | +| Swagger 수동 검증 | API 계약과 제품 동작을 변경하지 않는 Offline 통합 문서 작업 | 미실행, 범위 밖 | + +50·100문서 처리량과 합계 300문서 완전성 결과는 +[PDF·DOCX 원본 측정](gimin-%23143-pdf-docx-indexing-e2e-load-benchmark.md)에 기록된 실행을 +재사용했다. 이번 작업은 기존 Benchmark를 다시 실행하지 않고 검증된 수치를 정규화·시각화했다. + +| 결과 영역 | 원본 문서 | +|---|---| +| 초기 Claim 기준선 | [Embedding Job Claim 성능](gimin-%2361-embedding-job-claim-performance.md) | +| OpenSQL 17.8 | [OpenSQL 호환성·성능](gimin-%23124-opensql-compatibility-performance.md) | +| BGE-M3 Batch | [Batch Size 성능](gimin-%23128-bge-m3-batch-size-performance.md) | +| Exact·HNSW | [규모별 지연·Recall](gimin-%23131-hnsw-exact-scale-benchmark.md) | +| 자동 Worker 기준선 | [16·32문서 처리량](gimin-%23133-worker-indexing-throughput-benchmark.md) | +| Worker 수평 확장 | [Worker·Slot 비교](gimin-%23138-worker-horizontal-scaling-benchmark.md) | +| Queue·DB Pool | [Backpressure 측정](gimin-%23141-worker-queue-backpressure-benchmark.md) | +| 실제 문서 E2E | [PDF·DOCX 50·100문서](gimin-%23143-pdf-docx-indexing-e2e-load-benchmark.md) | + +## 12. 최종 결론 + +DocGrid는 실제 PDF·DOCX를 Parsing하고 BGE-M3로 Batch Embedding한 뒤 PostgreSQL +`vector(1024)`에 저장하는 전체 경로를 50·100문서 Profile에서 각각 2회 측정했다. 네 실행 합계 +300문서·1,200 Vector의 완전성도 별도로 확인했다. 성능 개선의 우선순위는 Claim Worker 수를 +늘리는 것이 아니라 Embedding 실행 자원과 Queue 운영 정책을 관리하는 것이다. + +현재 측정 근거로는 Batch 32와 실행 Slot 2가 로컬 CPU 환경의 균형점이다. HNSW는 규모가 커질수록 +큰 지연 이점을 보이지만, 검색 품질은 실제 Corpus에서 `ef_search`를 튜닝한 뒤 확정해야 한다. 마지막 +대회 제출 전에는 공급사 지원 Rocky Linux 9.7 원격 OpenSQL 환경에서 같은 호환성·E2E·성능 검증을 +재실행해 로컬 Emulation 결과와 공식 검증 결과를 분리해 제시한다. diff --git a/scripts/performance/generate_final_performance_report.py b/scripts/performance/generate_final_performance_report.py new file mode 100644 index 0000000..e411133 --- /dev/null +++ b/scripts/performance/generate_final_performance_report.py @@ -0,0 +1,597 @@ +#!/usr/bin/env python3 +"""Validate the consolidated performance data and render deterministic SVG charts.""" + +from __future__ import annotations + +import argparse +import html +import json +import math +import sys +from pathlib import Path +from typing import Any + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_DATA_PATH = ( + REPOSITORY_ROOT + / "docs/test-results/gimin-#145-final-performance-report-data.json" +) +DEFAULT_OUTPUT_DIRECTORY = ( + REPOSITORY_ROOT + / "docs/test-results/assets/final-performance-report" +) +REQUIRED_BENCHMARKS = { + "claim", + "bgeBatch", + "vectorSearch", + "workerScaling", + "queueBackpressure", + "realDocumentE2E", +} +PALETTE = ("#2563eb", "#dc2626", "#059669", "#7c3aed", "#d97706") +CANVAS_WIDTH = 1200 +CANVAS_HEIGHT = 680 +PLOT_LEFT = 105 +PLOT_RIGHT = 1095 +PLOT_TOP = 150 +PLOT_BOTTOM = 570 + + +class DataContractError(ValueError): + """Indicate that the consolidated benchmark data violates its public contract.""" + + +def load_data(path: Path) -> dict[str, Any]: + """Read the canonical JSON data as UTF-8.""" + try: + return json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise DataContractError(f"성능 데이터 파일을 찾을 수 없습니다: {path}") from exc + except json.JSONDecodeError as exc: + raise DataContractError(f"성능 데이터 JSON이 올바르지 않습니다: {exc}") from exc + + +def validate_data(data: dict[str, Any], repository_root: Path) -> None: + """Validate schema, provenance, numeric values, and chart references.""" + if data.get("schemaVersion") != 1: + raise DataContractError("schemaVersion은 1이어야 합니다.") + + sources = data.get("sources") + if not isinstance(sources, list) or not sources: + raise DataContractError("sources는 비어 있지 않은 배열이어야 합니다.") + + source_ids: set[str] = set() + for source in sources: + source_id = _require_non_empty_string(source, "id", "source") + if source_id in source_ids: + raise DataContractError(f"중복 source id입니다: {source_id}") + source_ids.add(source_id) + source_path = _require_non_empty_string(source, "path", source_id) + resolved = (repository_root / source_path).resolve() + if not resolved.is_relative_to(repository_root.resolve()): + raise DataContractError(f"저장소 밖의 source 경로입니다: {source_path}") + if not resolved.is_file(): + raise DataContractError(f"source 문서를 찾을 수 없습니다: {source_path}") + + benchmarks = data.get("benchmarks") + if not isinstance(benchmarks, dict): + raise DataContractError("benchmarks는 객체여야 합니다.") + missing = REQUIRED_BENCHMARKS - benchmarks.keys() + if missing: + raise DataContractError(f"필수 benchmark가 없습니다: {', '.join(sorted(missing))}") + + output_files: set[str] = set() + for benchmark_id in sorted(REQUIRED_BENCHMARKS): + _validate_benchmark( + benchmark_id, + benchmarks[benchmark_id], + source_ids, + output_files, + ) + + _validate_supporting_baselines(data.get("supportingBaselines"), source_ids) + + +def _validate_benchmark( + benchmark_id: str, + benchmark: dict[str, Any], + source_ids: set[str], + output_files: set[str], +) -> None: + """Validate one chart-backed benchmark.""" + if not isinstance(benchmark, dict): + raise DataContractError(f"{benchmark_id} benchmark는 객체여야 합니다.") + _require_non_empty_string(benchmark, "title", benchmark_id) + _require_non_empty_string(benchmark, "subtitle", benchmark_id) + _require_non_empty_string(benchmark, "categoryLabel", benchmark_id) + + categories = benchmark.get("categories") + if ( + not isinstance(categories, list) + or not categories + or any(not isinstance(value, str) or not value.strip() for value in categories) + ): + raise DataContractError(f"{benchmark_id}.categories가 올바르지 않습니다.") + + referenced_sources = benchmark.get("sourceIds") + if not isinstance(referenced_sources, list) or not referenced_sources: + raise DataContractError(f"{benchmark_id}.sourceIds는 비어 있을 수 없습니다.") + unknown_sources = set(referenced_sources) - source_ids + if unknown_sources: + raise DataContractError( + f"{benchmark_id}가 알 수 없는 source를 참조합니다: " + f"{', '.join(sorted(unknown_sources))}" + ) + + series = benchmark.get("series") + if not isinstance(series, dict) or not series: + raise DataContractError(f"{benchmark_id}.series는 비어 있을 수 없습니다.") + for series_id, definition in series.items(): + _require_non_empty_string(definition, "label", f"{benchmark_id}.{series_id}") + _require_non_empty_string(definition, "unit", f"{benchmark_id}.{series_id}") + values = definition.get("values") + if not isinstance(values, list) or len(values) != len(categories): + raise DataContractError( + f"{benchmark_id}.{series_id} 값 수가 category 수와 다릅니다." + ) + for value in values: + if ( + isinstance(value, bool) + or not isinstance(value, (int, float)) + or not math.isfinite(value) + or value < 0 + ): + raise DataContractError( + f"{benchmark_id}.{series_id}에는 0 이상의 유한한 수만 허용됩니다." + ) + + chart = benchmark.get("chart") + if not isinstance(chart, dict): + raise DataContractError(f"{benchmark_id}.chart는 객체여야 합니다.") + file_name = _require_non_empty_string(chart, "file", benchmark_id) + if Path(file_name).name != file_name or not file_name.endswith(".svg"): + raise DataContractError(f"올바르지 않은 SVG 파일 이름입니다: {file_name}") + if file_name in output_files: + raise DataContractError(f"중복 SVG 파일 이름입니다: {file_name}") + output_files.add(file_name) + + left_series = _require_series_list(chart, "leftSeries", benchmark_id, series) + right_series = _require_series_list(chart, "rightSeries", benchmark_id, series) + if set(left_series) & set(right_series): + raise DataContractError(f"{benchmark_id}의 좌우 축 Series가 중복됩니다.") + if chart.get("leftScale") not in {"linear", "log10"}: + raise DataContractError(f"{benchmark_id}.leftScale이 올바르지 않습니다.") + for style_key in ("leftStyle", "rightStyle"): + if chart.get(style_key) not in {"line", "bar"}: + raise DataContractError(f"{benchmark_id}.{style_key}이 올바르지 않습니다.") + if chart["leftScale"] == "log10": + for series_id in left_series: + if any(value <= 0 for value in series[series_id]["values"]): + raise DataContractError( + f"{benchmark_id}.{series_id}의 Log 축 값은 0보다 커야 합니다." + ) + + +def _validate_supporting_baselines( + baselines: Any, + source_ids: set[str], +) -> None: + """Validate provenance and aligned arrays for non-chart supporting baselines.""" + if not isinstance(baselines, dict) or not baselines: + raise DataContractError("supportingBaselines는 비어 있을 수 없습니다.") + for baseline_id, baseline in baselines.items(): + referenced_sources = baseline.get("sourceIds") + if not isinstance(referenced_sources, list) or not referenced_sources: + raise DataContractError(f"{baseline_id}.sourceIds는 비어 있을 수 없습니다.") + unknown_sources = set(referenced_sources) - source_ids + if unknown_sources: + raise DataContractError( + f"{baseline_id}가 알 수 없는 source를 참조합니다: " + f"{', '.join(sorted(unknown_sources))}" + ) + _require_non_empty_string(baseline, "comparisonBoundary", baseline_id) + + +def _require_non_empty_string( + value: dict[str, Any], + key: str, + context: str, +) -> str: + """Return a required, trimmed string or raise a contextual error.""" + result = value.get(key) if isinstance(value, dict) else None + if not isinstance(result, str) or not result.strip(): + raise DataContractError(f"{context}.{key}는 비어 있지 않은 문자열이어야 합니다.") + return result.strip() + + +def _require_series_list( + chart: dict[str, Any], + key: str, + benchmark_id: str, + series: dict[str, Any], +) -> list[str]: + """Return a non-empty list of registered series identifiers.""" + result = chart.get(key) + if not isinstance(result, list) or not result: + raise DataContractError(f"{benchmark_id}.{key}는 비어 있을 수 없습니다.") + unknown = set(result) - series.keys() + if unknown: + raise DataContractError( + f"{benchmark_id}.{key}가 알 수 없는 Series를 참조합니다: " + f"{', '.join(sorted(unknown))}" + ) + return result + + +def render_all(data: dict[str, Any]) -> dict[str, str]: + """Render every required benchmark to a file-name-to-SVG mapping.""" + return { + benchmark["chart"]["file"]: render_chart(benchmark) + for benchmark_id, benchmark in data["benchmarks"].items() + if benchmark_id in REQUIRED_BENCHMARKS + } + + +def render_chart(benchmark: dict[str, Any]) -> str: + """Render one accessible dual-axis chart as a deterministic SVG string.""" + categories = benchmark["categories"] + series = benchmark["series"] + chart = benchmark["chart"] + ordered_series = chart["leftSeries"] + chart["rightSeries"] + colors = { + series_id: PALETTE[index % len(PALETTE)] + for index, series_id in enumerate(ordered_series) + } + left_axis = _build_axis( + [series[series_id]["values"] for series_id in chart["leftSeries"]], + chart["leftScale"], + series[chart["leftSeries"][0]]["unit"], + ) + right_axis = _build_axis( + [series[series_id]["values"] for series_id in chart["rightSeries"]], + "linear", + series[chart["rightSeries"][0]]["unit"], + ) + + parts = [ + '', + ( + f'' + ), + f'{_escape(benchmark["title"])}', + ( + '' + f'{_escape(benchmark["subtitle"])}. ' + f'X축은 {_escape(benchmark["categoryLabel"])}이며 좌우 축 Series를 함께 표시한다.' + '' + ), + '', + '', + f'{_escape(benchmark["title"])}', + f'{_escape(benchmark["subtitle"])}', + ] + parts.extend(_render_legend(ordered_series, series, colors)) + parts.extend(_render_grid_and_axes(left_axis, right_axis)) + + x_positions = _category_positions(len(categories)) + left_style = chart["leftStyle"] + right_style = chart["rightStyle"] + if left_style == "bar": + parts.extend( + _render_bars(chart["leftSeries"], series, colors, x_positions, left_axis) + ) + if right_style == "bar": + parts.extend( + _render_bars(chart["rightSeries"], series, colors, x_positions, right_axis) + ) + if left_style == "line": + parts.extend( + _render_lines(chart["leftSeries"], series, colors, x_positions, left_axis) + ) + if right_style == "line": + parts.extend( + _render_lines(chart["rightSeries"], series, colors, x_positions, right_axis) + ) + + for x, category in zip(x_positions, categories, strict=True): + parts.append( + f'' + f'{_escape(category)}' + ) + parts.extend( + [ + ( + f'{_escape(benchmark["categoryLabel"])}' + ), + ( + f'' + f'{_escape(left_axis["unit"])}' + ), + ( + f'' + f'{_escape(right_axis["unit"])}' + ), + '', + '', + ] + ) + return "\n".join(parts) + + +def _build_axis(value_groups: list[list[float]], scale: str, unit: str) -> dict[str, Any]: + """Create an axis descriptor and value-to-y coordinate mapping inputs.""" + maximum = max(max(values) for values in value_groups) + if scale == "log10": + lower = 10 ** math.floor(math.log10(min(min(values) for values in value_groups))) + upper = 10 ** math.ceil(math.log10(maximum)) + if lower == upper: + upper *= 10 + ticks = [] + tick = lower + while tick <= upper: + ticks.append(float(tick)) + tick *= 10 + return {"minimum": lower, "maximum": upper, "ticks": ticks, "scale": scale, "unit": unit} + + upper = _nice_upper(maximum) + ticks = [upper * index / 5 for index in range(6)] + return {"minimum": 0.0, "maximum": upper, "ticks": ticks, "scale": scale, "unit": unit} + + +def _nice_upper(maximum: float) -> float: + """Round a positive maximum to a stable human-readable chart bound.""" + if maximum <= 0: + return 1.0 + rough = maximum * 1.08 + magnitude = 10 ** math.floor(math.log10(rough)) + normalized = rough / magnitude + if normalized <= 1: + nice = 1 + elif normalized <= 2: + nice = 2 + elif normalized <= 5: + nice = 5 + else: + nice = 10 + return nice * magnitude + + +def _category_positions(count: int) -> list[float]: + """Return centered x coordinates for a categorical axis.""" + slot = (PLOT_RIGHT - PLOT_LEFT) / count + return [PLOT_LEFT + slot * (index + 0.5) for index in range(count)] + + +def _axis_y(value: float, axis: dict[str, Any]) -> float: + """Map a numeric value onto the chart's y coordinate.""" + if axis["scale"] == "log10": + minimum = math.log10(axis["minimum"]) + maximum = math.log10(axis["maximum"]) + ratio = (math.log10(value) - minimum) / (maximum - minimum) + else: + ratio = (value - axis["minimum"]) / (axis["maximum"] - axis["minimum"]) + return PLOT_BOTTOM - ratio * (PLOT_BOTTOM - PLOT_TOP) + + +def _render_legend( + ordered_series: list[str], + series: dict[str, Any], + colors: dict[str, str], +) -> list[str]: + """Render a horizontal legend that includes a marker and unit.""" + parts: list[str] = [] + x = PLOT_LEFT + for series_id in ordered_series: + definition = series[series_id] + parts.append( + f'' + ) + parts.append( + f'' + ) + label = f'{definition["label"]} ({definition["unit"]})' + parts.append( + f'{_escape(label)}' + ) + x += 32 + max(135, len(label) * 8) + return parts + + +def _render_grid_and_axes( + left_axis: dict[str, Any], + right_axis: dict[str, Any], +) -> list[str]: + """Render plot frame, horizontal grid, and both numeric axes.""" + parts = [ + ( + f'' + ) + ] + for tick in left_axis["ticks"]: + y = _axis_y(tick, left_axis) + parts.append( + f'' + ) + parts.append( + f'{_format_number(tick)}' + ) + for tick in right_axis["ticks"]: + y = _axis_y(tick, right_axis) + parts.append( + f'{_format_number(tick)}' + ) + return parts + + +def _render_lines( + series_ids: list[str], + series: dict[str, Any], + colors: dict[str, str], + x_positions: list[float], + axis: dict[str, Any], +) -> list[str]: + """Render line series with visible point markers and exact-value metadata.""" + parts: list[str] = [] + for series_id in series_ids: + values = series[series_id]["values"] + points = " ".join( + f"{x:.2f},{_axis_y(value, axis):.2f}" + for x, value in zip(x_positions, values, strict=True) + ) + parts.append( + f'' + ) + for x, value in zip(x_positions, values, strict=True): + y = _axis_y(value, axis) + parts.append( + f'' + ) + return parts + + +def _render_bars( + series_ids: list[str], + series: dict[str, Any], + colors: dict[str, str], + x_positions: list[float], + axis: dict[str, Any], +) -> list[str]: + """Render grouped bars against one numeric axis.""" + category_slot = (PLOT_RIGHT - PLOT_LEFT) / len(x_positions) + group_width = category_slot * 0.58 + bar_width = group_width / len(series_ids) + baseline_y = _axis_y(axis["minimum"], axis) if axis["scale"] == "log10" else PLOT_BOTTOM + parts: list[str] = [] + for series_index, series_id in enumerate(series_ids): + values = series[series_id]["values"] + for x_center, value in zip(x_positions, values, strict=True): + x = x_center - group_width / 2 + series_index * bar_width + 2 + y = _axis_y(value, axis) + height = max(1.0, baseline_y - y) + parts.append( + f'' + ) + return parts + + +def _format_number(value: float) -> str: + """Format axis values without locale-dependent output.""" + if value >= 1000: + return f"{value:,.0f}" + if value >= 10: + return f"{value:.0f}" + if value >= 1: + return f"{value:.1f}".rstrip("0").rstrip(".") + return f"{value:.2f}".rstrip("0").rstrip(".") + + +def _escape(value: Any) -> str: + """Escape text used in SVG element content and attributes.""" + return html.escape(str(value), quote=True) + + +def write_outputs(rendered: dict[str, str], output_directory: Path) -> None: + """Write all rendered SVG files using stable UTF-8 and LF output.""" + output_directory.mkdir(parents=True, exist_ok=True) + for file_name in sorted(rendered): + # Force LF so the same normalized data produces identical bytes on every OS. + with (output_directory / file_name).open( + "w", encoding="utf-8", newline="\n" + ) as output_file: + output_file.write(rendered[file_name]) + + +def check_outputs(rendered: dict[str, str], output_directory: Path) -> None: + """Fail when committed SVG files are missing or differ from generated output.""" + mismatches: list[str] = [] + for file_name in sorted(rendered): + output_path = output_directory / file_name + if not output_path.is_file(): + mismatches.append(f"누락: {_display_path(output_path)}") + continue + # Compare bytes to detect platform newline conversion as output drift. + if output_path.read_bytes() != rendered[file_name].encode("utf-8"): + mismatches.append(f"불일치: {_display_path(output_path)}") + if mismatches: + details = "\n".join(f"- {mismatch}" for mismatch in mismatches) + raise DataContractError( + "Commit된 성능 그래프가 정규화 데이터와 일치하지 않습니다.\n" + f"{details}\n" + "다음 명령으로 다시 생성하세요: " + "python3 scripts/performance/generate_final_performance_report.py" + ) + + +def _display_path(path: Path) -> str: + """Prefer a repository-relative path while supporting isolated test directories.""" + try: + return str(path.relative_to(REPOSITORY_ROOT)) + except ValueError: + return str(path) + + +def parse_arguments(arguments: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for generation and verification.""" + parser = argparse.ArgumentParser( + description="DocGrid 최종 통합 성능 데이터 검증과 SVG 생성을 수행합니다." + ) + parser.add_argument("--check", action="store_true", help="Commit된 SVG와 생성 결과만 비교합니다.") + parser.add_argument("--data", type=Path, default=DEFAULT_DATA_PATH, help="정규화 JSON 경로") + parser.add_argument( + "--output-dir", + type=Path, + default=DEFAULT_OUTPUT_DIRECTORY, + help="SVG 출력 Directory", + ) + return parser.parse_args(arguments) + + +def main(arguments: list[str] | None = None) -> int: + """Run validation, rendering, and either deterministic check or file writing.""" + options = parse_arguments(arguments) + try: + # 1. 정규화 데이터와 저장소 내 출처를 먼저 검증한다. + data = load_data(options.data) + validate_data(data, REPOSITORY_ROOT) + # 2. 검증된 데이터만 결정적 SVG 문자열로 변환한다. + rendered = render_all(data) + # 3. 검사 모드는 파일을 변경하지 않고 Commit 결과와 비교한다. + if options.check: + check_outputs(rendered, options.output_dir) + print(f"성능 그래프 {len(rendered)}개가 정규화 데이터와 일치합니다.") + else: + # 4. 기본 모드는 검증된 결과만 대상 Directory에 기록한다. + write_outputs(rendered, options.output_dir) + print(f"성능 그래프 {len(rendered)}개를 생성했습니다: {options.output_dir}") + return 0 + except DataContractError as exc: + print(f"오류: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/performance/test_generate_final_performance_report.py b/scripts/performance/test_generate_final_performance_report.py new file mode 100644 index 0000000..df65c00 --- /dev/null +++ b/scripts/performance/test_generate_final_performance_report.py @@ -0,0 +1,71 @@ +"""Regression tests for the deterministic final performance chart generator.""" + +from __future__ import annotations + +import copy +import importlib.util +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_PATH = Path(__file__).with_name("generate_final_performance_report.py") +SPEC = importlib.util.spec_from_file_location("final_performance_report", SCRIPT_PATH) +if SPEC is None or SPEC.loader is None: + raise RuntimeError(f"생성기 Module을 읽을 수 없습니다: {SCRIPT_PATH}") +GENERATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(GENERATOR) + + +class FinalPerformanceReportGeneratorTest(unittest.TestCase): + """Verify data-contract failures and deterministic output behavior.""" + + def setUp(self) -> None: + """Load a fresh copy of the canonical data for every test.""" + self.data = GENERATOR.load_data(GENERATOR.DEFAULT_DATA_PATH) + + def test_canonical_data_and_sources_are_valid(self) -> None: + """The committed data must satisfy the complete provenance contract.""" + GENERATOR.validate_data(self.data, GENERATOR.REPOSITORY_ROOT) + + def test_every_required_chart_renders_accessible_svg(self) -> None: + """All required benchmarks must produce titled SVG images.""" + GENERATOR.validate_data(self.data, GENERATOR.REPOSITORY_ROOT) + rendered = GENERATOR.render_all(self.data) + + self.assertEqual(6, len(rendered)) + for file_name, svg in rendered.items(): + with self.subTest(file_name=file_name): + self.assertTrue(file_name.endswith(".svg")) + self.assertIn('role="img"', svg) + self.assertIn("", svg) + self.assertIn("<desc id=\"chart-description\">", svg) + self.assertTrue(svg.endswith("\n")) + + def test_series_length_mismatch_is_rejected(self) -> None: + """A chart cannot silently omit a category value.""" + invalid = copy.deepcopy(self.data) + invalid["benchmarks"]["claim"]["series"]["throughput"]["values"].pop() + + with self.assertRaisesRegex(GENERATOR.DataContractError, "category 수와 다릅니다"): + GENERATOR.validate_data(invalid, GENERATOR.REPOSITORY_ROOT) + + def test_check_detects_a_tampered_svg(self) -> None: + """Check mode must detect drift between data and committed charts.""" + GENERATOR.validate_data(self.data, GENERATOR.REPOSITORY_ROOT) + rendered = GENERATOR.render_all(self.data) + GENERATOR.check_outputs(rendered, GENERATOR.DEFAULT_OUTPUT_DIRECTORY) + + with tempfile.TemporaryDirectory() as directory: + output_directory = Path(directory) + GENERATOR.write_outputs(rendered, output_directory) + GENERATOR.check_outputs(rendered, output_directory) + first_file = output_directory / sorted(rendered)[0] + first_file.write_text("tampered", encoding="utf-8") + + with self.assertRaisesRegex(GENERATOR.DataContractError, "불일치"): + GENERATOR.check_outputs(rendered, output_directory) + + +if __name__ == "__main__": + unittest.main()