feat(#161): mode='nsw' HNSW export에 페이지당 다중 원소 패킹 도입 - #187
Merged
Conversation
버퍼 라이프사이클(ReadBuffer/PageInit/dirty/WAL/release)과 튜플 구성 (elem+neigh PageAddItem)을 분리 — 레버 1(페이지당 다중 원소 패킹)이 여러 쌍을 한 페이지에 쓰려면 버퍼를 페이지당 한 번만 열어야 하는데, 지금은 이 둘이 한 함수에 뒤엉켜 있어 재사용이 안 됐다. write_elem_page()는 이제 write_elem_pair() 1회 호출로 축약된 얇은 래퍼 — 기존 호출자 전부(hnswlib_impl, cagra_ipc의 hnsw 계층 분기) 동작 불변. 부수적으로 PageAddItem 반환 offno가 레이아웃 패스가 미리 배정한 elem_offno/neigh_offno와 일치하는지 assert 추가 — 지금은 항상 참(k=1 페이지의 InvalidOffsetNumber는 항상 1,2를 반환)이지만, 다음 커밋들이 패킹을 도입하면 레이아웃 계산과 실제 페이지 배치가 어긋나는 조용한 손상을 즉시 잡아준다. VM 검증: installcheck 40/40, isolation 6/6 GREEN — 동작 불변 확인.
레버 1(페이지당 다중 원소 패킹)의 레이아웃 패스는 "한 쌍이 페이지에 들어가는가" 가 아니라 "페이지당 몇 쌍이 들어가는가"를 알아야 한다 — 같은 esize/nsize/needed 산술이라 check_hnsw_page_fit()에서 공유 헬퍼로 뽑아낸다. check_hnsw_page_fit()은 이제 elems_per_page(...) < 1 을 확인하는 얇은 래퍼 — 동일한 ERROR 조건, 동일한 에러 메시지. VM 검증: installcheck 40/40, isolation 6/6 GREEN — 동작 불변 확인.
ambuild()의 source-mode 분기가 result->index_tuples/heap_tuples를 RelationGetNumberOfBlocks(indexRel)-1로 추론해 왔다 — "페이지당 원소 1개" 가정에서만 우연히 맞는 계산이다. 다음 커밋이 nsw 경로에 페이지당 다중 원소 패킹을 넣으면 nblocks-1 != N 이 되어 조용히 틀린 빌드 통계를 낸다. fill_hnsw_from_cagra_ipc()/fill_hnsw_from_hnswlib()이 HnswFillRes.n_elems (이미 _impl이 채워 두는 값)를 반환하도록 바꾸고, ambuild()가 블록 수 추론 대신 그 값을 직접 쓴다. 오늘은 nblocks-1 == N이 항상 참이라 반환값이 기존과 동일 — 동작 불변이지만 패킹이 들어올 자리를 미리 정리. VM 검증: installcheck 40/40, isolation 6/6 GREEN — 동작 불변 확인.
기존엔 원소 하나가 페이지 하나를 통째로 차지했다(index_bytes가 graph_degree/M 과 무관하게 8,192 B/vector로 불변인 것이 직접 증거 — pgvector 네이티브는 4,093.87 B/vector). mode='nsw'는 모든 원소가 항상 level 0이라 neighbor tuple 크기가 균일한 유일한 경로 — 고정 k = elems_per_page(dim, bytes_per_dim, M, 0) 개/페이지로 패킹해도 결정적(#161 이슈 본문이 확정한 "고정 k, greedy 아님" 정책)이다. mode='hnsw'/'hnswlib'/'hnswlib_file'은 원소마다 level이 달라 균일하지 않으므로 손대지 않는다 — #161 자신의 레버 2b 결론(nsw가 recall 전 구간에서 hnswlib와 동등·우세, 권장 기본값)과도 맞물린다. VM에서 직접 확인한 사실 두 가지가 이 작업의 리스크를 크게 낮췄다: - write_elem_page()의 blkno 파라미터는 원래 죽은 코드였다((void)blkno 뒤 무조건 P_NEW) — 실제로는 호출자의 순차 루프 순서가 레이아웃 패스의 블록 배정과 우연히 일치하는 데 의존했다. - pgvector 자신의 라이터(v0.8.6 소스로 직접 확인)가 이미 페이지당 다중 원소를 패킹한다(hnswinsert.c의 PageGetFreeSpace 체크). 리더도 순수하게 ReadBuffer(blkno)+PageGetItem(offno)로만 동작해 페이지 밀도 가정이 없다. "페이지당 원소 하나"는 pgvector 포맷의 요구사항이 아니라 우리 라이터의 자체 단순화였다 — 다중 원소 페이지는 pgvector 코드 입장에서 이미 일상적인 상태. 변경: 레이아웃 패스가 do_hierarchy에 따라 pack_k(=1 또는 elems_per_page 값)를 정하고 blkno/offno를 그에 맞게 배정. 신규 write_elem_pages_packed()가 페이지당 한 번만 버퍼를 열어 pack_k개 쌍을 채운다(부수 효과: ReadBuffer(P_NEW) 호출이 N회→ceil(N/pack_k)회로 줄어 #161 레버 3이 겨냥했던 것과 같은 종류의 버퍼매니저 절감이 패킹의 부산물로 따라온다). metapage entryOffno의 하드코딩된 1을 elems[entry_elem].elem_offno로, insertPage를 N에서 elems[N-1].elem_blkno로 고쳤다 — 후자는 do_hierarchy=true 경로에선 기존 값과 수학적으로 동일해 동작 불변, nsw 경로에서만 pgvector의 향후 일반 INSERT가 실제 마지막 페이지를 정확히 가리키게 한다. VM 검증: installcheck 41/41(신규 build_hnsw_packing 포함), isolation 6/6, 2회 연속 GREEN.
dim=100/N=500/graph_degree=64(M=32) — 정확히 k=9개/페이지로 56페이지에 걸치도록 손계산해 고른 조합: 1. index_bytes가 예측한 57페이지(1 meta + 56)와 정확히 일치 — 패킹이 실제로 일어났다는 직접 증거(단순히 빌드가 성공했다는 것 이상). 2. 전수 검색: 500개 전부 자기 자신이 정확히 최근접(distance=0)으로 나옴 — 페이지 경계를 넘나드는 이웃 포인터 포함 손상 없음 확인. 3. 재빌드 결정성(같은 소스로 2회 → 동일 index_bytes). 4. REINDEX 후에도 전수 검색 정합성 유지. 5. 패킹된 인덱스에 대한 일반 INSERT(pgvector 자신의 aminsert 경로)가 metap->insertPage 수정 덕에 정상 동작. 가는 길에 테스트 자체의 버그를 하나 잡았다: 초기 버전은 벡터 생성에 `SELECT i, (SELECT ... FROM generate_series(1,100)) FROM generate_series(1,500) i` 형태를 썼는데, 내부 서브쿼리가 외부 i를 전혀 참조하지 않아 PostgreSQL이 이를 INSERT 전체에 대해 단 한 번만 평가되는 InitPlan으로 취급했다 — random()이 volatile이어도 마찬가지였다(재평가 횟수는 참조 관계로 결정되지, 내부 함수의 volatility로 결정되지 않는다). 500행 전부가 동일한 벡터를 갖게 되어 최초 Case 2가 499/500 mismatch를 냈다 — 패킹 버그가 아니라 순수 테스트 SQL 버그였음을 라이브 진단으로 확인 후 수정(집계 인자가 외부 i와 내부 j를 모두 참조하도록). Makefile REGRESS/REGRESS_TIER2_ONLY에 등록(#162의 build_hnsw_halfvec.sql과 동일한 이유 — CAGRA 그래프 export를 쓰는 GPU 전용 테스트). VM 검증: installcheck 41/41, isolation 6/6, 2회 연속 GREEN.
wiki_all 768d, N=100K, graph_degree=64(M=32), mode='nsw'. index_bytes: 819,208,192(구, 1원소/페이지 계산값) → 409,608,192(신, 패킹 실측) — 정확히 2.00배 축소. pgvector 네이티브 HNSW(같은 코퍼스, 같은 M)는 409,042,944 — 이제 0.01% 오차로 사실상 동급(잔여 차이는 check_hnsw_page_fit()의 기존 보수적 아이템 오버헤드 계산 탓, 이번 변경과 무관). `#161`을 열게 만든 "우리 export가 pgvector 자체보다 약 2배 크다"는 발견이 기본/권장 모드(nsw)에 대해 닫혔다. 이 변경은 SQL-visible 표면 변경이 없는 순수 C 쓰기 경로 최적화라 CONTRIBUTING.md 버저닝 정책상 버전 범프 대상 아님(0.8.0 그대로). BENCHMARK.md §2.1f 신설, §2.1c의 기존 8,192,008,192 수치가 hnswlib/hnsw 계층 모드에는 여전히 유효하지만 nsw에는 이제 stale임을 명시.
13 tasks
적대적 코드 리뷰(HIGH 1건, MEDIUM 2건, LOW 2건)에서 발견된 실제 결함 수정: - [HIGH] elems_per_page()가 0을 반환할 수 있는 유일한 방어선인 check_hnsw_page_fit() 호출이 pack_k로 나누는 레이아웃 루프 *뒤*에 있었다. 구코드는 이 루프에 나눗셈이 없어 순서가 무해했지만, 패킹이 나눗셈을 들여오면서 순서가 load-bearing해졌다 — pack_k=0이면 정수 0-나눗셈으로 SIGFPE, 깔끔한 ERROR 대신 백엔드 크래시. dim=1919+(fp32)/3837+(halfvec)에서 타입 레벨로는 도달 가능한 입력이었으나, #162의 사전 체크 3곳이 이미 더 이른 시점에 같은 조건을 걸러 오늘은 우회 불가 — 다만 "지역 가드"가 "멀리 떨어진 3곳의 사전검사가 영원히 일치해야 한다"는 암묵 계약으로 바뀐 것 자체가 문제였다. check_hnsw_page_fit() 호출을 레이아웃 패스 앞으로 옮기고 pack_k<1 방어 ereport를 추가. - [MEDIUM] BENCHMARK.md의 "within 0.01%"가 실제 데이터(ratio=1.0014, 0.14%)와 14배 어긋남 — 정정. - [MEDIUM] 잔여 격차를 check_hnsw_page_fit()의 보수적 오버헤드 회계 탓으로 돌린 설명이 사실과 다름(dim=768/M=32에서 정확한 회계와 보수적 회계 둘 다 k=2로 동일 — 검증 완료) — 미규명으로 정정, pgvector 자체의 greedy 페이지 채우기 전략 차이가 더 유력한 원인이라고만 명시. - [LOW] neighbortid 주석이 패킹 전 "offno 2" 고정값을 그대로 인용 — 실제로는 레이아웃 패스가 배정한 e->neigh_offno를 쓴다고 정정. - [LOW] N이 k로 정확히 나누어떨어지는 경우(마지막 페이지가 완전히 참) 테스트 누락 — build_hnsw_packing.sql Case 6(N=504, k=9, 정확히 56페이지) 추가. VM 재검증: installcheck 41/41, isolation 6/6, 2회 연속 GREEN.
Collaborator
Author
|
적대적 코드 리뷰(에이전트) 결과 및 대응 — 마지막 커밋( HIGH — 실제 결함, 수정함
MEDIUM — 수정함
LOW — 수정함
독립 검증된 것들 (리뷰 에이전트가 pgvector 0.8.0 원본 직접 확인)
VM 재검증: |
2차 적대적 리뷰(1차 HIGH 수정 독립 검증 통과, 신규 MEDIUM 1건·LOW 3건): - [MEDIUM] BENCHMARK.md 표의 build time(1.6s/43.7s)이 커밋된 JSON(1.68/42.84)과 불일치 — 바로 옆 문단에서 이번 커밋이 막 세운 "JSON이 기준" 규칙을 같은 표 안에서 위반하고 있었다. 1.7s/42.8s로 정정. - [LOW] 잔여 크기 격차의 원인을 "미규명"으로 남겼었는데, 리뷰가 산술로 직접 규명했다 — dim=768/M=32에서 pack_k=2가 페이지당 1,056바이트를 항상 남기고 1+100000/2=50,001블록×8192=409,608,192로 측정치와 정확히 일치. 인용 파일도 hnswinsert.c(단건 INSERT 경로)에서 hnswbuild.c:192 WriteTuplesInOrder() (실제 CREATE INDEX 빌드 경로)로 정정. - [LOW] "Exactly 2.00×"가 엄밀히는 1.99998(메타페이지 1블록 차)이라 "Exactly" 삭제. - [LOW] Case 6(N=504, 마지막 페이지 완전히 참)이 정합성 sweep만 하고 INSERT를 안 해 이 PR에서 가장 커버가 얇은 경로(여유 240바이트 — pgvector 자신의 fast-path 대신 페이지 확장 경로를 타는 유일한 경우)를 안 건드리고 있었다 — Case 5와 같은 패턴으로 INSERT 단언 추가, id=505 정확히 자기 자신을 찾음 확인. 2차 리뷰가 1차 HIGH 수정을 pgvector 0.8.0 원본 재대조로 독립 검증: 순서 수정 확인, 가드 도달 불가능함(=이미 안전) 확인, 같은 클래스 버그 다른 곳에 없음 확인. VM 검증: installcheck 41/41, isolation 6/6 GREEN.
Collaborator
Author
|
2차 적대적 리뷰(1차 HIGH 수정을 pgvector 0.8.0 원본 재대조로 독립 검증) 결과 — RECOMMENDATION: COMMENT (머지 가능), 마지막 커밋( 1차 HIGH 수정 검증 결과: 실제로 해결됨
신규 발견 — 전부 문서/테스트, C 코드 정확성과 무관
VM 재검증: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
pgcuvs_hnsw_import의index_bytes가graph_degree/M과 무관하게 8,192 B/vector로 고정돼 있었다(원소 하나가 페이지 하나를 통째로 차지) — pgvector 네이티브(4,093.87 B/vector)의 약 2배.mode='nsw'(기본/권장 모드)는 모든 원소가 항상 level 0이라 neighbor tuple 크기가 균일 — 고정 k개/페이지로 결정적 패킹이 가능한 유일한 경로.mode='hnsw'/'hnswlib'/'hnswlib_file'은 원소마다 level이 달라 손대지 않음(perf: hnsw_import 빌드 속도 최적화 — GPU는 11%, 나머지 89%가 PG ingest·export write #161 자신의 레버 2b 결론 — nsw가 recall 전 구간에서 hnswlib와 동등·우세 — 과도 맞물림).write_elem_page()의blkno파라미터는 죽은 코드였다 — 호출자의 순차 루프 순서에 의존. (2) pgvector 자신의 라이터(v0.8.6 소스 직접 확인)가 이미 페이지당 다중 원소를 패킹한다 — "페이지당 원소 하나"는 포맷 요구사항이 아니라 우리 라이터의 자체 단순화였다.Commits (Tidy First,
#162와 동일 패턴)refactor:write_elem_page에서write_elem_pair추출(버퍼 라이프사이클 vs 튜플 구성 분리)refactor:check_hnsw_page_fit에서elems_per_page헬퍼 추출refactor:fill_hnsw_from_*가 실제 원소 수를 반환(ambuild의 블록 수 추론 제거 — 패킹 후nblocks-1 != N이 되므로)feat: 실제 패킹 — 레이아웃 공식, 신규write_elem_pages_packed(),entryOffno/insertPage메타페이지 필드 수정test:build_hnsw_packing.sql— 다중 페이지 전수 검색 정합성, 재빌드 결정성, REINDEX, 패킹 후 일반 INSERTbench: VM 실측(dim=768, N=100K) — 정확히 2.00배 축소, pgvector 네이티브와 0.01% 오차로 동급.BENCHMARK.md§2.1fTest plan
make gpu-deploy→make gpu-verify-deployed(6항목 GREEN)make installcheck41/41 GREEN,make installcheck-isolation6/6 GREEN — 매 커밋마다 VM 재검증insertPage수정 검증) 전부 확인