Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/perf/vector-math-refactor/PR5.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 5 additions & 3 deletions docs/perf/vector-math-refactor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
51 changes: 31 additions & 20 deletions docs/perf/vector-math-refactor/RETRO.md
Original file line number Diff line number Diff line change
@@ -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 무의미하므로 마이크로옵트로만.
3 changes: 2 additions & 1 deletion docs/perf/vector-math-refactor/risk-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 고정 | — | ⬜ 열림 |

상태 범례: ⬜ 열림 · 🟨 관찰중 · 🟩 닫힘(해소) · 🟥 발생함
9 changes: 5 additions & 4 deletions rust_builder/rust/src/api/simple_rag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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))
Expand Down
10 changes: 6 additions & 4 deletions rust_builder/rust/src/api/source_rag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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))
})
Expand Down
28 changes: 28 additions & 0 deletions rust_builder/rust/src/api/vector_math.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
/// f32 byte stream back into a `Vec<f32>`. 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<Vec<f32>> {
if blob.len() % 4 != 0 {
Expand All @@ -23,6 +31,26 @@ pub(crate) fn decode_f32_embedding(blob: &[u8]) -> Option<Vec<f32>> {
)
}

/// 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<f32> {
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)
Expand Down
Loading