diff --git a/mydocs/manual/cli_commands.md b/mydocs/manual/cli_commands.md index fd4c7623a..39bf8c01d 100644 --- a/mydocs/manual/cli_commands.md +++ b/mydocs/manual/cli_commands.md @@ -122,6 +122,16 @@ ingest JSON(시험문제 등) → HWPX 생성. (rhwp-exam-ingest 파이프라인 `Δ Line: 4→0 (-4) RawSvg: 1→0 (-1)`, 배치는 콘솔/`struct_delta` 컬럼에 `Line:-4;RawSvg:-1`). 음수=라운드트립 손실, 양수=추가. 손실 노드 타입으로 직렬화 누락 원인을 즉시 좁힌다. +### `bench <파일...> | --batch <폴더> [-n <반복수>] [--tsv <출력.tsv>]` +**단계별 처리 성능 계측** — parse / layout / render / serialize 를 워밍업 1회 후 N회(기본 3) +반복하여 median(ms)으로 보고한다. +- 단계: `parse`(바이트→IR, `parse_document`) · `layout`(=load−parse 근사) · + `render`(전 페이지 SVG) · `serialize`(`serialize_hwpx`, 저장 비용). +- 파일별 크기KB/쪽수 + 단계별 median + total 표, 다파일 시 합계·쪽당 평균. +- `--batch <폴더>` 재귀 전수(.hwp/.hwpx), `--tsv <경로>` 산출(부모 폴더 자동 생성). +- **주의**: 절대 수치는 측정 머신·빌드(release/debug) 의존. 동일 환경 **상대 비교·재현** + 지표로 해석(한컴 등 외부 기준 아님). release 빌드 권장. + --- ## 4. HWPX→HWP 저장 계약 분석 (hwp5-* 진단 도구) diff --git a/mydocs/pr/archives/pr_1538_report.md b/mydocs/pr/archives/pr_1538_report.md new file mode 100644 index 000000000..8c56abaef --- /dev/null +++ b/mydocs/pr/archives/pr_1538_report.md @@ -0,0 +1,154 @@ +# PR #1538 사전 처리 판단 보고서 — bench 단계별 처리 성능 계측 CLI + +- PR: https://github.com/edwardkim/rhwp/pull/1538 +- 제목: `Task #1537: bench — 단계별 처리 성능 계측 CLI 서브커맨드` +- 작성자: `planet6897` (Jaeuk Ryu) +- 관련 이슈: #1537 +- 검토일: 2026-06-26 +- 검증 head: `a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2` +- 처리 경로: collaborator-mediated 외부 PR 처리 경로 +- 문서 경로: `mydocs/pr/archives/pr_1538_review.md`, `mydocs/pr/archives/pr_1538_report.md` + +## 1. 사전 판단 + +**보정/문서 커밋 push 후 최신 CI 확인을 조건으로 수용 가능.** + +PR #1538은 `rhwp bench` CLI를 추가해 HWP/HWPX 처리 성능을 parse/layout/render/serialize 단계별 +median(ms)로 측정한다. 변경은 `src/diagnostics/bench.rs`, CLI dispatch/help, `cli_commands.md`, +contributor 성능 보고서에 한정된다. + +초기 검토에서 자동화 신뢰성 문제가 2건 발견되어 Request changes를 제출했다. + +- render 단계 실패가 성공처럼 숨겨질 수 있음. +- 하나 이상의 파일 처리 실패가 있어도 프로세스 exit code가 0으로 남음. + +contributor 수정 커밋 `2ca6a3463dad1a109286d44ac9ac7445bc23ad1c`에서 두 항목은 반영됐다. 이후 추가 +검토에서 TSV 쓰기 실패도 exit 0으로 남는 같은 계열 문제가 발견되어, collaborator 보정 커밋 +`a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2`로 `write_tsv` 실패를 failure count에 포함했다. + +현재 로컬 검증 기준 blocking finding은 없다. 다만 보정/문서 커밋 push 후 최신 PR head 기준 GitHub Actions와 +review decision 갱신이 필요하다. + +## 2. PR 상태 + +문서 작성 전 GitHub 확인 기준: + +| 항목 | 값 | +|---|---| +| state | open | +| draft | false | +| mergeable | `MERGEABLE` | +| merge state | `CLEAN` | +| review decision | `CHANGES_REQUESTED` | +| labels | `enhancement`, `performance` | +| milestone | 없음 | +| assignee | `planet6897` | +| head branch | `planet6897:pr-task1537` | +| maintainerCanModify | true | +| issue #1537 | open, milestone 없음 | + +GitHub Actions, contributor 수정 커밋 `2ca6a3463dad1a109286d44ac9ac7445bc23ad1c` 기준: + +| 체크 | 결과 | +|---|---| +| Build & Test | success | +| Analyze (rust) | success | +| Analyze (javascript-typescript) | success | +| Analyze (python) | success | +| Canvas visual diff | success | +| CodeQL | success | +| WASM Build | skipped | + +주의: + +- 이 보고서는 pre-merge 판단 보고서다. merge SHA, 실제 merge 시각, issue close 완료 여부를 단정하지 않는다. +- 보정/문서 커밋 push 후 위 상태는 최신 head 기준으로 다시 확인해야 한다. + +## 3. 변경 검토 + +| 파일 | 변경 | 판단 | +|---|---|---| +| `src/diagnostics/bench.rs` | `bench` 구현. 파일/배치 입력, 반복 median, TSV 산출, 실패 exit 처리 | 보정 후 타당 | +| `src/diagnostics/mod.rs` | `bench` 모듈 export | 타당 | +| `src/main.rs` | `bench` dispatch/help 추가 | 타당 | +| `mydocs/manual/cli_commands.md` | CLI 매뉴얼에 `bench` 추가 | 타당 | +| `mydocs/report/task_m100_1537_report.md` | contributor 성능 측정 보고서 | 한계 명시되어 수용 가능 | + +핵심 판단: + +- `bench`는 새로운 진단 CLI이며 renderer/serializer 동작 자체를 바꾸지 않는다. +- 측정값은 머신/빌드 의존값으로 문서화되어 있고, 같은 환경에서의 상대 비교·회귀 추적 지표로 설명된다. +- render 실패, 파일 실패, TSV 쓰기 실패가 모두 non-zero exit로 이어지도록 보정됐다. + +## 4. Contributor credit와 커밋 출처 + +원본 contributor 커밋: + +| source commit | author | 내용 | +|---|---|---| +| `75c7fd75a67aac99ea1547a34129c984b1819fa3` | Jaeook Ryu `` | `bench` CLI 본 구현, help/manual/report | +| `2ca6a3463dad1a109286d44ac9ac7445bc23ad1c` | Jaeook Ryu `` | Request changes 반영: render 실패 전파, 파일 실패 시 exit 1 | + +`75c7fd75`에는 다음 co-author trailer가 있다. + +```text +Co-Authored-By: Claude Opus 4.8 (1M context) +``` + +collaborator 보정 커밋: + +| commit | author | 내용 | +|---|---|---| +| `a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2` | postmelee `` | TSV 쓰기 실패도 exit 1로 처리 | + +contributor 커밋은 rewrite하지 않았고, collaborator 변경은 후속 커밋으로 분리했다. + +## 5. 로컬 검증 + +검증 head: + +```text +a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2 +``` + +| 명령 | 결과 | +|---|---| +| `cargo fmt --check` | 통과 | +| `git diff --check` | 통과 | +| `cargo clippy --all-targets -- -D warnings` | 통과 | +| `cargo build --release --bin rhwp` | 통과 | +| `./target/release/rhwp bench samples/hwpx_sample2.hwpx samples/task-001.hwp -n 1 --tsv output/poc/pr1538/bench-collab.tsv` | 통과, exit 0 | +| `./target/release/rhwp bench /no/such/file -n 1` | 실패 입력을 보고하고 exit 1 | +| `./target/release/rhwp bench samples/task-001.hwp -n 1 --tsv /dev/null/foo.tsv` | TSV 쓰기 실패를 보고하고 exit 1 | + +이전 기본 구현 검증: + +| 명령 | 결과 | +|---|---| +| `cargo test --release --lib` | 통과, 1937 passed / 0 failed / 6 ignored | + +## 6. 시각 검증 + +별도 시각 검증 산출물은 만들지 않았다. 이 PR은 진단 CLI 추가이며, 렌더러 출력이나 layout 알고리즘을 직접 +수정하지 않는다. render 단계는 기존 `DocumentCore::render_page_svg_native` 호출 시간을 측정할 뿐이다. + +GitHub의 Canvas visual diff는 contributor 수정 커밋 기준 success였다. 보정/문서 커밋 push 후 최신 check +상태를 다시 확인한다. + +## 7. merge 전 조건 + +1. 보정 커밋과 문서 커밋을 `planet6897/rhwp:pr-task1537`에 push한다. +2. PR diff에 `mydocs/pr/archives/pr_1538_review.md`와 `mydocs/pr/archives/pr_1538_report.md`가 포함되는지 확인한다. +3. 최신 PR head 기준 GitHub Actions를 확인한다. 코드 보정 커밋이 있으므로 최신 relevant checks가 필요하다. +4. 기존 `CHANGES_REQUESTED` review decision은 작업지시자 승인 후 새 review로 갱신한다. +5. merge 전 최신 `mergeable`, `mergeStateStatus`, head SHA를 다시 확인한다. +6. merge 후 #1537 상태를 확인한다. GitHub API의 `closingIssuesReferences`가 비어 있었으므로 자동 close 실패 가능성을 고려한다. +7. issue close 또는 수동 close comment는 작업지시자 승인 전에는 수행하지 않는다. + +## 8. 권장 처리 + +권고: **최신 CI 통과와 review decision 갱신을 조건으로 Approve 가능.** + +현재 로컬 검증 기준 blocking finding은 없다. 보정 커밋은 자동화에서 TSV 산출 실패를 성공으로 오인하는 문제를 +해소한다. 문서 커밋 push 후에는 최신 CI 상태를 확인하고, 작업지시자 승인에 따라 GitHub review를 +`Approve`로 갱신하면 된다. diff --git a/mydocs/pr/archives/pr_1538_review.md b/mydocs/pr/archives/pr_1538_review.md new file mode 100644 index 000000000..4ad168ad3 --- /dev/null +++ b/mydocs/pr/archives/pr_1538_review.md @@ -0,0 +1,220 @@ +# PR #1538 검토 기록 — bench 단계별 처리 성능 계측 CLI + +- PR: https://github.com/edwardkim/rhwp/pull/1538 +- 제목: `Task #1537: bench — 단계별 처리 성능 계측 CLI 서브커맨드` +- 작성자: `planet6897` (Jaeuk Ryu) +- 관련 이슈: #1537 +- 작성일: 2026-06-26 +- 처리 경로: collaborator-mediated 외부 PR 처리 경로. `maintainerCanModify=true`이므로 review 문서를 + PR head의 `mydocs/pr/archives/`에 직접 포함한다. +- base/head: `edwardkim/rhwp:devel` <- `planet6897/rhwp:pr-task1537` +- 문서 작성 시점 검증 head: `a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2` +- 규모: 5 files, +373 / -0 (문서 커밋 전 기준) + +`draft`, `mergeable`, `head SHA`, `CI 상태`는 변하는 값이므로 최종 판단 전 최신 상태를 다시 확인한다. + +## 1. 목적 + +PR #1538은 대용량 HWP/HWPX 처리 성능을 재현 가능한 수치로 계량하기 위해 `rhwp bench` 서브커맨드를 +추가한다. + +대상 명령: + +```text +rhwp bench <파일...> | --batch <폴더> [-n <반복수>] [--tsv <출력.tsv>] +``` + +계측 단계: + +- `parse`: 바이트 -> Document IR (`parse_document`) +- `layout`: `DocumentCore::from_bytes` 로드 비용에서 parse 비용을 뺀 근사값 +- `render`: 전 페이지 `render_page_svg_native` +- `serialize`: `serialize_hwpx` HWPX 바이트 생성 + +이 문서는 코드 리뷰에서 확인한 축, Request changes와 반영 상태, collaborator 보정 커밋, 로컬 검증, +merge 전 조건을 기록한다. + +## 2. 현재 PR 메타 + +| 항목 | 내용 | +|---|---| +| state | open | +| draft | false | +| mergeable | 문서 작성 전 확인 기준 `MERGEABLE` | +| merge state | 문서 작성 전 확인 기준 `CLEAN` | +| review decision | 기존 Request changes 때문에 `CHANGES_REQUESTED` | +| base | `devel` | +| head branch | `planet6897:pr-task1537` | +| labels | `enhancement`, `performance` | +| milestone | 없음 | +| PR assignee | `planet6897` | +| issue #1537 state | open | + +GitHub Actions, contributor 수정 커밋 `2ca6a3463dad1a109286d44ac9ac7445bc23ad1c` 기준 확인: + +| 체크 | 결과 | +|---|---| +| Build & Test | pass | +| Analyze (rust) | pass | +| Analyze (javascript-typescript) | pass | +| Analyze (python) | pass | +| Canvas visual diff | pass | +| CodeQL | pass | +| WASM Build | skipped | + +주의: + +- collaborator 보정 커밋 `a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2`와 review 문서 커밋 push 후에는 + GitHub Actions가 다시 실행될 수 있다. +- 기존 Request changes review decision은 자동으로 해소되지 않으므로, merge 전 새 GitHub review로 갱신해야 한다. + +## 3. 커밋별 검토 범위 + +| 커밋 | 작성자 | 내용 | 주요 검토 축 | +|---|---|---|---| +| `75c7fd75` | Jaeook Ryu | `bench` CLI 본 구현, help/manual 동기화, 성능 보고서 추가 | CLI 인자 처리, 단계별 계측 경계, TSV 산출, 문서 정합 | +| `2ca6a346` | Jaeook Ryu | Request changes 반영: render 실패 전파, 파일 처리 실패 시 exit 1 | 기존 리뷰 지적 해소 여부, 자동화 신뢰성 | +| `a6469c8d` | postmelee | TSV 쓰기 실패도 exit 1로 처리하는 collaborator 보정 | 산출물 실패의 non-zero 전파, contributor commit 보존 | + +원본 contributor 구현 커밋 `75c7fd75`에는 다음 trailer가 포함되어 있다. + +```text +Co-Authored-By: Claude Opus 4.8 (1M context) +``` + +collaborator 보정 커밋은 contributor 커밋을 rewrite하지 않고 후속 커밋으로만 추가했다. + +## 4. 코드 리뷰 체크리스트 + +### 4.1 CLI 인자와 입력 수집 + +- `<파일...>`와 `--batch <폴더>`를 모두 지원하는지 확인한다. +- `--batch`는 `.hwp`, `.hwpx`를 재귀 수집하고 정렬하는지 확인한다. +- `-n 0`은 최소 1회 반복으로 보정되는지 확인한다. +- 입력이 없으면 사용법만 출력하고 종료한다. 현재 이 경로는 exit 0이며, 도움말성 호출로 볼 수 있어 blocker로 보지 않는다. + +### 4.2 단계별 계측 + +- parse 단계는 `parse_document(&data)`를 별도 측정한다. +- load 단계는 `DocumentCore::from_bytes(&data)`를 측정하고, layout은 `load - parse` 근사값으로 계산한다. +- layout 근사가 음수일 때 0으로 clamp하는 한계를 문서와 보고서에 명시한다. +- render 단계는 전 페이지 `render_page_svg_native`를 호출한다. +- serialize 단계는 `serialize_hwpx(core.document())`의 HWPX 바이트 생성 비용을 측정한다. + +### 4.3 실패 전파와 자동화 신뢰성 + +초기 review에서 다음 두 항목을 Request changes로 남겼다. + +1. `render_page_svg_native(p)`의 `Result`를 버려 render 실패가 성공처럼 숨겨지는 문제. +2. 파일 처리 실패가 있어도 프로세스 exit code가 0으로 남는 문제. + +contributor 수정 커밋 `2ca6a346`에서 두 항목은 반영됐다. + +추가 검토 중 다음 항목을 발견했고 collaborator 보정 커밋으로 반영했다. + +3. `--tsv` 쓰기 실패가 있어도 exit code가 0으로 남는 문제. + +보정 후 `write_tsv` 실패도 `failures += 1`로 처리되어 마지막에 exit 1로 이어진다. + +### 4.4 문서와 보고서 + +- `src/main.rs`의 help 문자열에 `bench`가 추가됐다. +- `mydocs/manual/cli_commands.md`에 `bench` 명령이 추가됐다. +- contributor 보고서 `mydocs/report/task_m100_1537_report.md`는 측정 머신/빌드 의존성과 상대 비교 지표라는 한계를 명시한다. +- `layout = load - parse` 근사, 메모리 미측정, 단일 머신이라는 한계를 기록했다. + +## 5. 로컬 검증 기록 + +검증 head: + +```text +a6469c8d67e72bd7c83b1fea298ccc6cf11f50d2 +``` + +실행한 로컬 검증: + +| 명령 | 결과 | +|---|---| +| `cargo fmt --check` | 통과 | +| `git diff --check` | 통과 | +| `cargo clippy --all-targets -- -D warnings` | 통과 | +| `cargo build --release --bin rhwp` | 통과 | +| `./target/release/rhwp bench samples/hwpx_sample2.hwpx samples/task-001.hwp -n 1 --tsv output/poc/pr1538/bench-collab.tsv` | 통과, exit 0 | +| `./target/release/rhwp bench /no/such/file -n 1` | 실패 입력을 보고하고 exit 1 | +| `./target/release/rhwp bench samples/task-001.hwp -n 1 --tsv /dev/null/foo.tsv` | TSV 쓰기 실패를 보고하고 exit 1 | + +이전 head `6f9bf7ffa885ceb4afbfb972c8ca44e880e8876a` 기준 추가 검증: + +| 명령 | 결과 | +|---|---| +| `cargo test --release --lib` | 통과, 1937 passed / 0 failed / 6 ignored | + +`cargo test --release --lib`는 contributor 수정 전 기본 구현 검증에서 통과했다. 이후 변경은 `bench.rs`의 +실패 전파와 exit code 처리에 한정되며, 최신 head에서는 `fmt`, `clippy`, release build, 세 가지 CLI 스모크로 +재확인했다. + +## 6. visual/rendering 영향 + +이 PR은 renderer 결과의 시각 정합성 변경이 아니라 CLI 진단 도구 추가다. `render` 단계에서 기존 +`DocumentCore::render_page_svg_native`를 호출해 시간을 측정하지만, 렌더러 출력 로직 자체를 변경하지 않는다. + +따라서 별도 before/after 시각 산출물은 만들지 않았다. GitHub의 Canvas visual diff는 contributor 수정 커밋 +기준 pass였다. 보정 커밋 후에는 GitHub Actions 최신 상태를 다시 확인해야 한다. + +## 7. 위험 분류 + +### Blocking 후보 + +- render 실패 또는 serialize 실패가 성공처럼 숨겨지는 경우. +- 파일 처리 실패 또는 TSV 산출 실패가 자동화에서 exit 0으로 처리되는 경우. +- `bench` 측정값이 절대 성능 비교값처럼 문서화되는 경우. + +### 검토 결과 + +- render 실패는 `?`로 파일 처리 실패에 전파된다. +- 파일 처리 실패와 TSV 쓰기 실패는 모두 failure count에 반영되어 exit 1로 종료된다. +- 문서와 보고서에는 측정값이 머신/빌드 의존이며 같은 환경의 상대 비교·회귀 추적 지표임을 명시했다. + +### Non-blocking 후속 + +- layout 단계를 정확히 분리하려면 `DocumentCore` 또는 layout engine 내부 계측 훅이 필요하다. +- peak RSS 등 메모리 계측은 후속 범위다. +- CI 회귀 게이트로 고정할지는 별도 정책 판단이 필요하다. + +## 8. 리뷰 문서 push 계획 + +Route A, original PR merge 후보로 유지한다. + +이번 collaborator push는 두 커밋으로 분리한다. + +1. code 보정 커밋: `bench: fail on TSV write errors` +2. review 문서 커밋: `docs(pr): record PR #1538 review` + +문서 커밋에는 다음 파일만 포함한다. + +```text +mydocs/pr/archives/pr_1538_review.md +mydocs/pr/archives/pr_1538_report.md +``` + +push 대상: + +```text +planet6897/rhwp:pr-task1537 +``` + +push 후 확인: + +1. PR head SHA가 보정 커밋과 문서 커밋으로 갱신된다. +2. PR diff에 `mydocs/pr/archives/pr_1538_review.md`와 `mydocs/pr/archives/pr_1538_report.md`가 포함된다. +3. contributor 원 커밋 2건은 rewrite되지 않는다. +4. 최신 GitHub Actions 상태를 확인한다. +5. 작업지시자 승인 후 기존 `CHANGES_REQUESTED`를 새 review로 갱신한다. + +## 9. 현재 결론 + +현재 로컬 검증 기준으로 blocking finding은 없다. + +권고: **보정/문서 커밋 push 후 최신 CI 확인, 이후 작업지시자 승인에 따라 Approve 가능.** + +merge, issue close, final merge comment는 별도 작업지시자 승인 전에는 수행하지 않는다. diff --git a/mydocs/report/task_m100_1537_report.md b/mydocs/report/task_m100_1537_report.md new file mode 100644 index 000000000..584f03c97 --- /dev/null +++ b/mydocs/report/task_m100_1537_report.md @@ -0,0 +1,76 @@ +# 성능 벤치마크 (bench 명령) — 최종 보고서 + +- 이슈: #1537 (성능 벤치마크: bench CLI 서브커맨드) +- 브랜치: `local/task1537` +- 마일스톤: M100 (v1.0.0) + +## 1. 목적 + +대용량 HWP/HWPX 처리 성능을 **재현 가능한 수치**로 계량한다. "사용 가능"·"빠름" 같은 +모호 표현 대신 단계별 계량값으로 성능 특성을 기술하고, 성능 회귀를 추적할 기준을 만든다. + +## 2. bench 명령 + +``` +rhwp bench <파일...> | --batch <폴더> [-n <반복수>] [--tsv <출력.tsv>] +``` + +단계별 계측(워밍업 1회 후 N회 median, 기본 N=3): + +| 단계 | 측정 대상 | API | +|------|----------|-----| +| parse | 바이트 → Document IR | `parse_document` | +| layout | parse+layout 로드 − parse (근사) | `DocumentCore::from_bytes` | +| render | 전 페이지 SVG 렌더 | `render_page_svg_native` | +| serialize | Document → HWPX 바이트 (저장) | `serialize_hwpx` | + +파일별 크기/쪽수 + 단계별 median(ms) + total 표와 `--tsv` 산출. + +## 3. 측정 환경 + +- CPU: Intel Core i7-8850H @ 2.60GHz / RAM 16GB / macOS (darwin 24.6.0) +- 빌드: `cargo build --release` (rustc 1.93.1) +- 반복: 워밍업 1회 후 **N=5 median** + +> 정직성 주의: 아래 절대 수치는 **이 머신·이 빌드**에 한정된다. 동일 환경에서의 +> **상대 비교·회귀 추적·재현**을 위한 지표이며, 한컴 등 외부 제품과의 비교 기준이 +> 아니다. CPU/디스크/빌드가 바뀌면 절대값은 달라진다. + +## 4. 결과 (N=5 median) + +| 파일 | 크기KB | 쪽 | parse | layout | render | serialize | total | +|------|-------:|---:|------:|-------:|-------:|----------:|------:| +| exam_kor.hwp | 10174.5 | 20 | 340.7 | ~0 | 775.5 | 743.1 | **1859.3** | +| exam_kor.hwpx | 8133.6 | 20 | 206.3 | 24.3 | 767.6 | 739.6 | **1737.7** | +| 교육통합(격자) .hwp | 5738.5 | 23 | 162.7 | 7.3 | 497.7 | 277.9 | **945.6** | +| aift.hwpx | 4938.2 | 74 | 109.1 | 28.3 | 393.3 | 269.9 | **800.7** | +| k-water-rfp.hwpx | 2120.0 | 27 | 38.7 | 14.4 | 133.0 | 122.7 | **308.8** | +| exam-kor-2p.hwpx | 870.0 | 2 | 21.4 | 4.1 | 84.1 | 100.1 | **209.8** | +| form-002.hwpx | 128.5 | 10 | 9.9 | 11.3 | 39.8 | 14.0 | **75.1** | +| footnote-01.hwpx | 63.6 | 6 | 1.6 | 0.4 | 7.0 | 2.9 | **11.9** | +| business_overview.hwpx | 9.9 | 1 | 1.1 | 0.7 | 2.2 | 1.7 | **5.7** | + +단위: ms. 합계 9파일 / 183쪽 / total 5954.7ms (≈32.5ms/쪽). + +## 5. 해석 + +- **대용량 10MB HWP(20쪽)**: 열기(parse+layout) ≈ 0.34s, 전 페이지 렌더 ≈ 0.78s, + HWPX 저장 ≈ 0.74s. 전체 파이프라인 ≈ 1.9s. +- **비용 구성**: render·serialize 가 지배적(각 ~40%), parse 는 파일 크기에 비례. + layout 은 대체로 작다(전체의 수 %). +- **페이지 수에 대체로 선형**: aift(74쪽, 4.9MB)가 exam_kor(20쪽, 10MB)보다 total 이 + 작다 — total 은 파일 크기(parse/serialize)와 페이지 수(render)의 합성. +- **저장(serialize) 비용 가시화**: HWPX 직접 저장(#1532)이 대용량에서 ~0.7s 수준임을 + 계량 — UX(진행 표시/비동기) 설계 판단 근거. + +## 6. 한계·후속 + +- **layout 근사**: `load − parse` 차분이라 음수→0 클램프 발생(예: exam_kor.hwp). + 정밀 분리는 별도 계측 훅 필요(후속). +- **메모리 미측정**: peak RSS 는 v1 범위 외. getrusage/allocator 카운터 도입은 후속. +- **단일 머신**: CI 러너 등 복수 환경 교차 측정은 후속(회귀 게이트화 여부 별도 판단). + +## 7. 산출물 + +- `src/diagnostics/bench.rs` (+ main.rs 디스패치/help, cli_commands.md 동기화) +- `output/poc/bench/bench.tsv` (gitignore — 재현은 bench 명령으로) diff --git a/src/diagnostics/bench.rs b/src/diagnostics/bench.rs new file mode 100644 index 000000000..d86e38e21 --- /dev/null +++ b/src/diagnostics/bench.rs @@ -0,0 +1,284 @@ +//! `bench` — HWP/HWPX 단계별 처리 성능 계측. +//! +//! 단계: parse(바이트→IR) · layout(=load−parse) · render(전 페이지 SVG) · +//! serialize(serialize_hwpx). 워밍업 1회 후 N회 반복하여 median(ms) 으로 보고한다. +//! +//! 주의(정직성): 절대 수치는 측정 머신·빌드(release/debug)에 의존한다. 동일 머신· +//! 동일 빌드에서의 **상대 비교·재현용 지표**로 해석한다(한컴 등 외부 기준 아님). +//! +//! 사용법: +//! rhwp bench <파일...> [-n 반복수] [--tsv 출력.tsv] +//! rhwp bench --batch <폴더> [-n 반복수] [--tsv 출력.tsv] + +use std::fs; +use std::time::Instant; + +use crate::document_core::DocumentCore; +use crate::parser::parse_document; +use crate::serializer::serialize_hwpx; + +struct Row { + name: String, + size_kb: f64, + pages: u32, + out_kb: f64, + parse_ms: f64, + layout_ms: f64, + render_ms: f64, + serialize_ms: f64, +} + +fn median(mut v: Vec) -> f64 { + if v.is_empty() { + return 0.0; + } + v.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let n = v.len(); + if n % 2 == 1 { + v[n / 2] + } else { + (v[n / 2 - 1] + v[n / 2]) / 2.0 + } +} + +fn ms_since(t: Instant) -> f64 { + t.elapsed().as_secs_f64() * 1000.0 +} + +pub fn run(args: &[String]) { + let mut files: Vec = Vec::new(); + let mut batch: Option = None; + let mut iters = 3usize; + let mut tsv: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--batch" => { + i += 1; + batch = args.get(i).cloned(); + } + "-n" | "--iters" => { + i += 1; + iters = args.get(i).and_then(|s| s.parse().ok()).unwrap_or(3); + } + "--tsv" => { + i += 1; + tsv = args.get(i).cloned(); + } + other => files.push(other.to_string()), + } + i += 1; + } + + if let Some(dir) = &batch { + collect_samples(std::path::Path::new(dir), &mut files); + files.sort(); + } + if files.is_empty() { + eprintln!("사용법: rhwp bench <파일...> | --batch <폴더> [-n 반복수] [--tsv 출력.tsv]"); + return; + } + if iters == 0 { + iters = 1; + } + + println!("=== bench: 단계별 처리 성능 (median of {iters}회, 워밍업 1회) ==="); + println!("주의: 절대 수치는 측정 머신·빌드 의존. 동일 환경 상대·재현 지표로 해석."); + + let mut rows: Vec = Vec::new(); + let mut failures = 0usize; + for f in &files { + match bench_one(f, iters) { + Ok(r) => rows.push(r), + Err(e) => { + eprintln!(" {f}: 실패 — {e}"); + failures += 1; + } + } + } + print_table(&rows); + + if let Some(path) = tsv { + match write_tsv(&path, &rows) { + Ok(()) => println!("\nTSV: {path}"), + Err(e) => { + eprintln!("TSV 쓰기 실패: {e}"); + failures += 1; + } + } + } + + // 성능 계측은 CI·스크립트에서 자동화되므로, 하나 이상의 파일 처리 실패가 + // 있으면 non-zero 로 종료해 실패가 성공처럼 숨겨지지 않게 한다. + if failures > 0 { + eprintln!("\n{failures}개 파일 처리 실패 — 종료 코드 1"); + std::process::exit(1); + } +} + +fn collect_samples(dir: &std::path::Path, acc: &mut Vec) { + let Ok(rd) = fs::read_dir(dir) else { + return; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + collect_samples(&p, acc); + } else if p.extension().is_some_and(|x| { + let x = x.to_string_lossy().to_ascii_lowercase(); + x == "hwp" || x == "hwpx" + }) { + acc.push(p.to_string_lossy().into_owned()); + } + } +} + +fn bench_one(path: &str, iters: usize) -> Result { + let data = fs::read(path).map_err(|e| e.to_string())?; + let size_kb = data.len() as f64 / 1024.0; + + // 워밍업 (페이지 캐시·할당자 정상화) — 측정에서 제외. + let _ = DocumentCore::from_bytes(&data).map_err(|e| format!("{e:?}"))?; + + let mut parse_v = Vec::with_capacity(iters); + let mut load_v = Vec::with_capacity(iters); + let mut render_v = Vec::with_capacity(iters); + let mut ser_v = Vec::with_capacity(iters); + let mut pages = 0u32; + let mut out_kb = 0.0; + + for _ in 0..iters { + // parse: 바이트 → Document IR (격리) + let t = Instant::now(); + let _doc = parse_document(&data).map_err(|e| format!("{e:?}"))?; + parse_v.push(ms_since(t)); + + // load: parse + layout (studio "열기" 비용). layout ≈ load − parse. + let t = Instant::now(); + let core = DocumentCore::from_bytes(&data).map_err(|e| format!("{e:?}"))?; + load_v.push(ms_since(t)); + pages = core.page_count(); + + // render: 전 페이지 SVG 렌더 (페이지 렌더 실패는 파일 처리 실패로 전파) + let t = Instant::now(); + for p in 0..pages { + core.render_page_svg_native(p) + .map_err(|e| format!("{e:?}"))?; + } + render_v.push(ms_since(t)); + + // serialize: Document → HWPX 바이트 (studio "저장" 비용) + let t = Instant::now(); + let bytes = serialize_hwpx(core.document()).map_err(|e| format!("{e:?}"))?; + ser_v.push(ms_since(t)); + out_kb = bytes.len() as f64 / 1024.0; + } + + let parse_ms = median(parse_v); + let load_ms = median(load_v); + let layout_ms = (load_ms - parse_ms).max(0.0); + + Ok(Row { + name: path.to_string(), + size_kb, + pages, + out_kb, + parse_ms, + layout_ms, + render_ms: median(render_v), + serialize_ms: median(ser_v), + }) +} + +fn short_name(p: &str, max: usize) -> String { + let base = std::path::Path::new(p) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| p.to_string()); + if base.chars().count() <= max { + base + } else { + let tail: String = base + .chars() + .rev() + .take(max - 1) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("…{tail}") + } +} + +fn print_table(rows: &[Row]) { + println!( + "\n{:<40} {:>8} {:>5} {:>9} {:>9} {:>10} {:>11} {:>10}", + "파일", "크기KB", "쪽", "parse", "layout", "render", "serialize", "total" + ); + println!("{}", "-".repeat(112)); + for r in rows { + let total = r.parse_ms + r.layout_ms + r.render_ms + r.serialize_ms; + println!( + "{:<40} {:>8.1} {:>5} {:>7.1}ms {:>7.1}ms {:>8.1}ms {:>9.1}ms {:>8.1}ms", + short_name(&r.name, 40), + r.size_kb, + r.pages, + r.parse_ms, + r.layout_ms, + r.render_ms, + r.serialize_ms, + total + ); + } + if rows.len() > 1 { + let sum_total: f64 = rows + .iter() + .map(|r| r.parse_ms + r.layout_ms + r.render_ms + r.serialize_ms) + .sum(); + let pages: u32 = rows.iter().map(|r| r.pages).sum(); + println!("{}", "-".repeat(112)); + println!( + "합계 {}개 파일, {}쪽, total {:.1}ms ({:.1}ms/쪽)", + rows.len(), + pages, + sum_total, + if pages > 0 { + sum_total / pages as f64 + } else { + 0.0 + } + ); + } +} + +fn write_tsv(path: &str, rows: &[Row]) -> std::io::Result<()> { + use std::io::Write; + if let Some(parent) = std::path::Path::new(path).parent() { + if !parent.as_os_str().is_empty() { + fs::create_dir_all(parent)?; + } + } + let mut w = fs::File::create(path)?; + writeln!( + w, + "file\tsize_kb\tpages\tout_kb\tparse_ms\tlayout_ms\trender_ms\tserialize_ms\ttotal_ms" + )?; + for r in rows { + let total = r.parse_ms + r.layout_ms + r.render_ms + r.serialize_ms; + writeln!( + w, + "{}\t{:.1}\t{}\t{:.1}\t{:.2}\t{:.2}\t{:.2}\t{:.2}\t{:.2}", + r.name, + r.size_kb, + r.pages, + r.out_kb, + r.parse_ms, + r.layout_ms, + r.render_ms, + r.serialize_ms, + total + )?; + } + Ok(()) +} diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 153397350..0965da717 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -1,5 +1,6 @@ //! Diagnostic tooling for HWP/HWPX compatibility work. +pub mod bench; pub mod hwp5_anchor_trace; pub mod hwp5_borderfill_diagonal_probe; pub mod hwp5_cell_header_probe; diff --git a/src/main.rs b/src/main.rs index a6eb3b476..f54853ccd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -52,6 +52,7 @@ fn main() { Some("hwpx-roundtrip") => rhwp::diagnostics::hwpx_roundtrip_batch::run(&args[2..]), Some("hwp5-roundtrip") => rhwp::diagnostics::hwp5_roundtrip_batch::run(&args[2..]), Some("render-diff") => rhwp::diagnostics::render_geom_diff::run(&args[2..]), + Some("bench") => rhwp::diagnostics::bench::run(&args[2..]), Some("thumbnail") => extract_thumbnail(&args[2..]), _ => { println!("rhwp v{}", rhwp::version()); @@ -217,6 +218,10 @@ fn print_help() { println!(" 라운드트립 시각 정합성 게이트 — 페이지별 RenderNode bbox 변위(px) 정량화"); println!(" 자기 라운드트립(원본 IR vs 직렬화→재로드 IR) 또는 두 파일 직접 비교"); println!(" 배치: geom_inventory.tsv 산출(기본 output/poc/render_diff)"); + println!(" bench <파일...> | --batch <폴더> [-n <반복수>] [--tsv <출력.tsv>]"); + println!(" 단계별 처리 성능 계측 — parse/layout/render/serialize median(ms)"); + println!(" 워밍업 1회 후 N회(기본 3) 반복. 파일별 크기/쪽수 + total 표 + TSV"); + println!(" 주의: 절대 수치는 머신·빌드 의존, 동일 환경 상대·재현 지표로 해석"); println!(); println!(" thumbnail <파일.hwp> [옵션]"); println!(" HWP 파일에서 썸네일(PrvImage) 추출");