diff --git a/docs/perf/vector-math-refactor/PR5.md b/docs/perf/vector-math-refactor/PR5.md new file mode 100644 index 0000000..0193c2b --- /dev/null +++ b/docs/perf/vector-math-refactor/PR5.md @@ -0,0 +1,37 @@ +# PR5 — 위생: 손상 블롭 로깅 (N6) + 엔디안 문서화 (N5) + +- 브랜치: `feat/loc-63-hygiene` +- Linear: [LOC-63](https://linear.app/loceract/issue/LOC-63) +- 상태: 🟦 진행 (PR 열림, CI green 대기) + +## 스코프 (백엔드 무관, 독립) +### N6 — 손상 블롭 로깅 (실제 코드 변경) +HNSW 인덱스 빌드 경로의 `decode_f32_embedding(&blob).unwrap_or_default()` 6곳 +([simple_rag.rs](../../../rust_builder/rust/src/api/simple_rag.rs) 188/193/199, +[source_rag.rs](../../../rust_builder/rust/src/api/source_rag.rs) 891/896/902)이 손상 임베딩을 +빈 벡터로 만들어 `!embedding.is_empty()` 필터에서 **조용히 드롭**됨. +- `vector_math::decode_f32_embedding_or_warn(&blob, row_id)` 헬퍼 추가: 실패 시 `log::warn!`(row_id 포함) + 후 빈 Vec 반환 → **동작 보존**(여전히 드롭), 단 손상이 로그로 가시화. 6곳 패턴도 한 곳으로 dedup. + +### N5 — 엔디안 (보수적: 문서화만) +임베딩은 native-endian f32로 저장/읽기(`to_ne_bytes`/`from_ne_bytes`). 인코딩 사이트가 5곳에 분산 +(hybrid_search:714, source_rag:764/2614/2694, simple_rag:293)이고 **모든 타깃이 LE라 실효 이득 0**, +asymmetric 누락 위험만 있어 **전면 정규화는 하지 않음**. 대신 `decode_f32_embedding` doc에 native-endian/LE +가정 + (정렬 미보장으로) zero-copy 캐스팅 불가를 명시. +- 후속 backlog: 공유 `encode_f32_embedding`/`decode` 헬퍼로 5개 인코딩 사이트 dedup + LE 정규화(원하면). + +## 결과 (Before → After) +- 손상 임베딩: 무음 드롭 → **`log::warn!` 가시화** (동작 동일). +- 로컬 검증: fallback `cargo test --lib vector_math` 3 green; faer `--features vector_faer` 4 green(패리티 포함); + `cargo check --features vector_faer,vector_quant_i8` 통과(quant 분기의 헬퍼 호출 포함). +- CI: (PR #__ green 후 갱신) + +## 받은 피드백 (리뷰) +- (PR 리뷰 후 갱신) + +## 리스크 / 롤백 +- R7(엔디안 호환성): **포맷 미변경**(doc only) → 마이그레이션/호환 리스크 없음 → 닫힘. +- 동작 변경 없음(드롭 동작 보존 + 로그 추가). 롤백: PR revert. + +## 결정 로그 +- N5 전면 정규화 보류(LE-only 환경에서 실효 0, diff 넓음) → 문서화로 축소. 공유 encode 헬퍼는 backlog. diff --git a/docs/perf/vector-math-refactor/README.md b/docs/perf/vector-math-refactor/README.md index 99ea702..d6175d7 100644 --- a/docs/perf/vector-math-refactor/README.md +++ b/docs/perf/vector-math-refactor/README.md @@ -26,9 +26,11 @@ | PR0 | 작업 저널 스캐폴드 | 보존 체계 | 없음(문서) | — | [LOC-58](https://linear.app/loceract/issue/LOC-58) | 🟩 머지(#63) | | PR1 | 벤치 하니스 + faer/fused 패리티 안전망 | 측정근거, N2 선제 | 없음 | — | [LOC-59](https://linear.app/loceract/issue/LOC-59) | 🟦 진행([PR1.md](PR1.md)) | | PR2 | 출시 faer 백엔드 **CI 커버리지** (N2) [faer 유지] | N2 | 낮음 | PR1 ✅ | [LOC-60](https://linear.app/loceract/issue/LOC-60) | 🟦 진행([PR2.md](PR2.md)) | -| PR3 | decode 버퍼 재사용 | Claim1 | 낮음~중 | 벤치/N3 게이트 | [LOC-61](https://linear.app/loceract/issue/LOC-61) | ⬜ TODO | -| PR4 | 다중 누산기 언롤(선택) | Claim3 "진짜 NEON" | 중 | PR2 기반, 벤치 게이트 | [LOC-62](https://linear.app/loceract/issue/LOC-62) | ⬜ TODO | -| PR5 | 위생: 엔디안 정규화 + 손상 로깅 | N5, N6 | 낮음(독립) | — | [LOC-63](https://linear.app/loceract/issue/LOC-63) | ⬜ TODO | +| PR3 | decode 버퍼 재사용 | Claim1 | 낮음~중 | 벤치/N3 게이트 | [LOC-61](https://linear.app/loceract/issue/LOC-61) | ⏸ 보류(저가치, 다음 세션) | +| PR4 | ~~다중 누산기 언롤~~ | — | — | — | [LOC-62](https://linear.app/loceract/issue/LOC-62) | ❌ 폐기(faer 유지로 무의미) | +| PR5 | 위생: 손상 로깅(N6) + 엔디안 문서화(N5) | N6, N5 | 낮음(독립) | — | [LOC-63](https://linear.app/loceract/issue/LOC-63) | 🟦 진행([PR5.md](PR5.md)) | + +종료 회고: [RETRO.md](RETRO.md) · 다음 세션 이어가기: [LOC-61](https://linear.app/loceract/issue/LOC-61)(PR3 재평가) + 프로젝트 핸드오프 노트. 상태 범례: ⬜ TODO · 🟦 진행 · 🟩 머지 · ⏸ 보류 · ❌ 폐기 · PR별 상세는 `PRn.md` diff --git a/docs/perf/vector-math-refactor/RETRO.md b/docs/perf/vector-math-refactor/RETRO.md index 3a371e4..8bb9abf 100644 --- a/docs/perf/vector-math-refactor/RETRO.md +++ b/docs/perf/vector-math-refactor/RETRO.md @@ -1,30 +1,41 @@ # 회고 (RETRO) — vector_math 리팩터링 -> 모든 PR 종료 후 작성. 착수 시점 가설([00-review-and-analysis.md](00-review-and-analysis.md))과 -> 실제 결과를 대조하고, [risk-register.md](risk-register.md)의 각 리스크 처리 결과를 확정한다. +작성: 2026-05-30. 착수 가설([00-review-and-analysis.md](00-review-and-analysis.md))과 실제 결과 대조 + +[risk-register.md](risk-register.md) 처리 결과 확정. -## 1. 무엇을 바꿨나 (PR별 한 줄) -- PR1: -- PR2: -- PR3: -- PR4: -- PR5: +## 1. 무엇을 바꿨나 (PR별) +- **PR0** ([#63](https://github.com/dev07060/mobile_rag_engine/pull/63), 🟩): 작업 저널 스캐폴드 — PR 단위 결과/피드백 보존 체계. +- **PR1** ([#64](https://github.com/dev07060/mobile_rag_engine/pull/64), 🟩): criterion 벤치 + faer/fused 패리티 안전망. **핵심 발견의 출처.** +- **PR2** ([#65](https://github.com/dev07060/mobile_rag_engine/pull/65), 🟩): 출시 faer+quant 백엔드를 CI에서 빌드+테스트(N2 닫음). *원안 "faer 제거"에서 피벗.* +- **PR5** ([LOC-63](https://linear.app/loceract/issue/LOC-63), 🟦 머지 대기): 손상 블롭 `log::warn`(N6) + 엔디안 문서화(N5). +- **PR3** ⏸ 보류(저가치), **PR4** ❌ 폐기(faer 유지로 무의미). ## 2. 측정 결과 (가설 대조) -- 가설: faer 제거 시 현재 호출 형태에서 fused가 동등 이상. -- 실측 (Before → After, 차원별 / 바이너리 크기 / 할당): -- 가설이 맞았나? 빗나간 부분: +- **착수 가설(틀림):** "faer가 1-D 닷에서 fused보다 느리니 제거하고 fused로 통일하면 빨라진다." +- **PR1 실측(반증):** faer가 **2–8× 빠름** (cosine 768 3.3×, dot 1536 7.8×, **exact_scan 2000×768 2.8×**). decode는 백엔드 무관(=, sanity ✓). +- **원인:** f32 리덕션은 fast-math 미허용으로 **자동 벡터화 안 됨 → fused는 스칼라(latency-bound)**. faer는 SIMD gemm 마이크로커널. +- **정적 분석은 옳았으나 결론이 틀린 부분:** N1(호출당 힙 할당)·2-pass는 실재 → 그러나 **throughput에 무의미**(4000회 할당하는 scan에서도 faer 우위). "분석으로 옳아 보여도 측정 없이는 방향을 틀린다"의 표본. +- 캐비엇: 수치는 개발기(Apple Silicon). 방향(스칼라 vs SIMD)은 폰 NEON에서도 견고 예상, 크기는 온디바이스 프로파일로 확인 권장(미수행). ## 3. 리스크 처리 결과 -- R1 (배치 gemv 경로): -- R2 (수치 변동): -- R3/R4 (스칼라 한계 / 언롤): -- R5 (스택 고아화): -- R6/R7 (핫루프 / 엔디안): -- 남은 미해결 리스크 + 후속 트리거: +- **R1**(배치 gemv 경로) 🟩 — faer 유지로 경로 보존. +- **R2**(수치 변동) 🟩 — 백엔드 교체 없음. (참고: 컷오프 의존 0, RRF 랭크 기반 — 분석 §3에서 코드 근거 확정.) +- **R3**(스칼라 throughput-bound) 🟥 확정 — 단 faer 유지로 **출시 문제 아님**. +- **R7**(엔디안 호환성) 🟩 — PR5는 포맷 미변경(문서화). +- **R9**(PR2 전제 반증) 🟩 — faer 유지 확정 + PR2를 N2로 전환. +- **N2**(출시 백엔드 CI 미검증) 🟩 — PR2가 빌드+테스트 게이트 추가. +- **N6**(손상 무음 드롭) 🟩 — PR5 `log::warn`. +- **R5**(스택 PR 고아화) 🟨 — 매 PR을 main 머지 후 분기하여 회피(스택 안 씀). +- **R8**(SQLite 테스트 플레이크) 🟨 — `--test-threads=1` 고정 유지. +- **미해결/잔여:** R3(고차원 스칼라)는 faer 유지로 비활성. 온디바이스 실측 미수행(아래 후속). ## 4. 배운 점 / 다음에 다르게 할 것 -- +- **"측정 먼저" 원칙이 회귀(2.8–8×)를 막았다.** 코드 리뷰(외부 + 자체)가 만장일치로 틀린 방향을 가리켰고, 벤치 한 번이 뒤집었다. 성능 변경은 **파괴 전 벤치 PR을 선행**한다 — 이번 워크플로(PR1→PR2)를 표준으로. +- **f32 리덕션 = 스칼라**라는 사실은 일반적으로 재사용 가능한 교훈(다른 핫 루프에도 적용). +- 정적 분석은 "무엇이 비싼가"는 잘 잡지만 "무엇이 지배적인가"는 못 잡는다 → 둘을 분리해서 말할 것. -## 5. 후속 작업 (있으면) -- +## 5. 후속 작업 (다음 세션) +- **[LOC-61] PR3 재평가** (decode 버퍼 재사용): N1 무의미 + N3(i8 우선)로 저가치. 벤치로 f32 decode가 실제 핫임이 입증될 때만. 기본은 폐기 권장. +- **온디바이스 벤치**: 실제 폰(arm64)에서 faer 우위 크기 확인(선택). +- **공유 encode 헬퍼**: 5개 인코딩 사이트(`to_ne_bytes`) dedup + 원하면 LE 정규화(저가치). +- **(선택) N1 무할당 faer**: `faer::linalg::matmul`로 결과 할당 제거 — throughput 무의미하므로 마이크로옵트로만. diff --git a/docs/perf/vector-math-refactor/risk-register.md b/docs/perf/vector-math-refactor/risk-register.md index 554f60c..eb8a20e 100644 --- a/docs/perf/vector-math-refactor/risk-register.md +++ b/docs/perf/vector-math-refactor/risk-register.md @@ -13,7 +13,8 @@ | R4 | PR4 **언롤 새 버그**(remainder/lane 합산 off-by-one) | PR4 | 패리티 테스트 ε 초과 / 단위 테스트 실패 | 스칼라 대비 property/ε 테스트 필수 | PR4 revert(커널은 PR2 상태로) | ⬜ 열림 | | R5 | **스택 PR 고아화** (PR4/PR5 base=PR2) | PR2 머지 | PR4/PR5 diff에 PR2 변경분이 섞여 보임 | PR2 머지 직후 base→main retarget | 재타깃 PR | ⬜ 열림 | | R6 | PR3 핫 루프 수정으로 **동작 변경** | PR3 | 검색 결과 diff | 결과 동일성 테스트 + N3로 적용 여부 게이트 | PR3 revert | ⬜ 열림 | -| R7 | N5 엔디안 변경이 **기존 DB 호환성** 깨짐 | PR5 | 기존 인덱스 로드 실패 | LE 기기선 ne==le 바이트 동일 → 무마이그레이션 확인 후만 적용 | 변경 revert | ⬜ 열림 | +| R7 | N5 엔디안 변경이 **기존 DB 호환성** 깨짐 | PR5 | 기존 인덱스 로드 실패 | LE 기기선 ne==le 바이트 동일 → 무마이그레이션 확인 후만 적용 | 변경 revert | 🟩 닫힘 — PR5는 포맷 미변경(문서화만), 호환 리스크 없음 | +| N6 | 손상 임베딩 블롭이 **무음 드롭** | 상시 | 손상 DB에서 인덱스 누락이 로그 없이 발생 | PR5: `decode_f32_embedding_or_warn`로 `log::warn!` 가시화(동작 보존) | 헬퍼 revert | 🟩 닫힘(PR5) | | R8 | CI 병렬 실행으로 **공유 SQLite 테스트 플레이크** | 전체 | cargo test 간헐 실패 | `-- --test-threads=1` 고정 | — | ⬜ 열림 | 상태 범례: ⬜ 열림 · 🟨 관찰중 · 🟩 닫힘(해소) · 🟥 발생함 diff --git a/rust_builder/rust/src/api/simple_rag.rs b/rust_builder/rust/src/api/simple_rag.rs index d01d098..542ce07 100644 --- a/rust_builder/rust/src/api/simple_rag.rs +++ b/rust_builder/rust/src/api/simple_rag.rs @@ -23,7 +23,8 @@ use crate::api::hnsw_index::{ }; use crate::api::incremental_index::{clear_buffer, incremental_add}; use crate::api::vector_math::{ - cosine_f32, cosine_with_query_norm_f32, decode_f32_embedding, l2_norm_f32, + cosine_f32, cosine_with_query_norm_f32, decode_f32_embedding, decode_f32_embedding_or_warn, + l2_norm_f32, }; #[cfg(feature = "vector_quant_i8")] use crate::api::vector_quant::{ @@ -185,18 +186,18 @@ fn rebuild_hnsw_index_internal(conn: &Connection) -> anyhow::Result<()> { let quantized = i8_vec_from_blob(qblob); let restored = dequantize_i8_to_f32(&quantized, scale); if restored.is_empty() { - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) } else { restored } } else { - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) }; #[cfg(not(feature = "vector_quant_i8"))] let embedding = { let _ = (embedding_i8_blob, embedding_scale); - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) }; Ok((id, embedding)) diff --git a/rust_builder/rust/src/api/source_rag.rs b/rust_builder/rust/src/api/source_rag.rs index 0c09faf..267f72f 100644 --- a/rust_builder/rust/src/api/source_rag.rs +++ b/rust_builder/rust/src/api/source_rag.rs @@ -32,7 +32,9 @@ use crate::api::query_metrics::{ record_hydrated_content_read, QueryContentReadGuard, QueryContentReadPhase, }; use crate::api::tokenizer::count_plain_text_tokens_untruncated; -use crate::api::vector_math::{cosine_with_query_norm_f32, decode_f32_embedding, l2_norm_f32}; +use crate::api::vector_math::{ + cosine_with_query_norm_f32, decode_f32_embedding, decode_f32_embedding_or_warn, l2_norm_f32, +}; #[cfg(feature = "vector_quant_i8")] use crate::api::vector_quant::{ cosine_with_query_norm_i8_blob, dequantize_i8_to_f32, i8_vec_from_blob, l2_norm_i8, @@ -888,18 +890,18 @@ pub fn rebuild_chunk_hnsw_index_for_collection(collection_id: String) -> Result< let quantized = i8_vec_from_blob(qblob); let restored = dequantize_i8_to_f32(&quantized, scale); if restored.is_empty() { - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) } else { restored } } else { - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) }; #[cfg(not(feature = "vector_quant_i8"))] let embedding = { let _ = (embedding_i8_blob, embedding_scale); - decode_f32_embedding(&embedding_blob).unwrap_or_default() + decode_f32_embedding_or_warn(&embedding_blob, id) }; Ok((id, embedding)) }) diff --git a/rust_builder/rust/src/api/vector_math.rs b/rust_builder/rust/src/api/vector_math.rs index ea9f092..1bfb73c 100644 --- a/rust_builder/rust/src/api/vector_math.rs +++ b/rust_builder/rust/src/api/vector_math.rs @@ -11,6 +11,14 @@ /// f32 byte stream back into a `Vec`. Returns `None` when the /// stored length is not a multiple of 4, which would indicate a corrupt /// or unexpected payload. +/// +/// Endianness: embeddings are stored/read in **native** byte order (the +/// encode sites use `f32::to_ne_bytes`). All supported targets (iOS/Android +/// arm64, desktop x86_64) are little-endian, so the on-disk format is +/// effectively little-endian; moving a DB across a big-endian boundary is +/// unsupported. `chunks_exact(4) + from_ne_bytes` is also the alignment-safe +/// decode — SQLite blob buffers are not guaranteed 4-byte aligned, so a +/// zero-copy `&[u8] -> &[f32]` cast would be unsound. #[inline] pub(crate) fn decode_f32_embedding(blob: &[u8]) -> Option> { if blob.len() % 4 != 0 { @@ -23,6 +31,26 @@ pub(crate) fn decode_f32_embedding(blob: &[u8]) -> Option> { ) } +/// Decode like [`decode_f32_embedding`], but log a warning when the blob is +/// corrupt (length not a multiple of 4) instead of silently yielding nothing. +/// Returns an empty `Vec` on failure so existing "drop empty embeddings" +/// filters at call sites keep their behaviour — the only change is that the +/// corruption becomes visible in logs (keyed by `row_id`). +#[inline] +pub(crate) fn decode_f32_embedding_or_warn(blob: &[u8], row_id: i64) -> Vec { + match decode_f32_embedding(blob) { + Some(v) => v, + None => { + log::warn!( + "decode_f32_embedding: corrupt embedding blob (len={} not a multiple of 4) for row id={}; skipping", + blob.len(), + row_id + ); + Vec::new() + } + } +} + #[inline] pub(crate) fn dot_f32(a: &[f32], b: &[f32]) -> f32 { backend::dot_f32(a, b)